diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index 7da1560a156..d79f94e8a45 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -71,5 +71,5 @@ Add a changelog fragment under `.nextchanges/` when your change is user-visible. **How to add:** - Create `.nextchanges/
/.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `` is arbitrary (a feature name or your PR number) — just keep it unique. - Write one or two sentences in user-facing language, no Jira links. The leading `* ` is optional. Match the voice and tense of existing changelog entries. -- A PR link is optional: write `(#NNNN)` (with NNNN being the PR number) in the text and it's expanded to a full link automatically. +- You don't need a PR link: the `nextchanges PR link` workflow adds one to each fragment the PR adds and pushes it onto the branch (skipping fork PRs, and any entry that already mentions a `#NNNN`). To point at a different PR or an issue, write `(#NNNN)` yourself; it's expanded to a full link automatically. - See `.nextchanges/README.md` for details. diff --git a/.github/workflows/nextchanges-pr-link.yml b/.github/workflows/nextchanges-pr-link.yml new file mode 100644 index 00000000000..6d7fbc362b4 --- /dev/null +++ b/.github/workflows/nextchanges-pr-link.yml @@ -0,0 +1,121 @@ +name: nextchanges PR link + +# A changelog fragment may reference the PR it came from. The reference is +# optional (see .nextchanges/README.md) and easy to forget, so for every +# fragment a PR *adds* this appends `(#)` where none exists yet, expands it +# to a markdown link, and pushes the result onto the PR branch. +# +# `task links` (part of `task checks`) would otherwise fail the `lint` check on +# an unexpanded `(#1234)`, so the expansion has to happen here rather than being +# left to the release, which does not expand links either (see +# render_nextchanges in internal/genkit/release_tagging.py). +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - ".nextchanges/*/*.md" + +# One run per PR: a second run pushing concurrently would commit onto a stale head. +concurrency: + group: nextchanges-pr-link-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + add-link: + # The databricks org has an IP allow list, and GitHub-hosted runners are not + # on it: `gh api` from ubuntu-latest fails with HTTP 403. Every workflow here + # that calls the API runs on this group for the same reason. + runs-on: + group: databricks-deco-testing-runner-group + labels: ubuntu-latest-deco + + # Forks: the head branch lives in the contributor's repository and is not + # ours to push to, so those PRs are left to review. The label is the escape + # hatch for an author who wants the fragment left exactly as written + # (mirrors 'override-changelog-guard' in changelog-guard.yml). + if: >- + !github.event.pull_request.head.repo.fork && + !contains(github.event.pull_request.labels.*.name, 'skip-nextchanges-pr-link') + + steps: + # Fetch with the default GITHUB_TOKEN, and persist-credentials: false so + # nothing is left in .git/config. The steps below execute tools/*.py from + # the PR branch, so no push credential may be reachable while they run; + # DECO_GITHUB_TOKEN is therefore scoped to the final step, which runs no + # code from the PR. + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.head_ref }} + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "0.8.9" + + # Ask the API which files this PR adds rather than diffing locally: it is + # the same notion of "added" GitHub shows in the diff (against the merge + # base) and needs no full-history checkout. + # The file list is kept in RUNNER_TEMP so it never shows up in the working + # tree that the commit below is built from. + - name: List fragments added by this PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + gh api --paginate "repos/$REPO/pulls/$PR/files" \ + --jq '.[] | select(.status == "added") | .filename' > "$RUNNER_TEMP/all_added.txt" + # Section fragments only: not the section READMEs, not + # .nextchanges/version, and nothing at another depth (which + # validate_nextchanges.py rejects anyway). + grep -E '^\.nextchanges/[^/]+/[^/]+\.md$' "$RUNNER_TEMP/all_added.txt" \ + | grep -v '/README\.md$' > "$RUNNER_TEMP/added.txt" || true + cat "$RUNNER_TEMP/added.txt" + + - name: Add missing PR references + env: + PR: ${{ github.event.pull_request.number }} + run: | + if [ ! -s "$RUNNER_TEMP/added.txt" ]; then + echo "This PR adds no changelog fragments; nothing to do." + exit 0 + fi + # -d '\n': split the list on newlines only, so a fragment whose name + # contains a space stays one path. `--`: so a name that looks like a + # flag is still treated as a path. + xargs -d '\n' uv run tools/add_pr_links.py --pr "$PR" -- < "$RUNNER_TEMP/added.txt" + # Expand the `(#1234)` just added into the canonical markdown link, + # using the same script the `links` task runs. + xargs -d '\n' uv run tools/update_github_links.py -- < "$RUNNER_TEMP/added.txt" + + - name: Commit + id: commit + run: | + if git diff --quiet; then + echo "Every added fragment already references a PR." + exit 0 + fi + git diff + git config user.name "eng-dev-ecosystem-bot" + git config user.email "eng-dev-ecosystem-bot@users.noreply.github.com" + git commit -am "Add PR link to changelog fragments" + echo "pushable=true" >> "$GITHUB_OUTPUT" + + # The only step holding a push credential, and the only one that runs no + # code from the PR branch. DECO_GITHUB_TOKEN is the eng-dev-ecosystem-bot + # PAT: a push authenticated with the default GITHUB_TOKEN does not fire the + # `synchronize` event, so required checks would never run on the commit + # above and the PR could not enter the merge queue. + - name: Push + if: steps.commit.outputs.pushable == 'true' + env: + TOKEN: ${{ secrets.DECO_GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + run: |- + git push "https://x-access-token:$TOKEN@github.com/$REPO" "HEAD:refs/heads/$BRANCH" diff --git a/.nextchanges/README.md b/.nextchanges/README.md index f124c39ca64..aca4ffef158 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -19,10 +19,14 @@ type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. - `` is arbitrary — a feature name (`quickstart.md`) or your PR number (`5464.md`), whatever you like, as long as it's unique. - The leading `* ` is optional. -- A PR link is optional. If you want one, write `(#5464)` and run `task links` - (or `task checks`) to expand it into a full markdown link in place; CI fails - if a raw `(#5464)` is left unexpanded. The release does not expand links, so - the fragment must already be expanded when it lands. +- You don't need to add a PR link. The `nextchanges PR link` workflow appends + one to every fragment your PR adds that doesn't have a reference yet, and + pushes the result onto your branch. It skips an entry that already mentions + any `#NNNN`, so write the reference yourself to point at a different PR or an + issue. Fork PRs are not pushed to, so add the link by hand there. +- To add or expand a link locally, write `(#5464)` and run `task links` (or + `task checks`); CI fails if a raw `(#5464)` is left unexpanded. The release + does not expand links, so the fragment must already be expanded when it lands. - One file is usually one entry; for several, put each on its own `* ` line. ### Sections diff --git a/tools/add_pr_links.py b/tools/add_pr_links.py new file mode 100755 index 00000000000..384d7d16442 --- /dev/null +++ b/tools/add_pr_links.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Add a PR reference to changelog fragments that lack one. + +A PR reference in a `.nextchanges/` fragment is optional (see +`.nextchanges/README.md`) and easy to forget. The `nextchanges-pr-link` workflow +runs this over the fragments a PR *adds* and appends `(#)` to every entry +that has no reference yet. Expanding that into the canonical markdown link is +left to `tools/update_github_links.py`, which the workflow runs next. + +A fragment holds one entry per `* `/`- ` line, or a single entry when it has no +such marker (see `render_nextchanges` in `internal/genkit/release_tagging.py`, +which bullets the first line and passes continuation lines through). The +reference goes at the end of an entry, so an entry wrapped over several lines +gets it on the last line rather than mid-sentence. +""" + +import argparse +import pathlib +import re + +# A `* `/`- ` line starts a new entry; see the module docstring. +ENTRY_MARKER_RE = re.compile(r"^\s*[*-] ") + +# Any `#1234` counts as a reference, raw or already expanded into a link: the +# author pointed at a PR (or a related issue) themselves, so leave the entry +# alone. This is also what makes a re-run a no-op, so the workflow's own push +# cannot retrigger itself into a loop. +EXISTING_REF_RE = re.compile(r"#\d+") + + +def entry_ranges(lines): + """Return one ``(start, stop)`` line-index range per entry. + + >>> entry_ranges(["* first", "* second"]) + [(0, 1), (1, 2)] + >>> entry_ranges(["one entry", "wrapped over two lines"]) + [(0, 2)] + """ + starts = [i for i, line in enumerate(lines) if ENTRY_MARKER_RE.match(line)] + # No marker at all: the whole fragment is a single entry. + if not starts: + starts = [0] + stops = [*starts[1:], len(lines)] + return list(zip(starts, stops, strict=True)) + + +def append_reference(line, pr): + """Append ``(#pr)`` to one line, before its trailing period. + + Existing entries in CHANGELOG.md put the link inside the sentence rather + than after it, e.g. ``… on every run ([#6060](…)).`` + + >>> append_reference("Added the `databricks quickstart` command.", 1234) + 'Added the `databricks quickstart` command (#1234).' + >>> append_reference("* No trailing period", 1234) + '* No trailing period (#1234)' + """ + body = line.rstrip() + if body.endswith("."): + return f"{body[:-1]} (#{pr})." + return f"{body} (#{pr})" + + +def annotate_text(text, pr): + r"""Append ``(#pr)`` to every entry in a fragment that has no reference. + + >>> annotate_text("Added the `databricks quickstart` command.\n", 1234) + 'Added the `databricks quickstart` command (#1234).\n' + + Entries are annotated independently, and one that already references a PR + is left untouched: + + >>> annotate_text("* one\n* two ([#99](https://github.com/databricks/cli/pull/99))\n", 1234) + '* one (#1234)\n* two ([#99](https://github.com/databricks/cli/pull/99))\n' + + A wrapped entry gets the reference at its end, not mid-sentence: + + >>> annotate_text("A long entry that wraps\nover two lines.\n", 1234) + 'A long entry that wraps\nover two lines (#1234).\n' + """ + lines = text.split("\n") + for start, stop in entry_ranges(lines): + entry = lines[start:stop] + if EXISTING_REF_RE.search("\n".join(entry)): + continue + content = [i for i in range(start, stop) if lines[i].strip()] + if not content: + continue + lines[content[-1]] = append_reference(lines[content[-1]], pr) + return "\n".join(lines) + + +def process_file(path, pr): + """Process a single fragment. + + Returns True if the file was *modified*. + """ + original = path.read_text(encoding="utf-8") + updated = annotate_text(original, pr) + if updated != original: + path.write_text(updated, encoding="utf-8") + print(f"Updated {path}") + return True + + return False + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Add a PR reference to changelog fragments that lack one.") + parser.add_argument("--pr", type=int, required=True, help="pull request number to reference") + parser.add_argument("files", nargs="+", help="fragment files to annotate") + args = parser.parse_args(argv) + + for file_path in args.files: + process_file(pathlib.Path(file_path), args.pr) + + +if __name__ == "__main__": + main()