🔧 Add explicit issue-to-PR linking command

Add a link-issue command that creates GitHub's Development reference
and verifies both sides. Keep Closes in descriptions for context, but
make the API link the source of truth, including for merged PRs.

Add tests for successful links, missing verification, output, and
failures. Update the PR workflow memories and create-pr skill to use
the command.

AI-assisted-by: space-bunny-free
This commit is contained in:
Andrey Antukh 2026-09-23 16:40:39 +00:00
parent 992170a9a4
commit d6abd2fecd
7 changed files with 342 additions and 14 deletions

View File

@ -73,7 +73,8 @@ one. Title or description fixes belong to Update mode.
Write the title and body following `mem:workflow/creating-prs` (title format,
description structure, writing principles) and `mem:workflow/creating-commits`
(commit type emojis). Derive the title and body from the commits and, when
there is one, from the issue body. Reference the issue with `Closes #NNNN`.
there is one, from the issue body. Include `Closes #NNNN` for readable context,
but do not treat it as the link; use `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` after creating the PR.
Repeat the `AI-assisted-by:` trailer in the body, once per model that worked
on the branch, so the PR states the assistance where a reviewer reads it. The
@ -92,6 +93,13 @@ gh pr create --repo penpot/penpot --base "<BASE>" --title "<TITLE>" \
repository default, which is wrong for a branch cut from `staging`. `--project
"Main"` is required by `mem:workflow/creating-prs`.
If an issue is present, run the explicit assignment and verification command
from `mem:workflow/creating-prs` before reporting success:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
### 5. Report
Report the PR URL and stop.
@ -108,6 +116,14 @@ gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-b
gh pr view <NUMBER> --repo penpot/penpot --json title,body
```
If the updated body contains `Closes #NNNN`, run the explicit assignment
command from `mem:workflow/creating-prs` and require its verification to
succeed:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
4. Report and stop.
## User context

View File

@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
- Explicitly linking a GitHub issue to a pull request and verifying both sides.
- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@ -73,6 +74,17 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
### `link-issue`
Explicitly assign a GitHub issue to a pull request and verify the relationship from both sides:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
# Short alias: python3 scripts/gh.py link <ISSUE_NUMBER> <PR_NUMBER>
```
The command resolves both node IDs, calls `addCloseIssueReferences`, and checks the issue's manually linked PRs and the PR's closing issue references. It is safe to rerun, works for merged PRs, and does not close an issue retroactively. JSON goes to stdout; progress and errors go to stderr; a missing link exits non-zero.
### `advisories`
List or inspect GitHub Security Advisories for the repository.

View File

@ -50,4 +50,4 @@ that breaks them is wrong, even if the code is right.
## Referencing Issues
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
Use `Closes #NNNN` (not `Fixes #NNNN`) in commit and PR body text for readable context. The explicit issue-to-PR link is assigned with `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` as described in `mem:workflow/creating-prs`; do not rely on the keyword to create that link.

View File

@ -271,21 +271,13 @@ the issue was sourced.
### Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
Add `Closes #<ISSUE_NUMBER>` to the PR body for readable context, then run the explicit assignment command from `mem:workflow/creating-prs`:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue —
it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
The command creates the GitHub Development link and verifies it from both the issue and PR. It is safe to rerun and does not close an issue retroactively when the PR is already merged. Do not rely on the body keyword as the assignment operation.
### Clean up

View File

@ -36,7 +36,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Closes #NNNN` for issues resolved by the PR, `Relates to #NNNN` for context, `Taiga #NNNN`); use the explicit issue assignment process below for each `Closes` issue;
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
@ -77,6 +77,12 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
## Explicit Issue Assignment
- For each GitHub issue that a PR resolves, run `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` after creating or editing the PR. Do not rely on `Closes #NNNN` in the body; it is only human-readable context.
- The command calls `addCloseIssueReferences`, verifies the relationship from both the issue and PR, and exits non-zero if either side is missing. It is safe to rerun and also works for an already merged PR; it does not close an issue retroactively.
- Skip this process for `Relates to #NNNN` and Taiga references, which do not represent a closing relationship.
## Before Opening
- Follow `mem:workflow/creating-commits` for commits

View File

