diff --git a/.changes/source-control/fix-fork-repo-resolution-3f9c2a71.md b/.changes/source-control/fix-fork-repo-resolution-3f9c2a71.md new file mode 100644 index 00000000000..44cb8d13a2e --- /dev/null +++ b/.changes/source-control/fix-fork-repo-resolution-3f9c2a71.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- In a fork checkout, `pull-request` create, ready and evidence no longer leave the target repository to `gh`, which can pick upstream. Each phase resolves it once with the new `scripts/target-repo.sh`, prints it, and names it in every write. Ready and create export it as `GH_REPO` once it is resolved, so their later reads, and the scripts they run, target it too; evidence resolves it first and names it on every read. `branch-update-signals.sh` without `--repo` now reads the repository through `gh api`, which follows `GH_REPO`, instead of `gh repo view`, which ignores it. A fork checkout resolves to the fork even when `gh` records upstream as its default, but only a fork you can push to, so a maintainer who adds a contributor's fork as a remote keeps writing to the recorded default; a failed repository read stops the phase instead of falling back to upstream. Remotes naming two or more GitHub repositories, none a fork of another that you can push to, with no `gh repo set-default` recorded, are refused with that fix. +- The create provider reference no longer says the worktree-anchored default-branch read returns the fork's branch: it returns the branch of whichever repository `gh` resolves, which can be upstream. diff --git a/.changes/work-items/fix-fork-repo-resolution-8d41e6b0.md b/.changes/work-items/fix-fork-repo-resolution-8d41e6b0.md new file mode 100644 index 00000000000..46f46494c3b --- /dev/null +++ b/.changes/work-items/fix-fork-repo-resolution-8d41e6b0.md @@ -0,0 +1,9 @@ +--- +bump: patch +--- + +### Fixed + +- `setup check` now reports when the remotes name more than one GitHub repository and no `gh repo set-default` is recorded, or the recorded default is not the fork, and prints the `gh repo set-default /` fix. +- The tracker's `CONTRACT.md`, the GitHub adapter and the morning briefing script's comments describe `gh`'s real repository order (`GH_REPO`, the recorded default, then the first remote by name) instead of "the working directory's git remote". +- Plan comments, schedule reconciliation, label provisioning and the auto-merge disarm name the repository with `-R` instead of leaving it to `gh`. diff --git a/plugins/source-control/reference/providers/github/README.md b/plugins/source-control/reference/providers/github/README.md index f8160dbcfa5..9117c04b6f1 100644 --- a/plugins/source-control/reference/providers/github/README.md +++ b/plugins/source-control/reference/providers/github/README.md @@ -25,6 +25,50 @@ A pull request is GitHub's change request; this file uses its terms. | The repository's default branch | `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`; in a sandboxed session, `gh api "repos/{owner}/{repo}" --jq '.default_branch'` | | Read a tracked file from a repository's default branch | `gh api repos///contents/` (the `content` field is base64) | +### Target repository + +A `gh` command given no repository does not simply use `origin`. It takes `-R` or `GH_REPO` +first, then the remote `gh repo set-default` recorded, and without either, when it cannot +prompt (an agent's shell, or any `$(...)` capture), the first remote in its own order by remote +name, where `upstream` comes before `origin`. A stock `gh repo clone` of a fork records `upstream` +as the default. So in a fork checkout a bare write can land on the upstream repository. + +Each phase that writes resolves the repository once, before its first write (ready and evidence +before their first read too), with `bash "/scripts/target-repo.sh" [--dir ]` +(the pull-request skill's script), reports the `target repository:` line it prints, and targets +that repository with every later read and write of the phase. Each write names it: +`-R /` on `gh pr` and `gh issue`, `repos///...` on `gh api`, as +[merge](pull-request/merge.md#merge-command) already does. A phase that runs shell blocks also +exports `GH_REPO` with it, which every other `gh` call follows, the scripts the phase runs +included, and which `{owner}/{repo}` placeholders expand from. `gh repo view` with no argument +ignores `GH_REPO` (gh 2.103.3), so a script that needs the repository reads +`gh api 'repos/{owner}/{repo}' --jq .full_name` instead. The script resolves in this order: + +1. `GH_REPO`, when set: the caller's explicit choice, or the repository an earlier block resolved. +2. When the remotes name two or more github.com repositories, the fork among them: a remote's + repository the caller can push to (`.permissions.push`) whose parent (`.parent.full_name`, + both from `gh api repos//`) is the recorded default or another remote's + repository, `origin` checked first. A fork checkout works on the fork, even when a stock fork + clone recorded upstream as gh's default; a contributor's fork added as a remote does not + capture a maintainer's writes. A failed read of a repository stops the script with exit 1. +3. The recorded default. +4. The one github.com repository the remotes name. +5. With no github.com remote, gh's own resolution. + +It refuses when the remotes cannot decide: two or more github.com repositories, none a fork of +another that the caller can push to, and no default recorded. It then prints the `gh repo set-default /` fix, +and the refusal stops the phase: which repository is the target is the user's choice, not the +script's. A pull request meant for upstream from a fork names its base repository explicitly +([out-of-tree create](pull-request/create.md#open-the-pr-from-an-out-of-tree-orchestrator), +triangular flow). + +- **Pointer**: [`gh repo set-default`](https://cli.github.com/manual/gh_repo_set-default), + [`gh repo clone`](https://cli.github.com/manual/gh_repo_clone) and + [`gh help environment`](https://cli.github.com/manual/gh_help_environment) (`GH_REPO`). +- **As of**: 2026-11-11, gh 2.103.3. +- **Recheck trigger**: a gh release note that changes how commands pick the base repository or + what `gh repo set-default --view` prints. + ## Resolve a pull request | Operation | GitHub command | @@ -49,12 +93,12 @@ entry and pass it to every later `gh` call in that phase. | Operation | GitHub command | |---|---| -| Open a draft PR | `gh pr create --draft` (with `--head ` on the PR-only entry), never `--reviewer` | +| Open a draft PR | `gh pr create -R / --draft` (with `--head ` on the PR-only entry), never `--reviewer` | | Check out the PR head | `gh pr checkout `, or `gh pr checkout --detach` when the branch is locked in another worktree. It is fork-safe: a fork's head branch is not fetchable from `origin` by name | -| Merge the base into the head | `gh api --method PUT repos/{owner}/{repo}/pulls//update-branch -f expected_head_sha=`, pinned to the decision's head (pull-request `ready-for-review.md`) | -| Flip a draft ready | `gh pr ready `, run only inside `/source-control:pull-request ready`, never bare | +| Merge the base into the head | `gh api --method PUT repos///pulls//update-branch -f expected_head_sha=`, pinned to the decision's head (pull-request `ready-for-review.md`) | +| Flip a draft ready | `gh pr ready -R /`, run only inside `/source-control:pull-request ready`, never bare | | Merge a PR | `gh pr merge -R /` with the PR number and repository written literally, plus the flags the repository needs: `--squash`, `--auto` on a merge-queue or auto-merge base, `--admin` only where a code-owner review or merge queue blocks an otherwise ready merge and the consumer's policy allows it; `--match-head-commit ` is optional for an immediate merge and required with `--auto`. A refused merge is reported, never handed to the user or retried in another shape ([pull-request/merge.md](pull-request/merge.md#merge-command); pull-request `merge.md` §4.2.1 owns when to merge without asking). A babysit lane merges only through its merge-gate wrapper (babysit-prs `safety.md`) | -| Open a tracking issue | `gh issue create` | +| Open a tracking issue | `gh issue create -R /` | ## Checks and CI logs @@ -85,7 +129,7 @@ conversation comments carry only `created_at`. |---|---| | React to a comment | `POST` to the comment's `/reactions` endpoint (`+1`, `-1`, `eyes`); verify with a `GET` on the same endpoint. Review bodies have no reactions endpoint | | Reply on an inline thread | `gh api repos///pulls//comments//replies -F body=@` | -| Reply on the conversation | `gh pr comment --body-file ` | +| Reply on the conversation | `gh pr comment -R / --body-file ` | | Verify an inline reply | the inline surface filtered with `--jq '.[] \| select(.in_reply_to_id == )'` | | Verify a conversation reply (D5, before any fix) | the conversation surface filtered with `--jq '.[] \| select(.user.login == "") \| .body'` | | Verify a follow-up reply citing the fix | the conversation surface filtered with `--jq '.[] \| select((.body \| contains("")) and .user.login == "") \| .body'`, never `.[-1]` | diff --git a/plugins/source-control/reference/providers/github/pull-request/create.md b/plugins/source-control/reference/providers/github/pull-request/create.md index db20042bb9b..664ed507a74 100644 --- a/plugins/source-control/reference/providers/github/pull-request/create.md +++ b/plugins/source-control/reference/providers/github/pull-request/create.md @@ -93,8 +93,11 @@ from `gh api "repos/{owner}/{repo}/issues/${REFS_NUM}"` (state `open`) and the s ## Placeholder anchoring -`gh api` has no `--repo` flag. `{owner}` and `{repo}` expand from the repository of the **current -directory**, or from `GH_REPO`. Out of tree, run the call from the worktree +`gh api` has no `--repo` flag. `{owner}` and `{repo}` expand from `GH_REPO`, else from the +repository gh resolves for the **current directory**, which in a fork checkout can be upstream +([target repository](../README.md#target-repository)). Once `target-repo.sh` has resolved the +checkout's repository, the calls below name `$REPO` and the exported `GH_REPO` expands the +placeholders. Before that, out of tree, run a placeholder call from the worktree (`( cd "$WT" && gh api … )`) or prefix `GH_REPO=/`. ## Body overrides the template @@ -103,6 +106,20 @@ A body passed with `gh pr create --body-file` (or `--body`) fully overrides `.github/PULL_REQUEST_TEMPLATE.md` (cli/cli #10751). Create passes no `--attach` (gh 2.99.0 and later): captures go to the PR through the pull-request skill's `evidence` action. +## Resolve the target repository + +```bash +REPO=$(bash "/scripts/target-repo.sh" --dir "") || exit 1 +export GH_REPO="$REPO" +``` + +A non-zero exit opens no PR: report the script's stderr, which names the failed repository read, +or the `gh repo set-default` fix when the remotes name more than one repository, none a pushable +fork of another, and no default is recorded ([target repository](../README.md#target-repository)). +From here on every read and write targets `$REPO`: the writes name it, and the exported `GH_REPO` +carries it to every other `gh` call. On a triangular flow the base repository takes `$REPO`'s +place in both. + ## Open the draft PR ```bash @@ -110,7 +127,7 @@ later): captures go to the PR through the pull-request skill's `evidence` action # No reviewers are auto-requested by default — `gh pr create` runs without # `--reviewer`. # Always a draft: a `ready` open state is reached by Phase 2.5's flip. -PR_URL=$(gh pr create --draft --head "$BRANCH" --title ": " --body-file "$BODY_FILE") +PR_URL=$(gh pr create -R "$REPO" --draft --head "$BRANCH" --title ": " --body-file "$BODY_FILE") # Extract PR number from URL (gh pr create outputs the URL on success). # This number is the source of truth for the rest of this phase — pass it @@ -133,8 +150,8 @@ nothing. `POST /repos/{owner}/{repo}/pulls` is REST and works. It requires `head - **`draft` is a boolean, so it goes with `-F`.** `-f draft=true` sends the string `"true"`, which the API rejects; `-F` is the flag that type-converts it. The other literal fields (`title`, `head`, `base`) stay on `-f`, so a title that reads `true` or a number is still sent as a string. ```bash -BASE=$(gh api "repos/{owner}/{repo}" --jq '.default_branch') -PR_JSON=$(gh api --method POST "repos/{owner}/{repo}/pulls" \ +BASE=$(gh api "repos/$REPO" --jq '.default_branch') +PR_JSON=$(gh api --method POST "repos/$REPO/pulls" \ -f title=": " \ -f head="$BRANCH" \ -f base="$BASE" \ @@ -153,31 +170,33 @@ The REST form has no hook backstop: `pr-body-linkage-gate.sh` matches `gh pr cre ## PR identity ```bash -gh pr view --json number,url +gh pr view "$PR_NUMBER" -R "$REPO" --json number,url ``` In a sandboxed session, take identity from the create response, or re-read it with -`gh api "repos/{owner}/{repo}/pulls/" --jq '{number, html_url}'`. +`gh api "repos/$REPO/pulls/" --jq '{number, html_url}'`. ## Open the PR from an out-of-tree orchestrator -`gh pr create` MUST pass `--head "$BRANCH"` explicitly, since the invoker is not on the branch: +Resolve the repository for the worker's checkout, not the orchestrator's: +`REPO=$(bash "/scripts/target-repo.sh" --dir "$WT") || exit 1`, then +`export GH_REPO="$REPO"`. `gh pr create` MUST pass `--head "$BRANCH"` explicitly, since the +invoker is not on the branch: ```bash -PR_URL=$(gh pr create --draft --head "$BRANCH" --title ": " --body-file "$BODY_FILE") +PR_URL=$(gh pr create -R "$REPO" --draft --head "$BRANCH" --title ": " --body-file "$BODY_FILE") ``` -In a sandboxed session that 403s, the REST form, run from the worktree: +In a sandboxed session that 403s, the REST form, which names `$REPO` and so needs no `cd`: ```bash -PR_JSON=$( cd "$WT" && gh api --method POST "repos/{owner}/{repo}/pulls" \ - -f title=": " -f head="$BRANCH" -f base="$BASE" -F body=@"$BODY_FILE" -F draft=true ) +BASE=$(gh api "repos/$REPO" --jq '.default_branch') +PR_JSON=$(gh api --method POST "repos/$REPO/pulls" \ + -f title=": " -f head="$BRANCH" -f base="$BASE" -F body=@"$BODY_FILE" -F draft=true) ``` -Resolve the base branch the same anchored way: -`BASE=$( cd "$WT" && gh api "repos/{owner}/{repo}" --jq '.default_branch' )`. - -On a triangular flow, name the base repository explicitly and namespace `head`: +On a triangular flow, the base repository takes `$REPO`'s place, named explicitly, and `head` is +namespaced: ```bash BASE_REPO="/" # the PR's target, not the push destination @@ -188,5 +207,7 @@ PR_JSON=$(GH_REPO="$BASE_REPO" gh api --method POST "repos/{owner}/{repo}/pulls" `GH_REPO` overrides the cwd-derived placeholders, so this form needs no `cd`. On a triangular flow resolve `$BASE` through `GH_REPO` too, as -`BASE=$(GH_REPO="$BASE_REPO" gh api "repos/{owner}/{repo}" --jq '.default_branch')`, not through the -`cd "$WT"` form above, which would read the fork's default branch. +`BASE=$(GH_REPO="$BASE_REPO" gh api "repos/{owner}/{repo}" --jq '.default_branch')`, never from the +worktree's own resolution: that reads the default branch of whichever repository gh resolves for +the checkout, the fork or, where a fork clone recorded `upstream` as gh's default, upstream, and +neither is guaranteed to be `$BASE_REPO`. diff --git a/plugins/source-control/reference/providers/github/pull-request/ready-for-review.md b/plugins/source-control/reference/providers/github/pull-request/ready-for-review.md index 404b757c5e6..96e7e3bd9fe 100644 --- a/plugins/source-control/reference/providers/github/pull-request/ready-for-review.md +++ b/plugins/source-control/reference/providers/github/pull-request/ready-for-review.md @@ -7,9 +7,17 @@ and `gh` facts behind them. ## Find the PR for the branch +The repository is resolved once, before the PR, and every read and write below targets it +([target repository](../README.md#target-repository)): the writes name it, and the exported +`GH_REPO` carries it to every other `gh` call, the scripts' included. A shell that runs a later +block runs this block's first two lines first. A non-zero exit from `target-repo.sh` ends the phase with its stderr +reported. + ```bash +REPO=$(bash "/scripts/target-repo.sh") || exit 1 +export GH_REPO="$REPO" BRANCH=$(git branch --show-current) -PR_NUMBER=$(gh pr view "$BRANCH" --json number -q '.number' 2>/dev/null) +PR_NUMBER=$(gh pr view "$BRANCH" -R "$REPO" --json number -q '.number' 2>/dev/null) ``` ## Read the draft flag @@ -86,7 +94,7 @@ DECISION_HEAD=$(jq -r .head_sha <<<"$DECISION") || exit 1 [ -n "$DECISION_HEAD" ] && [ "$DECISION_HEAD" != unknown ] || exit 1 UPDATED=0 -if gh api --method PUT "repos/{owner}/{repo}/pulls/$PR_NUMBER/update-branch" \ +if gh api --method PUT "repos/$REPO/pulls/$PR_NUMBER/update-branch" \ -f expected_head_sha="$DECISION_HEAD" >/dev/null; then for _ in $(seq 12); do # 12 polls x 5 s = 60 s NOW=$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid') @@ -158,7 +166,7 @@ REQUESTED=$(gh pr view "$PR_NUMBER" --json reviewRequests -q '.reviewRequests | echo '```text' cat "$RANGE_DIFF_FILE" echo '```' -} | gh pr comment "$PR_NUMBER" --body-file - +} | gh pr comment "$PR_NUMBER" -R "$REPO" --body-file - ``` ## Read the diff @@ -194,7 +202,7 @@ build posts nothing: LOGIN=$(gh api user --jq .login) || exit 1 COMMENT=$(bash "/scripts/ready-skip-comment.sh" --login "$LOGIN" \ --reason "$REASON" --step code-review) || exit 1 -printf '%s\n' "$COMMENT" | gh pr comment "$PR_NUMBER" --body-file - +printf '%s\n' "$COMMENT" | gh pr comment "$PR_NUMBER" -R "$REPO" --body-file - ``` `check-ready-holds.sh` reads the title, body, labels and closing issue references, never @@ -256,7 +264,7 @@ RUNS_BEFORE=$(gh run list --commit "$HEAD_SHA" --event pull_request -L 100 \ [ "$HEAD_MATCH" = 1 ] && [ "$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')" = "$HEAD_SHA" ] && bash "/scripts/verify-gate.sh" check --head "$HEAD_SHA" && - gh pr ready "$PR_NUMBER" + gh pr ready "$PR_NUMBER" -R "$REPO" ``` The two head tests exit 1 when the PR's head on GitHub is not `HEAD_SHA`: the wait never saw it @@ -276,7 +284,7 @@ route, guarded the same way: [ "$HEAD_MATCH" = 1 ] && [ "$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')" = "$HEAD_SHA" ] && bash "/scripts/verify-gate.sh" check --head "$HEAD_SHA" && - gh api --method POST "repos/{owner}/{repo}/pulls/$PR_NUMBER/ccr/ready_for_review" + gh api --method POST "repos/$REPO/pulls/$PR_NUMBER/ccr/ready_for_review" ``` Or the GitHub MCP `update_pull_request` call with `draft: false`. It is a tool call, so a shell @@ -316,7 +324,7 @@ help: ```bash RUNS_BEFORE=$(gh run list --commit "$HEAD_SHA" --event pull_request -L 100 \ --json databaseId -q 'map(.databaseId)') || RUNS_BEFORE='[]' -gh pr ready "$PR_NUMBER" --undo && gh pr ready "$PR_NUMBER" && ready_run_seen +gh pr ready "$PR_NUMBER" -R "$REPO" --undo && gh pr ready "$PR_NUMBER" -R "$REPO" && ready_run_seen ``` Exit 0 after the toggle means a new run appeared; report it as "a new run appeared", never as a diff --git a/plugins/source-control/scripts/branch-update-signals.sh b/plugins/source-control/scripts/branch-update-signals.sh index c93917c1308..a0e16fc06d1 100755 --- a/plugins/source-control/scripts/branch-update-signals.sh +++ b/plugins/source-control/scripts/branch-update-signals.sh @@ -90,7 +90,8 @@ count_answer() { } load() { - [[ -n "$REPO" ]] || REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | tr -d '\r') + # gh api's placeholders follow GH_REPO; gh repo view with no argument ignores it. + [[ -n "$REPO" ]] || REPO=$(api 'repos/{owner}/{repo}' --jq .full_name) if [[ -n "$PR" ]]; then PULL=$(api "repos/$REPO/pulls/$PR") COMMITS=$(paged "repos/$REPO/pulls/$PR/commits") diff --git a/plugins/source-control/scripts/branch-update-signals.test.sh b/plugins/source-control/scripts/branch-update-signals.test.sh index fca05bd91d1..dc44b15a050 100755 --- a/plugins/source-control/scripts/branch-update-signals.test.sh +++ b/plugins/source-control/scripts/branch-update-signals.test.sh @@ -42,7 +42,7 @@ if [[ -n ${FAKE_REFUSE:-} && "$*" =~ $FAKE_REFUSE ]]; then fi slurp() { [[ -f "$FAKE/$1" ]] && printf '[%s]\n' "$(cat "$FAKE/$1")" || echo '[[]]'; } case "$*" in - "repo view --json nameWithOwner"*) echo example-org/app ;; + "api repos/{owner}/{repo} --jq .full_name") echo "${GH_REPO:-upstream-org/app}" ;; "repo view "*) cat "$FAKE/repo" ;; "pr list "*) cat "$FAKE/dependents" ;; *"/protection/required_status_checks") @@ -483,6 +483,15 @@ reset_fake "$(git -C "$WC" rev-parse HEAD)" LABEL="other merge" run "$WC" --pr 7 --repo example-org/merged-other expect "base_merged: a merge of another branch" base_merged no +######################################################################## +# No --repo: the repository comes from GH_REPO, not gh's recorded default +######################################################################## +WC="$WORKDIR/app" +reset_fake "$SHA" +GH_REPO=example-org/app LABEL="GH_REPO" run "$WC" --pr 7 +assert_contains "GH_REPO: the pull request is read from GH_REPO's repository" "$(cat "$FAKE/calls")" "api repos/example-org/app/pulls/7" +expect "GH_REPO: head_sha read" head_sha "$SHA" + ######################################################################## # Refused reads: every field unknown, nothing fails ######################################################################## diff --git a/plugins/source-control/skills/pull-request/reference/create.md b/plugins/source-control/skills/pull-request/reference/create.md index 2d0bc5dfd2d..22844c0a76d 100644 --- a/plugins/source-control/skills/pull-request/reference/create.md +++ b/plugins/source-control/skills/pull-request/reference/create.md @@ -189,7 +189,7 @@ and intent line uses when no plan is found. **Sandboxed sessions: run this check over REST.** The CLI's issue read routes through GitHub's GraphQL API, so where the GitHub proxy refuses that read it fails with `HTTP 403` whatever the credentials. The default block swallows that 403 and leaves `ISSUE_STATE` empty, reporting a live, open issue as "missing or not open" and dropping the `Closes #N` line §2.4.2.1 gates on. Substitute the REST issue read, which reports `state` in lower case and returns the body as well ([GitHub](../../../reference/providers/github/pull-request/create.md#issue-state-over-rest), with the evidence and its recheck trigger). -**Placeholder anchoring.** A REST call's `{owner}` and `{repo}` placeholders expand from the repository of the **current directory** ([GitHub](../../../reference/providers/github/pull-request/create.md#placeholder-anchoring)). That is safe on the normal `create` path, whose cwd is the branch's own clone, but not under §2.7, where the orchestrator sits out-of-tree: there, run the call from `$WT` (the subshell form §2.7 uses for `resolve-remote.sh`) or name the repository explicitly. The same rule governs the REST PR-create fallback in §2.4.3. +**Placeholder anchoring.** A REST call's `{owner}` and `{repo}` placeholders expand from the repository the forge CLI resolves for the **current directory**, which in a fork checkout is not always `origin` ([GitHub](../../../reference/providers/github/pull-request/create.md#placeholder-anchoring)). Under §2.7, where the orchestrator sits out-of-tree, run the call from `$WT` (the subshell form §2.7 uses for `resolve-remote.sh`) or name the repository explicitly. The REST PR-create fallback in §2.4.3 names the repository `target-repo.sh` resolved instead. **Multi-issue PR (same branch closes 2+ issues):** after primary line is set, ask user inline: @@ -614,10 +614,17 @@ fi bash "/scripts/push-branch.sh" --check --dir "" "$BRANCH" || exit 1 ``` -Then open the PR as a draft from head `$BRANCH`, with the title and the body file, and capture `PR_URL` and `PR_NUMBER` +Then resolve the repository the PR opens on, once, from `` with +`scripts/target-repo.sh`, and report the `target repository:` line it prints +([GitHub](../../../reference/providers/github/pull-request/create.md#resolve-the-target-repository)). +In a fork checkout it resolves to the fork, when the caller can push to it. A non-zero exit opens +no PR: report its stderr, which names the failed repository read, or the fix when the remotes name +more than one repository, none a pushable fork of another, and none is recorded as the default. On a triangular flow (§2.7) the base +repository, named explicitly, stands in for the script's result. Open the PR as a draft on that repository from +head `$BRANCH`, with the title and the body file, and capture `PR_URL` and `PR_NUMBER` ([GitHub](../../../reference/providers/github/pull-request/create.md#open-the-draft-pr)). That -number is the source of truth for the rest of this phase: pass it explicitly to every later forge -call. +repository and number are the source of truth for the rest of this phase: every later forge read +and write targets that repository, and names the number. - **Identity:** the human PR author by default. If the consuming project routes automation writes through a bot identity wrapper, use it for comments and reactions; the human account normally @@ -703,11 +710,11 @@ BRANCH=$(git -C "$WT" branch --show-current) - **§2.4.0 (`Closes #N`), §2.4.1 (body assembly), §2.4.2 (pre-create gates):** run unchanged, except every `git`/diff read is anchored with `git -C "$WT"` and the branch is `$BRANCH`, never the session branch. In §2.4.0 this means passing `$BRANCH` as `parse-branch-issue.sh`'s explicit first positional and running it with `CLAUDE_PROJECT_DIR="$WT"` (`CLAUDE_PROJECT_DIR="$WT" parse-branch-issue.sh "$BRANCH" ['']`), so it reads the worktree's own source-control config layers, not the orchestrator's. The script defaults to `git branch --show-current` **in its own process**, which `git -C` cannot redirect, so leaving it implicit would parse `Closes #N` from the orchestrator's branch and silently drop the linkage. - **§2.4.3 (create):** the PR create MUST name the head `$BRANCH` explicitly, since the invoker is not on the branch ([GitHub](../../../reference/providers/github/pull-request/create.md#open-the-pr-from-an-out-of-tree-orchestrator)). §2.4.3's open-state step reads `docs/conventions/source-control.yaml` at the PR's base branch, never from `$WT` or the worker's branch. A `--pushed` run is unattended but follows the resolved state: a resolved `ready` runs Phase 2.5 as `ready "$PR_NUMBER" --unattended`, whose holds still apply. It runs with `$WT` as its working directory: every Phase 2.5 command takes the `( cd "$WT" && … )` form this section uses for `resolve-remote.sh`, and every forge call names `$PR_NUMBER`, the PR this run just opened. It stops when the branch in `$WT` resolves a different PR. Its code and security reviews run in fresh-context subagents at their own model binding ([ready-for-review.md](ready-for-review.md) §2.5.3); a caller that needs the base update and the fixes at a stronger tier invokes `create --pushed` from an agent dispatched at that tier. - In a sandboxed session that 403s, substitute §2.4.3's REST form and anchor it, because the `{owner}`/`{repo}` placeholders expand from the current directory, which here is not the target repository. Run it from the worktree, in the subshell form this section uses for `resolve-remote.sh`. + §2.4.3's `target-repo.sh` runs with `--dir "$WT"`, so the repository comes from the worker's checkout, not the orchestrator's. In a sandboxed session that 403s, substitute §2.4.3's REST form, which names that repository and needs no anchoring. - `$BASE` needs its own resolution: §2.2 is skipped in this mode, so nothing set a default branch. Resolve it the same anchored way. + `$BASE` needs its own resolution: §2.2 is skipped in this mode, so nothing set a default branch. Resolve it from the same repository. - **On a triangular flow, anchoring to the worktree is not enough.** Where the worker pushed to a fork and the PR targets an upstream base, the placeholders resolve against the worktree's own remotes, so the REST create can silently post to the **fork**, opening a PR against the fork's default branch instead of upstream. Name the base repository (the PR's target, not the push destination) explicitly and namespace `head` as `:$BRANCH`, per §2.4.3. Resolve `$BASE` against that base repository too, never through the worktree-anchored form, which would read the fork's default branch. The provider reference holds each of these commands. + **On a triangular flow, the worktree's repository is not the target.** Where the worker pushed to a fork and the PR targets an upstream base, `target-repo.sh` resolves the worktree to the fork, and gh's own resolution to whichever repository it records, neither of which need be that base, so the create can post to the wrong repository's default branch. Name the base repository (the PR's target, not the push destination) explicitly in `$REPO`'s place and namespace `head` as `:$BRANCH`, per §2.4.3. Resolve `$BASE` against that base repository too, never through the worktree's own resolution. The provider reference holds each of these commands. - **§2.5 / §2.6:** record expected workflows, report the PR URL + number, and stop. §2.6's arming step is skipped: the watch is session-scoped ([monitor.md](monitor.md) §3.0.1), so it belongs to the session acting on the events. diff --git a/plugins/source-control/skills/pull-request/reference/evidence.md b/plugins/source-control/skills/pull-request/reference/evidence.md index 3b38d5c4a00..a1d4ad67307 100644 --- a/plugins/source-control/skills/pull-request/reference/evidence.md +++ b/plugins/source-control/skills/pull-request/reference/evidence.md @@ -38,9 +38,12 @@ scans it with the rest of the rendered body. ## Steps -1. **Head, body and verdict.** Read the PR's number, URL, head commit and body - (`gh pr view --json number,url,headRefOid,body`). The acceptance criteria are the list - items under the body's `## Acceptance criteria` heading, the section +1. **Head, body and verdict.** Resolve the PR's repository once with `scripts/target-repo.sh` + (`--dir ` out of tree), report the `target repository:` line it prints, and stop on + a non-zero exit with its stderr reported; every forge read and write below names that + `/`. Read the PR's number, URL, head commit and body + (`gh pr view -R / --json number,url,headRefOid,body`). The acceptance + criteria are the list items under the body's `## Acceptance criteria` heading, the section [create.md](create.md) §2.4.1 writes; a body with no such heading, or none under it, has none. Then run `bash "/scripts/confirm-verdict.sh" show --head `. Go on only when it prints @@ -96,10 +99,9 @@ scans it with the rest of the rendered body. pattern), never the matched text. A `values:` source that fails or yields no value, or a scan that cannot run, posts nothing either. 6. **One comment per identity.** `gh pr comment --edit-last` edits the last comment of the - account `gh` is logged in as, so that account must post nothing else on the PR. Read its login - (`gh api user --jq .login`), then its comments on the PR in order - (`gh api --paginate repos/{owner}/{repo}/issues//comments --jq '.[] | select(.user.login == "") | {id, evidence: (.body | startswith(""))}'`, - run from the checkout; `gh api` has no `--repo`, so set `GH_REPO=/` out of tree). + account `gh` is logged in as, so that account must post nothing else on the PR. Read the login + (`gh api user --jq .login`), then its comments on the PR in order, in step 1's repository + (`gh api --paginate repos///issues//comments --jq '.[] | select(.user.login == "") | {id, evidence: (.body | startswith(""))}'`). When any of the account's comments does not start with the marker ``, or more than one does, post nothing: an edit would overwrite another comment, or the PR would carry two. Name the account and say the step needs an @@ -119,7 +121,7 @@ scans it with the rest of the rendered body. 8. **Post or update** with one call that names each capture to attach: ```bash - gh pr comment --edit-last --create-if-none --body-file "" --attach "" --attach "" + gh pr comment -R / --edit-last --create-if-none --body-file "" --attach "" --attach "" ``` It edits the account's last comment, the evidence comment step 6 found, or creates it when @@ -129,8 +131,8 @@ scans it with the rest of the rendered body. found, when there is one, and post a new one, so the PR still carries one evidence comment: ```bash - gh pr comment --delete-last --yes - gh pr comment --body-file "" --attach "" + gh pr comment -R / --delete-last --yes + gh pr comment -R / --body-file "" --attach "" ``` With no evidence comment yet, run only the second command. Run the delete exactly as written, diff --git a/plugins/source-control/skills/pull-request/reference/ready-for-review.md b/plugins/source-control/skills/pull-request/reference/ready-for-review.md index ce72e257eca..6b6531cfbc8 100644 --- a/plugins/source-control/skills/pull-request/reference/ready-for-review.md +++ b/plugins/source-control/skills/pull-request/reference/ready-for-review.md @@ -12,7 +12,10 @@ Read it there; this phase cites it and never states a different one. ## 2.5.1 Refuse without a pull request -Set `BRANCH` to the current branch and look up its PR number into `PR_NUMBER` +Resolve the repository once with `scripts/target-repo.sh` and report the `target repository:` line +it prints; a non-zero exit stops the phase with its stderr reported. Every later forge read and +write in this phase targets that repository, even when the PR number came with the invocation. Then set +`BRANCH` to the current branch and look up its PR number on that repository into `PR_NUMBER` ([GitHub](../../../reference/providers/github/pull-request/ready-for-review.md#find-the-pr-for-the-branch)). Empty: stop and report; there is nothing to flip and no head a reviewer can reach. Route to diff --git a/plugins/source-control/skills/pull-request/scripts/ready-flip.test.sh b/plugins/source-control/skills/pull-request/scripts/ready-flip.test.sh index f5ea394e7c1..df37b274820 100755 --- a/plugins/source-control/skills/pull-request/scripts/ready-flip.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/ready-flip.test.sh @@ -113,14 +113,14 @@ STUB_BRANCH=$(git -C "$REPO" branch --show-current) run_flow() { (cd "${FLOW_CWD:-$REPO}" && PATH="$STUB_DIR:$PATH" STUB_STATE="$STATE" STUB_STALE="$1" STUB_PUSHED="$PUSHED" \ STUB_FORGE="${3:-$PUSHED}" STUB_BRANCH="$STUB_BRANCH" STUB_OID="${STUB_OID:-$BEFORE}" \ - READY_VERIFY_DIR="$STATE/verify" PR_NUMBER=5 HEAD_BEFORE="$2" timeout 30 bash "$FLOW" 2>&1) + READY_VERIFY_DIR="$STATE/verify" PR_NUMBER=5 REPO=acme/tool HEAD_BEFORE="$2" timeout 30 bash "$FLOW" 2>&1) } new_state "$PUSHED" out=$(run_flow "$BEFORE" "$BEFORE") assert_contains "lagging forge: the wait finds the pushed commit's runs" "$out" "WAITED=1 HEAD_SHA=$PUSHED" assert_contains "lagging forge: the guarded flip passes on the pushed commit" "$out" "FLIP_EXIT=0" -assert_eq "lagging forge: the PR is flipped once" "pr ready 5" "$(cat "$STATE/flipped" 2>/dev/null)" +assert_eq "lagging forge: the PR is flipped once" "pr ready 5 -R acme/tool" "$(cat "$STATE/flipped" 2>/dev/null)" assert_not_contains "lagging forge: no run list asks about the pre-push commit" "$(cat "$STATE/runs")" "$BEFORE" assert_contains "lagging forge: the confirmation sees the ready run, so no toggle" "$out" "CONFIRM=0" @@ -150,7 +150,7 @@ READY_VERIFY_DIR="$STATE/verify" bash "$SKILL_DIR/scripts/verify-gate.sh" check assert_exit "a NEEDS WORK verdict leaves the head's verify pass as it was" 0 "$?" out=$(CONFIRM_VERDICT_DIR="$STATE/confirm" run_flow "$BEFORE" "$BEFORE") assert_contains "a NEEDS WORK verdict for the head: the guarded flip passes" "$out" "FLIP_EXIT=0" -assert_eq "a NEEDS WORK verdict for the head: the PR is flipped once" "pr ready 5" "$(cat "$STATE/flipped" 2>/dev/null)" +assert_eq "a NEEDS WORK verdict for the head: the PR is flipped once" "pr ready 5 -R acme/tool" "$(cat "$STATE/flipped" 2>/dev/null)" out=$(run_report) assert_exit "the report's confirm verdict line exits 0 after the flip" 0 "$?" assert_eq "the report names NEEDS WORK for the head and each MISSING or PARTIAL item" "confirm: NEEDS WORK for head $PUSHED @@ -184,7 +184,7 @@ for verdict in 'CANNOT QUANTIFY' DEGRADED 'NOT CONFIRMED' INCONCLUSIVE MIXED CON assert_exit "a $verdict measure verdict is recorded for the head" 0 "$?" out=$(CONFIRM_VERDICT_DIR="$STATE/confirm" run_flow "$BEFORE" "$BEFORE") assert_contains "a $verdict measure verdict for the head: the guarded flip passes" "$out" "FLIP_EXIT=0" - assert_eq "a $verdict measure verdict for the head: the PR is flipped once" "pr ready 5" "$(cat "$STATE/flipped" 2>/dev/null)" + assert_eq "a $verdict measure verdict for the head: the PR is flipped once" "pr ready 5 -R acme/tool" "$(cat "$STATE/flipped" 2>/dev/null)" done new_state "$PUSHED" diff --git a/plugins/source-control/skills/pull-request/scripts/target-repo.sh b/plugins/source-control/skills/pull-request/scripts/target-repo.sh new file mode 100755 index 00000000000..87ef3e2bab1 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/target-repo.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Resolve the GitHub repository a pull-request phase reads and writes, once, so +# every later gh call names it (`-R /` for gh pr and gh issue, +# `repos///...` for gh api) instead of leaving gh to choose. +# +# Resolution order: +# 0. GH_REPO, when set (a HOST/ prefix is dropped); +# 1. when the remotes name two or more github.com repositories, the fork among +# them: a remote's repository the caller can push to whose parent is the +# recorded gh default or another remote's repository, `origin` checked +# first; a failed repository read stops with exit 1. A checkout of a fork +# works on the fork, even after a stock fork clone recorded upstream as gh's +# default; a pull request meant for upstream names its base explicitly; +# 2. the default `gh repo set-default` recorded for this checkout; +# 3. with none recorded, the one github.com repository the remotes name; +# 4. with no github.com remote at all, gh's own resolution (an Enterprise host). +# Two or more github.com repositories, none a pushable fork of another, with no +# recorded default is refused: gh would then pick one by remote name. +# +# Usage: target-repo.sh [--dir ] +# Exit 0: owner/repo on stdout, "target repository: owner/repo" on stderr. +# Exit 1: refused or unresolved; the reason, and the fix when there is one, on stderr. +# Exit 2: usage error. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../../lib/github-remote.sh +source "${SCRIPT_DIR}/../../../lib/github-remote.sh" + +usage() { + echo "usage: target-repo.sh [--dir ]" >&2 + exit 2 +} + +DIR=. +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) + [[ $# -ge 2 ]] || usage + DIR="$2" + shift 2 + ;; + *) usage ;; + esac +done +cd "$DIR" || exit 1 + +found() { + echo "target repository: $1" >&2 + echo "$1" + exit 0 +} + +# GH_REPO is the caller's choice, or the repository an earlier block resolved +# and exported, so it wins. It may carry a HOST/ prefix. +if [[ -n "${GH_REPO:-}" ]]; then + repo="${GH_REPO%/}" + [[ "$repo" == */*/* ]] && repo="${repo#*/}" + found "$repo" +fi + +ERR_FILE=$(mktemp) || exit 1 +trap 'rm -f "$ERR_FILE"' EXIT + +# --view exits 0 whether or not a default is recorded, so read its output. +VIEW=$(gh repo set-default --view 2>"$ERR_FILE" | tr -d '\r') || { + cat "$ERR_FILE" >&2 + exit 1 +} +if [[ -z "$VIEW" ]] && ! grep -q "No default remote repository has been set" "$ERR_FILE"; then + echo "target-repo: gh repo set-default --view printed no repository:" >&2 + cat "$ERR_FILE" >&2 + exit 1 +fi + +declare -A SEEN=() +REPOS=() +for remote in origin $(git remote | grep -vx origin); do + repo=$(github_repo_of "$(git remote get-url "$remote" 2>/dev/null)") + [[ -n "$repo" && -z "${SEEN[${repo,,}]:-}" ]] || continue + SEEN[${repo,,}]=1 + REPOS+=("$repo") +done + +if ((${#REPOS[@]} > 1)); then + for repo in "${REPOS[@]}"; do + parent=$(gh api "repos/$repo" --jq 'select(.permissions.push == true) | .parent.full_name // empty' | tr -d '\r') || { + echo "target-repo: could not read repository $repo from GitHub; see gh's error above." >&2 + exit 1 + } + [[ -n "$parent" ]] || continue + if [[ "${parent,,}" == "${VIEW,,}" || -n "${SEEN[${parent,,}]:-}" ]]; then + found "$repo" + fi + done +fi + +[[ -n "$VIEW" ]] && found "$VIEW" + +case ${#REPOS[@]} in +1) found "${REPOS[0]}" ;; +0) + REPO=$(gh api 'repos/{owner}/{repo}' --jq .full_name | tr -d '\r') && [[ -n "$REPO" ]] || exit 1 + found "$REPO" + ;; +*) + { + echo "target-repo: the remotes name ${#REPOS[@]} GitHub repositories (${REPOS[*]}), none a fork of another that you can push to, and no gh default is set, so gh would pick one by remote name." + echo "Choose the repository this checkout's issues and pull requests belong to, then run:" + echo " gh repo set-default /" + } >&2 + exit 1 + ;; +esac diff --git a/plugins/source-control/skills/pull-request/scripts/target-repo.test.sh b/plugins/source-control/skills/pull-request/scripts/target-repo.test.sh new file mode 100755 index 00000000000..fa4742418c7 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/target-repo.test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Tests for target-repo.sh. Each case builds a throwaway git repo with a given +# set of remotes and runs the resolver against a stub gh whose +# `repo set-default --view` and `api` output come from the STUB_* variables. +# The stub's `api repos/` builds the repository's JSON and runs the +# caller's --jq filter over it with jq. STUB_PARENTS lists `=` +# pairs, space-separated; any other repository has no parent. STUB_NOPUSH lists +# repositories the caller cannot push to. STUB_API_FAIL makes the call fail with +# that message. +# Non-zero exit on any FAIL. + +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG GH_REPO + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RESOLVER="${SCRIPT_DIR}/target-repo.sh" + +PASS=0 +FAIL=0 +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +mkdir -p "$WORKDIR/bin" +cat >"$WORKDIR/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$1 $2" in +"repo set-default") + [[ -n "${STUB_VIEW_ERR:-}" ]] && printf '%s\n' "$STUB_VIEW_ERR" >&2 + [[ -n "${STUB_VIEW_OUT:-}" ]] && printf '%s\n' "$STUB_VIEW_OUT" + exit "${STUB_VIEW_RC:-0}" + ;; +"api repos/{owner}/{repo}") printf '%s\n' "${STUB_API_OUT:-}" ;; +"api repos/"*) + if [[ -n "${STUB_API_FAIL:-}" ]]; then + printf '%s\n' "$STUB_API_FAIL" >&2 + exit 1 + fi + repo=${2#repos/} parent=null push=true + for pair in ${STUB_PARENTS:-}; do + [[ "${pair%%=*}" == "$repo" ]] && parent="{\"full_name\":\"${pair#*=}\"}" + done + [[ " ${STUB_NOPUSH:-} " == *" $repo "* ]] && push=false + [[ "$3" == --jq ]] || { echo "stub gh: expected --jq" >&2; exit 99; } + printf '{"full_name":"%s","parent":%s,"permissions":{"push":%s}}\n' "$repo" "$parent" "$push" | jq -r "$4" + ;; +*) echo "stub gh: unexpected call: $*" >&2; exit 99 ;; +esac +STUB +chmod +x "$WORKDIR/bin/gh" + +NO_DEFAULT='No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help' + +make_repo() { + local repo="$WORKDIR/$1" + shift + git init -q "$repo" + for pair in "$@"; do + git -C "$repo" remote add "${pair%%=*}" "${pair#*=}" + done + echo "$repo" +} + +# check [VAR=value...] +check() { + local desc="$1" repo="$2" want_out="$3" want_rc="$4" want_err="$5" + shift 5 + local out rc err + err=$(env PATH="$WORKDIR/bin:$PATH" "$@" bash "$RESOLVER" --dir "$repo" 2>&1 >"$WORKDIR/out") + rc=$? + out=$(<"$WORKDIR/out") + if [[ "$out" == "$want_out" && $rc -eq $want_rc && "$err" == *"$want_err"* ]]; then + echo "PASS: $desc" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc" + echo " got out='$out' exit=$rc err='$err'" + echo " wanted out='$want_out' exit=$want_rc err contains '$want_err'" + FAIL=$((FAIL + 1)) + fi +} + +FORK=$(make_repo fork origin=https://github.com/me/tool.git upstream=git@github.com:acme/tool.git) +SINGLE=$(make_repo single origin=https://github.com/acme/tool) +SAME=$(make_repo same origin=git@github.com:acme/tool.git github=https://github.com/acme/tool/) +GHE=$(make_repo ghe origin=https://ghe.example.com/acme/tool.git) +UNRELATED=$(make_repo unrelated origin=https://github.com/acme/tool.git other=https://github.com/beta/lib.git) +NO_ORIGIN=$(make_repo no-origin mine=https://github.com/me/tool.git upstream=https://github.com/acme/tool.git) + +check "a stock fork clone with upstream as the gh default pins the fork origin names" "$FORK" \ + "me/tool" 0 "target repository: me/tool" STUB_VIEW_OUT=acme/tool STUB_PARENTS=me/tool=acme/tool +check "no default with a fork and its upstream pins the fork" "$FORK" \ + "me/tool" 0 "target repository: me/tool" "STUB_VIEW_ERR=$NO_DEFAULT" STUB_PARENTS=me/tool=acme/tool +check "a fork under a remote other than origin is still found" "$NO_ORIGIN" \ + "me/tool" 0 "target repository: me/tool" "STUB_VIEW_ERR=$NO_DEFAULT" STUB_PARENTS=me/tool=acme/tool +check "a fork the caller cannot push to loses to the recorded default" "$FORK" \ + "acme/tool" 0 "target repository: acme/tool" STUB_VIEW_OUT=acme/tool STUB_PARENTS=me/tool=acme/tool STUB_NOPUSH=me/tool +check "a failed repository read stops and names the repository" "$FORK" \ + "" 1 "could not read repository me/tool" STUB_VIEW_OUT=acme/tool "STUB_API_FAIL=HTTP 503: Service Unavailable" +check "two unrelated repositories with no default are refused with the set-default fix" "$UNRELATED" \ + "" 1 "gh repo set-default /" "STUB_VIEW_ERR=$NO_DEFAULT" +check "two unrelated repositories follow a recorded default" "$UNRELATED" \ + "beta/lib" 0 "target repository: beta/lib" STUB_VIEW_OUT=beta/lib +check "an explicit GH_REPO wins over the recorded default" "$FORK" \ + "elsewhere/other" 0 "target repository: elsewhere/other" GH_REPO=elsewhere/other STUB_VIEW_OUT=me/tool +check "a GH_REPO with a host prefix drops the host" "$SINGLE" \ + "elsewhere/other" 0 "target repository: elsewhere/other" GH_REPO=github.com/elsewhere/other +check "no default and one GitHub repository resolves to it" "$SINGLE" \ + "acme/tool" 0 "target repository: acme/tool" "STUB_VIEW_ERR=$NO_DEFAULT" +check "two remotes naming the same repository count as one" "$SAME" \ + "acme/tool" 0 "target repository: acme/tool" "STUB_VIEW_ERR=$NO_DEFAULT" +check "no github.com remote falls back to gh's own resolution" "$GHE" \ + "acme/tool" 0 "target repository: acme/tool" "STUB_VIEW_ERR=$NO_DEFAULT" STUB_API_OUT=acme/tool +check "an unrecognized --view message is not read as no default" "$SINGLE" \ + "" 1 "unknown flag: --view" "STUB_VIEW_ERR=unknown flag: --view" +check "a failing gh is reported, not resolved" "$SINGLE" \ + "" 1 "HTTP 401" "STUB_VIEW_ERR=HTTP 401" STUB_VIEW_RC=1 +usage_err=$(bash "$RESOLVER" --bogus 2>&1) +if [[ $? -eq 2 && "$usage_err" == *"usage: target-repo.sh"* ]]; then + echo "PASS: an unknown argument is a usage error" + PASS=$((PASS + 1)) +else + echo "FAIL: an unknown argument is a usage error (got '$usage_err')" + FAIL=$((FAIL + 1)) +fi + +echo "target-repo.sh: ${PASS} passed, ${FAIL} failed" +[[ $FAIL -eq 0 ]] diff --git a/plugins/work-items/skills/morning-brief/scripts/morning-brief.sh b/plugins/work-items/skills/morning-brief/scripts/morning-brief.sh index a6269265939..2961495f748 100755 --- a/plugins/work-items/skills/morning-brief/scripts/morning-brief.sh +++ b/plugins/work-items/skills/morning-brief/scripts/morning-brief.sh @@ -20,8 +20,8 @@ # head does not contain its base tip, or whose comparison was not read, carries # an UNVERIFIED line. # -# Owner/repo is derived from `gh repo view`, or from the checkout's `origin` -# remote when that call is unavailable; never hardcoded, so the tool is +# Owner/repo is derived from `gh repo view` (gh's own repository resolution), or +# from the checkout's `origin` remote when that call is unavailable; never hardcoded, so the tool is # reusable across repos. # # Degraded-mode contract. Every section is one of three states: data, empty, @@ -763,9 +763,10 @@ if ((ANY_LIVE)) && ! have gh; then fi # --- Resolve the target repo -------------------------------------------------- -# `gh repo view` first (the checkout's default remote as gh sees it). When that -# call is unavailable, the `origin` remote of the current checkout is the same -# derivation without the network round-trip. +# `gh repo view` first: GH_REPO, else the remote `gh repo set-default` recorded, +# else gh's first remote by name, which is not necessarily origin. When that call +# is unavailable, the `origin` remote is the fallback; in a fork checkout the two +# can name different repositories. repo_from_git_remote() { local url url="$(git remote get-url origin 2>/dev/null | tr -d '\r')" || return 1 diff --git a/plugins/work-items/skills/setup/SKILL.md b/plugins/work-items/skills/setup/SKILL.md index d4247f7304c..c470270a6c8 100644 --- a/plugins/work-items/skills/setup/SKILL.md +++ b/plugins/work-items/skills/setup/SKILL.md @@ -338,7 +338,7 @@ unambiguous; ask only where an item genuinely needs the user. item (adapter: "List items", `--label `, with bodies) and keep only the one whose marker names the OLD `id` exactly. When exactly one exists. **Renamed id:** rewrite that marker line to the new `id` (a provider body-edit op, read-modify-write of the whole - body; GitHub adapter: `gh issue edit --body-file ...`), or close it (adapter: "Close item") if + body; GitHub adapter: `gh issue edit -R / --body-file ...`, the repository the item id names), or close it (adapter: "Close item") if the user is instead retiring the item; **Dropped row:** close that item (adapter: "Close item") with a comment noting the recurring item was retired from the schedule. Otherwise the `recurring`-labeled issue lingers unreachable. An id rename or drop with no matching open item @@ -360,13 +360,13 @@ schedule work. Triage marks an untrusted item with `provenance: untrusted`, the 1. **Skip when no binding exists**, nothing is bound yet. 2. **Skip when the bound provider has no label listing** (`local-markdown`, read-only `jira`), report INFO and continue; triage verifies at item-edit time. -3. **Discover** via the adapter's label listing (GitHub: `gh label list --limit 200`, filter +3. **Discover** via the adapter's label listing (GitHub: `gh label list -R / --limit 200`, the repository `check` probe 2 named, filter `provenance:`). Look for `provenance: untrusted`. 4. **Present**. Report "provenance label provisioned" and continue. 5. **Missing, label-as-code owner declared**, stop. Name the label and route remediation to that owner; never `gh label create` ad hoc. 6. **Missing, no label-as-code owner, interactive user present**, offer to create it via the - adapter's label-creation mechanics (GitHub: `gh label create "" --description + adapter's label-creation mechanics (GitHub: `gh label create "" -R / --description "" --color ""` using the reference table). RECOMMENDED: create it, this pass is the upgrade migration for repos that predated the label. Re-list after creation and confirm it exists before continuing. diff --git a/plugins/work-items/skills/setup/reference/check.md b/plugins/work-items/skills/setup/reference/check.md index 70ba3bd3769..fc871239dff 100644 --- a/plugins/work-items/skills/setup/reference/check.md +++ b/plugins/work-items/skills/setup/reference/check.md @@ -58,7 +58,29 @@ check. check is the wrong gate), so an unrelated stale credential cannot skip the probe and let the very binding this exists to catch go unreported. Verdict on *why* the call failed rather than on failure alone: - - Resolves → INFO naming the `owner/repo` the seam will address. + - Resolves → INFO naming the `owner/repo` the seam will address. Then check that this is the + repository the user means: in a fork checkout the repository gh resolves can be upstream, and + the tracker is the fork. Run `gh repo set-default --view` and list the distinct GitHub + `owner/repo` values the remotes' fetch URLs name (`git remote -v`; the ssh and https forms of + one repository, with or without `.git`, compared case-insensitively, are one). Judge + `--view` by what it prints on stdout, never by its exit code: + - One distinct repository → nothing to add. + - Two or more, and `--view` printed no repository on stdout and its stderr reports that no + default is set → FAIL: the seam's repository is gh's guess. + - Two or more, and the default `--view` printed is not the fork → FAIL. The fork is the + tracker in a fork checkout; it is normally the repository `origin` names, and + `gh api repos// --jq .parent.full_name` on it names its upstream. + - Two or more, and the default is the fork → INFO naming it. + - Any other `--view` output (gh missing, a gh without `--view`, an error) → INFO, naming the + message verbatim, for the reason the last bullet below gives. + + The remediation for either FAIL is to print, with the fork filled in, + `gh repo set-default /`, and to say that the user confirms the repository first; + `check` never runs it. Pointer: the + [`gh repo set-default`](https://cli.github.com/manual/gh_repo_set-default) and + [`gh repo clone`](https://cli.github.com/manual/gh_repo_clone) manuals for how gh picks and + records the default and what `--view` prints; as of 2026-11-11 (gh 2.103.3); recheck when a + gh release changes how commands pick the base repository or what `--view` prints. - No remote, or no remote pointing at a known GitHub host → FAIL: nothing here can derive a repo, so every repo-scoped verb that is not handed the CONTRACT's explicit `--repo /` override fails at call time. Remediation is `/work-items:setup apply` with a user present, diff --git a/plugins/work-items/skills/track/actions/publish-plan.md b/plugins/work-items/skills/track/actions/publish-plan.md index 249303581c7..4f7410ff5e0 100644 --- a/plugins/work-items/skills/track/actions/publish-plan.md +++ b/plugins/work-items/skills/track/actions/publish-plan.md @@ -50,11 +50,13 @@ this layout (marker line, blank line, plan text); a different layout takes a new 1. **Post or edit**, a WRITE through the adapter's identity policy. Before either command, check that `` (the item number) and `` (the comment ID) each match `^[0-9]+$`; stop on any other - value. The body always goes as a file: + value. `/` is the repository the item id names (`github:/#`), + written out rather than left to `gh`, which in a fork checkout can resolve to upstream. The body + always goes as a file: ```bash - gh issue comment --body-file - gh api --method PATCH "repos/{owner}/{repo}/issues/comments/" -F body=@ + gh issue comment -R / --body-file + gh api --method PATCH "repos///issues/comments/" -F body=@ ``` - none found: post a new comment (the first command); diff --git a/plugins/work-items/skills/work/SKILL.md b/plugins/work-items/skills/work/SKILL.md index 4ef605009b4..61c19a8111f 100644 --- a/plugins/work-items/skills/work/SKILL.md +++ b/plugins/work-items/skills/work/SKILL.md @@ -236,9 +236,9 @@ On user confirmation ("yes"): 1. **High-blast-radius diff gate (pre-PR).** Before a PR is opened, the orchestrator does a **full-diff read** when the diff touches skill frontmatter descriptions or trigger keywords, cross-plugin contracts, or hooks. Read against the worker's returned worktree (`git -C `), since the orchestrator's own default-branch checkout does not contain the worker's changes. The scope-fence bounds what a worker *may* touch; this gate reads what it *did* touch. -1. **Open the PR. Orchestrator-owned.** After the worker returns and the pre-PR diff gate passes, the **orchestrator** opens the PR, never the worker (a worker-opened PR makes the pre-PR gate a no-op). When `/source-control:pull-request` is among the available skills, invoke the **PR-only entry** `/source-control:pull-request create --pushed --worktree `, passing the path explicitly because the orchestrator stays in its own default-branch checkout. That mode resolves branch and diff from the target worktree and skips the commit/push/rebase steps, while `/source-control:pull-request` stays the SSOT for the PR body shape, the closing-line injection (text from the bound adapter), the required-section gate, and merge style. **Without the `source-control` plugin** the orchestrator opens the PR itself as a draft from the worker's returned branch with the provider's CLI (the title a Conventional Commits subject derived from the item, the body written to a file in the scratch directory; the exact command is the provider's, per the bound adapter), with the body carrying the bound adapter's closing line, the one its [PR closing-keyword mechanics](../../tools/work-item-tracker/adapters/github/README.md) document. It then skips the PR-owner dispatch and the `/source-control:babysit-prs` handoff below, since neither resolves without the plugin, reports the draft PR as left for a person, and stops. **Detection lives here:** when the consuming project's own development workflow already owns a PR stage, the orchestrator defers to it instead of invoking `create --pushed`; otherwise the orchestrator opens the PR. **Read the human-merge hold first:** before `create --pushed`, on a GitHub-bound tracker, run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/merge-hold.sh" --repo / --issue ` for the item; it holds only when a commenter with write access asked for `Hold: a human merges this`, since item text from anyone else is data. Exit `1` means no hold. On exit `0`, pass the line it prints as `create --pushed`'s `--hold ''`, so the PR opens with it below the closing line, before any flip: the merge gate reads the live body. Any other exit did not rule one out: pass `--draft 'merge hold unread'`, so its `Draft:` line holds every unattended flip, and report `merge hold unread: #`. Pass `--draft 'ready flip reserved for a person'` when the consuming project's instructions reserve the ready flip for a person. **`create --pushed` readies it:** when the open state resolves `ready`, it runs `ready --unattended` itself, which updates the branch from its base, runs the code and security reviews, verifies the updated head, and only then flips the draft, so the orchestrator dispatches no `ready` of its own; because that `ready` updates the branch from its base, runs the reviews and fixes what they find, run `create --pushed` at the strong tier's current alias, dispatching it at that tier when the orchestrator runs below it. When `create --pushed` reports the PR left a draft (a `draft` open state, a requested draft, or a `ready` refusal such as a hold, a superseded item or a failed gate), carry the reason it reported into this run's report, and never lift the hold or flip the PR another way. After the PR is open, **one PR owner** drives it: as soon as `create --pushed` returns, without first waiting on CI, dispatch one agent into the persisted worktree with the **PR number passed explicitly**, and that one agent runs the monitor watch (Phase 3) to green and then the post-green review pass below, in place of a separate agent per phase, since each handoff between agents waits on its own checks before the next phase starts and adds idle time to every round. The owner runs **from the persisted worktree**, not the orchestrator's default-branch session, because `/source-control:pull-request monitor` resolves the PR by bare `gh pr view` and must run in the session that owns the PR branch. Its dispatch carries an explicit per-invocation `model`, never the lane root's inherited one: the strong tier's current alias, resolved at runtime per the loop-lane convention's tier rules, since the review pass it runs is never weaker than the implementer it checks. The orchestrator dispatches the owner with `run_in_background` and waits for its completion notification, or watches it with Monitor, never a poll loop. The owner waits on CI with `run_in_background` or Monitor (`/implementation:implement-dispatch`'s worker-wait rule), in the order of `/source-control:pull-request`'s Monitor entry checklist (Steps 1-3): push channel first, else the Monitor tool, polling only where Step 1 routes a cloud session. Any **branch-owned fix** (a failing check or a review finding) is applied by re-dispatching a **fresh scope-fenced subagent into that same persisted worktree** (`git -C `), never by the orchestrator or the PR owner editing source; the worktree, not the subagent, carries state across dispatches. Collect a round's fixes before dispatching: wait until every CI and reviewer check run on the head has finished, then hand every failing check and VALID finding to one fix worker that commits and pushes once, since each push starts a full CI run and cancels the one in flight. **Before any push to the open PR** (a fix or a rebase), auto-merge is turned off so the new head cannot merge on `ci-status` alone before the AI reviews re-run. The fix worker's brief carries the step, since the worker pushes: immediately before the push, and again right after it (someone may arm the old head while the worker runs), when `gh pr view --json autoMergeRequest --jq '.autoMergeRequest'` is non-null, run `gh pr merge --disable-auto`. Neither the worker nor the orchestrator re-arms, and no lane arms auto-merge. Verified 2026-09-26 against gh 2.98.0: [`gh pr merge`](https://cli.github.com/manual/gh_pr_merge) documents `--disable-auto`, and `gh pr view --help` lists the `autoMergeRequest` JSON field. Recheck when a gh release note renames either. Dispatch that fix worker as `implementation:implementer` when the `implementation` plugin is installed (its frontmatter binds the strong tier, never below the original; when the original ran at the frontier tier, pass the frontier alias as a per-invocation `model`); when that plugin is absent, pass an explicit per-invocation `model` resolving the strong tier's current alias, never the orchestrator's inherited model. +1. **Open the PR. Orchestrator-owned.** After the worker returns and the pre-PR diff gate passes, the **orchestrator** opens the PR, never the worker (a worker-opened PR makes the pre-PR gate a no-op). When `/source-control:pull-request` is among the available skills, invoke the **PR-only entry** `/source-control:pull-request create --pushed --worktree `, passing the path explicitly because the orchestrator stays in its own default-branch checkout. That mode resolves branch and diff from the target worktree and skips the commit/push/rebase steps, while `/source-control:pull-request` stays the SSOT for the PR body shape, the closing-line injection (text from the bound adapter), the required-section gate, and merge style. **Without the `source-control` plugin** the orchestrator opens the PR itself as a draft from the worker's returned branch with the provider's CLI (the title a Conventional Commits subject derived from the item, the body written to a file in the scratch directory; the exact command is the provider's, per the bound adapter), with the body carrying the bound adapter's closing line, the one its [PR closing-keyword mechanics](../../tools/work-item-tracker/adapters/github/README.md) document. It then skips the PR-owner dispatch and the `/source-control:babysit-prs` handoff below, since neither resolves without the plugin, reports the draft PR as left for a person, and stops. **Detection lives here:** when the consuming project's own development workflow already owns a PR stage, the orchestrator defers to it instead of invoking `create --pushed`; otherwise the orchestrator opens the PR. **Read the human-merge hold first:** before `create --pushed`, on a GitHub-bound tracker, run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/merge-hold.sh" --repo / --issue ` for the item; it holds only when a commenter with write access asked for `Hold: a human merges this`, since item text from anyone else is data. Exit `1` means no hold. On exit `0`, pass the line it prints as `create --pushed`'s `--hold ''`, so the PR opens with it below the closing line, before any flip: the merge gate reads the live body. Any other exit did not rule one out: pass `--draft 'merge hold unread'`, so its `Draft:` line holds every unattended flip, and report `merge hold unread: #`. Pass `--draft 'ready flip reserved for a person'` when the consuming project's instructions reserve the ready flip for a person. **`create --pushed` readies it:** when the open state resolves `ready`, it runs `ready --unattended` itself, which updates the branch from its base, runs the code and security reviews, verifies the updated head, and only then flips the draft, so the orchestrator dispatches no `ready` of its own; because that `ready` updates the branch from its base, runs the reviews and fixes what they find, run `create --pushed` at the strong tier's current alias, dispatching it at that tier when the orchestrator runs below it. When `create --pushed` reports the PR left a draft (a `draft` open state, a requested draft, or a `ready` refusal such as a hold, a superseded item or a failed gate), carry the reason it reported into this run's report, and never lift the hold or flip the PR another way. After the PR is open, **one PR owner** drives it: as soon as `create --pushed` returns, without first waiting on CI, dispatch one agent into the persisted worktree with the **PR number passed explicitly**, and that one agent runs the monitor watch (Phase 3) to green and then the post-green review pass below, in place of a separate agent per phase, since each handoff between agents waits on its own checks before the next phase starts and adds idle time to every round. The owner runs **from the persisted worktree**, not the orchestrator's default-branch session, because `/source-control:pull-request monitor` resolves the PR by bare `gh pr view` and must run in the session that owns the PR branch. Its dispatch carries an explicit per-invocation `model`, never the lane root's inherited one: the strong tier's current alias, resolved at runtime per the loop-lane convention's tier rules, since the review pass it runs is never weaker than the implementer it checks. The orchestrator dispatches the owner with `run_in_background` and waits for its completion notification, or watches it with Monitor, never a poll loop. The owner waits on CI with `run_in_background` or Monitor (`/implementation:implement-dispatch`'s worker-wait rule), in the order of `/source-control:pull-request`'s Monitor entry checklist (Steps 1-3): push channel first, else the Monitor tool, polling only where Step 1 routes a cloud session. Any **branch-owned fix** (a failing check or a review finding) is applied by re-dispatching a **fresh scope-fenced subagent into that same persisted worktree** (`git -C `), never by the orchestrator or the PR owner editing source; the worktree, not the subagent, carries state across dispatches. Collect a round's fixes before dispatching: wait until every CI and reviewer check run on the head has finished, then hand every failing check and VALID finding to one fix worker that commits and pushes once, since each push starts a full CI run and cancels the one in flight. **Before any push to the open PR** (a fix or a rebase), auto-merge is turned off so the new head cannot merge on `ci-status` alone before the AI reviews re-run. The fix worker's brief carries the step, since the worker pushes: immediately before the push, and again right after it (someone may arm the old head while the worker runs), when `gh pr view -R / --json autoMergeRequest --jq '.autoMergeRequest'` is non-null, run `gh pr merge -R / --disable-auto`, the PR number and the repository written literally, both taken from the PR URL `create --pushed` reported (a triangular PR lives on its base repository, not the item's). Neither the worker nor the orchestrator re-arms, and no lane arms auto-merge. Verified 2026-09-26 against gh 2.98.0: [`gh pr merge`](https://cli.github.com/manual/gh_pr_merge) documents `--disable-auto`, and `gh pr view --help` lists the `autoMergeRequest` JSON field. Recheck when a gh release note renames either. Dispatch that fix worker as `implementation:implementer` when the `implementation` plugin is installed (its frontmatter binds the strong tier, never below the original; when the original ran at the frontier tier, pass the frontier alias as a per-invocation `model`); when that plugin is absent, pass an explicit per-invocation `model` resolving the strong tier's current alias, never the orchestrator's inherited model. -1. **Post-green review pass, then hand off.** After CI is green, the PR owner runs one review pass. The fetch-once → validate → classify → threaded-reply → react → resolve-bot-thread loop is owned by `/source-control:pull-request`; this skill adds only the sequencing and the work-item linkage: fix findings via the same **fresh-subagent-into-the-persisted-worktree** re-dispatch the orchestrator-owned PR step defines (neither the orchestrator nor the PR owner edits source). **Fix, don't file:** a small or medium VALID finding is fixed, never filed, and where an unrelated one lands is the scope test `/source-control:pull-request` owns. Only a finding that already reproduces on the base branch may defer; one this change introduced is fixed now at any size (the provenance test `/source-control:pull-request` owns). Of those, only three kinds are deferred: a **structural** finding, large enough to need its own planning pass; an **urgent, real** problem that cannot land in this PR; and a finding **blocked on research** this lane is not positioned to do. Nothing else is filed: no nits, no speculative items, no busy work. A **deferred finding requires a filed follow-up issue**. File it by invoking `/work-items:track add` via the Skill tool following the shared self-observation contract ([`${CLAUDE_PLUGIN_ROOT}/reference/dogfood-filing.md`](${CLAUDE_PLUGIN_ROOT}/reference/dogfood-filing.md): dedupe → categorize → fixed shape → `needs-triage`; per-user `ai_disclaimer`: `${user_config.ai_disclaimer}`), then cite that issue **both** in the classification reply **and** in the PR's `## Related` section. **ensure the section exists first** (the scaffold carries `## Related` only when the repo's `pr_body_required_sections` requires it or a reference populated it at create time). Adding it is a **read-modify-write**, never a bare `--body` replacement ([`gh pr edit` options](https://cli.github.com/manual/gh_pr_edit#options), as of 2026-11-10; recheck when gh changes how `--body` treats the existing body; see also the GitHub adapter's [PR closing-keyword mechanics](../../tools/work-item-tracker/adapters/github/README.md)), so read the current body first (`gh pr view --json body --jq '.body'`), append the `## Related` section (or its content, if the section already exists) to that read, and write the combined result back via `--body-file -`; a bare `gh pr edit --body "## Related\n..."` would silently drop the closing line and every section `/source-control:pull-request create` wrote. Make that body write before the push that carries the round's fixes, or, with no fix to push, while no CI run is in flight on the head; never as its own edit right after a push, since a body edit re-runs every workflow that triggers on `edited` beside the push's own run. A deferred finding cannot be resolved without it. When the owner returns, the orchestrator hands the PR off by invoking `/source-control:babysit-prs` via the Skill tool (fleet loop, owned there). +1. **Post-green review pass, then hand off.** After CI is green, the PR owner runs one review pass. The fetch-once → validate → classify → threaded-reply → react → resolve-bot-thread loop is owned by `/source-control:pull-request`; this skill adds only the sequencing and the work-item linkage: fix findings via the same **fresh-subagent-into-the-persisted-worktree** re-dispatch the orchestrator-owned PR step defines (neither the orchestrator nor the PR owner edits source). **Fix, don't file:** a small or medium VALID finding is fixed, never filed, and where an unrelated one lands is the scope test `/source-control:pull-request` owns. Only a finding that already reproduces on the base branch may defer; one this change introduced is fixed now at any size (the provenance test `/source-control:pull-request` owns). Of those, only three kinds are deferred: a **structural** finding, large enough to need its own planning pass; an **urgent, real** problem that cannot land in this PR; and a finding **blocked on research** this lane is not positioned to do. Nothing else is filed: no nits, no speculative items, no busy work. A **deferred finding requires a filed follow-up issue**. File it by invoking `/work-items:track add` via the Skill tool following the shared self-observation contract ([`${CLAUDE_PLUGIN_ROOT}/reference/dogfood-filing.md`](${CLAUDE_PLUGIN_ROOT}/reference/dogfood-filing.md): dedupe → categorize → fixed shape → `needs-triage`; per-user `ai_disclaimer`: `${user_config.ai_disclaimer}`), then cite that issue **both** in the classification reply **and** in the PR's `## Related` section. **ensure the section exists first** (the scaffold carries `## Related` only when the repo's `pr_body_required_sections` requires it or a reference populated it at create time). Adding it is a **read-modify-write**, never a bare `--body` replacement ([`gh pr edit` options](https://cli.github.com/manual/gh_pr_edit#options), as of 2026-11-10; recheck when gh changes how `--body` treats the existing body; see also the GitHub adapter's [PR closing-keyword mechanics](../../tools/work-item-tracker/adapters/github/README.md)), so read the current body first (`gh pr view -R / --json body --jq '.body'`), append the `## Related` section (or its content, if the section already exists) to that read, and write the combined result back to the same PR (`gh pr edit -R / --body-file -`, the repository from the PR URL `create --pushed` reported, on both the read and the write); a bare `gh pr edit --body "## Related\n..."` would silently drop the closing line and every section `/source-control:pull-request create` wrote. Make that body write before the push that carries the round's fixes, or, with no fix to push, while no CI run is in flight on the head; never as its own edit right after a push, since a body edit re-runs every workflow that triggers on `edited` beside the push's own run. A deferred finding cannot be resolved without it. When the owner returns, the orchestrator hands the PR off by invoking `/source-control:babysit-prs` via the Skill tool (fleet loop, owned there). 1. **Never-merge boundary.** This skill's lane ends at PR creation and the handoff above (review pass, then babysit). **Merging is the babysit lane or a human, never `work`**. Consistent with `/source-control:babysit-prs`'s safe default never merging (its opt-in `worker`/`autopilot` tiers merge only behind a deterministic readiness gate) and `/source-control:pull-request` merges being human-gated. The worker's worktree **persists** through the whole PR lifecycle (the same-worktree fix re-dispatch depends on it) and is cleaned up only by whoever merges (`/source-control:pull-request merge` Phase 4, the babysit worker tier, or a human via `/source-control:worktree cleanup`), never mid-lifecycle and never by this lane. diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index 0fd3f72bd08..abbd7474a8f 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -205,10 +205,16 @@ value (named by file, key and value; the personal override when it set the value - Optional `docs` (either layer): a free-form self-describing pointer naming what the file is and where its contract lives, so the binding file explains itself to a teammate who finds it. `/work-items:setup` writes it by default; the seam never reads it. -- Owner/repo are NEVER recorded in the binding. They are derived at runtime from the working - directory's git remote (`gh api repos/{owner}/{repo}`). Verbs that need a repo - context accept an explicit `--repo /` override (conformance, cross-repo - tooling). +- Owner/repo are NEVER recorded in the binding. They are derived at runtime by `gh`'s own + repository resolution for the working directory (`gh api repos/{owner}/{repo}`): `GH_REPO` + first, then the remote `gh repo set-default` recorded, then, with no default and no terminal, + the first remote in gh's order by remote name, which is not necessarily `origin`. In a fork + checkout (an `origin` fork beside an `upstream` remote), the repo therefore follows the gh + default, and a stock `gh repo clone` of a fork records `upstream`; `/work-items:setup check` + reports when the remotes name more than one repository. Verbs that need a repo context accept an + explicit `--repo /` override (conformance, cross-repo tooling). Pointer: + [`gh repo set-default`](https://cli.github.com/manual/gh_repo_set-default); as of 2026-11-11 + (gh 2.103.3); recheck when a gh release changes how commands pick the base repository. - No binding found → exit `3` and stderr points here; the seam runs no inline wizard. The `work-items` plugin's setup skill (`/work-items:setup`) seeds this file, provider + non-secret config, as the once-per-repo binding step. diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh index 29301f1dab7..647c1e6a6ea 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh @@ -186,8 +186,9 @@ wit_run_gh_native() { WIT_GH_NATIVE_TRY=1 wit_run_gh "$@" } -# wit_resolve_repo <--repo value or empty> — echo owner/repo (explicit override or -# derived from the CWD git remote per CONTRACT.md "Setup (binding file)"). +# wit_resolve_repo <--repo value or empty> — echo owner/repo: the explicit override, else +# gh's own resolution for the CWD (GH_REPO, the `gh repo set-default` remote, else gh's +# first remote by name, not necessarily origin) per CONTRACT.md "Setup (binding file)". wit_resolve_repo() { local explicit="${1:-}" if [[ -n "$explicit" ]]; then