first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
---
|
||||
name: changelog-draft
|
||||
description: Generate a reviewable changelog draft from PRs merged in a release range. Extracts explicit CHANGELOG markers, classifies unmarked PRs, adds external contributor attribution, and outputs markdown + JSON artifacts. Does NOT mutate channel_versions.json.
|
||||
---
|
||||
|
||||
# Changelog Draft Generator
|
||||
|
||||
## Inputs
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `channel` | yes | Release channel: `stable`, `preview`, or `dev` |
|
||||
| `release_tag` | yes | The release tag to generate the changelog for (e.g. `v0.2026.05.06.09.12.stable_00`) |
|
||||
| `output_dir` | no | Directory to write output files. Defaults to `$RUNNER_TEMP` or `/tmp/changelog-draft` |
|
||||
| `attribution` | no | Attribution mode: `external-only` (default), `all`, or `none` |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Determine the release range
|
||||
|
||||
Infer the previous release **cut** for comparison. Release tags follow the pattern `v0.YYYY.MM.DD.HH.MM.<channel>_NN`, where `_NN` is the RC/hotfix number within that release cut. Multiple tags can share the same date prefix (e.g. `_00`, `_01`, `_02` are all part of one release cut).
|
||||
|
||||
The base tag must be the `_00` tag of the **previous** release cut (i.e. a different date), not just the previous tag. For example, if generating a changelog for `v0.2026.04.29.08.57.stable_01`, the base should be `v0.2026.04.22.08.57.stable_00`, not `v0.2026.04.29.08.57.stable_00`.
|
||||
|
||||
```bash
|
||||
# 1. Extract the date prefix from the release_tag (everything before _NN)
|
||||
release_date_prefix="${release_tag%_*}"
|
||||
|
||||
# 2. List all _00 tags for the channel (these are release cut points), sorted descending
|
||||
git tag --list "v0.*.${channel}_00" --sort=-version:refname
|
||||
|
||||
# 3. Pick the first _00 tag whose date prefix differs from release_date_prefix
|
||||
```
|
||||
|
||||
Record the range as `previous_cut_tag..release_tag`.
|
||||
|
||||
### Step 2 — Fetch PR data
|
||||
|
||||
Run the `fetch_prs.py` script to collect all public-release PRs merged in the release range and extract explicit changelog markers. Pass the repository that the workflow checked out, not necessarily the public repository. Release workflows run from `warpdotdev/warp-internal`, and the script deterministically resolves `warp-repo-sync[bot]` PRs back to their original public `warpdotdev/warp` PR metadata before emitting JSON. When running from `warpdotdev/warp-internal`, the script intentionally omits PRs that were not authored by the repo-sync bot, because those are private internal changes that must not be exposed to the changelog agent or generated artifacts.
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/changelog-draft/scripts/fetch_prs.py \
|
||||
--repo "${GITHUB_REPOSITORY:-warpdotdev/warp}" \
|
||||
--base-ref <previous_tag> \
|
||||
--head-ref <release_tag>
|
||||
```
|
||||
|
||||
The script outputs JSON to stdout with this structure:
|
||||
```json
|
||||
{
|
||||
"range": { "base": "<previous_tag>", "head": "<release_tag>" },
|
||||
"prs": [
|
||||
{
|
||||
"number": 1234,
|
||||
"url": "https://github.com/warpdotdev/warp/pull/1234",
|
||||
"title": "...",
|
||||
"author": "username",
|
||||
"body": "...",
|
||||
"labels": ["..."],
|
||||
"merged_at": "2026-05-01T...",
|
||||
"explicit_entries": [
|
||||
{ "category": "NEW-FEATURE", "text": "Added dark mode" }
|
||||
],
|
||||
"linked_issues": [5678],
|
||||
"changed_files": ["app/src/ai/agent.rs", "crates/warp_features/src/lib.rs"],
|
||||
"source_repo": "warpdotdev/warp",
|
||||
"internal_pr": {
|
||||
"number": 25712,
|
||||
"url": "https://github.com/warpdotdev/warp-internal/pull/25712",
|
||||
"author": "warp-repo-sync[bot]",
|
||||
"title": "...",
|
||||
"repo": "warpdotdev/warp-internal"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Step 3 — Classify contributors
|
||||
|
||||
Run the `classify_contributors.py` script with the unique author logins from Step 2:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/changelog-draft/scripts/classify_contributors.py \
|
||||
--org warpdotdev \
|
||||
--authors author1,author2,author3
|
||||
```
|
||||
|
||||
Output JSON:
|
||||
```json
|
||||
{
|
||||
"internal": ["author1"],
|
||||
"external": ["author3"],
|
||||
"bot": ["author2"],
|
||||
"unknown": []
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Extract feature flags
|
||||
|
||||
Run the `extract_feature_flags.py` script to get the current flag gate lists:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/changelog-draft/scripts/extract_feature_flags.py \
|
||||
--file crates/warp_features/src/lib.rs
|
||||
```
|
||||
|
||||
Output JSON:
|
||||
```json
|
||||
{
|
||||
"release_flags": ["Autoupdate", "Changelog", ...],
|
||||
"preview_flags": ["Orchestration", ...],
|
||||
"dogfood_flags": ["LogExpensiveFramesInSentry", ...]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5 — Fetch issue reporters
|
||||
|
||||
Collect all unique `linked_issues` from Step 2 and fetch the original reporter for each. Pass `--org` so the script checks org membership and filters out internal reporters automatically:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/changelog-draft/scripts/fetch_issue_reporters.py \
|
||||
--repo warpdotdev/warp \
|
||||
--org warpdotdev \
|
||||
--issues 5678,9012
|
||||
```
|
||||
|
||||
Output JSON (only external reporters are included):
|
||||
```json
|
||||
{
|
||||
"issue_reporters": [
|
||||
{
|
||||
"issue_number": 5678,
|
||||
"title": "Crash when opening large file",
|
||||
"reporter": "community-user",
|
||||
"reporter_url": "https://github.com/community-user",
|
||||
"url": "https://github.com/warpdotdev/warp/issues/5678"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `--org` flag checks each reporter's org membership via the GitHub API, filtering out internal members so they aren't misattributed as external community reporters. These reporters will be credited in the "Community" section of the changelog.
|
||||
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
|
||||
|
||||
For each PR that has no explicit `CHANGELOG-*` entries, decide whether to include it and under which category.
|
||||
|
||||
Follow the classification guidance in `.agents/skills/classify-changelog-pr/SKILL.md`.
|
||||
|
||||
For each unmarked PR, produce a classification:
|
||||
```json
|
||||
{
|
||||
"pr_number": 1234,
|
||||
"include": true,
|
||||
"category": "IMPROVEMENT",
|
||||
"text": "Proposed changelog line",
|
||||
"confidence": "high",
|
||||
"rationale": "...",
|
||||
"feature_flag": null,
|
||||
"needs_review": false
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- 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
|
||||
- When in doubt, set `needs_review: true` and `confidence: "low"`
|
||||
- Bot PRs (dependabot, renovate, etc.) → `include: false`
|
||||
|
||||
**Feature-flag detection:** Use the `changed_files` list from Step 2 to check if any PR touches `crates/warp_features/src/lib.rs` or references a `FeatureFlag` variant in its title/body. Cross-reference with the flag lists from Step 4 to determine channel visibility.
|
||||
|
||||
**Unknown contributors:** Authors in the `unknown` bucket (org membership check failed due to auth) should be treated conservatively — do not attribute them as external. Note them in the output for manual verification.
|
||||
|
||||
### Step 7 — Assemble the draft
|
||||
|
||||
Combine explicit entries (Step 2) and inferred entries (Step 6) into the final report. Group by category in this order:
|
||||
|
||||
1. `NEW-FEATURE` — New Features
|
||||
2. `IMPROVEMENT` — Improvements
|
||||
3. `BUG-FIX` — Bug Fixes
|
||||
4. `OZ` — Oz Updates
|
||||
|
||||
PRs marked with `CHANGELOG-NONE` are explicitly opted out and must never appear in the changelog markdown.
|
||||
|
||||
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
|
||||
|
||||
Write two files to `output_dir`:
|
||||
|
||||
**`changelog-draft.md`** — Human-reviewable markdown, ready for Slack/Notion:
|
||||
|
||||
```markdown
|
||||
# Changelog Draft
|
||||
**Channel:** stable
|
||||
**Range:** v0.2026.05.01... → v0.2026.05.06...
|
||||
**Generated:** 2026-05-06T15:00:00Z
|
||||
|
||||
## New Features
|
||||
- Added dark mode ([#1234](https://github.com/warpdotdev/warp/pull/1234)) — [@external-contributor](https://github.com/external-contributor) ✨
|
||||
|
||||
## Improvements
|
||||
- Faster tab switching ([#1235](https://github.com/warpdotdev/warp/pull/1235))
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed crash on startup ([#1236](https://github.com/warpdotdev/warp/pull/1236))
|
||||
|
||||
## Oz Updates
|
||||
- Improved agent memory ([#1237](https://github.com/warpdotdev/warp/pull/1237))
|
||||
|
||||
## Community
|
||||
### Contributors
|
||||
- [@contributor1](https://github.com/contributor1) — [#1234](https://github.com/warpdotdev/warp/pull/1234) ✨
|
||||
|
||||
### Issue Reporters
|
||||
Thanks to the community members who reported issues fixed in this release:
|
||||
- [@reporter1](https://github.com/reporter1) — [#5678](https://github.com/warpdotdev/warp/issues/5678) "Crash when opening large file"
|
||||
```
|
||||
|
||||
The markdown draft must **not** include "Needs Review" or "Skipped PRs" sections — those are internal details that belong only in the JSON audit artifact.
|
||||
|
||||
**`changelog-draft.json`** — Machine-readable audit artifact (internal only):
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "stable",
|
||||
"range": { "base": "v0...", "head": "v0..." },
|
||||
"generated_at": "2026-05-06T15:00:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"pr_number": 1234,
|
||||
"url": "https://github.com/warpdotdev/warp/pull/1234",
|
||||
"category": "NEW-FEATURE",
|
||||
"text": "Added dark mode",
|
||||
"source": "explicit",
|
||||
"author": "external-contributor",
|
||||
"is_external": true,
|
||||
"confidence": "high",
|
||||
"rationale": null,
|
||||
"feature_flag": null,
|
||||
"source_repo": "warpdotdev/warp",
|
||||
"internal_pr": null
|
||||
}
|
||||
],
|
||||
"skipped": [...],
|
||||
"needs_review": [...],
|
||||
"issue_reporters": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The JSON artifact retains `skipped`, `needs_review`, and `issue_reporters` for audit purposes — every PR in the range must appear in either `entries`, `skipped`, or `needs_review`.
|
||||
|
||||
### Step 9 — Generate release-pipeline JSON
|
||||
|
||||
Run the conversion script to deterministically produce `changelog-release.json` from the audit artifact:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/changelog-draft/scripts/convert_to_release_json.py \
|
||||
--input <output_dir>/changelog-draft.json \
|
||||
--output <output_dir>/changelog-release.json
|
||||
```
|
||||
|
||||
This produces the flat JSON structure consumed by the `create_release` workflow for Slack and the in-app "What's New" dialog. Do **not** generate this file manually — always use the script so the output is deterministic and consistent.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Never** write to `channel_versions.json` or any production config file.
|
||||
- **Never** push commits, create branches, or open PRs.
|
||||
- All output goes to `output_dir` only.
|
||||
- The markdown draft should be copy-pasteable into Slack or Notion for review.
|
||||
- Keep the JSON artifact complete enough for audit: every PR in the range should appear in either `entries`, `skipped`, or `needs_review`.
|
||||
|
||||
## Validation
|
||||
|
||||
After generating output, verify:
|
||||
1. Every PR in the range is accounted for (entries + skipped + needs_review = total PRs).
|
||||
2. Explicit marker entries match what `fetch_prs.py` extracted (no dropped markers).
|
||||
3. No duplicate PR numbers across sections.
|
||||
4. The markdown renders cleanly (no broken links or formatting).
|
||||
@@ -0,0 +1,96 @@
|
||||
# Changelog Draft
|
||||
**Channel:** stable
|
||||
**Range:** v0.2026.04.29.08.56.stable_00 → v0.2026.05.06.09.12.stable_00
|
||||
**Generated:** 2026-05-06T19:00:00Z
|
||||
**Total PRs in range:** 211 | **Explicit markers:** 57 | **Unmarked:** 154
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
- You can now drag tabs out of a window into their own window, or between windows, similar to Chrome. ([#9275](https://github.com/warpdotdev/warp/pull/9275))
|
||||
- Added a `/set-tab-color` slash command for setting or clearing the current tab's color from the input bar. ([#9305](https://github.com/warpdotdev/warp/pull/9305))
|
||||
|
||||
## Improvements
|
||||
- Added tab context menu actions to copy visible tab and pane metadata when available. ([#10120](https://github.com/warpdotdev/warp/pull/10120))
|
||||
- The conversation details panel can now be opened and closed with a configurable keyboard shortcut. ([#9837](https://github.com/warpdotdev/warp/pull/9837))
|
||||
- Conversation details side panel is now available for local Warp Agent conversations, not just cloud Oz runs. Click the info button in the pane header to open it for any active AI conversation. ([#9493](https://github.com/warpdotdev/warp/pull/9493))
|
||||
- Reduced memory usage and CPU work in the agent runs management view while a conversation is streaming. ([#9866](https://github.com/warpdotdev/warp/pull/9866))
|
||||
- Added support for drag-and-drop of image files into an active CLI agent session (e.g. Claude Code). ([#9553](https://github.com/warpdotdev/warp/pull/9553))
|
||||
- Warp now renders inline local images and Mermaid diagrams in agent block output. ([#9993](https://github.com/warpdotdev/warp/pull/9993))
|
||||
- Warp now silently falls back to a regular SSH session on remote hosts where the prebuilt remote-server binary is incompatible (e.g. glibc < 2.31), instead of attempting an install that would fail at runtime. ([#9681](https://github.com/warpdotdev/warp/pull/9681))
|
||||
- HTML files using the .htm extension now open with HTML syntax highlighting in Warp's editor. ([#9360](https://github.com/warpdotdev/warp/pull/9360))
|
||||
- Recognize Block's `goose` CLI agent — running `goose` now activates the CLI-agent toolbar, status, brand color, and icon like other recognized third-party agents. ([#9497](https://github.com/warpdotdev/warp/pull/9497))
|
||||
- Added a `/continue-locally` slash command to continue cloud conversations locally. ([#9500](https://github.com/warpdotdev/warp/pull/9500))
|
||||
- Added a "Show in Finder" (macOS) / "Show containing folder" (Linux/Windows) option to the tooltip that appears when clicking a detected file link. ([#9475](https://github.com/warpdotdev/warp/pull/9475))
|
||||
- Tighten orchestration event subscription scope so SSE runs only for active parent and child agent runs. ([#9273](https://github.com/warpdotdev/warp/pull/9273))
|
||||
- Fix macOS IME candidate popup positioning in code editor panes so it anchors to the editor caret instead of stale terminal/input positions. ([#9555](https://github.com/warpdotdev/warp/pull/9555))
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed /feedback recording "Unknown" instead of the installed Warp version on packaged builds. ([#10219](https://github.com/warpdotdev/warp/pull/10219))
|
||||
- Fixed find (cmd+f) selection jumping to a different match when new output streams into the active block. ([#10057](https://github.com/warpdotdev/warp/pull/10057))
|
||||
- Fix Japanese IME losing the last character of a phrase that ends right before a punctuation mark on macOS. ([#9730](https://github.com/warpdotdev/warp/pull/9730))
|
||||
- Fixed local file tree blinking/reshuffling when connected to an SSH session ([#10184](https://github.com/warpdotdev/warp/pull/10184))
|
||||
- Fixed terminal text selection not auto-scrolling when dragging beyond bounds ([#9448](https://github.com/warpdotdev/warp/pull/9448))
|
||||
- Fixed Ctrl-G not closing CLI agent rich input on linux when editor is focused ([#10030](https://github.com/warpdotdev/warp/pull/10030))
|
||||
- Pressing backspace in the agent view when the buffer is empty no longer resets the conversation. ([#10114](https://github.com/warpdotdev/warp/pull/10114))
|
||||
- Fixed unnecessary reconnect attempts for remote SSH sessions after system sleep, reducing error noise ([#10096](https://github.com/warpdotdev/warp/pull/10096))
|
||||
- Fixes issue with repeated TUI redraws for CLI agents on terminal pane resize. ([#9877](https://github.com/warpdotdev/warp/pull/9877))
|
||||
- Fix new-session "+" dropdown alignment when the Tabs Panel is placed on the right side of the header toolbar. ([#9492](https://github.com/warpdotdev/warp/pull/9492))
|
||||
- Copy keybinding now prioritizes selected text in the input over a selected block when both are active. ([#9491](https://github.com/warpdotdev/warp/pull/9491))
|
||||
- [Windows] Fix hotkey window. ([#9891](https://github.com/warpdotdev/warp/pull/9891))
|
||||
- [Windows] Symlink traversal fixed. ([#9863](https://github.com/warpdotdev/warp/pull/9863))
|
||||
- Fixed a crash on Windows when handing off a Web conversation to the native client. ([#9987](https://github.com/warpdotdev/warp/pull/9987))
|
||||
- Fixed a bug where multiple 'open skill' buttons shared hover state. ([#9437](https://github.com/warpdotdev/warp/pull/9437))
|
||||
- Fixed the OSS Linux desktop entry so WarpOss launches through the packaged `warp-terminal-oss` command. ([#9424](https://github.com/warpdotdev/warp/pull/9424))
|
||||
- Fixed Ctrl/Cmd shortcuts (e.g. copy, paste) failing on Windows when a non-Latin keyboard layout was active. ([#9476](https://github.com/warpdotdev/warp/pull/9476))
|
||||
- Fixed background colour bleeding in alt screen programs (e.g. delta, diff-so-fancy). ([#9852](https://github.com/warpdotdev/warp/pull/9852))
|
||||
- Clip the warping indicator's action chips onto a new line on narrow panes instead of overflowing. ([#9297](https://github.com/warpdotdev/warp/pull/9297))
|
||||
- Inline `.bmp`, `.tiff` / `.tif`, and `.ico` images in agent block output now render correctly. ([#9397](https://github.com/warpdotdev/warp/pull/9397))
|
||||
- If user attaches an image in block input we should lock in agent mode, without running the NLD classifier. ([#9366](https://github.com/warpdotdev/warp/pull/9366))
|
||||
- Remote-server installs no longer fail when the staging-directory cleanup hits a race. ([#9681](https://github.com/warpdotdev/warp/pull/9681))
|
||||
- `.command` shell scripts now open with shell syntax highlighting in Warp's editor. ([#9345](https://github.com/warpdotdev/warp/pull/9345))
|
||||
- Fix git diff chip flickering between tracked-only and all-files count when untracked files are present ([#9244](https://github.com/warpdotdev/warp/pull/9244))
|
||||
- `Open File → Default App` now opens files in the running Warp channel instead of routing to a different installed Warp. ([#9285](https://github.com/warpdotdev/warp/pull/9285))
|
||||
- Fixed vertical tabs settings popup items being unclickable ([#9540](https://github.com/warpdotdev/warp/pull/9540))
|
||||
- Fixed a macOS memory leak that occurred when Warp enumerated system fonts or built a font fallback chain. ([#9665](https://github.com/warpdotdev/warp/pull/9665))
|
||||
- Executable shell scripts opened from a `file://` URL now run in the terminal instead of opening in the editor. ([#9503](https://github.com/warpdotdev/warp/pull/9503))
|
||||
- Fixed Option+Enter, Option+Tab, and Option+Escape sending literal key names instead of correct escape sequences ([#9514](https://github.com/warpdotdev/warp/pull/9514))
|
||||
- Fixed read_files tool showing an empty box when the LLM requests line ranges beyond the end of a file. ([#9326](https://github.com/warpdotdev/warp/pull/9326))
|
||||
- Prevent Warp from consuming too much memory when identifying filepaths in long block outputs. ([#9617](https://github.com/warpdotdev/warp/pull/9617))
|
||||
- Don't trigger the agent onboarding tutorial when Warp is running in headless SDK/CLI mode. ([#9590](https://github.com/warpdotdev/warp/pull/9590))
|
||||
- Added `--version` flag support in the Oz CLI ([#9252](https://github.com/warpdotdev/warp/pull/9252))
|
||||
- Fixed file tree flickering when transitioning to an SSH remote session ([#9320](https://github.com/warpdotdev/warp/pull/9320))
|
||||
- Fixed scroll-to-start/end of selected block keybinding not working when the input is focused. ([#9332](https://github.com/warpdotdev/warp/pull/9332))
|
||||
- Fix the terminal pane background appearing darker in horizontal tabs mode with background image or custom opacity. ([#9474](https://github.com/warpdotdev/warp/pull/9474))
|
||||
- AI code blocks tagged `vue`, `xml`, `dockerfile`, `jsx`, `tsx`, etc. now render with syntax highlighting. ([#9471](https://github.com/warpdotdev/warp/pull/9471))
|
||||
- Reopen Closed Session is now reachable from the new-session menu on Linux and Windows. ([#9347](https://github.com/warpdotdev/warp/pull/9347))
|
||||
- Fixed missing syntax highlighting for C++ header files using `.hpp`, `.hxx`, or `.H` extensions. ([#9388](https://github.com/warpdotdev/warp/pull/9388))
|
||||
- Fixed `/open-file` handling for relative WSL paths so Unix separators are preserved. ([#9322](https://github.com/warpdotdev/warp/pull/9322))
|
||||
|
||||
## Oz Updates
|
||||
- Add Codex as a supported harness for local child agents. ([#10176](https://github.com/warpdotdev/warp/pull/10176))
|
||||
- Configurable max context window per profile. ([#9352](https://github.com/warpdotdev/warp/pull/9352))
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
### Contributors
|
||||
- @Abdalla-Eldoumani ✨
|
||||
- @Akeuuh — [#9655](https://github.com/warpdotdev/warp/pull/9655) ✨
|
||||
- @AntonVishal ✨
|
||||
- @BennyWaitWhat ✨
|
||||
- @Faizanq ✨
|
||||
- @JamieMcMillan ✨
|
||||
- @R3flector ✨
|
||||
- @amriksingh0786 ✨
|
||||
- @princepal9120 ✨
|
||||
- @webdevtodayjason ✨
|
||||
- @zerone0x ✨
|
||||
|
||||
### Issue Reporters
|
||||
Thanks to the community members who reported issues fixed in this release:
|
||||
- @user123 — [#5678](https://github.com/warpdotdev/warp/issues/5678) "Crash when opening large file"
|
||||
|
||||
---
|
||||
|
||||
*This draft was generated by the `changelog-draft` Oz skill. Needs Review and Skipped PRs are available in the JSON audit artifact.*
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a Slack Block Kit payload from release-pipeline changelog JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
MAX_SECTION_TEXT_LENGTH = 3000
|
||||
MAX_MESSAGE_BLOCKS = 50
|
||||
|
||||
SECTION_ORDER = (
|
||||
("newFeatures", "New Features"),
|
||||
("improvements", "Improvements"),
|
||||
("bugFixes", "Bug Fixes"),
|
||||
("images", "Image"),
|
||||
# Keep the existing label stable for compatibility with recent Slack posts.
|
||||
("oz_updates", "oz_updates"),
|
||||
)
|
||||
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
|
||||
SLACK_LINK_URL_SAFE_CHARS = "/:?&=#%+~@!$'()*;,[]"
|
||||
|
||||
|
||||
def escape_slack_text(text: str) -> str:
|
||||
"""Escape Slack control characters in ordinary mrkdwn text."""
|
||||
return html.escape(text, quote=False)
|
||||
|
||||
|
||||
def escape_slack_link_url(url: str) -> str:
|
||||
"""Percent-encode Slack link delimiters before embedding a URL in mrkdwn."""
|
||||
return quote(url, safe=SLACK_LINK_URL_SAFE_CHARS)
|
||||
|
||||
|
||||
def slack_link(url: str, label: str) -> str:
|
||||
"""Build a Slack mrkdwn link from an already-validated URL and label."""
|
||||
return f"<{escape_slack_link_url(url)}|{escape_slack_text(label)}>"
|
||||
|
||||
|
||||
def markdown_links_to_slack(text: str) -> str:
|
||||
"""Convert standard Markdown links to Slack mrkdwn links."""
|
||||
parts: list[str] = []
|
||||
last_end = 0
|
||||
for match in MARKDOWN_LINK_RE.finditer(text):
|
||||
parts.append(escape_slack_text(text[last_end : match.start()]))
|
||||
label, url = match.groups()
|
||||
parts.append(slack_link(url, label))
|
||||
last_end = match.end()
|
||||
parts.append(escape_slack_text(text[last_end:]))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def slack_lines(changelog: dict) -> list[str]:
|
||||
"""Render non-empty changelog sections as Slack mrkdwn lines."""
|
||||
lines: list[str] = []
|
||||
for key, title in SECTION_ORDER:
|
||||
values = changelog.get(key, [])
|
||||
if not isinstance(values, list) or not values:
|
||||
continue
|
||||
lines.append(f"*{title}*")
|
||||
for value in values:
|
||||
lines.append(f" • {markdown_links_to_slack(str(value))}")
|
||||
return lines
|
||||
|
||||
|
||||
def split_overlong_line(line: str) -> Iterable[str]:
|
||||
"""Split a pathological line so every Slack section remains valid."""
|
||||
while len(line) > MAX_SECTION_TEXT_LENGTH - 1:
|
||||
yield line[: MAX_SECTION_TEXT_LENGTH - 1]
|
||||
line = line[MAX_SECTION_TEXT_LENGTH - 1 :]
|
||||
yield line
|
||||
|
||||
|
||||
def chunk_lines(lines: list[str]) -> list[str]:
|
||||
"""Split text into section-sized chunks while retaining copy boundaries."""
|
||||
chunks: list[str] = []
|
||||
buffer = ""
|
||||
for raw_line in lines:
|
||||
for line in split_overlong_line(raw_line):
|
||||
candidate = line if not buffer else f"{buffer}\n{line}"
|
||||
# Reserve a character for a trailing newline. Keeping it in each
|
||||
# section preserves a separator when Slack copies adjacent blocks.
|
||||
if len(candidate) + 1 > MAX_SECTION_TEXT_LENGTH:
|
||||
chunks.append(f"{buffer}\n")
|
||||
buffer = line
|
||||
else:
|
||||
buffer = candidate
|
||||
if buffer:
|
||||
chunks.append(f"{buffer}\n")
|
||||
return chunks
|
||||
|
||||
|
||||
def artifact_link_block(markdown_artifact_url: str) -> dict | None:
|
||||
"""Build a Slack block linking to the downloadable Markdown artifact."""
|
||||
if not markdown_artifact_url:
|
||||
return None
|
||||
return {
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
"Raw Markdown changelog: "
|
||||
f"{slack_link(markdown_artifact_url, 'Download raw Markdown changelog artifact')}"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_payload(
|
||||
changelog: dict, release_tag: str, markdown_artifact_url: str = ""
|
||||
) -> dict:
|
||||
"""Build a Block Kit message and reject payloads Slack cannot accept."""
|
||||
chunks = chunk_lines(slack_lines(changelog))
|
||||
if not chunks:
|
||||
return {"blocks": []}
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"Changelog for {release_tag}",
|
||||
},
|
||||
}
|
||||
]
|
||||
artifact_block = artifact_link_block(markdown_artifact_url)
|
||||
if artifact_block is not None:
|
||||
blocks.append(artifact_block)
|
||||
blocks.extend(
|
||||
{
|
||||
"type": "section",
|
||||
"expand": True,
|
||||
"text": {"type": "mrkdwn", "text": chunk},
|
||||
}
|
||||
for chunk in chunks
|
||||
)
|
||||
|
||||
if len(blocks) > MAX_MESSAGE_BLOCKS:
|
||||
raise ValueError(
|
||||
"Slack payload would require "
|
||||
f"{len(blocks)} blocks, exceeding Slack's {MAX_MESSAGE_BLOCKS}-block limit"
|
||||
)
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build Slack payload JSON from changelog release JSON"
|
||||
)
|
||||
parser.add_argument("--input", required=True, help="Path to changelog JSON")
|
||||
parser.add_argument("--release-tag", required=True, help="Release tag header text")
|
||||
parser.add_argument(
|
||||
"--markdown-artifact-url",
|
||||
default="",
|
||||
help="Download URL for the raw Markdown changelog artifact",
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Payload JSON output path")
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input) as f:
|
||||
changelog = json.load(f)
|
||||
|
||||
payload = build_payload(changelog, args.release_tag, args.markdown_artifact_url)
|
||||
Path(args.output).write_text(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
print(f"Built Slack payload with {len(payload['blocks'])} blocks", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify GitHub usernames as internal, external, or bot.
|
||||
|
||||
Uses `gh api` to check org membership — stdlib only, no pip deps.
|
||||
|
||||
Usage:
|
||||
python3 classify_contributors.py --org warpdotdev --authors user1,user2,user3
|
||||
|
||||
Outputs JSON to stdout.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
KNOWN_BOTS = frozenset(
|
||||
{
|
||||
"dependabot",
|
||||
"dependabot[bot]",
|
||||
"renovate",
|
||||
"renovate[bot]",
|
||||
"github-actions",
|
||||
"github-actions[bot]",
|
||||
"codecov",
|
||||
"codecov[bot]",
|
||||
"warp-bot",
|
||||
"warp-bot[bot]",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
|
||||
|
||||
def check_org_membership(org: str, username: str) -> str:
|
||||
"""Check if a user is a member of the given GitHub org via gh api.
|
||||
|
||||
Returns:
|
||||
'internal' if the user is an org member (HTTP 204),
|
||||
'external' if the user is confirmed not a member (HTTP 404),
|
||||
'unknown' if the check failed due to auth/permission issues.
|
||||
"""
|
||||
result = run(
|
||||
["gh", "api", f"orgs/{org}/members/{username}", "--silent"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return "internal"
|
||||
# Distinguish auth failures from genuine "not a member" responses.
|
||||
# gh api exits non-zero for both 404 (not a member) and 403/401 (no
|
||||
# read:org scope). Only treat an explicit 404 as "external";
|
||||
# everything else (network errors, rate limits, auth issues) is "unknown"
|
||||
# to avoid publicly crediting internal or unverified users.
|
||||
stderr = result.stderr.lower()
|
||||
if "404" in stderr:
|
||||
return "external"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Classify contributor types")
|
||||
parser.add_argument("--org", required=True, help="GitHub org to check membership")
|
||||
parser.add_argument(
|
||||
"--authors",
|
||||
required=True,
|
||||
help="Comma-separated list of GitHub usernames",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
authors = [a.strip() for a in args.authors.split(",") if a.strip()]
|
||||
|
||||
internal: list[str] = []
|
||||
external: list[str] = []
|
||||
bot: list[str] = []
|
||||
unknown: list[str] = []
|
||||
|
||||
for author in authors:
|
||||
if author.lower() in KNOWN_BOTS or author.endswith("[bot]"):
|
||||
bot.append(author)
|
||||
else:
|
||||
status = check_org_membership(args.org, author)
|
||||
if status == "internal":
|
||||
internal.append(author)
|
||||
elif status == "unknown":
|
||||
unknown.append(author)
|
||||
else:
|
||||
external.append(author)
|
||||
|
||||
output = {"internal": internal, "external": external, "bot": bot, "unknown": unknown}
|
||||
json.dump(output, sys.stdout, indent=2)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert changelog-draft.json to the release-pipeline-compatible changelog-release.json.
|
||||
|
||||
Reads the audit artifact produced by the changelog-draft skill and emits the
|
||||
flat JSON structure consumed by the create_release workflow (Slack payload
|
||||
builder + in-app changelog.json step).
|
||||
|
||||
Usage:
|
||||
python3 convert_to_release_json.py --input <changelog-draft.json> --output <changelog-release.json>
|
||||
|
||||
The output schema:
|
||||
{
|
||||
"newFeatures": ["..."],
|
||||
"improvements": ["..."],
|
||||
"bugFixes": ["..."],
|
||||
"images": ["..."],
|
||||
"oz_updates": ["..."]
|
||||
}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Map from changelog-draft.json category names to release JSON keys.
|
||||
CATEGORY_MAP = {
|
||||
"NEW-FEATURE": "newFeatures",
|
||||
"IMPROVEMENT": "improvements",
|
||||
"BUG-FIX": "bugFixes",
|
||||
"OZ": "oz_updates",
|
||||
"IMAGE": "images",
|
||||
}
|
||||
|
||||
|
||||
def github_profile_link(username: str) -> str:
|
||||
"""Format a GitHub username as a markdown profile link."""
|
||||
return f"[@{username}](https://github.com/{username})"
|
||||
|
||||
|
||||
def format_entry(entry: dict) -> str:
|
||||
"""Format a single changelog entry as a text line with a PR link.
|
||||
|
||||
Includes external contributor attribution when applicable.
|
||||
"""
|
||||
text = entry["text"]
|
||||
pr_number = entry.get("pr_number") or entry.get("number")
|
||||
url = entry.get("url") or entry.get("pr_url")
|
||||
|
||||
link = ""
|
||||
if url and pr_number:
|
||||
link = f" ([#{pr_number}]({url}))"
|
||||
|
||||
attribution = ""
|
||||
if entry.get("is_external") and entry.get("author"):
|
||||
attribution = f" — {github_profile_link(entry['author'])} ✨"
|
||||
return f"{text}{link}{attribution}"
|
||||
|
||||
|
||||
def convert(draft: dict) -> dict:
|
||||
"""Convert a changelog-draft.json dict to changelog-release.json dict."""
|
||||
release: dict[str, list[str]] = {
|
||||
"newFeatures": [],
|
||||
"improvements": [],
|
||||
"bugFixes": [],
|
||||
"images": [],
|
||||
"oz_updates": [],
|
||||
}
|
||||
|
||||
for entry in draft.get("entries", []):
|
||||
category = entry.get("category", "")
|
||||
release_key = CATEGORY_MAP.get(category)
|
||||
if release_key is None:
|
||||
continue
|
||||
|
||||
if category == "IMAGE":
|
||||
# IMAGE entries store a URL in "text" — pass through directly.
|
||||
release["images"].append(entry["text"])
|
||||
else:
|
||||
release[release_key].append(format_entry(entry))
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert changelog-draft.json to changelog-release.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
required=True,
|
||||
help="Path to changelog-draft.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="Path to write changelog-release.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input) as f:
|
||||
draft = json.load(f)
|
||||
|
||||
release = convert(draft)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(release, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Summary to stdout for CI logs
|
||||
for key, items in release.items():
|
||||
print(f" {key}: {len(items)} entries")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract RELEASE_FLAGS, PREVIEW_FLAGS, and DOGFOOD_FLAGS from warp_features.
|
||||
|
||||
Parses crates/warp_features/src/lib.rs to find the const arrays and extracts
|
||||
the FeatureFlag variant names. Stdlib only, no pip deps.
|
||||
|
||||
Usage:
|
||||
python3 extract_feature_flags.py --file crates/warp_features/src/lib.rs
|
||||
|
||||
Outputs JSON to stdout.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def extract_flag_list(source: str, const_name: str) -> list[str]:
|
||||
"""Extract FeatureFlag variant names from a const array definition."""
|
||||
# Match: pub const CONST_NAME: &[FeatureFlag] = &[ ... ];
|
||||
pattern = rf"pub\s+const\s+{re.escape(const_name)}\s*:\s*&\[FeatureFlag\]\s*=\s*&\[(.*?)\];"
|
||||
m = re.search(pattern, source, re.DOTALL)
|
||||
if not m:
|
||||
return []
|
||||
|
||||
block = m.group(1)
|
||||
# Extract FeatureFlag::VariantName entries, ignoring #[cfg(...)] attributes
|
||||
variants = re.findall(r"FeatureFlag::(\w+)", block)
|
||||
return variants
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Extract feature flag gate lists")
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
required=True,
|
||||
help="Path to warp_features lib.rs",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.file) as f:
|
||||
source = f.read()
|
||||
|
||||
output = {
|
||||
"release_flags": extract_flag_list(source, "RELEASE_FLAGS"),
|
||||
"preview_flags": extract_flag_list(source, "PREVIEW_FLAGS"),
|
||||
"dogfood_flags": extract_flag_list(source, "DOGFOOD_FLAGS"),
|
||||
}
|
||||
json.dump(output, sys.stdout, indent=2)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch the original reporters for GitHub issues linked to PRs in a release.
|
||||
|
||||
Uses `gh` CLI (must be authenticated) — stdlib only, no pip deps.
|
||||
|
||||
Usage:
|
||||
python3 fetch_issue_reporters.py --repo warpdotdev/warp --issues 1234,5678,9012
|
||||
|
||||
Outputs JSON to stdout mapping issue numbers to reporter info.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run(cmd: list[str], *, check: bool = True) -> str:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run_full(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
|
||||
|
||||
def is_org_member(org: str, username: str) -> bool:
|
||||
"""Check if a user is a member of the given GitHub org.
|
||||
|
||||
Returns True for members (HTTP 204), False for non-members (HTTP 404),
|
||||
and True (conservative) for auth failures so internal users aren't
|
||||
misattributed as external.
|
||||
"""
|
||||
result = run_full(
|
||||
["gh", "api", f"orgs/{org}/members/{username}", "--silent"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
stderr = result.stderr.lower()
|
||||
# Auth failure — be conservative, treat as internal
|
||||
if "403" in stderr or "401" in stderr or "saml" in stderr:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fetch_issue_reporter(repo: str, issue_number: int) -> dict | None:
|
||||
"""Fetch the reporter (author) of a GitHub issue via gh CLI."""
|
||||
raw = run(
|
||||
[
|
||||
"gh",
|
||||
"issue",
|
||||
"view",
|
||||
str(issue_number),
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"number,title,author,url",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
author = ""
|
||||
if isinstance(data.get("author"), dict):
|
||||
author = data["author"].get("login", "")
|
||||
elif isinstance(data.get("author"), str):
|
||||
author = data["author"]
|
||||
|
||||
return {
|
||||
"issue_number": data.get("number", issue_number),
|
||||
"title": data.get("title", ""),
|
||||
"reporter": author,
|
||||
"reporter_url": f"https://github.com/{author}" if author else "",
|
||||
"url": data.get("url", ""),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch issue reporters for linked issues"
|
||||
)
|
||||
parser.add_argument("--repo", required=True, help="GitHub repo (owner/name)")
|
||||
parser.add_argument(
|
||||
"--org",
|
||||
required=False,
|
||||
default="",
|
||||
help="GitHub org to filter out internal reporters (e.g. warpdotdev)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--issues",
|
||||
required=True,
|
||||
help="Comma-separated issue numbers",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
issue_numbers = [
|
||||
int(n.strip()) for n in args.issues.split(",") if n.strip().isdigit()
|
||||
]
|
||||
|
||||
org = args.org
|
||||
reporters: list[dict] = []
|
||||
seen_reporters: set[str] = set()
|
||||
for num in issue_numbers:
|
||||
info = fetch_issue_reporter(args.repo, num)
|
||||
if not info or not info["reporter"]:
|
||||
continue
|
||||
username = info["reporter"]
|
||||
# Skip internal org members when --org is provided
|
||||
if org and username not in seen_reporters and is_org_member(org, username):
|
||||
seen_reporters.add(username)
|
||||
continue
|
||||
if username not in seen_reporters:
|
||||
seen_reporters.add(username)
|
||||
reporters.append(info)
|
||||
|
||||
json.dump({"issue_reporters": reporters}, sys.stdout, indent=2)
|
||||
print() # trailing newline
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch PRs merged in a release range and extract explicit CHANGELOG markers.
|
||||
|
||||
Uses `gh` CLI (must be authenticated) and `git` — stdlib only, no pip deps.
|
||||
|
||||
Usage:
|
||||
python3 fetch_prs.py --repo warpdotdev/warp --base-ref <prev_tag> --head-ref <release_tag>
|
||||
|
||||
Outputs JSON to stdout.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# 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*(.*)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Matches issue-closing keywords: Fixes #123, Closes #456, Resolves #789
|
||||
LINKED_ISSUE_RE = re.compile(
|
||||
r"(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
PUBLIC_REPO = "warpdotdev/warp"
|
||||
INTERNAL_REPO = "warpdotdev/warp-internal"
|
||||
REPO_SYNC_AUTHORS = frozenset(
|
||||
{
|
||||
"app/warp-repo-sync",
|
||||
"warp-repo-sync",
|
||||
"warp-repo-sync[bot]",
|
||||
}
|
||||
)
|
||||
PUBLIC_PR_URL_RE = re.compile(r"https://github\.com/warpdotdev/warp/pull/(\d+)")
|
||||
|
||||
|
||||
def run(cmd: list[str], *, check: bool = True) -> str:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_commits(base_ref: str, head_ref: str) -> list[str]:
|
||||
"""Return SHAs of first-parent commits between base and head."""
|
||||
log = run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--first-parent",
|
||||
"--format=%H",
|
||||
f"{base_ref}..{head_ref}",
|
||||
]
|
||||
)
|
||||
if not log:
|
||||
return []
|
||||
return log.splitlines()
|
||||
|
||||
|
||||
def extract_pr_number(sha: str) -> int | None:
|
||||
"""Extract PR number from a squash-merge commit subject line.
|
||||
|
||||
Expects the GitHub squash format: 'feat: something (#1234)'.
|
||||
Matches the trailing parenthesized (#N) to avoid grabbing issue
|
||||
numbers from titles like 'Fixes #123 (#456)'.
|
||||
"""
|
||||
msg = run(["git", "log", "-1", "--format=%s", sha])
|
||||
# Match the last (#N) in the subject — GitHub always appends the PR number
|
||||
m = re.search(r"\(#(\d+)\)\s*$", msg)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# Fallback: first bare #N (for non-standard subjects)
|
||||
m = re.search(r"#(\d+)", msg)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def get_merged_commits(sha: str) -> list[str]:
|
||||
"""For a merge commit, return the SHAs brought in by the merge.
|
||||
|
||||
A merge commit has two parents: the first parent is the mainline, the
|
||||
second parent is the tip of the merged branch. The commits unique to
|
||||
the merge are those reachable from the second parent but not the first.
|
||||
Returns an empty list for non-merge commits.
|
||||
"""
|
||||
parents = run(["git", "log", "-1", "--format=%P", sha]).split()
|
||||
if len(parents) < 2:
|
||||
return []
|
||||
log = run(
|
||||
["git", "log", "--format=%H", f"{parents[0]}..{parents[1]}"],
|
||||
check=False,
|
||||
)
|
||||
if not log:
|
||||
return []
|
||||
return log.splitlines()
|
||||
|
||||
|
||||
def fetch_pr_data(repo: str, pr_number: int) -> dict | None:
|
||||
"""Fetch PR metadata and changed file paths via gh CLI."""
|
||||
fields = "number,title,author,body,labels,mergedAt,files,url"
|
||||
raw = run(
|
||||
["gh", "pr", "view", str(pr_number), "--repo", repo, "--json", fields],
|
||||
check=False,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_pr_commit_messages(repo: str, pr_number: int) -> list[str]:
|
||||
"""Fetch commit messages for a PR via the GitHub API."""
|
||||
raw = run(
|
||||
["gh", "api", f"repos/{repo}/pulls/{pr_number}/commits"],
|
||||
check=False,
|
||||
)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
commits = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
messages = []
|
||||
for commit in commits:
|
||||
if not isinstance(commit, dict):
|
||||
continue
|
||||
commit_data = commit.get("commit")
|
||||
if isinstance(commit_data, dict):
|
||||
message = commit_data.get("message")
|
||||
if message:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
|
||||
def get_author_login(data: dict) -> str:
|
||||
"""Extract a GitHub login from a gh PR JSON object."""
|
||||
if isinstance(data.get("author"), dict):
|
||||
return data["author"].get("login", "")
|
||||
if isinstance(data.get("author"), str):
|
||||
return data["author"]
|
||||
return ""
|
||||
|
||||
|
||||
def get_label_names(data: dict) -> list[str]:
|
||||
"""Extract label names from a gh PR JSON object."""
|
||||
label_names = []
|
||||
for lbl in data.get("labels", []) or []:
|
||||
if isinstance(lbl, dict):
|
||||
label_names.append(lbl.get("name", ""))
|
||||
else:
|
||||
label_names.append(str(lbl))
|
||||
return label_names
|
||||
|
||||
|
||||
def get_file_paths(data: dict) -> list[str]:
|
||||
"""Extract changed file paths from a gh PR JSON object."""
|
||||
file_paths = []
|
||||
for f in data.get("files", []) or []:
|
||||
if isinstance(f, dict):
|
||||
file_paths.append(f.get("path", ""))
|
||||
return file_paths
|
||||
|
||||
|
||||
def is_repo_sync_pr(data: dict) -> bool:
|
||||
"""Return whether this PR was created by the public-to-internal repo sync bot."""
|
||||
return get_author_login(data) in REPO_SYNC_AUTHORS
|
||||
|
||||
|
||||
def should_include_pr(repo: str, data: dict) -> bool:
|
||||
"""Return whether a PR should be exposed to changelog generation.
|
||||
|
||||
Releases are cut from warp-internal, but non-sync-bot PRs merged there are
|
||||
private/internal changes. Do not expose them to the Oz changelog agent or to
|
||||
generated artifacts.
|
||||
"""
|
||||
return repo != INTERNAL_REPO or is_repo_sync_pr(data)
|
||||
|
||||
|
||||
def extract_public_pr_number(text: str) -> int | None:
|
||||
"""Extract a public warpdotdev/warp PR number from text."""
|
||||
if not text:
|
||||
return None
|
||||
m = PUBLIC_PR_URL_RE.search(text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# Repo-sync commits commonly preserve the original public squash-merge
|
||||
# subject, such as "feat: add thing (#1234)".
|
||||
m = re.search(r"\(#(\d+)\)\s*$", text.splitlines()[0] if text else "")
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_public_pr_number(repo: str, pr_number: int, data: dict) -> int | None:
|
||||
"""Resolve a repo-sync PR back to its original public warpdotdev/warp PR."""
|
||||
public_pr_number = extract_public_pr_number(data.get("body", "") or "")
|
||||
if public_pr_number is not None:
|
||||
return public_pr_number
|
||||
|
||||
for message in fetch_pr_commit_messages(repo, pr_number):
|
||||
public_pr_number = extract_public_pr_number(message)
|
||||
if public_pr_number is not None:
|
||||
return public_pr_number
|
||||
return None
|
||||
|
||||
|
||||
def pr_reference(repo: str, pr_number: int, data: dict) -> dict:
|
||||
"""Build a compact audit reference to a PR."""
|
||||
return {
|
||||
"number": data.get("number", pr_number),
|
||||
"url": data.get("url", ""),
|
||||
"author": get_author_login(data),
|
||||
"title": data.get("title", ""),
|
||||
"repo": repo,
|
||||
}
|
||||
|
||||
|
||||
def normalize_pr_data(repo: str, pr_number: int, data: dict) -> tuple[str, dict, dict | None]:
|
||||
"""Resolve repo-sync PRs to public PR metadata.
|
||||
|
||||
The release workflow runs from warp-internal, where public PRs are mirrored
|
||||
as warp-repo-sync[bot] PRs with different PR numbers. For changelog output
|
||||
and contributor attribution, use the original public PR metadata when it can
|
||||
be resolved, and keep the internal PR under `internal_pr` for audit only.
|
||||
"""
|
||||
internal_pr = pr_reference(repo, pr_number, data) if repo != PUBLIC_REPO else None
|
||||
if repo == PUBLIC_REPO or not is_repo_sync_pr(data):
|
||||
return repo, data, internal_pr
|
||||
|
||||
public_pr_number = resolve_public_pr_number(repo, pr_number, data)
|
||||
if public_pr_number is None:
|
||||
return repo, data, internal_pr
|
||||
|
||||
public_data = fetch_pr_data(PUBLIC_REPO, public_pr_number)
|
||||
if public_data is None:
|
||||
return repo, data, internal_pr
|
||||
|
||||
return PUBLIC_REPO, public_data, internal_pr
|
||||
|
||||
|
||||
def extract_linked_issues(body: str) -> list[int]:
|
||||
"""Extract issue numbers from closing keywords in a PR body."""
|
||||
if not body:
|
||||
return []
|
||||
return sorted(set(int(m.group(1)) for m in LINKED_ISSUE_RE.finditer(body)))
|
||||
|
||||
|
||||
def strip_html_comments(text: str) -> str:
|
||||
"""Remove HTML comment blocks (<!-- ... -->) from text.
|
||||
|
||||
This prevents template placeholders inside HTML comments from being
|
||||
parsed as real CHANGELOG markers.
|
||||
"""
|
||||
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
||||
|
||||
|
||||
def extract_markers(body: str) -> list[dict]:
|
||||
"""Extract CHANGELOG-* markers from a PR body."""
|
||||
if not body:
|
||||
return []
|
||||
# Strip HTML comments so template placeholders aren't treated as real markers
|
||||
cleaned = strip_html_comments(body)
|
||||
entries = []
|
||||
has_opt_out = False
|
||||
for m in MARKER_RE.finditer(cleaned):
|
||||
category = m.group(1)
|
||||
text = m.group(2).strip()
|
||||
# CHANGELOG-NONE is an explicit opt-out — skip all other markers
|
||||
if category == "NONE":
|
||||
has_opt_out = True
|
||||
continue
|
||||
# Skip template placeholders
|
||||
if text.startswith("{{") or text.startswith("{text") or not text:
|
||||
continue
|
||||
entries.append({"category": category, "text": text})
|
||||
# If the PR explicitly opted out, return a special marker
|
||||
if has_opt_out:
|
||||
return [{"category": "NONE", "text": ""}]
|
||||
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)
|
||||
|
||||
seen_prs: set[int] = set()
|
||||
prs: list[dict] = []
|
||||
|
||||
def process_pr(pr_num: int) -> None:
|
||||
"""Fetch and record a single PR by number."""
|
||||
data = fetch_pr_data(args.repo, pr_num)
|
||||
if data is None:
|
||||
return
|
||||
if not should_include_pr(args.repo, data):
|
||||
return
|
||||
source_repo, data, internal_pr = normalize_pr_data(args.repo, pr_num, data)
|
||||
author_login = get_author_login(data)
|
||||
label_names = get_label_names(data)
|
||||
|
||||
body = data.get("body", "") or ""
|
||||
explicit_entries = extract_markers(body)
|
||||
linked_issues = extract_linked_issues(body)
|
||||
file_paths = get_file_paths(data)
|
||||
|
||||
pr = {
|
||||
"number": data.get("number", pr_num),
|
||||
"url": data.get("url", "") if source_repo == PUBLIC_REPO else "",
|
||||
"title": data.get("title", ""),
|
||||
"author": author_login,
|
||||
"body": body,
|
||||
"labels": label_names,
|
||||
"merged_at": data.get("mergedAt", ""),
|
||||
"explicit_entries": explicit_entries,
|
||||
"linked_issues": linked_issues,
|
||||
"changed_files": file_paths,
|
||||
"source_repo": source_repo,
|
||||
}
|
||||
if internal_pr is not None:
|
||||
pr["internal_pr"] = internal_pr
|
||||
prs.append(pr)
|
||||
|
||||
for sha in commit_shas:
|
||||
pr_num = extract_pr_number(sha)
|
||||
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)
|
||||
else:
|
||||
# Merge commit fallback: walk the merged-in commits for PR numbers.
|
||||
# This handles branches merged via merge commit (e.g. security-patches)
|
||||
# rather than the usual squash merge.
|
||||
for merged_sha in get_merged_commits(sha):
|
||||
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)
|
||||
|
||||
output = {
|
||||
"range": {"base": args.base_ref, "head": args.head_ref},
|
||||
"prs": prs,
|
||||
}
|
||||
json.dump(output, sys.stdout, indent=2)
|
||||
print() # trailing newline
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user