@senad-d/branchme 0.3.11 → 0.3.12
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 +5 -0
- package/README.md +4 -2
- package/docs/PROJECT_DEFINITION_BRIEF.md +4 -2
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +1 -1
- package/docs/TUI_CAPTURE.md +2 -0
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +2 -0
- package/src/constants.ts +5 -0
- package/src/github-feedback.ts +88 -5
- package/src/tools/workflow-tools.ts +47 -2
- package/src/types.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.12 - Unreleased
|
|
4
|
+
|
|
5
|
+
- Added `pull_request_files` (read-only: a PR's first 100 changed files with change type and line counts, plus its labels) and `label_pull_request` (adds 1 to 5 existing repository labels to a PR; GitHub would create an unknown label, so it is refused first). An agent can now choose and set a PR's area label from what it changes.
|
|
6
|
+
- `pull_request_feedback` prints each comment's and review's URL, and review ids, so a follow-up issue can quote and link the exact comment.
|
|
7
|
+
|
|
3
8
|
## 0.3.11 - Unreleased
|
|
4
9
|
|
|
5
10
|
- `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.
|
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-five 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, read and answer PR feedback, and list and label a PR's changed files.
|
|
20
20
|
|
|
21
21
|
<table align="center">
|
|
22
22
|
<tr>
|
|
@@ -257,6 +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
|
+
| `pull_request_files` | `{ "number": integer }` | List a pull request's first 100 changed files (path, change type, additions, deletions; the rest counted) and its current labels. Read-only. |
|
|
261
|
+
| `label_pull_request` | `{ "number": integer, "labels": string[] }` | Add 1 to 5 labels that already exist in the repository to a pull request; an unknown label is refused before anything changes, existing labels stay. |
|
|
260
262
|
| `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
263
|
| `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. |
|
|
262
264
|
| `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. |
|
|
@@ -566,7 +568,7 @@ npm run check:pack
|
|
|
566
568
|
printf '/branchme help\n/quit\n' | pi --no-extensions -e .
|
|
567
569
|
```
|
|
568
570
|
|
|
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-
|
|
571
|
+
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-five 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).
|
|
570
572
|
|
|
571
573
|
Refresh TUI captures intentionally with:
|
|
572
574
|
|
|
@@ -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-five strict agent-callable tools.
|
|
19
19
|
|
|
20
20
|
## 3. Users and use cases
|
|
21
21
|
|
|
@@ -57,6 +57,8 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
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
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 | `pull_request_files` | List a PR's changed files | Path, change type and line counts of the first 100 changed files, plus current labels; read-only |
|
|
61
|
+
| Tool | `label_pull_request` | Label a PR | Adds 1-5 labels that already exist in the repository; refuses unknown labels; never removes |
|
|
60
62
|
| 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 |
|
|
61
63
|
| 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 |
|
|
62
64
|
| 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 |
|
|
@@ -97,7 +99,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
97
99
|
- `src/github.ts`
|
|
98
100
|
- `src/ui/branchme-panel.ts`
|
|
99
101
|
- Module boundaries:
|
|
100
|
-
- The extension entry point registers the informational command, twenty-
|
|
102
|
+
- The extension entry point registers the informational command, twenty-five tools, and automatic context hook.
|
|
101
103
|
- `src/git-workflow.ts` and `src/tools/workflow-tools.ts` own verified remote tracking, base updates, and the new PR status registration.
|
|
102
104
|
- 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.
|
|
103
105
|
- 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-five tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `pull_request_feedback`, `reply_pull_request_feedback`, `pull_request_files`, `label_pull_request`, `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-five 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-five 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
|
@@ -44,6 +44,8 @@ Commands only show info; BranchMe tools perform actions.
|
|
|
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
46
|
- `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.
|
|
47
|
+
- `pull_request_files` — list a PR's changed files and current labels.
|
|
48
|
+
- `label_pull_request` — add existing repository labels to a PR.
|
|
47
49
|
- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.
|
|
48
50
|
- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.
|
|
49
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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",
|
|
@@ -53,6 +53,8 @@ export function getBranchMeHelpText(): string {
|
|
|
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
55
|
"- `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.",
|
|
56
|
+
"- `pull_request_files` — list a PR's changed files and current labels.",
|
|
57
|
+
"- `label_pull_request` — add existing repository labels to a PR.",
|
|
56
58
|
"- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.",
|
|
57
59
|
"- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.",
|
|
58
60
|
"",
|
package/src/constants.ts
CHANGED
|
@@ -18,6 +18,8 @@ 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
20
|
export const REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME = "reply_pull_request_feedback";
|
|
21
|
+
export const PULL_REQUEST_FILES_TOOL_NAME = "pull_request_files";
|
|
22
|
+
export const LABEL_PULL_REQUEST_TOOL_NAME = "label_pull_request";
|
|
21
23
|
export const LIST_WORKTREES_TOOL_NAME = "list_worktrees";
|
|
22
24
|
export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
|
|
23
25
|
export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
|
|
@@ -47,6 +49,8 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
47
49
|
PULL_REQUEST_STATUS_TOOL_NAME,
|
|
48
50
|
PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
49
51
|
REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
52
|
+
PULL_REQUEST_FILES_TOOL_NAME,
|
|
53
|
+
LABEL_PULL_REQUEST_TOOL_NAME,
|
|
50
54
|
LIST_WORKTREES_TOOL_NAME,
|
|
51
55
|
CREATE_WORKTREE_TOOL_NAME,
|
|
52
56
|
REMOVE_WORKTREE_TOOL_NAME,
|
|
@@ -98,5 +102,6 @@ export const PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS = 2_000;
|
|
|
98
102
|
export const PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS = 6_000;
|
|
99
103
|
export const PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS = 24_000;
|
|
100
104
|
export const PULL_REQUEST_REPLY_BODY_LIMIT_CHARS = 4_000;
|
|
105
|
+
export const PULL_REQUEST_LABEL_LIMIT = 5;
|
|
101
106
|
export const GITHUB_GRAPHQL_TIMEOUT_MS = 15_000;
|
|
102
107
|
export const GITHUB_JOB_LOG_TIMEOUT_MS = 30_000;
|
package/src/github-feedback.ts
CHANGED
|
@@ -2,7 +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
|
+
PULL_REQUEST_LABEL_LIMIT, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
6
6
|
} from "./constants.ts";
|
|
7
7
|
import {
|
|
8
8
|
encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
import { redactSecrets } from "./redaction.ts";
|
|
13
13
|
import type {
|
|
14
14
|
GitHubRepository, PullRequestFeedbackAnnotation, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
15
|
-
PullRequestFeedbackReview, PullRequestFeedbackThread,
|
|
15
|
+
PullRequestFeedbackReview, PullRequestFeedbackThread, PullRequestFilesDetails, PullRequestLabelDetails,
|
|
16
|
+
PullRequestReplyDetails, PullRequestReplyInput,
|
|
16
17
|
} from "./types.ts";
|
|
17
18
|
|
|
18
19
|
// One GraphQL request reads review threads with their resolution state, review summaries, conversation
|
|
@@ -255,9 +256,10 @@ function location(path: string, line: number | null): string {
|
|
|
255
256
|
return line === null ? path : `${path}:${line}`;
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
// "@alice (you) 2026-10-14T19:50:15Z" — the marker and time decide what is new
|
|
259
|
+
// "@alice (you) 2026-10-14T19:50:15Z <url>" — the marker and time decide what is new;
|
|
260
|
+
// the URL lets a follow-up issue link the exact comment.
|
|
259
261
|
function byline(comment: PullRequestFeedbackComment): string {
|
|
260
|
-
return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt].filter(Boolean).join(" ");
|
|
262
|
+
return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt, comment.url ? `<${comment.url}>` : ""].filter(Boolean).join(" ");
|
|
261
263
|
}
|
|
262
264
|
|
|
263
265
|
function idTag(kind: string, id: string): string {
|
|
@@ -291,7 +293,7 @@ export function formatPullRequestFeedback(details: PullRequestFeedbackDetails):
|
|
|
291
293
|
`Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
|
|
292
294
|
...details.unresolvedThreads.map(formatThread),
|
|
293
295
|
`Reviews with a body or requested changes: ${details.reviews.length}.`,
|
|
294
|
-
...details.reviews.map((review) => `- ${byline(review)} ${review.state}: ${review.body}`),
|
|
296
|
+
...details.reviews.map((review) => `- ${idTag("review", review.id)}${byline(review)} ${review.state}: ${review.body}`),
|
|
295
297
|
`Conversation comments: ${details.comments.length}${omittedComments}.`,
|
|
296
298
|
...details.comments.map((comment) => `- ${idTag("comment", comment.id)}${byline(comment)}: ${comment.body}`),
|
|
297
299
|
`Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
|
|
@@ -392,3 +394,84 @@ export function formatPullRequestReply(details: PullRequestReplyDetails): string
|
|
|
392
394
|
if (details.resolved !== null) resolution = details.resolved ? "; thread resolved" : "; thread NOT resolved";
|
|
393
395
|
return `Replied to thread ${details.threadId} on PR #${details.number}${resolution}: ${details.commentUrl}`;
|
|
394
396
|
}
|
|
397
|
+
|
|
398
|
+
const PULL_REQUEST_FILES_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
399
|
+
repository(owner: $owner, name: $repo) {
|
|
400
|
+
pullRequest(number: $number) {
|
|
401
|
+
number
|
|
402
|
+
labels(first: 50) { nodes { name } }
|
|
403
|
+
files(first: 100) { totalCount nodes { path changeType additions deletions } }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}`;
|
|
407
|
+
|
|
408
|
+
function requirePullRequestNode(payload: Record<string, unknown>, repository: GitHubRepository, number: number): Record<string, unknown> {
|
|
409
|
+
const pullRequest = isRecord(payload.data) && isRecord(payload.data.repository) ? payload.data.repository.pullRequest : null;
|
|
410
|
+
if (!isRecord(pullRequest) || pullRequest.number !== number) {
|
|
411
|
+
throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
|
|
412
|
+
}
|
|
413
|
+
return pullRequest;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The first 100 changed files of a pull request and its current labels. */
|
|
417
|
+
export async function getPullRequestFiles(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestFilesDetails> {
|
|
418
|
+
validateGitHubRepository(repository);
|
|
419
|
+
pullRequestNumberField(number);
|
|
420
|
+
const payload = await postGraphQL(PULL_REQUEST_FILES_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request files");
|
|
421
|
+
const pullRequest = requirePullRequestNode(payload, repository, number);
|
|
422
|
+
const files = nodes(pullRequest.files).map((file) => ({
|
|
423
|
+
path: safeText(file.path, token, 500),
|
|
424
|
+
change: safeText(file.changeType, token, 20).toLowerCase(),
|
|
425
|
+
additions: typeof file.additions === "number" ? file.additions : 0,
|
|
426
|
+
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
|
427
|
+
}));
|
|
428
|
+
return {
|
|
429
|
+
action: "pull_request_files",
|
|
430
|
+
repository,
|
|
431
|
+
number,
|
|
432
|
+
labels: nodes(pullRequest.labels).map((label) => safeText(label.name, token, 100)).filter(Boolean),
|
|
433
|
+
files,
|
|
434
|
+
omitted: Math.max(0, totalCount(pullRequest.files) - files.length),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function formatPullRequestFiles(details: PullRequestFilesDetails): string {
|
|
439
|
+
const omitted = details.omitted > 0 ? `; ${details.omitted} more not listed` : "";
|
|
440
|
+
return [
|
|
441
|
+
`PR #${details.number}: ${details.files.length} changed file(s)${omitted}. Labels: ${details.labels.join(", ") || "none"}.`,
|
|
442
|
+
...details.files.map((file) => `- ${file.path} (${file.change}, +${file.additions} -${file.deletions})`),
|
|
443
|
+
].join("\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function requireExistingLabel(repository: GitHubRepository, label: string, token: string, options: PullRequestFetchOptions): Promise<void> {
|
|
447
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/labels/${encodePathSegment(label)}`;
|
|
448
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
449
|
+
method: "GET", headers: gitHubJsonHeaders(token), signal: options.signal,
|
|
450
|
+
}, "GitHub label lookup failed", token);
|
|
451
|
+
if (response.status === 404) throw new Error(`Label "${label}" does not exist in ${repository.owner}/${repository.repo}; label_pull_request adds existing labels only.`);
|
|
452
|
+
if (!response.ok) throw new Error(`GitHub label lookup returned HTTP ${response.status}.`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Adds existing repository labels to a pull request; GitHub would create unknown ones, so they are refused first. */
|
|
456
|
+
export async function labelPullRequest(repository: GitHubRepository, number: number, labels: readonly string[], token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestLabelDetails> {
|
|
457
|
+
validateGitHubRepository(repository);
|
|
458
|
+
pullRequestNumberField(number);
|
|
459
|
+
const wanted = [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
|
|
460
|
+
if (wanted.length === 0 || wanted.length > PULL_REQUEST_LABEL_LIMIT) throw new Error(`labels must name 1 to ${PULL_REQUEST_LABEL_LIMIT} labels.`);
|
|
461
|
+
const found = await postGraphQL(PULL_REQUEST_EXISTS_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request");
|
|
462
|
+
requirePullRequestNode(found, repository, number);
|
|
463
|
+
await Promise.all(wanted.map((label) => requireExistingLabel(repository, label, token, options)));
|
|
464
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/issues/${number}/labels`;
|
|
465
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
466
|
+
method: "POST", headers: gitHubJsonHeaders(token), body: JSON.stringify({ labels: wanted }), signal: options.signal,
|
|
467
|
+
}, "GitHub pull request labeling failed", token);
|
|
468
|
+
if (!response.ok) throw new Error(`GitHub pull request labeling returned HTTP ${response.status}.`);
|
|
469
|
+
const text = await readBoundedResponseText(response, options.signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
470
|
+
const payload: unknown = parseGitHubJson(text.text, "GitHub pull request labels response", token);
|
|
471
|
+
const after = Array.isArray(payload) ? payload.filter(isRecord).map((label) => safeText(label.name, token, 100)).filter(Boolean) : [];
|
|
472
|
+
return { action: "label_pull_request", repository, number, added: wanted, labels: after };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function formatPullRequestLabel(details: PullRequestLabelDetails): string {
|
|
476
|
+
return `Labeled PR #${details.number} with ${details.added.join(", ")}; labels now: ${details.labels.join(", ") || "none"}.`;
|
|
477
|
+
}
|
|
@@ -3,12 +3,16 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import {
|
|
5
5
|
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
6
|
-
|
|
6
|
+
LABEL_PULL_REQUEST_TOOL_NAME, PULL_REQUEST_FILES_TOOL_NAME, PULL_REQUEST_LABEL_LIMIT, PULL_REQUEST_STATUS_TOOL_NAME,
|
|
7
|
+
REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
|
|
7
8
|
} from "../constants.ts";
|
|
8
9
|
import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
|
|
9
10
|
import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
|
|
10
11
|
import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
formatPullRequestFeedback, formatPullRequestFiles, formatPullRequestLabel, formatPullRequestReply, getPullRequestFeedback,
|
|
14
|
+
getPullRequestFiles, labelPullRequest, replyPullRequestFeedback,
|
|
15
|
+
} from "../github-feedback.ts";
|
|
12
16
|
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
13
17
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
14
18
|
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
@@ -146,6 +150,47 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
146
150
|
return { content: [{ type: "text", text: formatPullRequestReply(details) }], details };
|
|
147
151
|
},
|
|
148
152
|
});
|
|
153
|
+
pi.registerTool({
|
|
154
|
+
name: PULL_REQUEST_FILES_TOOL_NAME,
|
|
155
|
+
label: "Pull Request Files",
|
|
156
|
+
description: "pull_request_files lists the changed files of a pull request of this GitHub repository (path, change type, additions, deletions; the first 100, with a count of the rest) and its current labels. No mutations.",
|
|
157
|
+
promptSnippet: "pull_request_files: list a PR's changed files and current labels",
|
|
158
|
+
promptGuidelines: [
|
|
159
|
+
"Use pull_request_files to see which areas of the repository a pull request touches, for example before choosing its labels.",
|
|
160
|
+
"pull_request_files never reads file contents or diffs, and never changes the pull request.",
|
|
161
|
+
],
|
|
162
|
+
parameters: Type.Object({
|
|
163
|
+
number: Type.Integer({ minimum: 1, description: "PR number." }),
|
|
164
|
+
}, { additionalProperties: false }),
|
|
165
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
166
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
167
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
168
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
169
|
+
const details = await getPullRequestFiles(repository, params.number, token, { fetchImpl: options.fetchImpl, signal });
|
|
170
|
+
return { content: [{ type: "text", text: formatPullRequestFiles(details) }], details };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
pi.registerTool({
|
|
174
|
+
name: LABEL_PULL_REQUEST_TOOL_NAME,
|
|
175
|
+
label: "Label Pull Request",
|
|
176
|
+
description: "label_pull_request adds labels that already exist in this GitHub repository to one of its pull requests (1 to 5 per call). A label that does not exist is refused before anything changes; existing labels on the pull request stay.",
|
|
177
|
+
promptSnippet: "label_pull_request: add existing repository labels to a PR",
|
|
178
|
+
promptGuidelines: [
|
|
179
|
+
"Use label_pull_request only with label names the assignment or the repository's labels name exactly; it never creates, renames or removes labels.",
|
|
180
|
+
"label_pull_request adds labels and keeps the pull request's existing ones.",
|
|
181
|
+
],
|
|
182
|
+
parameters: Type.Object({
|
|
183
|
+
number: Type.Integer({ minimum: 1, description: "PR number." }),
|
|
184
|
+
labels: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: PULL_REQUEST_LABEL_LIMIT, description: "Exact names of existing labels." }),
|
|
185
|
+
}, { additionalProperties: false }),
|
|
186
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
187
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
188
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
189
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
190
|
+
const details = await labelPullRequest(repository, params.number, params.labels, token, { fetchImpl: options.fetchImpl, signal });
|
|
191
|
+
return { content: [{ type: "text", text: formatPullRequestLabel(details) }], details };
|
|
192
|
+
},
|
|
193
|
+
});
|
|
149
194
|
pi.registerTool({
|
|
150
195
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
151
196
|
label: "Update From Base",
|
package/src/types.ts
CHANGED
|
@@ -660,6 +660,24 @@ export interface PullRequestFeedbackDetails {
|
|
|
660
660
|
omitted: { threads: number; comments: number; checks: number };
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
+
export interface PullRequestFilesDetails {
|
|
664
|
+
action: "pull_request_files";
|
|
665
|
+
repository: GitHubRepository;
|
|
666
|
+
number: number;
|
|
667
|
+
labels: string[];
|
|
668
|
+
files: { path: string; change: string; additions: number; deletions: number }[];
|
|
669
|
+
omitted: number;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export interface PullRequestLabelDetails {
|
|
673
|
+
action: "label_pull_request";
|
|
674
|
+
repository: GitHubRepository;
|
|
675
|
+
number: number;
|
|
676
|
+
added: string[];
|
|
677
|
+
/** Every label on the pull request after the call. */
|
|
678
|
+
labels: string[];
|
|
679
|
+
}
|
|
680
|
+
|
|
663
681
|
export interface PullRequestReplyInput {
|
|
664
682
|
number: number;
|
|
665
683
|
body: string;
|