@senad-d/branchme 0.3.5 → 0.3.8
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 +14 -1
- package/README.md +5 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +3 -2
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +2 -2
- package/docs/TUI_CAPTURE.md +1 -0
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +1 -0
- package/src/constants.ts +11 -0
- package/src/git.ts +17 -4
- package/src/github-feedback.ts +252 -0
- package/src/github.ts +10 -10
- package/src/tools/branchme-tools.ts +5 -4
- package/src/tools/workflow-tools.ts +33 -2
- package/src/types.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.3.
|
|
3
|
+
## 0.3.8 - Unreleased
|
|
4
|
+
|
|
5
|
+
- Resolved four Sonar maintainability findings: fetch the bounded failing-job logs concurrently and simplify feedback formatting by extracting nested template literals.
|
|
6
|
+
|
|
7
|
+
## 0.3.7 - Unreleased
|
|
8
|
+
|
|
9
|
+
- Added read-only `pull_request_feedback`: one GraphQL request returns a same-repository PR's unresolved, current review threads with their comments (resolved and outdated threads counted only), review summaries with a body or requested changes, the latest 50 conversation comments, and the head commit's failing checks; for up to three failing GitHub Actions jobs it adds the log lines leading to the last `##[error]`. Agents that address PR feedback no longer need `gh pr view`, `gh api` or `gh run view`.
|
|
10
|
+
|
|
11
|
+
## 0.3.6 - Unreleased
|
|
12
|
+
|
|
13
|
+
- Resolved Sonar maintainability findings by using a set for integration-branch membership and non-mutating sorting for changed worktree paths.
|
|
14
|
+
|
|
15
|
+
- `create_branch`, `change_branch`, `pull_branch` and `push_branch` receipts carry the resulting full HEAD (`head`); `push_branch` also names the remote-tracking ref it updated (`remoteTrackingRef`) and its commit after the push (`remoteHead`), and the text result says whether it matches the local HEAD. A caller no longer needs `branch_status` after these calls to learn or prove the HEAD.
|
|
16
|
+
## 0.3.5 - Released
|
|
4
17
|
|
|
5
18
|
- `push_branch` never pushes onto a differently named integration branch: when the current branch's upstream is `main`, `master`, `trunk`, `develop`, or the origin default branch under another name (a feature branch created from `origin/main` with tracking), it publishes to the same-named branch on that remote with `--set-upstream`, reports `mode: "publish"`, and keeps the previous upstream in `upstream`. Previously such a branch was pushed straight onto the integration branch. Other differently named upstreams keep their explicit `HEAD:<upstream-ref>` push.
|
|
6
19
|
- Added optional `remove_worktree.discardChanges` (default `false`, unchanged behavior). When `true`, the user's explicit authorization, a worktree with staged, unstaged, untracked, or unmerged changes is removed with `git worktree remove --force` and the result lists every discarded path in `discardedPaths`; without it a dirty worktree is still refused, now naming the option. Locked, detached, main, current, prunable, missing, and foreign worktrees stay rejected, and the local branch is retained.
|
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-two 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, and create GitHub pull requests.
|
|
20
20
|
|
|
21
21
|
<table align="center">
|
|
22
22
|
<tr>
|
|
@@ -184,7 +184,7 @@ git remote set-url origin git@github.com:OWNER/REPO.git
|
|
|
184
184
|
export GITHUB_REPOSITORY=OWNER/REPO
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
For automatic related-PR lookup, `pull_request_status`, PR-aware `land_branch`, and `pull_request`, set a token in the process environment before starting pi:
|
|
187
|
+
For automatic related-PR lookup, `pull_request_status`, `pull_request_feedback`, PR-aware `land_branch`, and `pull_request`, set a token in the process environment before starting pi:
|
|
188
188
|
|
|
189
189
|
```bash
|
|
190
190
|
export GITHUB_TOKEN=github_pat_...
|
|
@@ -214,7 +214,7 @@ BranchMe has no separate project config file. It reads process environment varia
|
|
|
214
214
|
|
|
215
215
|
| Variable | Meaning |
|
|
216
216
|
| --- | --- |
|
|
217
|
-
| `GITHUB_TOKEN` | Preferred token for automatic related-PR lookup, `pull_request_status`, PR-aware `land_branch`, and `pull_request`; process environment first, then local `.env` fallback. |
|
|
217
|
+
| `GITHUB_TOKEN` | Preferred token for automatic related-PR lookup, `pull_request_status`, `pull_request_feedback`, PR-aware `land_branch`, and `pull_request`; process environment first, then local `.env` fallback. |
|
|
218
218
|
| `GH_TOKEN` | Fallback token for the same GitHub API operations; process environment first, then local `.env` fallback. |
|
|
219
219
|
| `BRANCHME_PR_AUTOFILL=true` | Allow `pull_request` to fill omitted PR fields. Accepts `true`/`false`, `1`/`0`, `yes`/`no`, or `on`/`off`; disabled by default. |
|
|
220
220
|
| `GITHUB_REPOSITORY=owner/repo` | Optional CI fallback and boundary check for the current GitHub repository; process environment only. |
|
|
@@ -257,6 +257,7 @@ 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
|
+
| `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. 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. |
|
|
260
261
|
| `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. |
|
|
261
262
|
| `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. |
|
|
262
263
|
| `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. |
|
|
@@ -564,7 +565,7 @@ npm run check:pack
|
|
|
564
565
|
printf '/branchme help\n/quit\n' | pi --no-extensions -e .
|
|
565
566
|
```
|
|
566
567
|
|
|
567
|
-
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-
|
|
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-two 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).
|
|
568
569
|
|
|
569
570
|
Refresh TUI captures intentionally with:
|
|
570
571
|
|
|
@@ -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-two strict agent-callable tools.
|
|
19
19
|
|
|
20
20
|
## 3. Users and use cases
|
|
21
21
|
|
|
@@ -56,6 +56,7 @@ 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
60
|
| 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 |
|
|
60
61
|
| 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 |
|
|
61
62
|
| 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 |
|
|
@@ -95,7 +96,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
95
96
|
- `src/github.ts`
|
|
96
97
|
- `src/ui/branchme-panel.ts`
|
|
97
98
|
- Module boundaries:
|
|
98
|
-
- The extension entry point registers the informational command, twenty-
|
|
99
|
+
- The extension entry point registers the informational command, twenty-two tools, and automatic context hook.
|
|
99
100
|
- `src/git-workflow.ts` and `src/tools/workflow-tools.ts` own verified remote tracking, base updates, and the new PR status registration.
|
|
100
101
|
- 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.
|
|
101
102
|
- 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-two tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `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-two 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
|
@@ -28,7 +28,7 @@ src/
|
|
|
28
28
|
|
|
29
29
|
## Module boundaries
|
|
30
30
|
|
|
31
|
-
1. `src/extension.ts` stays small and registers the command, twenty-
|
|
31
|
+
1. `src/extension.ts` stays small and registers the command, twenty-two tools, and one `before_agent_start` context hook.
|
|
32
32
|
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`.
|
|
33
33
|
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.
|
|
34
34
|
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.
|
|
@@ -86,7 +86,7 @@ src/
|
|
|
86
86
|
- `pull_request` reads `GITHUB_TOKEN` or `GH_TOKEN` from process environment first; only when neither process token is set does it read those token keys from a small regular `.env` file in the verified git root as a fallback. `BRANCHME_PR_AUTOFILL` uses the same process-first, `.env`-fallback precedence and defaults off.
|
|
87
87
|
- BranchMe does not force checkout/removal, move/prune/repair/lock/unlock worktrees, create detached/orphan worktrees, infer remote worktree branches, copy ignored/untracked files such as `.env`, delete ignored worktree residue through standalone `remove_worktree` without explicit `deleteIgnored: true` authorization, delete retained worktree branches during standalone removal, change Pi's cwd, or start Pi sessions. It also does not stash, create user-authored commits, accept commit messages, reset, force-push, directly edit project files, read unsupported `.env` keys, follow unsafe `.env` file types, depend on GitHub CLI, or collect telemetry. Explicit `integrate_branch`, `update_from_base`, and `conclude_merge` may let Git create a standard merge commit for divergent histories; `conclude_merge` is the only tool that stages, and only the formerly unmerged paths; only explicit leased `retire_branch` or merged-only `land_branch` may delete one exact local branch ref. Retirement has no bulk, inferred-target, remote, remote-tracking, rollback, or automatic worktree deletion. Git documents submodule worktree support as incomplete; BranchMe adds no force-based submodule cleanup.
|
|
88
88
|
|
|
89
|
-
`list_branches` returns bounded display metadata, including cached upstream counts and occupancy; optional kind and Git branch-list patterns filter before raw/entry limits and display redaction. `{}` preserves the full cached inventory, and automatic context is unchanged. For unknown issue branches, run `fetch_remote` separately and wait before listing. `pull_request_status` reads exact-number or latest-head PR state, not CI/review requirements. `pull_request` reuses matching open PRs without overwriting their metadata. PR-aware landing verifies repository/remote identity, exact merged head/base, and merge-commit containment before allowing non-ancestor local retirement; it retains the expected-HEAD lease and reports graph ancestry separately from host evidence.
|
|
89
|
+
`list_branches` returns bounded display metadata, including cached upstream counts and occupancy; optional kind and Git branch-list patterns filter before raw/entry limits and display redaction. `{}` preserves the full cached inventory, and automatic context is unchanged. For unknown issue branches, run `fetch_remote` separately and wait before listing. `pull_request_status` reads exact-number or latest-head PR state, not CI/review requirements. `pull_request_feedback` (in `src/github-feedback.ts`) reads unresolved review threads, review summaries, conversation comments and failing checks with Actions log tails. `pull_request` reuses matching open PRs without overwriting their metadata. PR-aware landing verifies repository/remote identity, exact merged head/base, and merge-commit containment before allowing non-ancestor local retirement; it retains the expected-HEAD lease and reports graph ancestry separately from host evidence.
|
|
90
90
|
|
|
91
91
|
## Documentation
|
|
92
92
|
|
package/docs/TUI_CAPTURE.md
CHANGED
|
@@ -42,6 +42,7 @@ Commands only show info; BranchMe tools perform actions.
|
|
|
42
42
|
- `update_from_base` — fetch and merge an explicit base into the current clean feature, preserving published history and upstream; a conflict is aborted unless `keepConflicts: true` leaves it in progress.
|
|
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
|
+
- `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.
|
|
45
46
|
- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.
|
|
46
47
|
- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.
|
|
47
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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",
|
|
@@ -51,6 +51,7 @@ export function getBranchMeHelpText(): string {
|
|
|
51
51
|
"- `update_from_base` — fetch and merge an explicit base into the current clean feature, preserving published history and upstream; a conflict is aborted unless `keepConflicts: true` leaves it in progress.",
|
|
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
|
+
"- `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.",
|
|
54
55
|
"- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.",
|
|
55
56
|
"- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.",
|
|
56
57
|
"",
|
package/src/constants.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
|
|
|
16
16
|
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
|
+
export const PULL_REQUEST_FEEDBACK_TOOL_NAME = "pull_request_feedback";
|
|
19
20
|
export const LIST_WORKTREES_TOOL_NAME = "list_worktrees";
|
|
20
21
|
export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
|
|
21
22
|
export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
|
|
@@ -43,6 +44,7 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
43
44
|
PUSH_BRANCH_TOOL_NAME,
|
|
44
45
|
PULL_REQUEST_TOOL_NAME,
|
|
45
46
|
PULL_REQUEST_STATUS_TOOL_NAME,
|
|
47
|
+
PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
46
48
|
LIST_WORKTREES_TOOL_NAME,
|
|
47
49
|
CREATE_WORKTREE_TOOL_NAME,
|
|
48
50
|
REMOVE_WORKTREE_TOOL_NAME,
|
|
@@ -86,3 +88,12 @@ export const GITHUB_USER_AGENT = "BranchMe Pi extension";
|
|
|
86
88
|
|
|
87
89
|
export const MAX_SUMMARY_OUTPUT_CHARS = 4_000;
|
|
88
90
|
export const GITHUB_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
|
|
91
|
+
export const PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES = 1024 * 1024;
|
|
92
|
+
export const PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES = 128 * 1024;
|
|
93
|
+
export const PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES = 60;
|
|
94
|
+
export const PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT = 3;
|
|
95
|
+
export const PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS = 2_000;
|
|
96
|
+
export const PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS = 6_000;
|
|
97
|
+
export const PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS = 24_000;
|
|
98
|
+
export const GITHUB_GRAPHQL_TIMEOUT_MS = 15_000;
|
|
99
|
+
export const GITHUB_JOB_LOG_TIMEOUT_MS = 30_000;
|
package/src/git.ts
CHANGED
|
@@ -563,7 +563,7 @@ async function resolvePushTarget(
|
|
|
563
563
|
};
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
const INTEGRATION_BRANCH_NAMES = ["main", "master", "trunk", "develop"];
|
|
566
|
+
const INTEGRATION_BRANCH_NAMES = new Set(["main", "master", "trunk", "develop"]);
|
|
567
567
|
|
|
568
568
|
async function isIntegrationBranch(
|
|
569
569
|
pi: Pick<ExtensionAPI, "exec">,
|
|
@@ -573,7 +573,7 @@ async function isIntegrationBranch(
|
|
|
573
573
|
signal?: AbortSignal,
|
|
574
574
|
): Promise<boolean> {
|
|
575
575
|
const branch = remoteRef.slice("refs/heads/".length);
|
|
576
|
-
if (INTEGRATION_BRANCH_NAMES.
|
|
576
|
+
if (INTEGRATION_BRANCH_NAMES.has(branch)) return true;
|
|
577
577
|
return remote === "origin" && branch === await getOriginDefaultBranch(pi, ctx, signal);
|
|
578
578
|
}
|
|
579
579
|
|
|
@@ -1828,7 +1828,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
|
1828
1828
|
return {
|
|
1829
1829
|
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1830
1830
|
ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
|
|
1831
|
-
changedPaths: changedPaths.sort(compareIgnoredPaths),
|
|
1831
|
+
changedPaths: [...changedPaths].sort(compareIgnoredPaths),
|
|
1832
1832
|
};
|
|
1833
1833
|
}
|
|
1834
1834
|
|
|
@@ -2687,8 +2687,9 @@ export async function createLocalBranch(
|
|
|
2687
2687
|
signal,
|
|
2688
2688
|
timeout: GIT_MUTATION_TIMEOUT_MS,
|
|
2689
2689
|
});
|
|
2690
|
+
const head = await getVerifiedRefCommit(pi, rootCtx, "HEAD", "HEAD", signal);
|
|
2690
2691
|
|
|
2691
|
-
return { repoRoot, previousBranch: safeDetail(previousBranch), newBranch: safeDetail(branchName) };
|
|
2692
|
+
return { repoRoot, previousBranch: safeDetail(previousBranch), newBranch: safeDetail(branchName), head };
|
|
2692
2693
|
});
|
|
2693
2694
|
}
|
|
2694
2695
|
|
|
@@ -2726,6 +2727,7 @@ export async function changeExistingLocalBranch(
|
|
|
2726
2727
|
if (current.detached || current.currentBranch !== branchName) {
|
|
2727
2728
|
throw new Error(`git switch did not end on branch '${redactSecrets(branchName)}'.`);
|
|
2728
2729
|
}
|
|
2730
|
+
const head = await getVerifiedRefCommit(pi, rootCtx, "HEAD", "HEAD", signal);
|
|
2729
2731
|
|
|
2730
2732
|
return {
|
|
2731
2733
|
repoRoot,
|
|
@@ -2733,6 +2735,7 @@ export async function changeExistingLocalBranch(
|
|
|
2733
2735
|
previousDetached: previous.detached,
|
|
2734
2736
|
currentBranch: safeDetail(current.currentBranch),
|
|
2735
2737
|
hasChangesBeforeSwitch: false,
|
|
2738
|
+
head,
|
|
2736
2739
|
};
|
|
2737
2740
|
});
|
|
2738
2741
|
}
|
|
@@ -2882,6 +2885,7 @@ export async function pullCurrentBranch(
|
|
|
2882
2885
|
signal,
|
|
2883
2886
|
timeout: GIT_PULL_TIMEOUT_MS,
|
|
2884
2887
|
});
|
|
2888
|
+
const head = await getVerifiedRefCommit(pi, rootCtx, "HEAD", "HEAD", signal);
|
|
2885
2889
|
|
|
2886
2890
|
return {
|
|
2887
2891
|
repoRoot,
|
|
@@ -2890,6 +2894,7 @@ export async function pullCurrentBranch(
|
|
|
2890
2894
|
remote: safeDetail(target.remote),
|
|
2891
2895
|
remoteRef: safeDetail(target.remoteRef),
|
|
2892
2896
|
output: safeOutput(result.stdout || result.stderr),
|
|
2897
|
+
head,
|
|
2893
2898
|
};
|
|
2894
2899
|
});
|
|
2895
2900
|
}
|
|
@@ -2981,6 +2986,11 @@ export async function pushCurrentBranch(
|
|
|
2981
2986
|
signal,
|
|
2982
2987
|
timeout: GIT_PUSH_TIMEOUT_MS,
|
|
2983
2988
|
});
|
|
2989
|
+
// The push updated the remote-tracking ref of the pushed branch; reading it
|
|
2990
|
+
// back is the remote-visible HEAD the receipt proves, without another fetch.
|
|
2991
|
+
const remoteTrackingRef = `${target.remote}/${target.remoteRef.replace(/^refs\/heads\//u, "")}`;
|
|
2992
|
+
const head = await getVerifiedRefCommit(pi, rootCtx, "HEAD", "HEAD", signal);
|
|
2993
|
+
const remoteHead = await getRemoteTrackingRefCommit(pi, rootCtx, remoteTrackingRef, signal);
|
|
2984
2994
|
|
|
2985
2995
|
return {
|
|
2986
2996
|
repoRoot,
|
|
@@ -2991,6 +3001,9 @@ export async function pushCurrentBranch(
|
|
|
2991
3001
|
remoteRef: safeDetail(target.remoteRef),
|
|
2992
3002
|
refspec: safeDetail(target.refspec),
|
|
2993
3003
|
output: safeOutput(result.stdout || result.stderr),
|
|
3004
|
+
head,
|
|
3005
|
+
remoteTrackingRef: safeDetail(remoteTrackingRef),
|
|
3006
|
+
remoteHead,
|
|
2994
3007
|
};
|
|
2995
3008
|
});
|
|
2996
3009
|
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GITHUB_API_BASE_URL, GITHUB_GRAPHQL_TIMEOUT_MS, GITHUB_JOB_LOG_TIMEOUT_MS, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS,
|
|
3
|
+
PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT, PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES,
|
|
4
|
+
PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS,
|
|
5
|
+
} from "./constants.ts";
|
|
6
|
+
import {
|
|
7
|
+
encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
|
|
8
|
+
readBoundedResponseText, requireFetchImplementation, requireGitHubResponseObject, validateGitHubRepository,
|
|
9
|
+
type PullRequestFetchOptions,
|
|
10
|
+
} from "./github.ts";
|
|
11
|
+
import { redactSecrets } from "./redaction.ts";
|
|
12
|
+
import type {
|
|
13
|
+
GitHubRepository, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
14
|
+
PullRequestFeedbackReview, PullRequestFeedbackThread,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// One GraphQL request reads review threads with their resolution state, review summaries, conversation
|
|
18
|
+
// comments and the head commit's check rollup; REST has no thread resolution state.
|
|
19
|
+
const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
20
|
+
repository(owner: $owner, name: $repo) {
|
|
21
|
+
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 } }
|
|
26
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { totalCount nodes {
|
|
27
|
+
__typename
|
|
28
|
+
... on CheckRun { name conclusion databaseId detailsUrl }
|
|
29
|
+
... on StatusContext { context state targetUrl }
|
|
30
|
+
} } } } } }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}`;
|
|
34
|
+
|
|
35
|
+
const FAILING_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "CANCELLED"]);
|
|
36
|
+
const FAILING_STATUS_STATES = new Set(["FAILURE", "ERROR"]);
|
|
37
|
+
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/gu;
|
|
38
|
+
const LOG_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z ?/u;
|
|
39
|
+
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu;
|
|
40
|
+
|
|
41
|
+
function safeText(value: unknown, token: string, limit: number): string {
|
|
42
|
+
if (typeof value !== "string") return "";
|
|
43
|
+
const text = redactSecrets(value, [token]).replace(UNSAFE_TEXT_PATTERN, (character) => (character === "\n" ? "\n" : " ")).trim();
|
|
44
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nodes(connection: unknown): Record<string, unknown>[] {
|
|
48
|
+
if (!isRecord(connection) || !Array.isArray(connection.nodes)) return [];
|
|
49
|
+
return connection.nodes.filter(isRecord);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function totalCount(connection: unknown): number {
|
|
53
|
+
return isRecord(connection) && typeof connection.totalCount === "number" ? connection.totalCount : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseComment(node: Record<string, unknown>, token: string): PullRequestFeedbackComment {
|
|
57
|
+
const author = isRecord(node.author) ? safeText(node.author.login, token, 100) : "";
|
|
58
|
+
return {
|
|
59
|
+
author: author || "ghost",
|
|
60
|
+
body: safeText(node.body, token, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS),
|
|
61
|
+
url: safeText(node.url, token, 300),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
66
|
+
const unresolvedThreads: PullRequestFeedbackThread[] = [];
|
|
67
|
+
let resolvedThreadCount = 0;
|
|
68
|
+
let outdatedThreadCount = 0;
|
|
69
|
+
for (const thread of nodes(connection)) {
|
|
70
|
+
if (thread.isResolved === true) resolvedThreadCount += 1;
|
|
71
|
+
else if (thread.isOutdated === true) outdatedThreadCount += 1;
|
|
72
|
+
else {
|
|
73
|
+
unresolvedThreads.push({
|
|
74
|
+
path: safeText(thread.path, token, 300),
|
|
75
|
+
line: typeof thread.line === "number" ? thread.line : null,
|
|
76
|
+
comments: nodes(thread.comments).map((comment) => parseComment(comment, token)),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { unresolvedThreads, resolvedThreadCount, outdatedThreadCount };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseReviews(connection: unknown, token: string): PullRequestFeedbackReview[] {
|
|
84
|
+
return nodes(connection)
|
|
85
|
+
.map((review) => ({ ...parseComment(review, token), state: safeText(review.state, token, 40) }))
|
|
86
|
+
.filter((review) => review.body !== "" || review.state === "CHANGES_REQUESTED");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCheck(node: Record<string, unknown>, token: string): PullRequestFeedbackCheck | null {
|
|
90
|
+
if (node.__typename === "CheckRun") {
|
|
91
|
+
const conclusion = typeof node.conclusion === "string" ? node.conclusion : "";
|
|
92
|
+
if (!FAILING_CHECK_CONCLUSIONS.has(conclusion)) return null;
|
|
93
|
+
const detailsUrl = safeText(node.detailsUrl, token, 300);
|
|
94
|
+
const isActionsJob = detailsUrl.includes("/actions/runs/") && typeof node.databaseId === "number";
|
|
95
|
+
return { name: safeText(node.name, token, 200), conclusion, url: detailsUrl, jobId: isActionsJob ? node.databaseId as number : null };
|
|
96
|
+
}
|
|
97
|
+
const state = typeof node.state === "string" ? node.state : "";
|
|
98
|
+
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 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function postFeedbackQuery(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions): Promise<Record<string, unknown>> {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timer = setTimeout(controller.abort.bind(controller), GITHUB_GRAPHQL_TIMEOUT_MS);
|
|
105
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), `${GITHUB_API_BASE_URL}/graphql`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: gitHubJsonHeaders(token),
|
|
110
|
+
body: JSON.stringify({ query: FEEDBACK_QUERY, variables: { owner: repository.owner, repo: repository.repo, number } }),
|
|
111
|
+
signal,
|
|
112
|
+
}, "GitHub pull request feedback lookup failed", token);
|
|
113
|
+
if (!response.ok) throw new Error(`GitHub pull request feedback lookup returned HTTP ${response.status}.`);
|
|
114
|
+
const body = await readBoundedResponseText(response, signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
115
|
+
if (body.truncated) throw new Error("GitHub pull request feedback exceeded the response byte limit.");
|
|
116
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(body.text, "GitHub pull request feedback response", token), "Pull request feedback");
|
|
117
|
+
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
118
|
+
const messages = payload.errors.map((error) => (isRecord(error) ? safeText(error.message, token, 300) : "")).filter(Boolean);
|
|
119
|
+
throw new Error(`GitHub pull request feedback query failed: ${messages.join("; ") || "unknown GraphQL error"}`);
|
|
120
|
+
}
|
|
121
|
+
return payload;
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readResponseTail(response: Response, signal: AbortSignal): Promise<string> {
|
|
128
|
+
if (!response.body) return "";
|
|
129
|
+
const reader = response.body.getReader();
|
|
130
|
+
let tail = Buffer.alloc(0);
|
|
131
|
+
try {
|
|
132
|
+
while (true) {
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
if (!value || value.byteLength === 0) continue;
|
|
137
|
+
tail = Buffer.concat([tail, Buffer.from(value)]);
|
|
138
|
+
if (tail.byteLength > PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES) tail = tail.subarray(tail.byteLength - PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
reader.releaseLock();
|
|
142
|
+
}
|
|
143
|
+
return tail.toString("utf8");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Keep the lines leading up to the last error annotation; without one, the end of the log.
|
|
147
|
+
export function extractLogTail(log: string, token: string): string {
|
|
148
|
+
const lines = log.split(/\r?\n/u).map((line) => line.replace(ANSI_ESCAPE_PATTERN, "").replace(LOG_TIMESTAMP_PATTERN, "")).filter((line) => line.trim() !== "");
|
|
149
|
+
let end = lines.length;
|
|
150
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
151
|
+
if (lines[index].includes("##[error]")) {
|
|
152
|
+
end = index + 1;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const excerpt = lines.slice(Math.max(0, end - PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES), end).join("\n");
|
|
157
|
+
const text = safeText(excerpt, token, Number.MAX_SAFE_INTEGER);
|
|
158
|
+
return text.length <= PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS ? text : `…${text.slice(text.length - PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS + 1)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function attachJobLog(repository: GitHubRepository, check: PullRequestFeedbackCheck, token: string, options: PullRequestFetchOptions): Promise<void> {
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timer = setTimeout(controller.abort.bind(controller), GITHUB_JOB_LOG_TIMEOUT_MS);
|
|
164
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
165
|
+
try {
|
|
166
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/actions/jobs/${check.jobId}/logs`;
|
|
167
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
168
|
+
method: "GET", headers: gitHubJsonHeaders(token), signal,
|
|
169
|
+
}, "GitHub job log lookup failed", token);
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
check.logError = `job log returned HTTP ${response.status}`;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
check.logTail = extractLogTail(await readResponseTail(response, signal), token);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
check.logError = safeText(error instanceof Error ? error.message : String(error), token, 300);
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function getPullRequestFeedback(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestFeedbackDetails> {
|
|
183
|
+
validateGitHubRepository(repository);
|
|
184
|
+
pullRequestNumberField(number);
|
|
185
|
+
const payload = await postFeedbackQuery(repository, number, token, options);
|
|
186
|
+
const data = isRecord(payload.data) ? payload.data : {};
|
|
187
|
+
const pullRequest = isRecord(data.repository) && isRecord(data.repository.pullRequest) ? data.repository.pullRequest : null;
|
|
188
|
+
if (!pullRequest) throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
|
|
189
|
+
if (pullRequestNumberField(pullRequest.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
190
|
+
|
|
191
|
+
const commit = nodes(pullRequest.commits)[0];
|
|
192
|
+
const rollup = isRecord(commit?.commit) && isRecord(commit.commit.statusCheckRollup) ? commit.commit.statusCheckRollup : null;
|
|
193
|
+
const contexts = rollup ? nodes(rollup.contexts) : [];
|
|
194
|
+
const failingChecks = contexts.map((node) => parseCheck(node, token)).filter((check): check is PullRequestFeedbackCheck => check !== null);
|
|
195
|
+
// ponytail: logs for the first few failing Actions jobs only; raise the limit if wide matrices hide the cause.
|
|
196
|
+
const logChecks = failingChecks.filter((item) => item.jobId !== null).slice(0, PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT);
|
|
197
|
+
await Promise.all(logChecks.map((check) => attachJobLog(repository, check, token, options)));
|
|
198
|
+
|
|
199
|
+
const threadNodes = nodes(pullRequest.reviewThreads);
|
|
200
|
+
const commentNodes = nodes(pullRequest.comments);
|
|
201
|
+
return {
|
|
202
|
+
repository,
|
|
203
|
+
number,
|
|
204
|
+
url: safeText(pullRequest.url, token, 300),
|
|
205
|
+
headSha: safeText(pullRequest.headRefOid, token, 64),
|
|
206
|
+
checksState: rollup ? safeText(rollup.state, token, 40) : null,
|
|
207
|
+
...parseThreads(pullRequest.reviewThreads, token),
|
|
208
|
+
reviews: parseReviews(pullRequest.reviews, token),
|
|
209
|
+
comments: commentNodes.map((comment) => parseComment(comment, token)).filter((comment) => comment.body !== ""),
|
|
210
|
+
failingChecks,
|
|
211
|
+
omitted: {
|
|
212
|
+
threads: Math.max(0, totalCount(pullRequest.reviewThreads) - threadNodes.length),
|
|
213
|
+
comments: Math.max(0, totalCount(pullRequest.comments) - commentNodes.length),
|
|
214
|
+
checks: rollup ? Math.max(0, totalCount(rollup.contexts) - contexts.length) : 0,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function indent(text: string): string {
|
|
220
|
+
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatThread(thread: PullRequestFeedbackThread): string {
|
|
224
|
+
const location = thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
|
|
225
|
+
return [`- ${location}`, ...thread.comments.map((comment) => ` @${comment.author}: ${comment.body}`)].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatCheck(check: PullRequestFeedbackCheck): string {
|
|
229
|
+
const lines = [`- ${check.name}: ${check.conclusion} ${check.url}`.trimEnd()];
|
|
230
|
+
if (check.logTail) lines.push(` Log tail (job ${check.jobId}):`, indent(check.logTail));
|
|
231
|
+
if (check.logError) lines.push(` Log unavailable: ${check.logError}`);
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function formatPullRequestFeedback(details: PullRequestFeedbackDetails): string {
|
|
236
|
+
const omittedThreads = details.omitted.threads ? `; ${details.omitted.threads} beyond the first 100 not read` : "";
|
|
237
|
+
const omittedComments = details.omitted.comments ? ` (latest 50; ${details.omitted.comments} older not read)` : "";
|
|
238
|
+
const omittedChecks = details.omitted.checks ? ` (${details.omitted.checks} checks beyond the first 100 not read)` : "";
|
|
239
|
+
const sections = [
|
|
240
|
+
`PR #${details.number} feedback at head ${details.headSha.slice(0, 12)}; checks ${details.checksState ?? "not reported"}. ${details.url}`,
|
|
241
|
+
`Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
|
|
242
|
+
...details.unresolvedThreads.map(formatThread),
|
|
243
|
+
`Reviews with a body or requested changes: ${details.reviews.length}.`,
|
|
244
|
+
...details.reviews.map((review) => `- @${review.author} ${review.state}: ${review.body}`),
|
|
245
|
+
`Conversation comments: ${details.comments.length}${omittedComments}.`,
|
|
246
|
+
...details.comments.map((comment) => `- @${comment.author}: ${comment.body}`),
|
|
247
|
+
`Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
|
|
248
|
+
...details.failingChecks.map(formatCheck),
|
|
249
|
+
];
|
|
250
|
+
const text = sections.join("\n");
|
|
251
|
+
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
|
+
}
|
package/src/github.ts
CHANGED
|
@@ -380,7 +380,7 @@ function truncate(value: string): string {
|
|
|
380
380
|
|
|
381
381
|
export { redactSecrets } from "./redaction.ts";
|
|
382
382
|
|
|
383
|
-
function encodePathSegment(value: string): string {
|
|
383
|
+
export function encodePathSegment(value: string): string {
|
|
384
384
|
return encodeURIComponent(value);
|
|
385
385
|
}
|
|
386
386
|
|
|
@@ -433,7 +433,7 @@ function validatePullRequestInput(input: unknown): asserts input is PullRequestI
|
|
|
433
433
|
if (typeof input.draft !== "boolean") throw new Error("draft must be a boolean.");
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
436
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
437
437
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
438
438
|
}
|
|
439
439
|
|
|
@@ -442,7 +442,7 @@ function stringField(value: unknown, field: string): string {
|
|
|
442
442
|
return value;
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
function pullRequestNumberField(value: unknown): number {
|
|
445
|
+
export function pullRequestNumberField(value: unknown): number {
|
|
446
446
|
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
|
|
447
447
|
throw new Error("GitHub response pull request number must be a finite positive safe integer.");
|
|
448
448
|
}
|
|
@@ -458,7 +458,7 @@ function throwIfResponseReadAborted(signal: AbortSignal | undefined): void {
|
|
|
458
458
|
if (signal?.aborted) throw new Error("GitHub pull request response read was aborted.");
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
async function readBoundedResponseText(response: Response, signal: AbortSignal | undefined): Promise<BoundedResponseBody> {
|
|
461
|
+
export async function readBoundedResponseText(response: Response, signal: AbortSignal | undefined, limitBytes = GITHUB_RESPONSE_BODY_LIMIT_BYTES): Promise<BoundedResponseBody> {
|
|
462
462
|
throwIfResponseReadAborted(signal);
|
|
463
463
|
if (!response.body) return { text: "", truncated: false };
|
|
464
464
|
|
|
@@ -474,7 +474,7 @@ async function readBoundedResponseText(response: Response, signal: AbortSignal |
|
|
|
474
474
|
if (done) break;
|
|
475
475
|
if (!value || value.byteLength === 0) continue;
|
|
476
476
|
|
|
477
|
-
const remainingBytes =
|
|
477
|
+
const remainingBytes = limitBytes - capturedBytes;
|
|
478
478
|
if (remainingBytes > 0) {
|
|
479
479
|
const capturedChunk = value.byteLength <= remainingBytes ? value : value.subarray(0, remainingBytes);
|
|
480
480
|
chunks.push(Buffer.from(capturedChunk));
|
|
@@ -495,7 +495,7 @@ async function readBoundedResponseText(response: Response, signal: AbortSignal |
|
|
|
495
495
|
return { text: truncated ? `${text}… [truncated]` : text, truncated };
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
498
|
+
export function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
499
499
|
return {
|
|
500
500
|
Accept: "application/vnd.github+json",
|
|
501
501
|
Authorization: `Bearer ${token}`,
|
|
@@ -505,7 +505,7 @@ function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
|
505
505
|
};
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
-
function requireFetchImplementation(fetchImpl: typeof fetch | undefined): typeof fetch {
|
|
508
|
+
export function requireFetchImplementation(fetchImpl: typeof fetch | undefined): typeof fetch {
|
|
509
509
|
if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node.js runtime.");
|
|
510
510
|
return fetchImpl;
|
|
511
511
|
}
|
|
@@ -515,7 +515,7 @@ function redactedErrorMessage(error: unknown, tokens: readonly string[]): string
|
|
|
515
515
|
return redactSecrets(message, tokens);
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
async function fetchGitHubResponse(
|
|
518
|
+
export async function fetchGitHubResponse(
|
|
519
519
|
fetchImpl: typeof fetch,
|
|
520
520
|
url: string,
|
|
521
521
|
init: RequestInit,
|
|
@@ -542,7 +542,7 @@ async function readGitHubResponseBody(
|
|
|
542
542
|
}
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
function parseGitHubJson(text: string, responseContext: string, token: string): unknown {
|
|
545
|
+
export function parseGitHubJson(text: string, responseContext: string, token: string): unknown {
|
|
546
546
|
try {
|
|
547
547
|
return JSON.parse(text);
|
|
548
548
|
} catch (error) {
|
|
@@ -550,7 +550,7 @@ function parseGitHubJson(text: string, responseContext: string, token: string):
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
function requireGitHubResponseObject(payload: unknown, responseContext: string): Record<string, unknown> {
|
|
553
|
+
export function requireGitHubResponseObject(payload: unknown, responseContext: string): Record<string, unknown> {
|
|
554
554
|
if (!isRecord(payload)) throw new Error(`${responseContext} was not an object.`);
|
|
555
555
|
return payload;
|
|
556
556
|
}
|
|
@@ -245,7 +245,7 @@ export function formatBranchStatus(details: GitContextDetails): string {
|
|
|
245
245
|
|
|
246
246
|
export function formatChangeBranch(details: ChangeBranchDetails): string {
|
|
247
247
|
const previous = details.previousDetached ? "detached HEAD" : details.previousBranch ?? "unknown branch";
|
|
248
|
-
return `Changed branch from ${previous} to ${details.currentBranch}.`;
|
|
248
|
+
return `Changed branch from ${previous} to ${details.currentBranch} at ${details.head}.`;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
export function formatPullRequest(details: PullRequestToolDetails, autofilledFields: PullRequestInputField[] = []): string {
|
|
@@ -641,7 +641,7 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
641
641
|
content: [
|
|
642
642
|
{
|
|
643
643
|
type: "text",
|
|
644
|
-
text: `Created and checked out branch ${details.newBranch} from ${details.previousBranch}.`,
|
|
644
|
+
text: `Created and checked out branch ${details.newBranch} from ${details.previousBranch} at ${details.head}.`,
|
|
645
645
|
},
|
|
646
646
|
],
|
|
647
647
|
details,
|
|
@@ -735,7 +735,7 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
735
735
|
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
|
|
736
736
|
const details = await pullCurrentBranch(pi, ctx, signal);
|
|
737
737
|
return {
|
|
738
|
-
content: [{ type: "text", text: `Pulled current branch ${details.currentBranch} with fast-forward-only semantics.` }],
|
|
738
|
+
content: [{ type: "text", text: `Pulled current branch ${details.currentBranch} with fast-forward-only semantics; HEAD ${details.head}.` }],
|
|
739
739
|
details,
|
|
740
740
|
};
|
|
741
741
|
},
|
|
@@ -846,8 +846,9 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
846
846
|
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
|
|
847
847
|
const details = await pushCurrentBranch(pi, ctx, signal);
|
|
848
848
|
const action = details.mode === "publish" ? "Published" : "Pushed";
|
|
849
|
+
const remoteState = details.remoteHead === details.head ? "matches" : `is at ${details.remoteHead}`;
|
|
849
850
|
return {
|
|
850
|
-
content: [{ type: "text", text: `${action} current branch ${details.currentBranch}.` }],
|
|
851
|
+
content: [{ type: "text", text: `${action} current branch ${details.currentBranch} at ${details.head}; ${details.remoteTrackingRef} ${remoteState}.` }],
|
|
851
852
|
details,
|
|
852
853
|
};
|
|
853
854
|
},
|
|
@@ -2,12 +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,
|
|
6
|
-
UPDATE_FROM_BASE_TOOL_NAME,
|
|
5
|
+
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_STATUS_TOOL_NAME,
|
|
6
|
+
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
12
|
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
12
13
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
13
14
|
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
@@ -90,6 +91,36 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
90
91
|
};
|
|
91
92
|
},
|
|
92
93
|
});
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
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.",
|
|
98
|
+
promptSnippet: "pull_request_feedback: read a PR's unresolved review comments and failing checks with their log tails",
|
|
99
|
+
promptGuidelines: [
|
|
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.",
|
|
101
|
+
"Without number, pull_request_feedback reads the most recently updated PR for headBranch or the current branch.",
|
|
102
|
+
"pull_request_feedback never comments, resolves threads, reruns checks, or edits the PR; a job log that cannot be read is reported per check, not as a failure of the call.",
|
|
103
|
+
],
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
number: Type.Optional(Type.Integer({ minimum: 1, description: "Exact PR number; mutually exclusive with headBranch." })),
|
|
106
|
+
headBranch: Type.Optional(Type.String({ minLength: 1, description: "Head branch to look up; defaults to current branch when number is omitted." })),
|
|
107
|
+
}, { additionalProperties: false }),
|
|
108
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
109
|
+
if (params.number !== undefined && params.headBranch !== undefined) throw new Error("Supply number or headBranch, not both.");
|
|
110
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
111
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
112
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
113
|
+
const requestOptions = { fetchImpl: options.fetchImpl, signal };
|
|
114
|
+
let number = params.number;
|
|
115
|
+
if (number === undefined) {
|
|
116
|
+
const found = await findGitHubPullRequest(repository, params.headBranch ?? await requireCurrentBranch(pi, rootCtx, signal), token, requestOptions);
|
|
117
|
+
if (!found) return { content: [{ type: "text", text: "No matching pull request found." }], details: { action: "pull_request_feedback", feedback: null } };
|
|
118
|
+
number = found.number;
|
|
119
|
+
}
|
|
120
|
+
const feedback = await getPullRequestFeedback(repository, number, token, requestOptions);
|
|
121
|
+
return { content: [{ type: "text", text: formatPullRequestFeedback(feedback) }], details: { action: "pull_request_feedback", feedback } };
|
|
122
|
+
},
|
|
123
|
+
});
|
|
93
124
|
pi.registerTool({
|
|
94
125
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
95
126
|
label: "Update From Base",
|
package/src/types.ts
CHANGED
|
@@ -280,6 +280,8 @@ export interface CreateBranchDetails {
|
|
|
280
280
|
repoRoot: string;
|
|
281
281
|
previousBranch: string;
|
|
282
282
|
newBranch: string;
|
|
283
|
+
/** Full commit identity the new branch starts at (HEAD after the switch). */
|
|
284
|
+
head: string;
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
export interface ChangeBranchDetails {
|
|
@@ -288,6 +290,8 @@ export interface ChangeBranchDetails {
|
|
|
288
290
|
previousDetached: boolean;
|
|
289
291
|
currentBranch: string;
|
|
290
292
|
hasChangesBeforeSwitch: false;
|
|
293
|
+
/** Full commit identity of HEAD after the switch. */
|
|
294
|
+
head: string;
|
|
291
295
|
}
|
|
292
296
|
|
|
293
297
|
export interface FetchBranchDetails {
|
|
@@ -318,6 +322,8 @@ export interface PullBranchDetails {
|
|
|
318
322
|
remote: string;
|
|
319
323
|
remoteRef: string;
|
|
320
324
|
output: string;
|
|
325
|
+
/** Full commit identity of HEAD after the fast-forward. */
|
|
326
|
+
head: string;
|
|
321
327
|
}
|
|
322
328
|
|
|
323
329
|
export interface RebaseBranchDetails {
|
|
@@ -338,6 +344,12 @@ export interface PushBranchDetails {
|
|
|
338
344
|
remoteRef: string;
|
|
339
345
|
refspec: string;
|
|
340
346
|
output: string;
|
|
347
|
+
/** Full commit identity of the local HEAD that was pushed. */
|
|
348
|
+
head: string;
|
|
349
|
+
/** Remote-tracking ref updated by the push, for example origin/feature/x. */
|
|
350
|
+
remoteTrackingRef: string;
|
|
351
|
+
/** Full commit identity of that remote-tracking ref after the push: the remote-visible HEAD. */
|
|
352
|
+
remoteHead: string;
|
|
341
353
|
}
|
|
342
354
|
|
|
343
355
|
export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict" | "conflict_kept";
|
|
@@ -589,6 +601,46 @@ export interface PullRequestStatusDetails extends PullRequestDetails {
|
|
|
589
601
|
mergeCommitSha: string | null;
|
|
590
602
|
}
|
|
591
603
|
|
|
604
|
+
export interface PullRequestFeedbackComment {
|
|
605
|
+
author: string;
|
|
606
|
+
body: string;
|
|
607
|
+
url: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface PullRequestFeedbackThread {
|
|
611
|
+
path: string;
|
|
612
|
+
line: number | null;
|
|
613
|
+
comments: PullRequestFeedbackComment[];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface PullRequestFeedbackReview extends PullRequestFeedbackComment {
|
|
617
|
+
state: string;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface PullRequestFeedbackCheck {
|
|
621
|
+
name: string;
|
|
622
|
+
conclusion: string;
|
|
623
|
+
url: string;
|
|
624
|
+
jobId: number | null;
|
|
625
|
+
logTail?: string;
|
|
626
|
+
logError?: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export interface PullRequestFeedbackDetails {
|
|
630
|
+
repository: GitHubRepository;
|
|
631
|
+
number: number;
|
|
632
|
+
url: string;
|
|
633
|
+
headSha: string;
|
|
634
|
+
checksState: string | null;
|
|
635
|
+
unresolvedThreads: PullRequestFeedbackThread[];
|
|
636
|
+
resolvedThreadCount: number;
|
|
637
|
+
outdatedThreadCount: number;
|
|
638
|
+
reviews: PullRequestFeedbackReview[];
|
|
639
|
+
comments: PullRequestFeedbackComment[];
|
|
640
|
+
failingChecks: PullRequestFeedbackCheck[];
|
|
641
|
+
omitted: { threads: number; comments: number; checks: number };
|
|
642
|
+
}
|
|
643
|
+
|
|
592
644
|
export interface PullRequestInput {
|
|
593
645
|
headBranch: string;
|
|
594
646
|
baseBranch: string;
|