@ -8,6 +8,7 @@ Subcommands:
issues List issues in a milestone (or unassigned with milestone=none)
prs Fetch details for one or more PRs (by number or milestone)
advisories List or inspect GitHub security advisories
link-issue Explicitly link a GitHub issue to a pull request
Usage:
python3 scripts/gh.py issues <milestone-title> (default: state=closed)
@ -27,6 +28,7 @@ Usage:
python3 scripts/gh.py advisories (list all advisories)
python3 scripts/gh.py advisories --severity critical (filter by severity)
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail)
python3 scripts/gh.py link-issue 11235 11243
Prerequisites:
- gh CLI authenticated (gh auth status)
@ -77,6 +79,141 @@ def run_gh_rest(path: str) -> Any:
return json.loads(result.stdout)
# ─────────────────────────────────────────────
# Subcommand: link-issue
# ─────────────────────────────────────────────
GQL_LINK_TARGETS_QUERY = """\
query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) { id number }
pullRequest(number: $prNumber) { id number }
}
}
"""
GQL_ADD_CLOSE_ISSUE_REFERENCES = """\
mutation($issueId: ID!, $pullRequestIds: [ID!]!) {
addCloseIssueReferences(input: {issueId: $issueId, pullRequestIds: $pullRequestIds}) {
issue { id number }
}
}
"""
GQL_VERIFY_ISSUE_LINK_QUERY = """\
query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
number
state
closedByPullRequestsReferences(
includeClosedPrs: true
userLinkedOnly: true
first: 100
) {
nodes { number state url }
}
}
pullRequest(number: $prNumber) {
number
state
closingIssuesReferences(first: 100) {
nodes { number state url }
}
}
}
}
"""
def link_issue_to_pr(issue_number: int, pr_number: int) -> dict:
"""Add and verify an explicit GitHub issue-to-PR link."""
if issue_number <= 0 or pr_number <= 0:
raise ValueError("issue and pull request numbers must be positive")
variables = {
"owner": OWNER,
"repo": REPO_NAME,
"issueNumber": issue_number,
"prNumber": pr_number,
}
target_data = run_gh_graphql(GQL_LINK_TARGETS_QUERY, variables)
repository = target_data.get("repository") or {}
issue = repository.get("issue") or {}
pull_request = repository.get("pullRequest") or {}
if not issue.get("id"):
raise RuntimeError(f"issue #{issue_number} was not found in {REPO}")
if not pull_request.get("id"):
raise RuntimeError(f"pull request #{pr_number} was not found in {REPO}")
mutation_data = run_gh_graphql(
GQL_ADD_CLOSE_ISSUE_REFERENCES,
{
"issueId": issue["id"],
"pullRequestIds": [pull_request["id"]],
},
)
mutation_result = mutation_data.get("addCloseIssueReferences") or {}
linked_issue = mutation_result.get("issue") or {}
if linked_issue.get("number") != issue_number:
raise RuntimeError(f"GitHub did not link issue #{issue_number}")
verification_data = run_gh_graphql(GQL_VERIFY_ISSUE_LINK_QUERY, variables)
repository = verification_data.get("repository") or {}
issue = repository.get("issue") or {}
pull_request = repository.get("pullRequest") or {}
if not issue or not pull_request:
raise RuntimeError("GitHub did not return both link targets during verification")
issue_links = [
node
for node in issue["closedByPullRequestsReferences"]["nodes"]
if node.get("number") == pr_number
]
pr_links = [
node
for node in pull_request["closingIssuesReferences"]["nodes"]
if node.get("number") == issue_number
]
if not issue_links or not pr_links:
raise RuntimeError(
f"issue #{issue_number} and pull request #{pr_number} are not linked"
)
return {
"linked": True,
"issue": {
"number": issue["number"],
"state": issue["state"],
"linked_pull_requests": issue_links,
},
"pull_request": {
"number": pull_request["number"],
"state": pull_request["state"],
"linked_issues": pr_links,
},
}
def cmd_link_issue(args: argparse.Namespace) -> None:
"""Handle the ``link-issue`` subcommand."""
print(
f"Linking issue #{args.issue_number} to pull request #{args.pr_number}...",
file=sys.stderr,
)
try:
result = link_issue_to_pr(args.issue_number, args.pr_number)
except (ValueError, RuntimeError) as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print(
f"Verified issue #{args.issue_number} -> pull request #{args.pr_number}",
file=sys.stderr,
)
print(json.dumps(result, indent=2))
# ─────────────────────────────────────────────
# Shared: milestone lookup
# ─────────────────────────────────────────────
@ -764,6 +901,16 @@ def main() -> None:
)
p_prs.set_defaults(func=cmd_prs)
# --- link-issue ---
p_link = sub.add_parser(
"link-issue",
aliases=["link"],
help="Explicitly link an issue to a pull request and verify both sides",
)
p_link.add_argument("issue_number", type=int, help="Issue number")
p_link.add_argument("pr_number", type=int, help="Pull request number")
p_link.set_defaults(func=cmd_link_issue)
# --- advisories ---
p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories")
p_adv.add_argument(

155
scripts/test_gh.py Normal file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Tests for scripts/gh.py.
Run with:
python3 scripts/test_gh.py
"""
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import pathlib
import sys
import types
import unittest
from unittest.mock import patch
# Loading scripts/gh.py should not emit scripts/__pycache__/.
sys.dont_write_bytecode = True
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "gh.py"
def load_gh():
"""Load scripts/gh.py as a module without running its CLI."""
loader = importlib.machinery.SourceFileLoader("gh_helper", str(SCRIPT_PATH))
spec = importlib.util.spec_from_loader("gh_helper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
gh = load_gh()
class LinkIssueTests(unittest.TestCase):
def setUp(self):
self.target_response = {
"repository": {
"issue": {"id": "issue-id", "number": 11235},
"pullRequest": {"id": "pr-id", "number": 11243},
}
}
self.mutation_response = {
"addCloseIssueReferences": {
"issue": {"id": "issue-id", "number": 11235}
}
}
self.verification_response = {
"repository": {
"issue": {
"number": 11235,
"state": "OPEN",
"closedByPullRequestsReferences": {
"nodes": [
{
"number": 11243,
"state": "MERGED",
"url": "https://github.com/penpot/penpot/pull/11243",
}
]
},
},
"pullRequest": {
"number": 11243,
"state": "MERGED",
"closingIssuesReferences": {
"nodes": [
{
"number": 11235,
"state": "OPEN",
"url": "https://github.com/penpot/penpot/issues/11235",
}
]
},
},
}
}
@patch.object(gh, "run_gh_graphql")
def test_link_issue_to_pr_adds_and_verifies_reference(self, run_graphql):
run_graphql.side_effect = [
self.target_response,
self.mutation_response,
self.verification_response,
]
result = gh.link_issue_to_pr(11235, 11243)
self.assertTrue(result["linked"])
self.assertEqual(
result["issue"]["linked_pull_requests"][0]["number"],
11243,
)
self.assertEqual(
result["pull_request"]["linked_issues"][0]["number"],
11235,
)
self.assertEqual(run_graphql.call_count, 3)
self.assertEqual(
run_graphql.call_args_list[1].args[1],
{"issueId": "issue-id", "pullRequestIds": ["pr-id"]},
)
@patch.object(gh, "run_gh_graphql")
def test_link_issue_to_pr_fails_when_verification_is_missing(self, run_graphql):
self.verification_response["repository"]["issue"][
"closedByPullRequestsReferences"
]["nodes"] = []
run_graphql.side_effect = [
self.target_response,
self.mutation_response,
self.verification_response,
]
with self.assertRaisesRegex(RuntimeError, "are not linked"):
gh.link_issue_to_pr(11235, 11243)
@patch.object(gh, "link_issue_to_pr")
def test_cmd_link_issue_outputs_verified_result(self, link_issue):
expected = {
"linked": True,
"issue": {"number": 11235, "state": "OPEN"},
"pull_request": {"number": 11243, "state": "MERGED"},
}
link_issue.return_value = expected
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
gh.cmd_link_issue(args)
self.assertEqual(json.loads(stdout.getvalue()), expected)
self.assertIn("Verified issue #11235", stderr.getvalue())
link_issue.assert_called_once_with(11235, 11243)
@patch.object(gh, "link_issue_to_pr", side_effect=RuntimeError("link missing"))
def test_cmd_link_issue_fails_when_verification_is_missing(self, _link_issue):
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as error:
gh.cmd_link_issue(args)
self.assertEqual(error.exception.code, 1)
self.assertIn("link missing", stderr.getvalue())
if __name__ == "__main__":
unittest.main(verbosity=2)