Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .agents/skills/changelog-draft/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The script outputs JSON to stdout with this structure:
"number": 1234,
"url": "https://github.com/warpdotdev/warp/pull/1234",
"title": "...",
"commit_subject": "... (#1234)",
"author": "username",
"body": "...",
"labels": ["..."],
Expand All @@ -76,7 +77,7 @@ The script outputs JSON to stdout with this structure:
}
```

Use the top-level `number`, `url`, `author`, `body`, `labels`, `changed_files`, and `source_repo` fields as the source of truth. `internal_pr` is audit-only and must never be used for contributor attribution or user-facing changelog links. If `url` is empty, omit the PR link from user-facing markdown rather than synthesizing one.
Use the top-level `number`, `url`, `commit_subject`, `author`, `body`, `labels`, `changed_files`, and `source_repo` fields as the source of truth. `internal_pr` is audit-only and must never be used for contributor attribution or user-facing changelog links. If `url` is empty, omit the PR link from user-facing markdown rather than synthesizing one.

### Step 3 — Classify contributors

Expand Down Expand Up @@ -146,6 +147,7 @@ The `--org` flag checks each reporter's org membership via the GitHub API, filte
Whenever the markdown draft credits a PR author, contributor, or issue reporter, render the username as a GitHub profile link such as `[@username](https://github.com/username)`.

### Step 6 — Classify unmarked PRs
Determine TUI impact independently from the regular changelog category for every PR. Explicit `CHANGELOG-TUI` and `CHANGELOG-OZ` markers are authoritative entries and may coexist with New Feature, Improvement, or Bug Fix entries. When a PR has an explicit TUI entry, preserve its other explicit entries. Otherwise, if `commit_subject` contains `TUI` as a standalone case-insensitive token, route any explicit New Feature, Improvement, or Bug Fix text to `TUI` instead of its regular category. This keeps clearly TUI-labeled changes out of the desktop changelog while allowing explicitly marked shared changes to appear on both surfaces.

For each PR that has no explicit `CHANGELOG-*` entries, decide whether to include it and under which category.

Expand All @@ -158,6 +160,7 @@ For each unmarked PR, produce a classification:
"include": true,
"category": "IMPROVEMENT",
"text": "Proposed changelog line",
"impacts_tui": true,
"confidence": "high",
"rationale": "...",
"feature_flag": null,
Expand All @@ -169,6 +172,7 @@ For each unmarked PR, produce a classification:
- PRs that only touch CI, tests, docs, or internal tooling → `include: false`
- PRs behind dogfood-only feature flags → `include: false` for stable channel
- PRs behind preview flags → `include: false` for stable, `include: true` for preview
- Set `impacts_tui: true` when Warp Agent CLI users observe the change, including shared Agent capabilities such as tool-call or edit-file behavior
- When in doubt, set `needs_review: true` and `confidence: "low"`
- Bot PRs (dependabot, renovate, etc.) → `include: false`

Expand All @@ -183,10 +187,13 @@ Combine explicit entries (Step 2) and inferred entries (Step 6) into the final r
1. `NEW-FEATURE` — New Features
2. `IMPROVEMENT` — Improvements
3. `BUG-FIX` — Bug Fixes
4. `OZ` — Oz Updates
4. `TUI` — TUI Updates
5. `OZ` — Oz Updates

PRs marked with `CHANGELOG-NONE` are explicitly opted out and must never appear in the changelog markdown.

Preserve every explicit entry independently. For an inferred regular entry with `impacts_tui: true`, also create a `TUI` entry with the same user-facing text and PR metadata. Do not duplicate an inferred entry whose category is already `TUI`.

When creating entries, copy `pr_number`, `url`, `author`, `source_repo`, and `internal_pr` from the normalized PR record. The release JSON converter uses `url` directly; do not invent public PR URLs from PR numbers.

### Step 8 — Write output files
Expand All @@ -210,6 +217,9 @@ Write two files to `output_dir`:
## Bug Fixes
- Fixed crash on startup ([#1236](https://github.com/warpdotdev/warp/pull/1236))

## TUI Updates
- Added inline command menus to Warp Agent CLI ([#1238](https://github.com/warpdotdev/warp/pull/1238))

## Oz Updates
- Improved agent memory ([#1237](https://github.com/warpdotdev/warp/pull/1237))

Expand Down
167 changes: 167 additions & 0 deletions .agents/skills/changelog-draft/scripts/add_tui_updates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Add updates that impact TUI users to legacy changelog JSON.

Preview and dev releases use the legacy changelog action, which only parses
explicit PR-body markers. This post-processor reuses the normalized PR metadata
collector used by stable releases, copies explicit TUI-impact entries, and moves
commit-labeled TUI-only entries out of the regular changelog buckets.
"""

import argparse
import json
import re
import subprocess

from fetch_prs import collect_prs

REGULAR_CATEGORY_KEYS = {
"NEW-FEATURE": "newFeatures",
"IMPROVEMENT": "improvements",
"BUG-FIX": "bugFixes",
}
TUI_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9])TUI(?![A-Za-z0-9])", re.IGNORECASE)
TRAILING_PR_RE = re.compile(r"\s*\(#\d+\)\s*$")
LEADING_TICKET_RE = re.compile(r"^\s*\[[A-Z]+-\d+\]\s*")
TUI_PREFIX_RE = re.compile(
r"""(?ix)
^\s*
(?:
(?:\[TUI\]|TUI)\s*(?::|-)\s*
|
(?:feat|fix|chore|refactor|perf|test|docs)\s*\(\s*TUI\s*\)\s*:\s*
)
"""
)


def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.strip()


def previous_release_cut(release_tag: str, channel: str) -> str:
"""Find the _00 tag from the previous release date."""
release_date_prefix = release_tag.rsplit("_", 1)[0]
tags = run(
[
"git",
"tag",
"--list",
f"v0.*.{channel}_00",
"--sort=-version:refname",
]
)
for tag in tags.splitlines():
if tag.rsplit("_", 1)[0] != release_date_prefix:
return tag
raise ValueError(f"could not find a previous {channel} release cut")


def is_tui_subject(subject: str) -> bool:
return bool(TUI_TOKEN_RE.search(subject))


def normalize_subject(subject: str) -> str:
"""Turn a TUI-labeled commit subject into compact changelog text."""
text = TRAILING_PR_RE.sub("", subject).strip()
text = LEADING_TICKET_RE.sub("", text)
text = TUI_PREFIX_RE.sub("", text).strip()
if text:
text = text[0].upper() + text[1:]
return text


def remove_first(items: list, value: str) -> None:
try:
items.remove(value)
except ValueError:
pass


def append_unique(items: list[str], value: str) -> None:
if value and value not in items:
items.append(value)


def add_tui_updates(changelog: dict, prs: list[dict]) -> dict:
"""Return a changelog with entries that impact TUI in tui_updates."""
tui_updates = list(changelog.get("tui_updates") or [])

for pr in prs:
explicit_entries = pr.get("explicit_entries") or []
explicit_categories = {
entry.get("category", "") for entry in explicit_entries
}
if "NONE" in explicit_categories:
continue

regular_entries = [
entry
for entry in explicit_entries
if entry.get("category") in REGULAR_CATEGORY_KEYS
]
explicit_tui_entries = [
entry for entry in explicit_entries if entry.get("category") == "TUI"
]

if explicit_tui_entries:
selected_text = [
entry.get("text", "").strip() for entry in explicit_tui_entries
]
move_regular_entries = False
else:
subject = pr.get("commit_subject") or pr.get("title") or ""
title = pr.get("title") or ""
tui_headline = subject if is_tui_subject(subject) else title
if "OZ" in explicit_categories or not is_tui_subject(tui_headline):
continue
selected_text = [
entry.get("text", "").strip() for entry in regular_entries
]
if not selected_text:
selected_text = [normalize_subject(tui_headline)]
move_regular_entries = True

if move_regular_entries:
for entry in regular_entries:
release_key = REGULAR_CATEGORY_KEYS[entry["category"]]
values = changelog.get(release_key)
if isinstance(values, list):
remove_first(values, entry.get("text", "").strip())

for text in selected_text:
append_unique(tui_updates, text)

changelog["tui_updates"] = tui_updates
return changelog


def main() -> None:
parser = argparse.ArgumentParser(
description="Add updates that impact TUI users to legacy changelog JSON"
)
parser.add_argument("--input", required=True, help="Legacy changelog JSON")
parser.add_argument("--output", required=True, help="Updated changelog JSON")
parser.add_argument("--repo", required=True, help="GitHub repo (owner/name)")
parser.add_argument("--channel", required=True, help="Release channel")
parser.add_argument("--release-tag", required=True, help="Current release tag")
args = parser.parse_args()

base_ref = previous_release_cut(args.release_tag, args.channel)
release_prs = collect_prs(args.repo, base_ref, args.release_tag)["prs"]
with open(args.input) as f:
changelog = json.load(f)

changelog = add_tui_updates(changelog, release_prs)
with open(args.output, "w") as f:
json.dump(changelog, f, indent=2)
f.write("\n")

print(
f"Added {len(changelog['tui_updates'])} TUI updates "
f"from {base_ref}..{args.release_tag}"
)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
("improvements", "Improvements"),
("bugFixes", "Bug Fixes"),
("images", "Image"),
("tui_updates", "TUI Updates"),
# Keep the existing label stable for compatibility with recent Slack posts.
("oz_updates", "oz_updates"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"improvements": ["..."],
"bugFixes": ["..."],
"images": ["..."],
"oz_updates": ["..."]
"oz_updates": ["..."],
"tui_updates": ["..."]
}
"""

Expand All @@ -28,6 +29,7 @@
"IMPROVEMENT": "improvements",
"BUG-FIX": "bugFixes",
"OZ": "oz_updates",
"TUI": "tui_updates",
"IMAGE": "images",
}

Expand Down Expand Up @@ -64,6 +66,7 @@ def convert(draft: dict) -> dict:
"bugFixes": [],
"images": [],
"oz_updates": [],
"tui_updates": [],
}

for entry in draft.get("entries", []):
Expand Down
45 changes: 27 additions & 18 deletions .agents/skills/changelog-draft/scripts/fetch_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

# Matches lines like: CHANGELOG-NEW-FEATURE: Added dark mode
MARKER_RE = re.compile(
r"^CHANGELOG-(NEW-FEATURE|IMPROVEMENT|BUG-FIX|IMAGE|OZ|NONE)\s*:?\s*(.*)$",
r"^CHANGELOG-(NEW-FEATURE|IMPROVEMENT|BUG-FIX|IMAGE|OZ|TUI|NONE)\s*:?\s*(.*)$",
re.MULTILINE,
)

Expand Down Expand Up @@ -58,6 +58,10 @@ def get_commits(base_ref: str, head_ref: str) -> list[str]:
return []
return log.splitlines()

def get_commit_subject(sha: str) -> str:
"""Return the first line of a commit message."""
return run(["git", "log", "-1", "--format=%s", sha])


def extract_pr_number(sha: str) -> int | None:
"""Extract PR number from a squash-merge commit subject line.
Expand Down Expand Up @@ -285,26 +289,20 @@ def extract_markers(body: str) -> list[dict]:
return entries


def main() -> None:
parser = argparse.ArgumentParser(description="Fetch PRs in a release range")
parser.add_argument("--repo", required=True, help="GitHub repo (owner/name)")
parser.add_argument("--base-ref", required=True, help="Previous release tag")
parser.add_argument("--head-ref", required=True, help="Current release tag")
args = parser.parse_args()

commit_shas = get_commits(args.base_ref, args.head_ref)

def collect_prs(repo: str, base_ref: str, head_ref: str) -> dict:
"""Collect normalized PR metadata for a release range."""
commit_shas = get_commits(base_ref, head_ref)
seen_prs: set[int] = set()
prs: list[dict] = []

def process_pr(pr_num: int) -> None:
def process_pr(pr_num: int, commit_sha: str) -> None:
"""Fetch and record a single PR by number."""
data = fetch_pr_data(args.repo, pr_num)
data = fetch_pr_data(repo, pr_num)
if data is None:
return
if not should_include_pr(args.repo, data):
if not should_include_pr(repo, data):
return
source_repo, data, internal_pr = normalize_pr_data(args.repo, pr_num, data)
source_repo, data, internal_pr = normalize_pr_data(repo, pr_num, data)
author_login = get_author_login(data)
label_names = get_label_names(data)

Expand All @@ -317,6 +315,7 @@ def process_pr(pr_num: int) -> None:
"number": data.get("number", pr_num),
"url": data.get("url", "") if source_repo == PUBLIC_REPO else "",
"title": data.get("title", ""),
"commit_subject": get_commit_subject(commit_sha),
"author": author_login,
"body": body,
"labels": label_names,
Expand All @@ -335,7 +334,7 @@ def process_pr(pr_num: int) -> None:
if pr_num is not None and pr_num not in seen_prs:
# Normal squash-merge commit
seen_prs.add(pr_num)
process_pr(pr_num)
process_pr(pr_num, sha)
else:
# Merge commit fallback: walk the merged-in commits for PR numbers.
# This handles branches merged via merge commit (e.g. security-patches)
Expand All @@ -344,12 +343,22 @@ def process_pr(pr_num: int) -> None:
inner_pr = extract_pr_number(merged_sha)
if inner_pr is not None and inner_pr not in seen_prs:
seen_prs.add(inner_pr)
process_pr(inner_pr)
process_pr(inner_pr, merged_sha)

output = {
"range": {"base": args.base_ref, "head": args.head_ref},
return {
"range": {"base": base_ref, "head": head_ref},
"prs": prs,
}


def main() -> None:
parser = argparse.ArgumentParser(description="Fetch PRs in a release range")
parser.add_argument("--repo", required=True, help="GitHub repo (owner/name)")
parser.add_argument("--base-ref", required=True, help="Previous release tag")
parser.add_argument("--head-ref", required=True, help="Current release tag")
args = parser.parse_args()

output = collect_prs(args.repo, args.base_ref, args.head_ref)
json.dump(output, sys.stdout, indent=2)
print() # trailing newline

Expand Down
Loading
Loading