@senad-d/branchme 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +5 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +4 -3
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +1 -1
- package/docs/TUI_CAPTURE.md +1 -0
- package/package.json +1 -1
- package/src/blocker-guidance.ts +10 -0
- package/src/commands/branchme-command.ts +1 -0
- package/src/constants.ts +3 -0
- package/src/git-landing.ts +23 -7
- package/src/github-feedback.ts +169 -27
- package/src/tools/workflow-tools.ts +29 -4
- package/src/types.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.11 - Unreleased
|
|
4
|
+
|
|
5
|
+
- `land_branch` with `pullRequestNumber` accepts a local source branch that is the merged PR head or one of its ancestors: a branch left behind because the PR received commits pushed from another checkout (for example a merge of the base) holds nothing the PR lacks, and is retired. A local branch with commits the PR head does not contain is still refused, now with its own message and Blocked/Resolve guidance; a PR head that is not available locally counts as not containing it.
|
|
6
|
+
- Blocked/Resolve guidance for the PR-evidence refusals of `land_branch`.
|
|
7
|
+
|
|
8
|
+
## 0.3.10 - Unreleased
|
|
9
|
+
|
|
10
|
+
- Added `reply_pull_request_feedback`: one reply in a review thread of a PR, optionally resolving it, or one PR conversation comment, on open, merged and closed PRs of the repository (forks included). The thread is verified to belong to the PR, and a conversation comment's number to be a pull request, before anything is posted. Agents that address PR feedback can now answer every comment on GitHub, so the reviewer, and the next automated check, see what was handled.
|
|
11
|
+
- `pull_request_feedback` returns what replying and deduplication need: review thread ids, conversation comment ids, the author and time of every comment and review, the token's own login (`viewer`) with its comments marked `(you)`, and the pull request's node id. Failing check runs list up to 20 failure and warning annotations (`path:line LEVEL: message`) before their log tail, the most precise pointer for lint and test failures.
|
|
12
|
+
|
|
3
13
|
## 0.3.9 - Unreleased
|
|
4
14
|
|
|
5
15
|
- Git network commands (`fetch`, `pull`, `push`, `ls-remote`) run with terminal prompts disabled: missing credentials fail at once with git's own message instead of waiting on an unanswerable prompt until the timeout reported only "killed". For `https://github.com` the `GITHUB_TOKEN`/`GH_TOKEN` BranchMe already uses for the API now also answers git's credential request (after the user's own helpers), through the child environment, never argv. A token alone is enough for push and pull.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty-
|
|
19
|
+
BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty-three agent-callable tools that initialize a repository, refresh state, manage, integrate, and retire local branches, conclude a kept merge, inspect/create/remove linked worktrees, push the current branch, create GitHub pull requests, and read and answer PR feedback.
|
|
20
20
|
|
|
21
21
|
<table align="center">
|
|
22
22
|
<tr>
|
|
@@ -257,7 +257,8 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
257
257
|
| `track_branch` | `{ "branchName": string, "remote"?: string, "remoteBranch"?: string }` | Fetch one exact remote branch and create/check out a new local tracking branch. Remote defaults to `origin`; remoteBranch defaults to branchName. Requires a clean idle checkout; verifies HEAD and upstream. |
|
|
258
258
|
| `update_from_base` | `{ "baseBranch": string, "remote"?: string, "keepConflicts"?: boolean }` | Fetch the exact remote base (remote defaults to `origin`) and merge its captured commit into the clean current feature. Uses verified normal-merge policy; a `conflict` is automatically aborted with its paths listed, or with `keepConflicts: true` reported as `conflict_kept` and left in progress (MERGE_HEAD set, conflicted paths unmerged) for `conclude_merge`. Never rebases, pushes, or changes upstream configuration. |
|
|
259
259
|
| `conclude_merge` | `{ "action": "conclude" \| "abort" }` | Finish or abandon the in-progress merge on the current checkout. `conclude` refuses default/custom-sized `<<<<<<<`, `|||||||`, `=======`, or `>>>>>>>` marker lines in unmerged working-tree paths and staged blobs (including CRLF and binary content, naming the paths), stages only the currently unmerged paths, rechecks the candidate index, commits with `git commit --no-edit`, and verifies MERGE_HEAD is gone and HEAD is a two-parent merge of the previous HEAD and MERGE_HEAD; unrelated unstaged changes stay unstaged. `abort` runs `git merge --abort` and verifies the restored HEAD, cleared operation state, and clean tree. Refuses when no merge is in progress. |
|
|
260
|
-
| `
|
|
260
|
+
| `reply_pull_request_feedback` | `{ "number": integer, "body": string, "threadId"?: string, "resolve"?: boolean }` | Answer PR feedback on an open, merged or closed PR of this repository (forks included). With `threadId` (from `pull_request_feedback`) it replies in that review thread after verifying the thread belongs to the PR, and `resolve: true` then resolves it; without `threadId` it posts one conversation comment after confirming the number is a pull request, not an issue. Body is Markdown, at most 4,000 characters. Never edits, deletes, approves, merges, or closes. |
|
|
261
|
+
| `pull_request_feedback` | `{ "number"?: integer, "headBranch"?: string }` | Read what a same-repository PR asks to be fixed in one GraphQL request: unresolved, current review threads with their comments (resolved and outdated threads are counted only), review summaries with a body or requested changes, the latest 50 conversation comments, and the head commit's failing checks with up to 20 failure and warning annotations each (`path:line LEVEL: message`). Threads and conversation comments carry their GraphQL ids, every comment its author and time, and comments by the token's own user are marked `(you)`, so a caller can reply to a thread and tell new comments from its own replies. For up to three failing GitHub Actions jobs it adds the log lines leading to the last `##[error]`. A job log that cannot be read is reported per check. No mutations. |
|
|
261
262
|
| `pull_request_status` | `{ "number"?: integer, "headBranch"?: string }` | Read exact same-repository PR lifecycle and head/base/merge identities. Number and headBranch are mutually exclusive. Without number, returns the most recently updated PR for headBranch or the current branch. Does not certify CI checks or review approvals. |
|
|
262
263
|
| `init_repository` | `{ "initialBranch"?: string }` | Initializes only pi's exact current working directory as a verified non-bare Git repository with an unborn branch (default `main`). Rejects existing or nested repositories and accepts no path, bare, template, shared, remote, commit, or project-file controls. |
|
|
263
264
|
| `branch_status` | `{ "ancestry"?: { "sourceBranch": string, "targetBranch": string } }` | Explicitly refreshes the same bounded context used at agent start. An optional strict ancestry query captures both exact HEADs and reports whether the source commit is an ancestor of the target commit; each endpoint may be an exact local branch or a remote-tracking ref such as `origin/main` (a local branch of the same name takes precedence). It is read-only and never checks out or resets remote-tracking refs; automatic Git context does not run ancestry queries. |
|
|
@@ -414,7 +415,7 @@ After the pull request **merged on the host**, run from the repository root:
|
|
|
414
415
|
|
|
415
416
|
`remote` defaults to `origin`. Omit `worktreePath` to find the linked worktree holding `sourceBranch`, if any. An explicit path can also select a worktree incorrectly parked on `targetBranch`; unrelated branches, primary/root checkouts, detached, locked, dirty, or foreign worktrees are not removed. Invocation from inside the removal directory (including a nested or symlinked cwd) is refused: **run from the repository root**. Other caller directories in the same repository are supported; every Git command has an explicit `-C` directory, and cleanup runs through the primary checkout. When the merged source branch is checked out in the primary checkout and its tracked files are clean, `land_branch` switches that checkout to the target branch first (`checkout: switched`) so the source can be retired; untracked files stay in place.
|
|
416
417
|
|
|
417
|
-
The tool fetches the exact remote target and by default requires the captured source tip to be its ancestor before cleanup. For squash/rebase merges, supply `pullRequestNumber`. BranchMe verifies a closed, merged PR in the same GitHub repository as the configured landing remote, exact head/base branch names,
|
|
418
|
+
The tool fetches the exact remote target and by default requires the captured source tip to be its ancestor before cleanup. For squash/rebase merges, supply `pullRequestNumber`. BranchMe verifies a closed, merged PR in the same GitHub repository as the configured landing remote, exact head/base branch names, a source SHA equal to the PR head or one of its ancestors (a local branch left behind holds nothing the PR lacks; one with commits the PR head does not contain is refused), and containment of the reported merge commit in the fetched target. Supplying a PR number requires valid evidence even when ordinary ancestry succeeds. There is no general force escape hatch. This host-merge proof authorizes retiring the original rewritten source history; those original commits may eventually become unreachable. An existing selected source checkout must be at the proven PR head or at that contained source SHA; a checkout parked on the target still needs target ancestry. Tracked/staged changes and non-ignored untracked files block removal. **Ignored `.env`, `.pi/`, `node_modules/`, `dist/`, and other ignored residue are deleted with the worktree**; preserve anything needed first. Only their top-level path entries, never contents, appear in `deletedIgnoredPaths`. Source retirement uses the captured expected-HEAD lease and the **remote-tracking target**, not local `HEAD` or a stale local target.
|
|
418
419
|
|
|
419
420
|
Target sync is last, even when worktree removal or branch retirement is refused:
|
|
420
421
|
|
|
@@ -565,7 +566,7 @@ npm run check:pack
|
|
|
565
566
|
printf '/branchme help\n/quit\n' | pi --no-extensions -e .
|
|
566
567
|
```
|
|
567
568
|
|
|
568
|
-
Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty-
|
|
569
|
+
Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty-three BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, `conclude_merge`, and targeted `branch_status.ancestry`, with no generic `continue_merge` or `abort_merge` tool. Runtime smoke inspects retirement and landing registration/schema but never executes either cleanup tool. Smoke-test notes are recorded in [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md), and TUI/help captures are stored in [`docs/TUI_CAPTURE.md`](docs/TUI_CAPTURE.md).
|
|
569
570
|
|
|
570
571
|
Refresh TUI captures intentionally with:
|
|
571
572
|
|
|
@@ -15,7 +15,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
15
15
|
- Exported extension function: `branchMeExtension`
|
|
16
16
|
- Repository URL: `https://github.com/senad-d/branchme`
|
|
17
17
|
- One-sentence pitch: Verified Pi tools for Git repository initialization plus current-repository branch, integration, retirement, linked-worktree, push, and GitHub pull request workflows.
|
|
18
|
-
- Tool count: twenty-
|
|
18
|
+
- Tool count: twenty-three strict agent-callable tools.
|
|
19
19
|
|
|
20
20
|
## 3. Users and use cases
|
|
21
21
|
|
|
@@ -56,7 +56,8 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
56
56
|
| Tool | `update_from_base` | Fetch and merge an explicit remote base | Preserves published history; fixed merge policy, listed conflict paths, automatic abort unless `keepConflicts` |
|
|
57
57
|
| Tool | `conclude_merge` | Commit or abort the in-progress kept merge | Refuses remaining markers; stages only formerly unmerged paths; `commit --no-edit`; verified two-parent result or verified abort |
|
|
58
58
|
| Tool | `pull_request_status` | Read exact PR or latest PR for a head | Open/closed/merged state and commit identities; not a CI/review verdict |
|
|
59
|
-
| Tool | `pull_request_feedback` | Read what a PR asks to be fixed | Unresolved review threads, review summaries, conversation comments, failing checks with Actions log tails; read-only |
|
|
59
|
+
| Tool | `pull_request_feedback` | Read what a PR asks to be fixed | Unresolved review threads, review summaries, conversation comments, failing checks with annotations and Actions log tails; read-only |
|
|
60
|
+
| Tool | `reply_pull_request_feedback` | Answer PR feedback | One reply in a review thread of the PR (optionally resolving it) or one conversation comment; the thread must belong to the PR; never edits, deletes, approves, merges or closes |
|
|
60
61
|
| Tool | `init_repository` | Initialize the exact current directory as a new non-bare Git repository | Optional initial branch; rejects reinitialization/nesting; no path, commit, or project-file controls |
|
|
61
62
|
| Tool | `branch_status` | Refresh bounded current-worktree Git and related-PR context; optionally prove captured local or remote-tracking ancestry | Read-only; targeted ancestry is absent from automatic context |
|
|
62
63
|
| Tool | `integrate_branch` | Integrate one exact local source into the already-current clean local target | Fixed normal-merge policy; verified automatic abort/restoration on conflict |
|
|
@@ -96,7 +97,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
96
97
|
- `src/github.ts`
|
|
97
98
|
- `src/ui/branchme-panel.ts`
|
|
98
99
|
- Module boundaries:
|
|
99
|
-
- The extension entry point registers the informational command, twenty-
|
|
100
|
+
- The extension entry point registers the informational command, twenty-three tools, and automatic context hook.
|
|
100
101
|
- `src/git-workflow.ts` and `src/tools/workflow-tools.ts` own verified remote tracking, base updates, and the new PR status registration.
|
|
101
102
|
- The context module owns bounded read-only collection, prompt formatting, targeted ancestry rendering, and the `before_agent_start` hook; automatic context never runs ancestry queries.
|
|
102
103
|
- The command and UI modules own mode-safe informational help/status behavior and never invoke mutations.
|
package/docs/SMOKE_TEST.md
CHANGED
|
@@ -23,9 +23,9 @@ pi --no-extensions -e .
|
|
|
23
23
|
## Automated smoke behavior
|
|
24
24
|
|
|
25
25
|
- `npm run smoke:pi` first runs isolated checkout Pi processes from a temporary non-Git working directory: one with `pi --no-extensions -e <package> -e <temporary verifier>` and `/branchmeverify verify`, then one with `pi --no-extensions -e <package>` and `/branchme help`.
|
|
26
|
-
- The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty-
|
|
26
|
+
- The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty-three tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `pull_request_feedback`, `reply_pull_request_feedback`, `branch_status`, `init_repository`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `land_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
|
|
27
27
|
- Runtime schema inspection verifies that `integrate_branch` has exactly the required `sourceBranch` and `targetBranch` fields and that `retire_branch` has exactly the required `branchName`, `expectedHead`, `targetBranch`, and `force` fields with `additionalProperties: false`. It also verifies that `branch_status` has only an optional top-level `ancestry` object whose required nested `sourceBranch` and `targetBranch` fields reject additional nested or top-level properties.
|
|
28
|
-
- A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty-
|
|
28
|
+
- A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty-three tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, `conclude_merge`, and every remote or worktree mutation tool (including `fetch_remote`) from executing.
|
|
29
29
|
- The Pi runtime smoke validates worktree, integration, and retirement tool registration, strict schemas, and prompt contracts only; it never creates or removes a worktree, runs a merge, or retires a branch. Real-Git lifecycle coverage runs under `npm run test` in isolated temporary local repositories with no remote contact.
|
|
30
30
|
- Isolated real-Git integration tests cover `already_integrated`, `fast_forward`, exact two-parent `merge_commit`, and conflict-path capture followed by verified automatic abort/restoration. They also cover target mismatch, dirty control state, rejection of branch-specific target merge options, unrelated histories, ignored-file overwrite protection, preserved repository hooks, and a committed source ref checked out in another dirty linked worktree. The recorded merge argv proves autostash and rerere are disabled, ignored-file protection is enabled, and `--no-verify` is absent.
|
|
31
31
|
- Isolated real-Git retirement tests cover merged and explicitly forced-unmerged local-ref deletion, stale expected-`HEAD` rejection, current and linked-worktree occupancy, dirty unrelated worktrees, non-current targets, and preservation of target refs, remote-tracking refs, worktrees, working-tree files, and `branch.<name>.*` configuration without contacting a remote. Recorded argv proves retirement uses `update-ref --no-deref -d` with the captured expected commit and never uses `git branch -d/-D`, fetch, push, or a remote ref target.
|
package/docs/STRUCTURE.md
CHANGED
|
@@ -30,7 +30,7 @@ src/
|
|
|
30
30
|
|
|
31
31
|
## Module boundaries
|
|
32
32
|
|
|
33
|
-
1. `src/extension.ts` stays small and registers the command, twenty-
|
|
33
|
+
1. `src/extension.ts` stays small and registers the command, twenty-three tools, and one `before_agent_start` context hook.
|
|
34
34
|
2. `src/git-context.ts` owns the shared read-only collector, escaped/bounded formatter, automatic system-prompt append, and the current-state output used by `branch_status`.
|
|
35
35
|
3. `src/commands/branchme-command.ts` parses `/branchme`, `/branchme help`, `--help`, and `-h`; it never performs git or GitHub mutations and avoids raw stdout in JSON mode.
|
|
36
36
|
4. `src/tools/branchme-tools.ts` owns strict TypeBox schemas, prompt metadata, bounded tool content, and safe structured details. `branch_status` delegates to the shared context collector and optional ancestry verifier; `integrate_branch` and `retire_branch` delegate to focused mutation state machines; worktree tools expose explicit inventory and verified handoff operations.
|
package/docs/TUI_CAPTURE.md
CHANGED
|
@@ -43,6 +43,7 @@ Commands only show info; BranchMe tools perform actions.
|
|
|
43
43
|
- `conclude_merge` — `action: "conclude"` commits the kept merge once every conflict marker is removed; `action: "abort"` restores the branch.
|
|
44
44
|
- `pull_request_status` — inspect a PR by number, or the latest PR for a head branch; not a CI/review verdict.
|
|
45
45
|
- `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.
|
|
46
|
+
- `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.
|
|
46
47
|
- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.
|
|
47
48
|
- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.
|
|
48
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension for verified Git repository initialization, branch, worktree, integration, retirement, push, and pull request workflows.",
|
|
6
6
|
"license": "MIT",
|
package/src/blocker-guidance.ts
CHANGED
|
@@ -112,6 +112,16 @@ const BLOCKER_RULES: readonly BlockerRule[] = [
|
|
|
112
112
|
why: "no local branch has that name.",
|
|
113
113
|
resolve: "list_branches to check names (fetch_remote first for remote branches); track_branch creates a local branch from a remote one.",
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
pattern: /has commits the merged PR head does not contain/iu,
|
|
117
|
+
why: "the local branch holds commits that never reached the merged pull request, so deleting it would lose them.",
|
|
118
|
+
resolve: "report the branch and the PR; those commits need their own delivery (a new branch and pull request from another step) or the user's decision to discard them. Do not retry land_branch unchanged.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
pattern: /PR merge evidence does not match/iu,
|
|
122
|
+
why: "the pull request is not merged, or names a different head or base branch than the landing request.",
|
|
123
|
+
resolve: "read pull_request_status for the PR and retry land_branch once with its exact head and base branch names.",
|
|
124
|
+
},
|
|
115
125
|
{
|
|
116
126
|
pattern: /moved after|changed while|moved concurrently|changed during/iu,
|
|
117
127
|
why: "repository state changed while the call ran.",
|
|
@@ -52,6 +52,7 @@ export function getBranchMeHelpText(): string {
|
|
|
52
52
|
"- `conclude_merge` — `action: \"conclude\"` commits the kept merge once every conflict marker is removed; `action: \"abort\"` restores the branch.",
|
|
53
53
|
"- `pull_request_status` — inspect a PR by number, or the latest PR for a head branch; not a CI/review verdict.",
|
|
54
54
|
"- `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.",
|
|
55
|
+
"- `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.",
|
|
55
56
|
"- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.",
|
|
56
57
|
"- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.",
|
|
57
58
|
"",
|
package/src/constants.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const PUSH_BRANCH_TOOL_NAME = "push_branch";
|
|
|
17
17
|
export const PULL_REQUEST_TOOL_NAME = "pull_request";
|
|
18
18
|
export const PULL_REQUEST_STATUS_TOOL_NAME = "pull_request_status";
|
|
19
19
|
export const PULL_REQUEST_FEEDBACK_TOOL_NAME = "pull_request_feedback";
|
|
20
|
+
export const REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME = "reply_pull_request_feedback";
|
|
20
21
|
export const LIST_WORKTREES_TOOL_NAME = "list_worktrees";
|
|
21
22
|
export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
|
|
22
23
|
export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
|
|
@@ -45,6 +46,7 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
45
46
|
PULL_REQUEST_TOOL_NAME,
|
|
46
47
|
PULL_REQUEST_STATUS_TOOL_NAME,
|
|
47
48
|
PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
49
|
+
REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
48
50
|
LIST_WORKTREES_TOOL_NAME,
|
|
49
51
|
CREATE_WORKTREE_TOOL_NAME,
|
|
50
52
|
REMOVE_WORKTREE_TOOL_NAME,
|
|
@@ -95,5 +97,6 @@ export const PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT = 3;
|
|
|
95
97
|
export const PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS = 2_000;
|
|
96
98
|
export const PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS = 6_000;
|
|
97
99
|
export const PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS = 24_000;
|
|
100
|
+
export const PULL_REQUEST_REPLY_BODY_LIMIT_CHARS = 4_000;
|
|
98
101
|
export const GITHUB_GRAPHQL_TIMEOUT_MS = 15_000;
|
|
99
102
|
export const GITHUB_JOB_LOG_TIMEOUT_MS = 30_000;
|
package/src/git-landing.ts
CHANGED
|
@@ -55,7 +55,8 @@ export interface LandBranchReceipt {
|
|
|
55
55
|
sourceHead: string | null;
|
|
56
56
|
ancestry: { isAncestor: boolean | null };
|
|
57
57
|
mergeProof: "ancestry" | "pull-request" | null;
|
|
58
|
-
|
|
58
|
+
/** sourceInHead: the local source head is the merged PR head or one of its ancestors (a branch left behind). */
|
|
59
|
+
pullRequest: { number: number; headSha: string; mergeCommitSha: string; sourceInHead: boolean } | null;
|
|
59
60
|
worktree: {
|
|
60
61
|
path: string | null;
|
|
61
62
|
outcome: "removed" | "absent" | "refused";
|
|
@@ -240,8 +241,8 @@ async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, sig
|
|
|
240
241
|
const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
241
242
|
if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
|
|
242
243
|
const worktreeHead = validated.worktree.head;
|
|
243
|
-
const exactMergedPrHead = receipt.mergeProof === "pull-request" &&
|
|
244
|
-
|
|
244
|
+
const exactMergedPrHead = receipt.mergeProof === "pull-request" && validated.worktree.branch === receipt.sourceBranch &&
|
|
245
|
+
(worktreeHead === receipt.pullRequest?.headSha || (worktreeHead === receipt.sourceHead && receipt.pullRequest?.sourceInHead === true));
|
|
245
246
|
if (!worktreeHead || !receipt.remoteTargetHead ||
|
|
246
247
|
(!exactMergedPrHead && !(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal)))) {
|
|
247
248
|
throw new Error("Selected worktree HEAD is not merged into the fetched target.");
|
|
@@ -297,7 +298,7 @@ async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signa
|
|
|
297
298
|
targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
|
|
298
299
|
// A verified host merge authorizes retiring rewritten source history, not a
|
|
299
300
|
// general force option. Identity, occupancy, target stability and lease checks remain.
|
|
300
|
-
force: receipt.mergeProof === "pull-request" && receipt.pullRequest?.
|
|
301
|
+
force: receipt.mergeProof === "pull-request" && receipt.pullRequest?.sourceInHead === true && head === receipt.sourceHead,
|
|
301
302
|
}, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
|
|
302
303
|
receipt.branch.outcome = "deleted";
|
|
303
304
|
receipt.branch.reason = null;
|
|
@@ -415,6 +416,15 @@ async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unkn
|
|
|
415
416
|
return receipt;
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
// A commit missing locally (an unfetched squash-merged PR head) proves nothing: not contained.
|
|
420
|
+
async function commitContains(pi: GitAPI, ctx: GitCommandContext, ancestor: string, descendant: string, signal?: AbortSignal): Promise<boolean> {
|
|
421
|
+
try {
|
|
422
|
+
return await isCommitAncestor(pi, ctx, ancestor, descendant, signal);
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
418
428
|
async function verifyLandingPullRequest(
|
|
419
429
|
pi: GitAPI,
|
|
420
430
|
ctx: GitCommandContext,
|
|
@@ -431,14 +441,20 @@ async function verifyLandingPullRequest(
|
|
|
431
441
|
}
|
|
432
442
|
const token = (await resolveGitHubToken(options.env, { cwd: ctx.cwd, signal })).token;
|
|
433
443
|
const pr = await getGitHubPullRequest(repository, number, token, { fetchImpl: options.fetchImpl, signal });
|
|
434
|
-
if (!pr.merged || pr.state !== "closed" || !pr.mergeCommitSha || pr.head !== receipt.sourceBranch ||
|
|
435
|
-
pr.base !== receipt.targetBranch || (receipt.sourceHead !== null && pr.headSha !== receipt.sourceHead)) {
|
|
444
|
+
if (!pr.merged || pr.state !== "closed" || !pr.mergeCommitSha || pr.head !== receipt.sourceBranch || pr.base !== receipt.targetBranch) {
|
|
436
445
|
throw new Error("PR merge evidence does not match the exact source commit and target branch; landing refused.");
|
|
437
446
|
}
|
|
447
|
+
// A local branch behind the merged head (the PR received commits pushed from another
|
|
448
|
+
// checkout, such as a merge of the base) holds nothing the PR lacks.
|
|
449
|
+
const sourceInHead = receipt.sourceHead === null || pr.headSha === receipt.sourceHead ||
|
|
450
|
+
await commitContains(pi, ctx, receipt.sourceHead, pr.headSha, signal);
|
|
451
|
+
if (!sourceInHead) {
|
|
452
|
+
throw new Error("The local source branch has commits the merged PR head does not contain; landing refused to keep them.");
|
|
453
|
+
}
|
|
438
454
|
if (!receipt.remoteTargetHead || !(await isCommitAncestor(pi, ctx, pr.mergeCommitSha, receipt.remoteTargetHead, signal))) {
|
|
439
455
|
throw new Error("The PR merge commit is not contained in the fetched remote target; landing refused.");
|
|
440
456
|
}
|
|
441
|
-
receipt.pullRequest = { number: pr.number, headSha: pr.headSha, mergeCommitSha: pr.mergeCommitSha };
|
|
457
|
+
receipt.pullRequest = { number: pr.number, headSha: pr.headSha, mergeCommitSha: pr.mergeCommitSha, sourceInHead };
|
|
442
458
|
receipt.mergeProof = "pull-request";
|
|
443
459
|
}
|
|
444
460
|
|
package/src/github-feedback.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
GITHUB_API_BASE_URL, GITHUB_GRAPHQL_TIMEOUT_MS, GITHUB_JOB_LOG_TIMEOUT_MS, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS,
|
|
3
3
|
PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT, PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES,
|
|
4
4
|
PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS,
|
|
5
|
+
PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
5
6
|
} from "./constants.ts";
|
|
6
7
|
import {
|
|
7
8
|
encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
|
|
@@ -10,22 +11,24 @@ import {
|
|
|
10
11
|
} from "./github.ts";
|
|
11
12
|
import { redactSecrets } from "./redaction.ts";
|
|
12
13
|
import type {
|
|
13
|
-
GitHubRepository, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
14
|
-
PullRequestFeedbackReview, PullRequestFeedbackThread,
|
|
14
|
+
GitHubRepository, PullRequestFeedbackAnnotation, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
15
|
+
PullRequestFeedbackReview, PullRequestFeedbackThread, PullRequestReplyDetails, PullRequestReplyInput,
|
|
15
16
|
} from "./types.ts";
|
|
16
17
|
|
|
17
18
|
// One GraphQL request reads review threads with their resolution state, review summaries, conversation
|
|
18
19
|
// comments and the head commit's check rollup; REST has no thread resolution state.
|
|
20
|
+
// Ids, times and the viewer let an agent reply to a thread and skip its own earlier replies.
|
|
19
21
|
const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
22
|
+
viewer { login }
|
|
20
23
|
repository(owner: $owner, name: $repo) {
|
|
21
24
|
pullRequest(number: $number) {
|
|
22
|
-
number url headRefOid
|
|
23
|
-
reviewThreads(first: 100) { totalCount nodes { isResolved isOutdated path line comments(first: 20) { nodes { author { login } body url } } } }
|
|
24
|
-
reviews(last: 50) { nodes { author { login } state body url } }
|
|
25
|
-
comments(last: 50) { totalCount nodes { author { login } body url } }
|
|
25
|
+
id number url headRefOid
|
|
26
|
+
reviewThreads(first: 100) { totalCount nodes { id isResolved isOutdated path line comments(first: 20) { nodes { id author { login } createdAt body url } } } }
|
|
27
|
+
reviews(last: 50) { nodes { id author { login } submittedAt state body url } }
|
|
28
|
+
comments(last: 50) { totalCount nodes { id author { login } createdAt body url } }
|
|
26
29
|
commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { totalCount nodes {
|
|
27
30
|
__typename
|
|
28
|
-
... on CheckRun { name conclusion databaseId detailsUrl }
|
|
31
|
+
... on CheckRun { name conclusion databaseId detailsUrl annotations(first: 20) { nodes { path location { start { line } } annotationLevel message } } }
|
|
29
32
|
... on StatusContext { context state targetUrl }
|
|
30
33
|
} } } } } }
|
|
31
34
|
}
|
|
@@ -34,6 +37,8 @@ const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
|
34
37
|
|
|
35
38
|
const FAILING_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "CANCELLED"]);
|
|
36
39
|
const FAILING_STATUS_STATES = new Set(["FAILURE", "ERROR"]);
|
|
40
|
+
const REPORTED_ANNOTATION_LEVELS = new Set(["FAILURE", "WARNING"]);
|
|
41
|
+
const ANNOTATION_MESSAGE_LIMIT_CHARS = 500;
|
|
37
42
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/gu;
|
|
38
43
|
const LOG_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z ?/u;
|
|
39
44
|
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu;
|
|
@@ -53,16 +58,19 @@ function totalCount(connection: unknown): number {
|
|
|
53
58
|
return isRecord(connection) && typeof connection.totalCount === "number" ? connection.totalCount : 0;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
function parseComment(node: Record<string, unknown
|
|
61
|
+
function parseComment(viewer: string | null, token: string, node: Record<string, unknown>): PullRequestFeedbackComment {
|
|
57
62
|
const author = isRecord(node.author) ? safeText(node.author.login, token, 100) : "";
|
|
58
63
|
return {
|
|
64
|
+
id: safeText(node.id, token, 200),
|
|
59
65
|
author: author || "ghost",
|
|
66
|
+
byViewer: viewer !== null && author === viewer,
|
|
67
|
+
createdAt: safeText(node.createdAt ?? node.submittedAt, token, 40),
|
|
60
68
|
body: safeText(node.body, token, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS),
|
|
61
69
|
url: safeText(node.url, token, 300),
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
73
|
+
function parseThreads(connection: unknown, viewer: string | null, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
66
74
|
const unresolvedThreads: PullRequestFeedbackThread[] = [];
|
|
67
75
|
let resolvedThreadCount = 0;
|
|
68
76
|
let outdatedThreadCount = 0;
|
|
@@ -71,35 +79,55 @@ function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedb
|
|
|
71
79
|
else if (thread.isOutdated === true) outdatedThreadCount += 1;
|
|
72
80
|
else {
|
|
73
81
|
unresolvedThreads.push({
|
|
82
|
+
id: safeText(thread.id, token, 200),
|
|
74
83
|
path: safeText(thread.path, token, 300),
|
|
75
84
|
line: typeof thread.line === "number" ? thread.line : null,
|
|
76
|
-
comments: nodes(thread.comments).map(
|
|
85
|
+
comments: nodes(thread.comments).map(parseComment.bind(undefined, viewer, token)),
|
|
77
86
|
});
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
return { unresolvedThreads, resolvedThreadCount, outdatedThreadCount };
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
function parseReviews(connection: unknown, token: string): PullRequestFeedbackReview[] {
|
|
92
|
+
function parseReviews(connection: unknown, viewer: string | null, token: string): PullRequestFeedbackReview[] {
|
|
84
93
|
return nodes(connection)
|
|
85
|
-
.map((review) => ({ ...parseComment(
|
|
94
|
+
.map((review) => ({ ...parseComment(viewer, token, review), state: safeText(review.state, token, 40) }))
|
|
86
95
|
.filter((review) => review.body !== "" || review.state === "CHANGES_REQUESTED");
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
function parseAnnotation(token: string, node: Record<string, unknown>): PullRequestFeedbackAnnotation | null {
|
|
99
|
+
const level = typeof node.annotationLevel === "string" ? node.annotationLevel : "";
|
|
100
|
+
if (!REPORTED_ANNOTATION_LEVELS.has(level)) return null;
|
|
101
|
+
const start = isRecord(node.location) && isRecord(node.location.start) ? node.location.start : null;
|
|
102
|
+
return {
|
|
103
|
+
path: safeText(node.path, token, 300),
|
|
104
|
+
line: typeof start?.line === "number" ? start.line : null,
|
|
105
|
+
level,
|
|
106
|
+
message: safeText(node.message, token, ANNOTATION_MESSAGE_LIMIT_CHARS),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
function parseCheck(node: Record<string, unknown>, token: string): PullRequestFeedbackCheck | null {
|
|
90
111
|
if (node.__typename === "CheckRun") {
|
|
91
112
|
const conclusion = typeof node.conclusion === "string" ? node.conclusion : "";
|
|
92
113
|
if (!FAILING_CHECK_CONCLUSIONS.has(conclusion)) return null;
|
|
93
114
|
const detailsUrl = safeText(node.detailsUrl, token, 300);
|
|
94
115
|
const isActionsJob = detailsUrl.includes("/actions/runs/") && typeof node.databaseId === "number";
|
|
95
|
-
|
|
116
|
+
const annotations = nodes(node.annotations)
|
|
117
|
+
.map(parseAnnotation.bind(undefined, token))
|
|
118
|
+
.filter((annotation): annotation is PullRequestFeedbackAnnotation => annotation !== null);
|
|
119
|
+
return { name: safeText(node.name, token, 200), conclusion, url: detailsUrl, jobId: isActionsJob ? node.databaseId as number : null, annotations };
|
|
96
120
|
}
|
|
97
121
|
const state = typeof node.state === "string" ? node.state : "";
|
|
98
122
|
if (!FAILING_STATUS_STATES.has(state)) return null;
|
|
99
|
-
return { name: safeText(node.context, token, 200), conclusion: state, url: safeText(node.targetUrl, token, 300), jobId: null };
|
|
123
|
+
return { name: safeText(node.context, token, 200), conclusion: state, url: safeText(node.targetUrl, token, 300), jobId: null, annotations: [] };
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
async function postFeedbackQuery(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions): Promise<Record<string, unknown>> {
|
|
127
|
+
return postGraphQL(FEEDBACK_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request feedback");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function postGraphQL(query: string, variables: Record<string, unknown>, token: string, options: PullRequestFetchOptions, label: string): Promise<Record<string, unknown>> {
|
|
103
131
|
const controller = new AbortController();
|
|
104
132
|
const timer = setTimeout(controller.abort.bind(controller), GITHUB_GRAPHQL_TIMEOUT_MS);
|
|
105
133
|
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
@@ -107,16 +135,16 @@ async function postFeedbackQuery(repository: GitHubRepository, number: number, t
|
|
|
107
135
|
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), `${GITHUB_API_BASE_URL}/graphql`, {
|
|
108
136
|
method: "POST",
|
|
109
137
|
headers: gitHubJsonHeaders(token),
|
|
110
|
-
body: JSON.stringify({ query
|
|
138
|
+
body: JSON.stringify({ query, variables }),
|
|
111
139
|
signal,
|
|
112
|
-
},
|
|
113
|
-
if (!response.ok) throw new Error(
|
|
140
|
+
}, `${label} lookup failed`, token);
|
|
141
|
+
if (!response.ok) throw new Error(`${label} lookup returned HTTP ${response.status}.`);
|
|
114
142
|
const body = await readBoundedResponseText(response, signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
115
|
-
if (body.truncated) throw new Error(
|
|
116
|
-
const payload = requireGitHubResponseObject(parseGitHubJson(body.text,
|
|
143
|
+
if (body.truncated) throw new Error(`${label} exceeded the response byte limit.`);
|
|
144
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(body.text, `${label} response`, token), label);
|
|
117
145
|
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
118
146
|
const messages = payload.errors.map((error) => (isRecord(error) ? safeText(error.message, token, 300) : "")).filter(Boolean);
|
|
119
|
-
throw new Error(
|
|
147
|
+
throw new Error(`${label} query failed: ${messages.join("; ") || "unknown GraphQL error"}`);
|
|
120
148
|
}
|
|
121
149
|
return payload;
|
|
122
150
|
} finally {
|
|
@@ -184,6 +212,7 @@ export async function getPullRequestFeedback(repository: GitHubRepository, numbe
|
|
|
184
212
|
pullRequestNumberField(number);
|
|
185
213
|
const payload = await postFeedbackQuery(repository, number, token, options);
|
|
186
214
|
const data = isRecord(payload.data) ? payload.data : {};
|
|
215
|
+
const viewer = isRecord(data.viewer) ? safeText(data.viewer.login, token, 100) || null : null;
|
|
187
216
|
const pullRequest = isRecord(data.repository) && isRecord(data.repository.pullRequest) ? data.repository.pullRequest : null;
|
|
188
217
|
if (!pullRequest) throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
|
|
189
218
|
if (pullRequestNumberField(pullRequest.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
@@ -201,12 +230,14 @@ export async function getPullRequestFeedback(repository: GitHubRepository, numbe
|
|
|
201
230
|
return {
|
|
202
231
|
repository,
|
|
203
232
|
number,
|
|
233
|
+
id: safeText(pullRequest.id, token, 200),
|
|
234
|
+
viewer,
|
|
204
235
|
url: safeText(pullRequest.url, token, 300),
|
|
205
236
|
headSha: safeText(pullRequest.headRefOid, token, 64),
|
|
206
237
|
checksState: rollup ? safeText(rollup.state, token, 40) : null,
|
|
207
|
-
...parseThreads(pullRequest.reviewThreads, token),
|
|
208
|
-
reviews: parseReviews(pullRequest.reviews, token),
|
|
209
|
-
comments: commentNodes.map(
|
|
238
|
+
...parseThreads(pullRequest.reviewThreads, viewer, token),
|
|
239
|
+
reviews: parseReviews(pullRequest.reviews, viewer, token),
|
|
240
|
+
comments: commentNodes.map(parseComment.bind(undefined, viewer, token)).filter((comment) => comment.body !== ""),
|
|
210
241
|
failingChecks,
|
|
211
242
|
omitted: {
|
|
212
243
|
threads: Math.max(0, totalCount(pullRequest.reviewThreads) - threadNodes.length),
|
|
@@ -220,13 +251,31 @@ function indent(text: string): string {
|
|
|
220
251
|
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
221
252
|
}
|
|
222
253
|
|
|
254
|
+
function location(path: string, line: number | null): string {
|
|
255
|
+
return line === null ? path : `${path}:${line}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// "@alice (you) 2026-10-14T19:50:15Z" — the marker and time decide what is new.
|
|
259
|
+
function byline(comment: PullRequestFeedbackComment): string {
|
|
260
|
+
return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt].filter(Boolean).join(" ");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function idTag(kind: string, id: string): string {
|
|
264
|
+
return id ? `[${kind} ${id}] ` : "";
|
|
265
|
+
}
|
|
266
|
+
|
|
223
267
|
function formatThread(thread: PullRequestFeedbackThread): string {
|
|
224
|
-
|
|
225
|
-
|
|
268
|
+
return [
|
|
269
|
+
`- ${idTag("thread", thread.id)}${location(thread.path, thread.line)}`,
|
|
270
|
+
...thread.comments.map((comment) => ` ${byline(comment)}: ${comment.body}`),
|
|
271
|
+
].join("\n");
|
|
226
272
|
}
|
|
227
273
|
|
|
228
274
|
function formatCheck(check: PullRequestFeedbackCheck): string {
|
|
229
275
|
const lines = [`- ${check.name}: ${check.conclusion} ${check.url}`.trimEnd()];
|
|
276
|
+
if (check.annotations.length > 0) {
|
|
277
|
+
lines.push(" Annotations:", ...check.annotations.map((item) => ` ${location(item.path, item.line)} ${item.level}: ${item.message}`));
|
|
278
|
+
}
|
|
230
279
|
if (check.logTail) lines.push(` Log tail (job ${check.jobId}):`, indent(check.logTail));
|
|
231
280
|
if (check.logError) lines.push(` Log unavailable: ${check.logError}`);
|
|
232
281
|
return lines.join("\n");
|
|
@@ -238,15 +287,108 @@ export function formatPullRequestFeedback(details: PullRequestFeedbackDetails):
|
|
|
238
287
|
const omittedChecks = details.omitted.checks ? ` (${details.omitted.checks} checks beyond the first 100 not read)` : "";
|
|
239
288
|
const sections = [
|
|
240
289
|
`PR #${details.number} feedback at head ${details.headSha.slice(0, 12)}; checks ${details.checksState ?? "not reported"}. ${details.url}`,
|
|
290
|
+
...(details.viewer ? [`Token user: @${details.viewer}; comments marked (you) are its own earlier replies.`] : []),
|
|
241
291
|
`Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
|
|
242
292
|
...details.unresolvedThreads.map(formatThread),
|
|
243
293
|
`Reviews with a body or requested changes: ${details.reviews.length}.`,
|
|
244
|
-
...details.reviews.map((review) => `-
|
|
294
|
+
...details.reviews.map((review) => `- ${byline(review)} ${review.state}: ${review.body}`),
|
|
245
295
|
`Conversation comments: ${details.comments.length}${omittedComments}.`,
|
|
246
|
-
...details.comments.map((comment) => `-
|
|
296
|
+
...details.comments.map((comment) => `- ${idTag("comment", comment.id)}${byline(comment)}: ${comment.body}`),
|
|
247
297
|
`Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
|
|
248
298
|
...details.failingChecks.map(formatCheck),
|
|
249
299
|
];
|
|
250
300
|
const text = sections.join("\n");
|
|
251
301
|
return text.length <= PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS ? text : `${text.slice(0, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS - 40)}\n… [truncated; full data in details]`;
|
|
252
302
|
}
|
|
303
|
+
|
|
304
|
+
const THREAD_OWNER_QUERY = `query($id: ID!) {
|
|
305
|
+
node(id: $id) { ... on PullRequestReviewThread { id isResolved pullRequest { number repository { name owner { login } } } } }
|
|
306
|
+
}`;
|
|
307
|
+
const PULL_REQUEST_EXISTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
308
|
+
repository(owner: $owner, name: $repo) { pullRequest(number: $number) { number } }
|
|
309
|
+
}`;
|
|
310
|
+
const THREAD_REPLY_MUTATION = `mutation($threadId: ID!, $body: String!) {
|
|
311
|
+
addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { comment { url } }
|
|
312
|
+
}`;
|
|
313
|
+
const RESOLVE_THREAD_MUTATION = `mutation($threadId: ID!) {
|
|
314
|
+
resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
|
|
315
|
+
}`;
|
|
316
|
+
|
|
317
|
+
function validateReplyInput(input: PullRequestReplyInput): string {
|
|
318
|
+
pullRequestNumberField(input.number);
|
|
319
|
+
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
320
|
+
if (body === "") throw new Error("body is required and cannot be blank.");
|
|
321
|
+
if (body.length > PULL_REQUEST_REPLY_BODY_LIMIT_CHARS) throw new Error(`body exceeds ${PULL_REQUEST_REPLY_BODY_LIMIT_CHARS} characters.`);
|
|
322
|
+
if (input.resolve === true && !input.threadId) throw new Error("resolve requires threadId; conversation comments cannot be resolved.");
|
|
323
|
+
return body;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The thread must belong to this pull request of this repository before anything is posted.
|
|
327
|
+
async function requireThreadOfPullRequest(repository: GitHubRepository, input: PullRequestReplyInput, token: string, options: PullRequestFetchOptions): Promise<boolean> {
|
|
328
|
+
const payload = await postGraphQL(THREAD_OWNER_QUERY, { id: input.threadId }, token, options, "GitHub review thread");
|
|
329
|
+
const node = isRecord(payload.data) && isRecord(payload.data.node) ? payload.data.node : null;
|
|
330
|
+
const pullRequest = node && isRecord(node.pullRequest) ? node.pullRequest : null;
|
|
331
|
+
const owner = pullRequest && isRecord(pullRequest.repository) ? pullRequest.repository : null;
|
|
332
|
+
const ownerLogin = owner && isRecord(owner.owner) ? owner.owner.login : null;
|
|
333
|
+
if (!node || node.id !== input.threadId || pullRequest?.number !== input.number ||
|
|
334
|
+
typeof ownerLogin !== "string" || ownerLogin.toLowerCase() !== repository.owner.toLowerCase() ||
|
|
335
|
+
typeof owner?.name !== "string" || owner.name.toLowerCase() !== repository.repo.toLowerCase()) {
|
|
336
|
+
throw new Error(`Review thread ${safeText(input.threadId, token, 200)} is not a thread of pull request #${input.number} in ${repository.owner}/${repository.repo}; nothing was posted.`);
|
|
337
|
+
}
|
|
338
|
+
return node.isResolved === true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function replyToThread(repository: GitHubRepository, input: PullRequestReplyInput, body: string, token: string, options: PullRequestFetchOptions): Promise<PullRequestReplyDetails> {
|
|
342
|
+
const alreadyResolved = await requireThreadOfPullRequest(repository, input, token, options);
|
|
343
|
+
const reply = await postGraphQL(THREAD_REPLY_MUTATION, { threadId: input.threadId, body }, token, options, "GitHub review thread reply");
|
|
344
|
+
const added = isRecord(reply.data) && isRecord(reply.data.addPullRequestReviewThreadReply) ? reply.data.addPullRequestReviewThreadReply : null;
|
|
345
|
+
const commentUrl = added && isRecord(added.comment) ? safeText(added.comment.url, token, 300) : "";
|
|
346
|
+
if (!commentUrl) throw new Error("GitHub did not confirm the review thread reply.");
|
|
347
|
+
let resolved: boolean | null = null;
|
|
348
|
+
if (input.resolve === true) {
|
|
349
|
+
resolved = alreadyResolved;
|
|
350
|
+
if (!alreadyResolved) {
|
|
351
|
+
const result = await postGraphQL(RESOLVE_THREAD_MUTATION, { threadId: input.threadId }, token, options, "GitHub review thread resolution");
|
|
352
|
+
const thread = isRecord(result.data) && isRecord(result.data.resolveReviewThread) && isRecord(result.data.resolveReviewThread.thread)
|
|
353
|
+
? result.data.resolveReviewThread.thread : null;
|
|
354
|
+
resolved = thread?.isResolved === true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { action: "reply_pull_request_feedback", repository, number: input.number, threadId: input.threadId ?? null, commentUrl, resolved };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function commentOnPullRequest(repository: GitHubRepository, input: PullRequestReplyInput, body: string, token: string, options: PullRequestFetchOptions): Promise<PullRequestReplyDetails> {
|
|
361
|
+
// Issues and pull requests share numbers; confirming a pull request first keeps the comment off an issue.
|
|
362
|
+
// GraphQL, not the REST status read: a pull request from a fork is still this repository's pull request.
|
|
363
|
+
const found = await postGraphQL(PULL_REQUEST_EXISTS_QUERY, { owner: repository.owner, repo: repository.repo, number: input.number }, token, options, "GitHub pull request");
|
|
364
|
+
const pullRequest = isRecord(found.data) && isRecord(found.data.repository) ? found.data.repository.pullRequest : null;
|
|
365
|
+
if (!isRecord(pullRequest) || pullRequest.number !== input.number) {
|
|
366
|
+
throw new Error(`Pull request #${input.number} was not found in ${repository.owner}/${repository.repo}; nothing was posted.`);
|
|
367
|
+
}
|
|
368
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/issues/${input.number}/comments`;
|
|
369
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
370
|
+
method: "POST", headers: gitHubJsonHeaders(token), body: JSON.stringify({ body }), signal: options.signal,
|
|
371
|
+
}, "GitHub pull request comment failed", token);
|
|
372
|
+
if (!response.ok) throw new Error(`GitHub pull request comment returned HTTP ${response.status}.`);
|
|
373
|
+
const text = await readBoundedResponseText(response, options.signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
374
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(text.text, "GitHub pull request comment response", token), "Pull request comment");
|
|
375
|
+
const commentUrl = safeText(payload.html_url, token, 300);
|
|
376
|
+
if (!commentUrl) throw new Error("GitHub did not confirm the pull request comment.");
|
|
377
|
+
return { action: "reply_pull_request_feedback", repository, number: input.number, threadId: null, commentUrl, resolved: null };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Replies to one review thread (and optionally resolves it), or posts one conversation comment. */
|
|
381
|
+
export async function replyPullRequestFeedback(repository: GitHubRepository, input: PullRequestReplyInput, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestReplyDetails> {
|
|
382
|
+
validateGitHubRepository(repository);
|
|
383
|
+
const body = validateReplyInput(input);
|
|
384
|
+
return input.threadId
|
|
385
|
+
? replyToThread(repository, input, body, token, options)
|
|
386
|
+
: commentOnPullRequest(repository, input, body, token, options);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function formatPullRequestReply(details: PullRequestReplyDetails): string {
|
|
390
|
+
if (details.threadId === null) return `Commented on PR #${details.number}: ${details.commentUrl}`;
|
|
391
|
+
let resolution = "";
|
|
392
|
+
if (details.resolved !== null) resolution = details.resolved ? "; thread resolved" : "; thread NOT resolved";
|
|
393
|
+
return `Replied to thread ${details.threadId} on PR #${details.number}${resolution}: ${details.commentUrl}`;
|
|
394
|
+
}
|
|
@@ -2,13 +2,13 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import {
|
|
5
|
-
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
6
|
-
TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
|
|
5
|
+
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
6
|
+
PULL_REQUEST_STATUS_TOOL_NAME, REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
|
|
7
7
|
} from "../constants.ts";
|
|
8
8
|
import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
|
|
9
9
|
import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
|
|
10
10
|
import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
|
|
11
|
-
import { formatPullRequestFeedback, getPullRequestFeedback } from "../github-feedback.ts";
|
|
11
|
+
import { formatPullRequestFeedback, formatPullRequestReply, getPullRequestFeedback, replyPullRequestFeedback } from "../github-feedback.ts";
|
|
12
12
|
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
13
13
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
14
14
|
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
@@ -94,7 +94,7 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
94
94
|
pi.registerTool({
|
|
95
95
|
name: PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
96
96
|
label: "Pull Request Feedback",
|
|
97
|
-
description: "pull_request_feedback reads what a same-repository GitHub PR asks to be fixed: unresolved review threads with their comments, review summaries, conversation comments, and the head commit's failing checks with the log tail of up to three failing GitHub Actions jobs. Resolved and outdated threads are counted, not listed. No mutations.",
|
|
97
|
+
description: "pull_request_feedback reads what a same-repository GitHub PR asks to be fixed: unresolved review threads (with thread ids) and their comments, review summaries, conversation comments (with ids), each with author and time and comments by the token's own user marked (you), and the head commit's failing checks with their file:line annotations and the log tail of up to three failing GitHub Actions jobs. Resolved and outdated threads are counted, not listed. No mutations.",
|
|
98
98
|
promptSnippet: "pull_request_feedback: read a PR's unresolved review comments and failing checks with their log tails",
|
|
99
99
|
promptGuidelines: [
|
|
100
100
|
"Use pull_request_feedback with number to collect the review comments and failing checks a follow-up must address; number and headBranch are mutually exclusive.",
|
|
@@ -121,6 +121,31 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
121
121
|
return { content: [{ type: "text", text: formatPullRequestFeedback(feedback) }], details: { action: "pull_request_feedback", feedback } };
|
|
122
122
|
},
|
|
123
123
|
});
|
|
124
|
+
pi.registerTool({
|
|
125
|
+
name: REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
126
|
+
label: "Reply To Pull Request Feedback",
|
|
127
|
+
description: "reply_pull_request_feedback posts one reply on a pull request of this GitHub repository (including one opened from a fork): with threadId it replies in that review thread and, with resolve: true, resolves the thread; without threadId it posts one conversation comment. Works on open, merged and closed PRs. The thread must belong to the PR; nothing else is changed.",
|
|
128
|
+
promptSnippet: "reply_pull_request_feedback: answer a PR review thread (optionally resolving it) or post a PR comment",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Use reply_pull_request_feedback with the thread ids pull_request_feedback reports ([thread <id>]); one call per thread, the body states what was done or why not.",
|
|
131
|
+
"Pass reply_pull_request_feedback resolve: true only for a thread whose request is fixed or already addressed; leave a thread open when it needs a person's decision.",
|
|
132
|
+
"Without threadId, reply_pull_request_feedback posts one conversation comment on the PR; conversation comments cannot be resolved.",
|
|
133
|
+
"reply_pull_request_feedback never edits or deletes comments, approves, requests changes, merges, or closes a PR.",
|
|
134
|
+
],
|
|
135
|
+
parameters: Type.Object({
|
|
136
|
+
number: Type.Integer({ minimum: 1, description: "PR number." }),
|
|
137
|
+
body: Type.String({ minLength: 1, maxLength: PULL_REQUEST_REPLY_BODY_LIMIT_CHARS, description: "Markdown reply." }),
|
|
138
|
+
threadId: Type.Optional(Type.String({ minLength: 1, description: "Review thread id from pull_request_feedback; omit for a conversation comment." })),
|
|
139
|
+
resolve: Type.Optional(Type.Boolean({ description: "Resolve the thread after replying; requires threadId." })),
|
|
140
|
+
}, { additionalProperties: false }),
|
|
141
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
142
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
143
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
144
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
145
|
+
const details = await replyPullRequestFeedback(repository, params, token, { fetchImpl: options.fetchImpl, signal });
|
|
146
|
+
return { content: [{ type: "text", text: formatPullRequestReply(details) }], details };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
124
149
|
pi.registerTool({
|
|
125
150
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
126
151
|
label: "Update From Base",
|
package/src/types.ts
CHANGED
|
@@ -602,12 +602,19 @@ export interface PullRequestStatusDetails extends PullRequestDetails {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
export interface PullRequestFeedbackComment {
|
|
605
|
+
/** GraphQL node id; empty when GitHub did not return one. */
|
|
606
|
+
id: string;
|
|
605
607
|
author: string;
|
|
608
|
+
/** True when the token's own user wrote it (Mission's earlier replies). */
|
|
609
|
+
byViewer: boolean;
|
|
610
|
+
createdAt: string;
|
|
606
611
|
body: string;
|
|
607
612
|
url: string;
|
|
608
613
|
}
|
|
609
614
|
|
|
610
615
|
export interface PullRequestFeedbackThread {
|
|
616
|
+
/** Review thread node id, the target for replies and resolution. */
|
|
617
|
+
id: string;
|
|
611
618
|
path: string;
|
|
612
619
|
line: number | null;
|
|
613
620
|
comments: PullRequestFeedbackComment[];
|
|
@@ -622,13 +629,25 @@ export interface PullRequestFeedbackCheck {
|
|
|
622
629
|
conclusion: string;
|
|
623
630
|
url: string;
|
|
624
631
|
jobId: number | null;
|
|
632
|
+
annotations: PullRequestFeedbackAnnotation[];
|
|
625
633
|
logTail?: string;
|
|
626
634
|
logError?: string;
|
|
627
635
|
}
|
|
628
636
|
|
|
637
|
+
export interface PullRequestFeedbackAnnotation {
|
|
638
|
+
path: string;
|
|
639
|
+
line: number | null;
|
|
640
|
+
level: string;
|
|
641
|
+
message: string;
|
|
642
|
+
}
|
|
643
|
+
|
|
629
644
|
export interface PullRequestFeedbackDetails {
|
|
630
645
|
repository: GitHubRepository;
|
|
631
646
|
number: number;
|
|
647
|
+
/** Pull request node id. */
|
|
648
|
+
id: string;
|
|
649
|
+
/** Login of the token's user; null when GitHub did not return it. */
|
|
650
|
+
viewer: string | null;
|
|
632
651
|
url: string;
|
|
633
652
|
headSha: string;
|
|
634
653
|
checksState: string | null;
|
|
@@ -641,6 +660,23 @@ export interface PullRequestFeedbackDetails {
|
|
|
641
660
|
omitted: { threads: number; comments: number; checks: number };
|
|
642
661
|
}
|
|
643
662
|
|
|
663
|
+
export interface PullRequestReplyInput {
|
|
664
|
+
number: number;
|
|
665
|
+
body: string;
|
|
666
|
+
threadId?: string;
|
|
667
|
+
resolve?: boolean;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface PullRequestReplyDetails {
|
|
671
|
+
action: "reply_pull_request_feedback";
|
|
672
|
+
repository: GitHubRepository;
|
|
673
|
+
number: number;
|
|
674
|
+
threadId: string | null;
|
|
675
|
+
commentUrl: string;
|
|
676
|
+
/** Thread state after the call; null when resolution was not requested or this was a conversation comment. */
|
|
677
|
+
resolved: boolean | null;
|
|
678
|
+
}
|
|
679
|
+
|
|
644
680
|
export interface PullRequestInput {
|
|
645
681
|
headBranch: string;
|
|
646
682
|
baseBranch: string;
|