@senad-d/branchme 0.1.6 → 0.1.7
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/.env.example +6 -2
- package/CHANGELOG.md +1 -0
- package/README.md +22 -6
- package/SECURITY.md +4 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +4 -4
- package/docs/STRUCTURE.md +5 -5
- package/docs/TUI_CAPTURE.md +1 -0
- package/package.json +3 -3
- package/src/commands/branchme-command.ts +1 -0
- package/src/constants.ts +3 -0
- package/src/git-context.ts +22 -2
- package/src/git.ts +97 -2
- package/src/github.ts +76 -27
- package/src/tools/branchme-tools.ts +150 -21
- package/src/types.ts +9 -0
package/.env.example
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# BranchMe GitHub token template.
|
|
2
|
-
# Copy this file to .env only when you need pull_request support.
|
|
3
|
-
# Keep committed placeholders empty; put real tokens only in your local .env file.
|
|
2
|
+
# Copy this file to .env only when you need pull_request support or PR field autofill.
|
|
3
|
+
# Keep committed token placeholders empty; put real tokens only in your local .env file.
|
|
4
4
|
GITHUB_TOKEN=
|
|
5
5
|
GH_TOKEN=
|
|
6
|
+
|
|
7
|
+
# Optional: allow pull_request to fill omitted branches, title, body, and draft.
|
|
8
|
+
# Explicit values always win. Disabled by default.
|
|
9
|
+
BRANCHME_PR_AUTOFILL=false
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- Added strict BranchMe tools: `branch_status`, `change_branch`, `fetch_branch`, `pull_branch`, `rebase_branch`, `create_branch`, `push_branch`, and `pull_request`.
|
|
7
7
|
- Added argv-style git helpers for repository status, branch validation/creation/switching, clean-worktree preflight, upstream detection, configured-upstream fetch, fast-forward-only current-branch pull, current-branch rebase with automatic abort on failure, and current-branch push/publish.
|
|
8
8
|
- Added GitHub repository resolution, environment-token and local `.env` token fallback handling, REST pull request creation, response validation, and token redaction.
|
|
9
|
+
- Added opt-in `BRANCHME_PR_AUTOFILL` support for omitted PR fields, including current/default branch inference, bounded, Markdown-safe, and token-redacted title/body generation from commit subjects, and a non-draft default.
|
|
9
10
|
- Added bounded automatic Git context before each agent run with branch/upstream state, working-tree counts and unstaged paths, authenticated related-open-PR lookup, and recent commits.
|
|
10
11
|
- Expanded `branch_status` into a shared, explicit, read-only context refresh for state that may change during a run.
|
|
11
12
|
- Added unit tests with mocked `pi.exec` and `fetch` for Git context collection and prompt safety, git helpers, GitHub helpers, command behavior, tool schemas, prompt metadata, and extension registration.
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ BranchMe is a Pi extension for safe branch workflow automation. Before each agen
|
|
|
34
34
|
- **Explicit history rewrites:** `rebase_branch` runs only when explicitly requested, requires a clean current branch with an upstream, disables autostash and multi-ref updates, and automatically attempts to abort on failure.
|
|
35
35
|
- **Commit-safe:** context collection is read-only, and BranchMe never stages files, creates user-authored commits, generates commit messages, force-pushes, creates merge commits, resets, or edits files directly.
|
|
36
36
|
- **Strict tools:** tool schemas reject extra properties such as `force`, `stash`, `discard`, `owner`, `repo`, `path`, or `baseRef`.
|
|
37
|
-
- **PR-ready:** create GitHub pull requests from existing local branches after verifying the `headBranch` matches GitHub and the base is visible
|
|
37
|
+
- **PR-ready:** create GitHub pull requests from existing local branches after verifying the `headBranch` matches GitHub and the base is visible. PR fields can stay explicit, or configured autofill can derive omitted fields from the current branch, default branch, and commit subjects.
|
|
38
38
|
|
|
39
39
|
> **Security:** pi packages run with your full system permissions. BranchMe runs local `git` commands, may make an automatic authenticated GitHub request to find a related open pull request, can fetch remotes, switch/create/pull/rebase branches, push the current branch, and create GitHub pull requests. Read [`SECURITY.md`](SECURITY.md).
|
|
40
40
|
|
|
@@ -153,11 +153,12 @@ export GH_TOKEN=ghp_...
|
|
|
153
153
|
pi
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
Or copy `.env.example` to `.env` in the repository root
|
|
156
|
+
Or copy `.env.example` to `.env` in the repository root, fill in one token value, and optionally enable PR field autofill:
|
|
157
157
|
|
|
158
158
|
```bash
|
|
159
159
|
cp .env.example .env
|
|
160
160
|
$EDITOR .env
|
|
161
|
+
# Set BRANCHME_PR_AUTOFILL=true in .env if desired.
|
|
161
162
|
pi
|
|
162
163
|
```
|
|
163
164
|
|
|
@@ -169,15 +170,26 @@ Run `pull_request` only after `push_branch` has completed; `pull_request` prefli
|
|
|
169
170
|
|
|
170
171
|
## Configuration
|
|
171
172
|
|
|
172
|
-
BranchMe has no project config file. It reads process environment variables
|
|
173
|
+
BranchMe has no separate project config file. It reads process environment variables and supported keys from a local `.env` file in the verified git root. Token lookup checks `process.env.GITHUB_TOKEN`, then `process.env.GH_TOKEN`; if neither is set, BranchMe checks the matching `.env` keys. Pull request field autofill checks `BRANCHME_PR_AUTOFILL` in the process environment first, then `.env`, and defaults to disabled.
|
|
173
174
|
|
|
174
175
|
| Variable | Meaning |
|
|
175
176
|
| --- | --- |
|
|
176
177
|
| `GITHUB_TOKEN` | Preferred token for automatic related-PR lookup and `pull_request`; process environment first, then local `.env` fallback. |
|
|
177
178
|
| `GH_TOKEN` | Fallback token for automatic related-PR lookup and `pull_request`; process environment first, then local `.env` fallback. |
|
|
179
|
+
| `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. |
|
|
178
180
|
| `GITHUB_REPOSITORY=owner/repo` | Optional CI fallback and boundary check for the current GitHub repository; process environment only. |
|
|
179
181
|
|
|
180
|
-
BranchMe reads only `GITHUB_TOKEN` and `
|
|
182
|
+
BranchMe reads only `GITHUB_TOKEN`, `GH_TOKEN`, and `BRANCHME_PR_AUTOFILL` from a small regular `.env` file; it rejects directories, symlinks, special files, and oversized files. BranchMe does not import other `.env` keys, read shell profiles, GitHub CLI credentials, or local credential stores. Token values are redacted from automatic context, errors, tool content, and tool details.
|
|
183
|
+
|
|
184
|
+
With autofill enabled, omitted fields are resolved as follows:
|
|
185
|
+
|
|
186
|
+
- `headBranch`: current local branch.
|
|
187
|
+
- `baseBranch`: the branch named by `origin/HEAD` when it exists locally, falling back to an existing local `main`, `master`, `trunk`, or `develop` branch.
|
|
188
|
+
- `title`: first commit subject in `baseBranch..headBranch`, falling back to a title derived from the head branch name.
|
|
189
|
+
- `body`: a bounded Markdown summary of commit subjects in `baseBranch..headBranch`.
|
|
190
|
+
- `draft`: `false`.
|
|
191
|
+
|
|
192
|
+
Explicit tool arguments always take precedence. Autofill does not create a PR by itself: the user must still ask the agent to create one.
|
|
181
193
|
|
|
182
194
|
If local `origin` and `GITHUB_REPOSITORY` both resolve but disagree, `pull_request` fails closed.
|
|
183
195
|
|
|
@@ -207,7 +219,7 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
207
219
|
| `rebase_branch` | `{}` | Requires a clean current branch with a configured upstream and runs `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure. |
|
|
208
220
|
| `create_branch` | `{ "branchName": string }` | Validates `branchName`, rejects existing local branches, and runs `git switch -c <branchName>` from current `HEAD`. |
|
|
209
221
|
| `push_branch` | `{}` | Pushes the current branch to its configured upstream remote with an explicit `HEAD:<upstream-branch-ref>` refspec, or publishes it with `git push --set-upstream origin <currentBranch>` when no upstream exists. |
|
|
210
|
-
| `pull_request` | `{ "headBranch"
|
|
222
|
+
| `pull_request` | `{ "headBranch"?: string, "baseBranch"?: string, "title"?: string, "body"?: string, "draft"?: boolean }` | Preflights GitHub branch visibility and verifies the GitHub `headBranch` commit matches the local branch, then creates a pull request in the resolved current repository. Omitted fields require `BRANCHME_PR_AUTOFILL=true`; branch refs must be distinct, exist locally, and cannot use `owner:branch`. |
|
|
211
223
|
|
|
212
224
|
All schemas reject additional properties. `change_branch` never accepts `baseRef`, `force`, `stash`, `discard`, `create`, `owner`, `repo`, or path inputs. `fetch_branch`, `pull_branch`, and `rebase_branch` have strict empty schemas and never accept a branch, remote, refspec, force, autostash, or arbitrary rebase target. `pull_request` never accepts `owner`, `repo`, or owner-prefixed branch refs; BranchMe resolves the repository from local `origin` and/or matching `GITHUB_REPOSITORY`.
|
|
213
225
|
|
|
@@ -223,6 +235,7 @@ Before each agent run, BranchMe appends an **Automatic Git Context** snapshot to
|
|
|
223
235
|
- working-tree state and staged, unstaged, and untracked counts;
|
|
224
236
|
- up to 20 unstaged or untracked change entries with Git status, path, and original path for renames/copies;
|
|
225
237
|
- related open PR status and, when found, its number, title, repository, head/base branches, URL, state, and draft flag;
|
|
238
|
+
- whether pull request field autofill is enabled;
|
|
226
239
|
- up to 5 recent commits with short hash, date, and subject.
|
|
227
240
|
|
|
228
241
|
Collection defaults are a 5-second timeout per local Git command, a 4-second related-PR lookup timeout, at most 512 characters per metadata value, and at most 4,000 characters for the rendered snapshot. GitHub response bodies are limited to 64 KiB. The formatter can further shorten values or omit entries to stay within the total limit.
|
|
@@ -242,6 +255,7 @@ Fetch the current branch upstream with fetch_branch, wait for it to complete, th
|
|
|
242
255
|
Create branch feature/docs-refresh from the updated current HEAD with create_branch.
|
|
243
256
|
Push the current branch with push_branch.
|
|
244
257
|
After push_branch completes, create a draft pull request from feature/docs-refresh to main titled "Refresh docs" with this body: "...".
|
|
258
|
+
If pull request field autofill is enabled, after push_branch completes create a pull request and fill any details I did not provide.
|
|
245
259
|
```
|
|
246
260
|
|
|
247
261
|
BranchMe operates only on the repository where pi is running:
|
|
@@ -254,7 +268,7 @@ BranchMe operates only on the repository where pi is running:
|
|
|
254
268
|
- `rebase_branch` requires a clean worktree and configured upstream, rewrites only the current branch onto the locally available upstream with autostash and multi-ref updates disabled, and automatically attempts to abort on failure.
|
|
255
269
|
- `create_branch` creates from the current `HEAD` only and has no `baseRef` input.
|
|
256
270
|
- `push_branch` pushes only the current branch, uses no bare upstream `git push`, and has no `branchName` input.
|
|
257
|
-
- `pull_request` creates PRs only for the resolved current GitHub repository, requires `headBranch` and `baseBranch` to exist locally, requires the GitHub `headBranch` commit to match the local branch, queues behind in-flight same-repository git mutation windows when possible, and rejects `owner:branch` head refs.
|
|
271
|
+
- `pull_request` creates PRs only for the resolved current GitHub repository, requires resolved `headBranch` and `baseBranch` values to be distinct and exist locally, requires the GitHub `headBranch` commit to match the local branch, queues behind in-flight same-repository git mutation windows when possible, and rejects `owner:branch` head refs. Missing PR fields fail unless `BRANCHME_PR_AUTOFILL=true`.
|
|
258
272
|
- If local `origin` and `GITHUB_REPOSITORY` both resolve but disagree, `pull_request` fails closed.
|
|
259
273
|
|
|
260
274
|
BranchMe intentionally does **not** stage files, create user-authored commits, force checkout, stash changes, discard changes, force-push, create merge commits, edit files directly, or generate commit messages. Rebase-driven commit rewriting occurs only through explicit `rebase_branch` calls.
|
|
@@ -310,6 +324,8 @@ Ensure the token and Git credentials have permission for the branch and pull req
|
|
|
310
324
|
| Push fails | Confirm the current branch is correct and your normal Git remote credentials can push. |
|
|
311
325
|
| Related PR is unavailable | Set `GITHUB_TOKEN` or `GH_TOKEN` before starting pi if related-PR context is wanted. Without a token, BranchMe keeps local context and intentionally makes no unauthenticated GitHub request. |
|
|
312
326
|
| PR auth fails | Set `GITHUB_TOKEN` or `GH_TOKEN` before starting pi, or copy `.env.example` to `.env` and fill in one token. |
|
|
327
|
+
| Missing PR fields | Provide all five fields, or set `BRANCHME_PR_AUTOFILL=true` in the process environment or repository `.env`. |
|
|
328
|
+
| Autofill cannot infer the base | Ensure `origin/HEAD` names an existing local branch, keep a local `main`, `master`, `trunk`, or `develop` branch, or provide `baseBranch` explicitly. |
|
|
313
329
|
| PR branch does not exist locally | Create or fetch/check out the local `headBranch` and `baseBranch` branches first; BranchMe does not use remote-only or cross-repository PR refs. |
|
|
314
330
|
| PR branch is not visible or is stale on GitHub | Run `push_branch`, wait for it to complete, then retry `pull_request`; do not batch `push_branch` and `pull_request` in the same assistant tool call. |
|
|
315
331
|
| Repository mismatch | Make `origin` and `GITHUB_REPOSITORY` refer to the same `owner/repo`. |
|
package/SECURITY.md
CHANGED
|
@@ -41,7 +41,7 @@ POST https://api.github.com/repos/{owner}/{repo}/pulls
|
|
|
41
41
|
|
|
42
42
|
The first request is an automatic network boundary: it can run before each agent run and whenever `branch_status` explicitly refreshes context. It is a bounded, read-only lookup for one open pull request whose head is the current local branch (`per_page=1`), with a default 4-second timeout and a 64 KiB response-body limit. It is skipped when repository/branch resolution or authentication is unavailable, so BranchMe never makes an unauthenticated fallback request. Timeout, HTTP, network, malformed, and oversized-response failures become a safe unavailable state without exposing response bodies or raw network errors. Git alone is not used or claimed to provide PR metadata.
|
|
43
43
|
|
|
44
|
-
The branch preflight requests have no body. BranchMe uses the `headBranch` preflight response to compare GitHub's branch commit with the local branch commit before creating the PR. The PR request body contains only the
|
|
44
|
+
The branch preflight requests have no body. BranchMe uses the resolved `headBranch` preflight response to compare GitHub's branch commit with the local branch commit before creating the PR. The PR request body contains only the resolved title, head branch, base branch, body, and draft flag. By default every field must be supplied explicitly. When `BRANCHME_PR_AUTOFILL=true`, omitted fields may be derived from local branch names and bounded commit subjects before being sent to GitHub. Generated body bullets escape Markdown punctuation so commit subjects remain text rather than active mentions or formatting. All GitHub response reads are bounded.
|
|
45
45
|
|
|
46
46
|
## Repository boundary
|
|
47
47
|
|
|
@@ -54,7 +54,7 @@ BranchMe operates on the current repository only.
|
|
|
54
54
|
- `pull_branch` accepts no parameters, updates only the clean current branch from its configured upstream, and uses fast-forward-only semantics.
|
|
55
55
|
- `rebase_branch` accepts no parameters, rebases only the clean current branch onto its configured upstream, disables autostash and multi-ref updates, never pushes, and attempts to abort on failure.
|
|
56
56
|
- If local `origin` and `GITHUB_REPOSITORY` both resolve but disagree, PR creation and related-PR lookup fail closed.
|
|
57
|
-
- PR
|
|
57
|
+
- Resolved PR branches are validated as distinct, existing local branch-name refs; identical or missing local branches and cross-repository `head` values are rejected before any GitHub request.
|
|
58
58
|
- PR branch inputs must also be visible on GitHub before the PR is created, and `headBranch` must match the local branch commit; unpublished or stale `headBranch` values fail with guidance to run `push_branch`, wait for it to complete, and retry `pull_request`.
|
|
59
59
|
|
|
60
60
|
## Credentials
|
|
@@ -66,7 +66,7 @@ Git fetch, pull, and push authentication is handled by the user's configured Git
|
|
|
66
66
|
- `GITHUB_TOKEN` (preferred)
|
|
67
67
|
- `GH_TOKEN` (fallback)
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
BranchMe also reads the non-secret `BRANCHME_PR_AUTOFILL` setting from the process environment or verified-root `.env`; all other `.env` keys are ignored. The `.env` reader uses async file I/O, requires a small regular file, and rejects directories, symlinks, special files, and oversized files. BranchMe does not read shell profiles, GitHub CLI credentials, or local credential stores. Token values are redacted from thrown errors, automatic context, generated PR text, tool content, and tool details.
|
|
70
70
|
|
|
71
71
|
## System prompt boundary
|
|
72
72
|
|
|
@@ -76,7 +76,7 @@ The automatic snapshot can become stale after a Git or filesystem mutation durin
|
|
|
76
76
|
|
|
77
77
|
## Telemetry
|
|
78
78
|
|
|
79
|
-
BranchMe does not collect telemetry. Related-PR lookup sends only the resolved repository owner/name and current branch in the authenticated GitHub API URL. PR creation sends only the
|
|
79
|
+
BranchMe does not collect telemetry. Related-PR lookup sends only the resolved repository owner/name and current branch in the authenticated GitHub API URL. PR creation sends only the resolved GitHub pull request fields described above. When autofill supplies title or body, those fields can contain bounded, redacted local commit subjects. BranchMe does not send diff contents, filenames, or local file contents to GitHub during context collection.
|
|
80
80
|
|
|
81
81
|
## Reporting vulnerabilities
|
|
82
82
|
|
|
@@ -26,7 +26,7 @@ Approved on 2026-06-30.
|
|
|
26
26
|
- Push the current branch to its configured upstream, or publish it to `origin` when no upstream exists.
|
|
27
27
|
- Create a PR in the current GitHub repo via REST API.
|
|
28
28
|
- Non-goals:
|
|
29
|
-
- No commit, staging,
|
|
29
|
+
- No commit, staging, or diff generation behavior. Optional PR title/body autofill may summarize bounded commit subjects.
|
|
30
30
|
- No GitHub CLI dependency.
|
|
31
31
|
- No cross-repository PR creation.
|
|
32
32
|
- No labels, reviewers, projects, or issue linking in v1.
|
|
@@ -41,7 +41,7 @@ Approved on 2026-06-30.
|
|
|
41
41
|
| Tool | `change_branch` | Switch to an existing local branch | Required `branchName`; rejects dirty worktrees |
|
|
42
42
|
| Tool | `create_branch` | Create + checkout new branch from current `HEAD` | Required `branchName`; fail if exists/invalid |
|
|
43
43
|
| Tool | `push_branch` | Push current branch with explicit upstream target; publish with upstream if needed | No commits/staging |
|
|
44
|
-
| Tool | `pull_request` | Preflight branch visibility/commit state and create PR via GitHub REST API |
|
|
44
|
+
| Tool | `pull_request` | Preflight branch visibility/commit state and create PR via GitHub REST API | PR fields are explicit by default and may be omitted only with configured autofill; repo inferred from current checkout |
|
|
45
45
|
| Event | `session_start/session_shutdown` | Optional status footer cleanup | No long-lived resources |
|
|
46
46
|
| UI | TUI panel | Compact BranchMe workflow/config view | No persisted config assumed |
|
|
47
47
|
| Resource | none | No skills/prompts/themes planned | Keep package minimal |
|
|
@@ -68,7 +68,7 @@ Approved on 2026-06-30.
|
|
|
68
68
|
|
|
69
69
|
## 6. Config, state, and persistence
|
|
70
70
|
|
|
71
|
-
- Config source: no BranchMe config file; `/branchme` displays runtime status and workflow notes. GitHub tokens may come from process environment or a small regular `.env` fallback in the verified git root.
|
|
71
|
+
- Config source: no separate BranchMe config file; `/branchme` displays runtime status and workflow notes. GitHub tokens and `BRANCHME_PR_AUTOFILL` may come from process environment or a small regular `.env` fallback in the verified git root.
|
|
72
72
|
- Session state: none; tool results include useful `details`.
|
|
73
73
|
- Files written: none by extension code, except normal git metadata changes from branch checkout/push.
|
|
74
74
|
- Cleanup behavior: clear any footer/status key on `session_shutdown` if used.
|
|
@@ -111,4 +111,4 @@ Approved on 2026-06-30.
|
|
|
111
111
|
- Decisions:
|
|
112
112
|
- No commit functionality.
|
|
113
113
|
- Tools perform all actions; slash commands are help/config only.
|
|
114
|
-
- PR tool requires all PR fields explicitly.
|
|
114
|
+
- PR tool requires all PR fields explicitly unless `BRANCHME_PR_AUTOFILL=true`; explicit values always take precedence.
|
package/docs/STRUCTURE.md
CHANGED
|
@@ -26,8 +26,8 @@ src/
|
|
|
26
26
|
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`.
|
|
27
27
|
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.
|
|
28
28
|
4. `src/tools/branchme-tools.ts` owns TypeBox schemas, prompt metadata, tool content, and safe structured details. `branch_status` delegates to the shared context collector for an explicit refresh.
|
|
29
|
-
5. `src/git.ts` owns current-repository git behavior: root detection, branch/upstream/ahead-behind inspection, working-tree parsing, recent-commit collection, branch validation, branch creation, existing-local-branch switching, configured-upstream fetch, clean-worktree preflight, fast-forward-only current-branch pull, current-branch rebase with automatic abort on failure, current-branch push/publish, and the per-repository workflow queue.
|
|
30
|
-
6. `src/github.ts` owns GitHub `owner/repo` parsing, repository boundary checks, `GITHUB_TOKEN`/`GH_TOKEN` process-env
|
|
29
|
+
5. `src/git.ts` owns current-repository git behavior: root detection, branch/upstream/ahead-behind inspection, working-tree parsing, recent-commit collection, PR base/commit-subject inference, branch validation, branch creation, existing-local-branch switching, configured-upstream fetch, clean-worktree preflight, fast-forward-only current-branch pull, current-branch rebase with automatic abort on failure, current-branch push/publish, and the per-repository workflow queue.
|
|
30
|
+
6. `src/github.ts` owns GitHub `owner/repo` parsing, repository boundary checks, `GITHUB_TOKEN`/`GH_TOKEN` and `BRANCHME_PR_AUTOFILL` process-env/hardened git-root `.env` resolution, authenticated related-open-PR lookup, PR branch-name syntax validation, GitHub branch visibility/commit preflight, PR REST calls, bounded response validation, and redacted errors.
|
|
31
31
|
7. `src/redaction.ts` owns shared credential redaction for Git, GitHub, and prompt-bound metadata.
|
|
32
32
|
8. `src/types.ts` keeps serializable details shared by helpers, context, and tools.
|
|
33
33
|
9. `src/ui/branchme-panel.ts` renders a compact status panel and clips lines to terminal width.
|
|
@@ -56,9 +56,9 @@ src/
|
|
|
56
56
|
- `rebase_branch` requires a clean worktree and configured upstream, then rebases only the current branch with `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure.
|
|
57
57
|
- `create_branch` mutates local branch/HEAD only with `git switch -c`.
|
|
58
58
|
- `push_branch` mutates remote refs only for the current branch and uses an explicit upstream remote/refspec instead of bare `git push` when an upstream exists.
|
|
59
|
-
- `pull_request` requires `headBranch` and `baseBranch` to exist locally, requires `headBranch` to match the GitHub-visible branch commit, queues behind already-started same-repository git mutation windows, makes GitHub REST API calls for the resolved current repository only, and rejects
|
|
60
|
-
- `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.
|
|
61
|
-
- BranchMe does not force checkout, stash, stage, create user-authored commits, reset, force-push, create merge commits, directly edit files, read
|
|
59
|
+
- `pull_request` requires resolved `headBranch` and `baseBranch` values to be distinct and exist locally, requires `headBranch` to match the GitHub-visible branch commit, queues behind already-started same-repository git mutation windows, makes GitHub REST API calls for the resolved current repository only, and rejects owner-prefixed or unsafe branch refs before the request. Omitted fields require configured autofill.
|
|
60
|
+
- `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.
|
|
61
|
+
- BranchMe does not force checkout, stash, stage, create user-authored commits, reset, force-push, create merge commits, directly edit files, read unsupported `.env` keys, follow unsafe `.env` file types, depend on GitHub CLI, or collect telemetry. Rebase-driven commit rewriting occurs only through explicit `rebase_branch` calls.
|
|
62
62
|
|
|
63
63
|
## Documentation
|
|
64
64
|
|
package/docs/TUI_CAPTURE.md
CHANGED
|
@@ -35,6 +35,7 @@ Commands only show info; BranchMe tools perform actions.
|
|
|
35
35
|
|
|
36
36
|
- Run inside a Git repo with `git` available.
|
|
37
37
|
- For PRs: GitHub `origin` and `GITHUB_TOKEN` or `GH_TOKEN` (environment or `.env`).
|
|
38
|
+
- Optional: set `BRANCHME_PR_AUTOFILL=true` in the environment or `.env` to generate omitted PR fields.
|
|
38
39
|
- `fetch_branch`, `pull_branch`, and `rebase_branch` require a configured upstream.
|
|
39
40
|
- `pull_branch` and `rebase_branch` require a clean working tree.
|
|
40
41
|
- `rebase_branch` rewrites local commits only when explicitly requested and auto-aborts on failure.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Minimal Pi extension for current-repository git branch, push, and pull request workflows.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"typebox": "*"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
73
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
74
74
|
"@earendil-works/pi-tui": "^0.80.2",
|
|
75
75
|
"@types/node": "^26.0.1",
|
|
76
76
|
"typebox": "^1.2.18",
|
|
77
|
-
"typescript": "^
|
|
77
|
+
"typescript": "^7.0.2"
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -44,6 +44,7 @@ export function getBranchMeHelpText(): string {
|
|
|
44
44
|
"",
|
|
45
45
|
"- Run inside a Git repo with `git` available.",
|
|
46
46
|
"- For PRs: GitHub `origin` and `GITHUB_TOKEN` or `GH_TOKEN` (environment or `.env`).",
|
|
47
|
+
"- Optional: set `BRANCHME_PR_AUTOFILL=true` in the environment or `.env` to generate omitted PR fields.",
|
|
47
48
|
"- `fetch_branch`, `pull_branch`, and `rebase_branch` require a configured upstream.",
|
|
48
49
|
"- `pull_branch` and `rebase_branch` require a clean working tree.",
|
|
49
50
|
"- `rebase_branch` rewrites local commits only when explicitly requested and auto-aborts on failure.",
|
package/src/constants.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const PULL_BRANCH_TOOL_NAME = "pull_branch";
|
|
|
9
9
|
export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
|
|
10
10
|
export const PUSH_BRANCH_TOOL_NAME = "push_branch";
|
|
11
11
|
export const PULL_REQUEST_TOOL_NAME = "pull_request";
|
|
12
|
+
export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
|
|
12
13
|
|
|
13
14
|
export const BRANCHME_TOOL_NAMES = [
|
|
14
15
|
BRANCH_STATUS_TOOL_NAME,
|
|
@@ -33,6 +34,8 @@ export const GIT_CONTEXT_RECENT_COMMIT_LIMIT = 5;
|
|
|
33
34
|
export const GIT_CONTEXT_CHANGE_LIMIT = 20;
|
|
34
35
|
export const GIT_CONTEXT_VALUE_LIMIT_CHARS = 512;
|
|
35
36
|
export const GIT_CONTEXT_SUMMARY_LIMIT_CHARS = 4_000;
|
|
37
|
+
export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
|
|
38
|
+
export const PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS = 256;
|
|
36
39
|
|
|
37
40
|
export const GITHUB_API_BASE_URL = "https://api.github.com";
|
|
38
41
|
export const GITHUB_API_VERSION = "2022-11-28";
|
package/src/git-context.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
getWorkingTreeStatus,
|
|
20
20
|
type GitCommandContext,
|
|
21
21
|
} from "./git.ts";
|
|
22
|
-
import { lookupRelatedPullRequest } from "./github.ts";
|
|
22
|
+
import { lookupRelatedPullRequest, resolvePullRequestAutofill } from "./github.ts";
|
|
23
23
|
import { redactSecrets } from "./redaction.ts";
|
|
24
24
|
import type { GitContextDetails, GitFileChange, RecentCommit, RelatedPullRequest } from "./types.ts";
|
|
25
25
|
|
|
@@ -138,6 +138,7 @@ export async function collectGitContext(
|
|
|
138
138
|
const recentCommits = await getRecentCommits(pi, rootCtx, options.signal);
|
|
139
139
|
const upstream = current.detached ? null : await getUpstreamBranch(pi, rootCtx, options.signal);
|
|
140
140
|
const warnings: string[] = [];
|
|
141
|
+
let pullRequestAutofill: boolean | null = null;
|
|
141
142
|
let ahead: number | null = null;
|
|
142
143
|
let behind: number | null = null;
|
|
143
144
|
|
|
@@ -152,6 +153,13 @@ export async function collectGitContext(
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
try {
|
|
157
|
+
pullRequestAutofill = await resolvePullRequestAutofill(options.env, { cwd: repoRoot, signal: options.signal });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throwIfCollectionAborted(options.signal);
|
|
160
|
+
warnings.push(`pull request autofill configuration unavailable (${safeFailureType(error)})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
155
163
|
const relatedPullRequest = await collectRelatedPullRequest(pi, rootCtx, options);
|
|
156
164
|
|
|
157
165
|
return {
|
|
@@ -166,6 +174,7 @@ export async function collectGitContext(
|
|
|
166
174
|
...(relatedPullRequest.status === "found"
|
|
167
175
|
? { githubRepository: relatedPullRequest.pullRequest.repository }
|
|
168
176
|
: {}),
|
|
177
|
+
pullRequestAutofill,
|
|
169
178
|
workingTree: workingTreeStatus.workingTree,
|
|
170
179
|
unstagedChanges: workingTreeStatus.unstagedChanges,
|
|
171
180
|
relatedPullRequest,
|
|
@@ -223,6 +232,13 @@ function formatRelatedPullRequest(details: GitContextDetails, valueLimit: number
|
|
|
223
232
|
return `- Related PR: #${safeNonNegativeInteger(pullRequest.number)} ${quoteMetadata(pullRequest.title, valueLimit)}; repository ${quoteMetadata(repository, valueLimit)}; ${quoteMetadata(pullRequest.head, valueLimit)} -> ${quoteMetadata(pullRequest.base, valueLimit)}; ${quoteMetadata(pullRequest.url, valueLimit)}; ${quoteMetadata(pullRequest.state, valueLimit)}${draft}`;
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
function formatPullRequestAutofill(details: GitContextDetails): string {
|
|
236
|
+
let state = "unavailable";
|
|
237
|
+
if (details.pullRequestAutofill === true) state = "enabled";
|
|
238
|
+
if (details.pullRequestAutofill === false) state = "disabled";
|
|
239
|
+
return `- Pull request field autofill: ${state} (BRANCHME_PR_AUTOFILL)`;
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
function appendRecentCommits(lines: string[], selection: FormatSelection): void {
|
|
227
243
|
if (selection.commits.length === 0 && selection.omittedCommits === 0) {
|
|
228
244
|
lines.push("- Recent commits: none");
|
|
@@ -257,7 +273,10 @@ function renderGitContext(
|
|
|
257
273
|
formatWorkingTree(details),
|
|
258
274
|
];
|
|
259
275
|
appendUnstagedChanges(lines, selection);
|
|
260
|
-
lines.push(
|
|
276
|
+
lines.push(
|
|
277
|
+
formatRelatedPullRequest(details, selection.valueLimit),
|
|
278
|
+
formatPullRequestAutofill(details),
|
|
279
|
+
);
|
|
261
280
|
appendRecentCommits(lines, selection);
|
|
262
281
|
lines.push(
|
|
263
282
|
"",
|
|
@@ -295,6 +314,7 @@ function boundedFallbackContext(mode: GitContextFormatMode): string {
|
|
|
295
314
|
"- Working tree: unavailable",
|
|
296
315
|
"- Unstaged changes: [entries omitted]",
|
|
297
316
|
"- Related PR: unavailable",
|
|
317
|
+
"- Pull request field autofill: unavailable",
|
|
298
318
|
"- Recent commits: [entries omitted]",
|
|
299
319
|
"",
|
|
300
320
|
"Call branch_status to request a current bounded snapshot.",
|
package/src/git.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
GIT_REBASE_TIMEOUT_MS,
|
|
11
11
|
GIT_STATUS_TIMEOUT_MS,
|
|
12
12
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
13
|
+
PULL_REQUEST_AUTOFILL_COMMIT_LIMIT,
|
|
14
|
+
PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS,
|
|
13
15
|
} from "./constants.ts";
|
|
14
16
|
import { redactSecrets } from "./redaction.ts";
|
|
15
17
|
import type {
|
|
@@ -37,6 +39,7 @@ export interface GitRunOptions {
|
|
|
37
39
|
signal?: AbortSignal;
|
|
38
40
|
timeout?: number;
|
|
39
41
|
allowFailure?: boolean;
|
|
42
|
+
tokens?: readonly string[];
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const repositoryMutationQueues = new Map<string, Promise<void>>();
|
|
@@ -114,11 +117,11 @@ export async function runGit(
|
|
|
114
117
|
});
|
|
115
118
|
|
|
116
119
|
if (result.killed) {
|
|
117
|
-
throw new Error(formatGitFailure(args, result));
|
|
120
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
if (!options.allowFailure && result.code !== 0) {
|
|
121
|
-
throw new Error(formatGitFailure(args, result));
|
|
124
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
return result;
|
|
@@ -568,6 +571,98 @@ export async function getRecentCommits(
|
|
|
568
571
|
throw new Error(formatGitFailure(args, result));
|
|
569
572
|
}
|
|
570
573
|
|
|
574
|
+
function truncatePullRequestCommitSubject(value: string): string {
|
|
575
|
+
if (value.length <= PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS) return value;
|
|
576
|
+
|
|
577
|
+
let end = PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS - 1;
|
|
578
|
+
const lastCodePoint = value.codePointAt(end - 1);
|
|
579
|
+
if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
|
|
580
|
+
return `${value.slice(0, end).trimEnd()}…`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function safePullRequestCommitSubject(value: string, tokens: readonly string[]): string {
|
|
584
|
+
const normalized = redactSecrets(value, tokens)
|
|
585
|
+
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
|
|
586
|
+
.replace(/\s+/gu, " ")
|
|
587
|
+
.trim();
|
|
588
|
+
return truncatePullRequestCommitSubject(normalized);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function getOriginDefaultBranch(
|
|
592
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
593
|
+
ctx: GitCommandContext,
|
|
594
|
+
signal?: AbortSignal,
|
|
595
|
+
): Promise<string | null> {
|
|
596
|
+
const args = ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"];
|
|
597
|
+
const result = await runGit(pi, ctx, args, {
|
|
598
|
+
signal,
|
|
599
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
600
|
+
allowFailure: true,
|
|
601
|
+
});
|
|
602
|
+
if (result.code !== 0) return null;
|
|
603
|
+
|
|
604
|
+
const remoteBranch = trimOutput(result.stdout);
|
|
605
|
+
const prefix = "origin/";
|
|
606
|
+
if (!remoteBranch.startsWith(prefix)) return null;
|
|
607
|
+
|
|
608
|
+
const branch = remoteBranch.slice(prefix.length);
|
|
609
|
+
try {
|
|
610
|
+
validateBranchNameInput(branch, "Default base branch");
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
return branch;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function inferPullRequestBaseBranch(
|
|
618
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
619
|
+
ctx: GitCommandContext,
|
|
620
|
+
headBranch: string,
|
|
621
|
+
signal?: AbortSignal,
|
|
622
|
+
): Promise<string> {
|
|
623
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
624
|
+
const originDefault = await getOriginDefaultBranch(pi, ctx, signal);
|
|
625
|
+
if (originDefault) {
|
|
626
|
+
if (originDefault === headBranch) {
|
|
627
|
+
throw new Error("Unable to infer baseBranch because the current branch is the origin default branch.");
|
|
628
|
+
}
|
|
629
|
+
if (await localBranchExists(pi, ctx, originDefault, signal)) return originDefault;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
633
|
+
if (candidate !== headBranch && await localBranchExists(pi, ctx, candidate, signal)) return candidate;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
throw new Error(
|
|
637
|
+
"Unable to infer baseBranch from origin/HEAD or a local main, master, trunk, or develop branch; provide baseBranch explicitly.",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function getPullRequestCommitSubjects(
|
|
642
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
643
|
+
ctx: GitCommandContext,
|
|
644
|
+
headBranch: string,
|
|
645
|
+
baseBranch: string,
|
|
646
|
+
signal?: AbortSignal,
|
|
647
|
+
tokens: readonly string[] = [],
|
|
648
|
+
): Promise<string[]> {
|
|
649
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
650
|
+
validateBranchNameInput(baseBranch, "baseBranch");
|
|
651
|
+
const revisionRange = `refs/heads/${baseBranch}..refs/heads/${headBranch}`;
|
|
652
|
+
const args = [
|
|
653
|
+
"log",
|
|
654
|
+
`--max-count=${PULL_REQUEST_AUTOFILL_COMMIT_LIMIT}`,
|
|
655
|
+
"--format=%s",
|
|
656
|
+
revisionRange,
|
|
657
|
+
"--",
|
|
658
|
+
];
|
|
659
|
+
const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, tokens });
|
|
660
|
+
return result.stdout
|
|
661
|
+
.split(/\r?\n/u)
|
|
662
|
+
.map((subject) => safePullRequestCommitSubject(subject, tokens))
|
|
663
|
+
.filter(Boolean);
|
|
664
|
+
}
|
|
665
|
+
|
|
571
666
|
export async function getAheadBehindCount(
|
|
572
667
|
pi: Pick<ExtensionAPI, "exec">,
|
|
573
668
|
ctx: GitCommandContext,
|
package/src/github.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
GITHUB_USER_AGENT,
|
|
11
11
|
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
12
12
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
13
|
+
PULL_REQUEST_AUTOFILL_ENV_NAME,
|
|
13
14
|
} from "./constants.ts";
|
|
14
15
|
import {
|
|
15
16
|
getCurrentBranch,
|
|
@@ -27,6 +28,9 @@ import type {
|
|
|
27
28
|
} from "./types.ts";
|
|
28
29
|
|
|
29
30
|
type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
|
|
31
|
+
type PullRequestAutofillEnvironmentKey = typeof PULL_REQUEST_AUTOFILL_ENV_NAME;
|
|
32
|
+
type SupportedDotEnvKey = TokenEnvironmentKey | PullRequestAutofillEnvironmentKey;
|
|
33
|
+
type DotEnvReadPurpose = "GitHub token fallback" | "pull request autofill configuration";
|
|
30
34
|
|
|
31
35
|
export type TokenResolutionSource = TokenEnvironmentKey | `${TokenEnvironmentKey} (.env)`;
|
|
32
36
|
|
|
@@ -149,8 +153,8 @@ function resolveProcessToken(env: NodeJS.ProcessEnv): TokenResolution | null {
|
|
|
149
153
|
return null;
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
function
|
|
153
|
-
return value === "GITHUB_TOKEN" || value === "GH_TOKEN";
|
|
156
|
+
function isSupportedDotEnvKey(value: string): value is SupportedDotEnvKey {
|
|
157
|
+
return value === "GITHUB_TOKEN" || value === "GH_TOKEN" || value === PULL_REQUEST_AUTOFILL_ENV_NAME;
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
function decodeDoubleQuotedDotEnvValue(value: string): string {
|
|
@@ -182,23 +186,46 @@ function stripDotEnvInlineComment(value: string): string {
|
|
|
182
186
|
return value;
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
function closingDotEnvQuoteIndex(value: string, quote: "\"" | "'"): number {
|
|
190
|
+
let escaped = false;
|
|
191
|
+
for (let index = 1; index < value.length; index += 1) {
|
|
192
|
+
const character = value.charAt(index);
|
|
193
|
+
if (quote === "\"" && character === "\\" && !escaped) {
|
|
194
|
+
escaped = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (character === quote && !escaped) return index;
|
|
198
|
+
escaped = false;
|
|
199
|
+
}
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseQuotedDotEnvValue(value: string): string | null {
|
|
204
|
+
const quote = value.charAt(0);
|
|
205
|
+
if (quote !== "\"" && quote !== "'") return null;
|
|
206
|
+
|
|
207
|
+
const closingIndex = closingDotEnvQuoteIndex(value, quote);
|
|
208
|
+
if (closingIndex === -1) return null;
|
|
209
|
+
|
|
210
|
+
const remainder = value.slice(closingIndex + 1).trim();
|
|
211
|
+
if (remainder && !remainder.startsWith("#")) return null;
|
|
212
|
+
|
|
213
|
+
const quotedValue = value.slice(1, closingIndex);
|
|
214
|
+
return quote === "\"" ? decodeDoubleQuotedDotEnvValue(quotedValue).trim() : quotedValue.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
function parseDotEnvValue(rawValue: string): string {
|
|
186
218
|
const value = rawValue.trim();
|
|
187
219
|
if (!value) return "";
|
|
188
220
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (value.startsWith("'") && value.endsWith("'")) {
|
|
194
|
-
return value.slice(1, -1).trim();
|
|
195
|
-
}
|
|
221
|
+
const quotedValue = parseQuotedDotEnvValue(value);
|
|
222
|
+
if (quotedValue !== null) return quotedValue;
|
|
196
223
|
|
|
197
224
|
return stripDotEnvInlineComment(value).trim();
|
|
198
225
|
}
|
|
199
226
|
|
|
200
|
-
function
|
|
201
|
-
const
|
|
227
|
+
function parseDotEnvValues(contents: string): Partial<Record<SupportedDotEnvKey, string>> {
|
|
228
|
+
const values: Partial<Record<SupportedDotEnvKey, string>> = {};
|
|
202
229
|
|
|
203
230
|
for (const line of contents.split(/\r?\n/u)) {
|
|
204
231
|
const trimmed = line.trim();
|
|
@@ -209,12 +236,12 @@ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey
|
|
|
209
236
|
if (separatorIndex === -1) continue;
|
|
210
237
|
|
|
211
238
|
const key = assignment.slice(0, separatorIndex).trim();
|
|
212
|
-
if (!
|
|
239
|
+
if (!isSupportedDotEnvKey(key)) continue;
|
|
213
240
|
|
|
214
|
-
|
|
241
|
+
values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
return
|
|
244
|
+
return values;
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
const MAX_DOTENV_BYTES = 64 * 1024;
|
|
@@ -223,55 +250,77 @@ function isMissingFileError(error: unknown): boolean {
|
|
|
223
250
|
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
227
|
-
if (signal?.aborted) throw new Error(
|
|
253
|
+
function throwIfAborted(signal: AbortSignal | undefined, purpose: DotEnvReadPurpose): void {
|
|
254
|
+
if (signal?.aborted) throw new Error(`${purpose} .env read was aborted.`);
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
function errorMessage(error: unknown): string {
|
|
231
258
|
return redactSecrets(error instanceof Error ? error.message : String(error));
|
|
232
259
|
}
|
|
233
260
|
|
|
234
|
-
async function
|
|
261
|
+
async function readDotEnvValues(
|
|
235
262
|
cwd: string | undefined,
|
|
236
263
|
signal?: AbortSignal,
|
|
237
|
-
|
|
264
|
+
purpose: DotEnvReadPurpose = "GitHub token fallback",
|
|
265
|
+
): Promise<Partial<Record<SupportedDotEnvKey, string>>> {
|
|
238
266
|
if (!cwd) return {};
|
|
239
267
|
|
|
240
268
|
const envPath = join(cwd, ".env");
|
|
241
|
-
throwIfAborted(signal);
|
|
269
|
+
throwIfAborted(signal, purpose);
|
|
242
270
|
|
|
243
271
|
let stats: Awaited<ReturnType<typeof lstat>>;
|
|
244
272
|
try {
|
|
245
273
|
stats = await lstat(envPath);
|
|
246
274
|
} catch (error) {
|
|
247
275
|
if (isMissingFileError(error)) return {};
|
|
248
|
-
throw new Error(`Unable to inspect .env file for
|
|
276
|
+
throw new Error(`Unable to inspect .env file for ${purpose}: ${errorMessage(error)}`);
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
if (!stats.isFile()) {
|
|
252
|
-
throw new Error(
|
|
280
|
+
throw new Error(`Unable to read .env file for ${purpose}: .env must be a small regular file.`);
|
|
253
281
|
}
|
|
254
282
|
if (stats.size > MAX_DOTENV_BYTES) {
|
|
255
283
|
throw new Error(
|
|
256
|
-
`Unable to read .env file for
|
|
284
|
+
`Unable to read .env file for ${purpose}: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
|
|
257
285
|
);
|
|
258
286
|
}
|
|
259
287
|
|
|
260
|
-
throwIfAborted(signal);
|
|
288
|
+
throwIfAborted(signal, purpose);
|
|
261
289
|
|
|
262
290
|
let contents: string;
|
|
263
291
|
try {
|
|
264
292
|
contents = await readFile(envPath, { encoding: "utf8", signal });
|
|
265
293
|
} catch (error) {
|
|
266
294
|
if (isMissingFileError(error)) return {};
|
|
267
|
-
throw new Error(`Unable to read .env file for
|
|
295
|
+
throw new Error(`Unable to read .env file for ${purpose}: ${errorMessage(error)}`);
|
|
268
296
|
}
|
|
269
297
|
|
|
270
298
|
if (Buffer.byteLength(contents, "utf8") > MAX_DOTENV_BYTES) {
|
|
271
|
-
throw new Error(`Unable to read .env file for
|
|
299
|
+
throw new Error(`Unable to read .env file for ${purpose}: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
return
|
|
302
|
+
return parseDotEnvValues(contents);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parsePullRequestAutofillValue(value: string): boolean {
|
|
306
|
+
const normalized = value.trim().toLowerCase();
|
|
307
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
308
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
309
|
+
throw new Error(
|
|
310
|
+
`${PULL_REQUEST_AUTOFILL_ENV_NAME} must be one of true, false, 1, 0, yes, no, on, or off.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function resolvePullRequestAutofill(
|
|
315
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
316
|
+
options: TokenResolutionOptions = {},
|
|
317
|
+
): Promise<boolean> {
|
|
318
|
+
const processValue = env[PULL_REQUEST_AUTOFILL_ENV_NAME];
|
|
319
|
+
if (processValue !== undefined) return parsePullRequestAutofillValue(processValue);
|
|
320
|
+
|
|
321
|
+
const dotEnvValues = await readDotEnvValues(options.cwd, options.signal, "pull request autofill configuration");
|
|
322
|
+
const dotEnvValue = dotEnvValues[PULL_REQUEST_AUTOFILL_ENV_NAME];
|
|
323
|
+
return dotEnvValue === undefined ? false : parsePullRequestAutofillValue(dotEnvValue);
|
|
275
324
|
}
|
|
276
325
|
|
|
277
326
|
export async function resolveGitHubToken(
|
|
@@ -281,7 +330,7 @@ export async function resolveGitHubToken(
|
|
|
281
330
|
const processToken = resolveProcessToken(env);
|
|
282
331
|
if (processToken) return processToken;
|
|
283
332
|
|
|
284
|
-
const dotEnvTokens = await
|
|
333
|
+
const dotEnvTokens = await readDotEnvValues(options.cwd, options.signal);
|
|
285
334
|
const githubToken = dotEnvTokens.GITHUB_TOKEN?.trim();
|
|
286
335
|
if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN (.env)" };
|
|
287
336
|
|
|
@@ -16,10 +16,13 @@ import {
|
|
|
16
16
|
fetchCurrentBranch,
|
|
17
17
|
getGitRoot,
|
|
18
18
|
getLocalBranchCommit,
|
|
19
|
+
getPullRequestCommitSubjects,
|
|
20
|
+
inferPullRequestBaseBranch,
|
|
19
21
|
localBranchExists,
|
|
20
22
|
pullCurrentBranch,
|
|
21
23
|
pushCurrentBranch,
|
|
22
24
|
rebaseCurrentBranch,
|
|
25
|
+
requireCurrentBranch,
|
|
23
26
|
validateBranchName,
|
|
24
27
|
withRepositoryMutationQueue,
|
|
25
28
|
} from "../git.ts";
|
|
@@ -31,9 +34,18 @@ import {
|
|
|
31
34
|
repositoryLabel,
|
|
32
35
|
resolveGitHubRepository,
|
|
33
36
|
resolveGitHubToken,
|
|
37
|
+
resolvePullRequestAutofill,
|
|
34
38
|
validatePullRequestBranchRef,
|
|
35
39
|
} from "../github.ts";
|
|
36
|
-
import type {
|
|
40
|
+
import type {
|
|
41
|
+
ChangeBranchDetails,
|
|
42
|
+
GitContextDetails,
|
|
43
|
+
PullRequestDetails,
|
|
44
|
+
PullRequestInput,
|
|
45
|
+
PullRequestInputField,
|
|
46
|
+
PullRequestToolDetails,
|
|
47
|
+
PullRequestToolInput,
|
|
48
|
+
} from "../types.ts";
|
|
37
49
|
|
|
38
50
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
39
51
|
|
|
@@ -53,11 +65,17 @@ const ChangeBranchParametersSchema = Type.Object(
|
|
|
53
65
|
|
|
54
66
|
const PullRequestParametersSchema = Type.Object(
|
|
55
67
|
{
|
|
56
|
-
headBranch: Type.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
headBranch: Type.Optional(
|
|
69
|
+
Type.String({ minLength: 1, description: "Existing local branch containing the pull request changes. May be omitted in PR autofill mode." }),
|
|
70
|
+
),
|
|
71
|
+
baseBranch: Type.Optional(
|
|
72
|
+
Type.String({ minLength: 1, description: "Existing local target branch for the pull request. May be omitted in PR autofill mode." }),
|
|
73
|
+
),
|
|
74
|
+
title: Type.Optional(Type.String({ minLength: 1, description: "Pull request title. May be omitted in PR autofill mode." })),
|
|
75
|
+
body: Type.Optional(
|
|
76
|
+
Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank; may be omitted in PR autofill mode." }),
|
|
77
|
+
),
|
|
78
|
+
draft: Type.Optional(Type.Boolean({ description: "Whether to create the pull request as a draft. Defaults to false in PR autofill mode." })),
|
|
61
79
|
},
|
|
62
80
|
{ additionalProperties: false },
|
|
63
81
|
);
|
|
@@ -77,8 +95,62 @@ export function formatChangeBranch(details: ChangeBranchDetails): string {
|
|
|
77
95
|
return `Changed branch from ${previous} to ${details.currentBranch}.`;
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
export function formatPullRequest(details: PullRequestDetails): string {
|
|
81
|
-
|
|
98
|
+
export function formatPullRequest(details: PullRequestDetails, autofilledFields: PullRequestInputField[] = []): string {
|
|
99
|
+
const autofill = autofilledFields.length > 0 ? ` Autofilled fields: ${autofilledFields.join(", ")}.` : "";
|
|
100
|
+
return `Created pull request #${details.number} (${details.state}) for ${repositoryLabel(details.repository)}: ${details.url}.${autofill}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
|
|
104
|
+
|
|
105
|
+
interface ResolvedPullRequestBranches {
|
|
106
|
+
headBranch: string;
|
|
107
|
+
baseBranch: string;
|
|
108
|
+
autofilledFields: PullRequestInputField[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ResolvedPullRequestInput {
|
|
112
|
+
input: PullRequestInput;
|
|
113
|
+
autofilledFields: PullRequestInputField[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function missingPullRequestInputFields(params: PullRequestToolInput): PullRequestInputField[] {
|
|
117
|
+
return PULL_REQUEST_INPUT_FIELDS.filter((field) => params[field] === undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function generatedPullRequestTitle(
|
|
121
|
+
headBranch: string,
|
|
122
|
+
commitSubjects: string[],
|
|
123
|
+
tokens: readonly string[],
|
|
124
|
+
): string {
|
|
125
|
+
if (commitSubjects[0]) return commitSubjects[0];
|
|
126
|
+
|
|
127
|
+
const branchSegment = headBranch.split("/").at(-1) ?? headBranch;
|
|
128
|
+
const words = redactSecrets(branchSegment, tokens).replace(/[-_]+/gu, " ").trim();
|
|
129
|
+
if (!words) return "Update project";
|
|
130
|
+
return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapePullRequestMarkdownText(value: string): string {
|
|
134
|
+
return value.replace(/[!-/:-@[-`{-~]/gu, String.raw`\$&`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function generatedPullRequestBody(commitSubjects: string[]): string {
|
|
138
|
+
if (commitSubjects.length === 0) return "";
|
|
139
|
+
const bullets = commitSubjects.map((subject) => `- ${escapePullRequestMarkdownText(subject)}`);
|
|
140
|
+
return ["## Summary", "", ...bullets, "", "_Generated by BranchMe from commit subjects._"].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function requirePullRequestAutofill(
|
|
144
|
+
missingFields: PullRequestInputField[],
|
|
145
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
146
|
+
repoRoot: string,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
if (missingFields.length === 0) return;
|
|
150
|
+
if (await resolvePullRequestAutofill(env, { cwd: repoRoot, signal })) return;
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Missing pull_request fields: ${missingFields.join(", ")}. Provide them explicitly or set BRANCHME_PR_AUTOFILL=true in the process environment or repository .env file.`,
|
|
153
|
+
);
|
|
82
154
|
}
|
|
83
155
|
|
|
84
156
|
async function validateLocalPullRequestBranchName(
|
|
@@ -109,6 +181,60 @@ async function requireExistingValidatedLocalPullRequestBranch(
|
|
|
109
181
|
throw new Error(`${field} local branch '${redactSecrets(branchName)}' does not exist.`);
|
|
110
182
|
}
|
|
111
183
|
|
|
184
|
+
async function resolvePullRequestBranches(
|
|
185
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
186
|
+
ctx: { cwd: string },
|
|
187
|
+
params: PullRequestToolInput,
|
|
188
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
189
|
+
signal?: AbortSignal,
|
|
190
|
+
): Promise<ResolvedPullRequestBranches> {
|
|
191
|
+
const autofilledFields = missingPullRequestInputFields(params);
|
|
192
|
+
await requirePullRequestAutofill(autofilledFields, env, ctx.cwd, signal);
|
|
193
|
+
|
|
194
|
+
const headBranch = params.headBranch ?? await requireCurrentBranch(pi, ctx, signal);
|
|
195
|
+
const baseBranch = params.baseBranch ?? await inferPullRequestBaseBranch(pi, ctx, headBranch, signal);
|
|
196
|
+
if (headBranch === baseBranch) throw new Error("headBranch and baseBranch must be different branches.");
|
|
197
|
+
|
|
198
|
+
await validateLocalPullRequestBranchName(pi, ctx, headBranch, "headBranch", signal);
|
|
199
|
+
await validateLocalPullRequestBranchName(pi, ctx, baseBranch, "baseBranch", signal);
|
|
200
|
+
await requireExistingValidatedLocalPullRequestBranch(pi, ctx, headBranch, "headBranch", signal);
|
|
201
|
+
await requireExistingValidatedLocalPullRequestBranch(pi, ctx, baseBranch, "baseBranch", signal);
|
|
202
|
+
|
|
203
|
+
return { headBranch, baseBranch, autofilledFields };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function resolvePullRequestInput(
|
|
207
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
208
|
+
ctx: { cwd: string },
|
|
209
|
+
params: PullRequestToolInput,
|
|
210
|
+
branches: ResolvedPullRequestBranches,
|
|
211
|
+
tokens: readonly string[],
|
|
212
|
+
signal?: AbortSignal,
|
|
213
|
+
): Promise<ResolvedPullRequestInput> {
|
|
214
|
+
const needsCommitSubjects = params.title === undefined || params.body === undefined;
|
|
215
|
+
const commitSubjects = needsCommitSubjects
|
|
216
|
+
? await getPullRequestCommitSubjects(
|
|
217
|
+
pi,
|
|
218
|
+
ctx,
|
|
219
|
+
branches.headBranch,
|
|
220
|
+
branches.baseBranch,
|
|
221
|
+
signal,
|
|
222
|
+
tokens,
|
|
223
|
+
)
|
|
224
|
+
: [];
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
input: {
|
|
228
|
+
headBranch: branches.headBranch,
|
|
229
|
+
baseBranch: branches.baseBranch,
|
|
230
|
+
title: params.title ?? generatedPullRequestTitle(branches.headBranch, commitSubjects, tokens),
|
|
231
|
+
body: params.body ?? generatedPullRequestBody(commitSubjects),
|
|
232
|
+
draft: params.draft ?? false,
|
|
233
|
+
},
|
|
234
|
+
autofilledFields: branches.autofilledFields,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
112
238
|
function shortCommit(commit: string): string {
|
|
113
239
|
return commit.slice(0, 12);
|
|
114
240
|
}
|
|
@@ -288,10 +414,12 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
288
414
|
pi.registerTool({
|
|
289
415
|
name: PULL_REQUEST_TOOL_NAME,
|
|
290
416
|
label: "Pull Request",
|
|
291
|
-
description: "pull_request creates a GitHub pull request in the resolved current repository.
|
|
292
|
-
promptSnippet: "pull_request: create a GitHub pull request
|
|
417
|
+
description: "pull_request creates a GitHub pull request in the resolved current repository. PR fields are optional only when BRANCHME_PR_AUTOFILL=true; pull_request then infers branches, derives title/body from commit subjects, and defaults draft to false. Branches must exist locally and on GitHub, and headBranch must match the GitHub-visible commit. Owner-prefixed refs, owner, and repo are never accepted as inputs.",
|
|
418
|
+
promptSnippet: "pull_request: create a GitHub pull request with explicit fields or optional configured PR field autofill",
|
|
293
419
|
promptGuidelines: [
|
|
294
|
-
"Use pull_request only when the user
|
|
420
|
+
"Use pull_request only when the user explicitly asks to create a pull request; pull_request must not create one merely because a branch was pushed.",
|
|
421
|
+
"When automatic Git context reports pull request field autofill disabled, use pull_request only with explicit headBranch, baseBranch, title, body, and draft values.",
|
|
422
|
+
"When automatic Git context reports pull request field autofill enabled, pull_request may omit fields the user did not provide; prefer the user's explicit values whenever present.",
|
|
295
423
|
"Use pull_request only with existing local branches for headBranch and baseBranch; headBranch must match the GitHub-visible branch commit.",
|
|
296
424
|
"Do not call push_branch and pull_request in the same tool batch; call pull_request only after push_branch has completed.",
|
|
297
425
|
"Use pull_request only for the resolved current repository; pull_request never accepts owner, repo, or owner-prefixed branch refs.",
|
|
@@ -303,30 +431,31 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
303
431
|
const rootCtx = { cwd: repoRoot };
|
|
304
432
|
|
|
305
433
|
return withRepositoryMutationQueue(repoRoot, async () => {
|
|
306
|
-
await
|
|
307
|
-
await validateLocalPullRequestBranchName(pi, rootCtx, params.baseBranch, "baseBranch", signal);
|
|
308
|
-
await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.headBranch, "headBranch", signal);
|
|
309
|
-
await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.baseBranch, "baseBranch", signal);
|
|
434
|
+
const branches = await resolvePullRequestBranches(pi, rootCtx, params, options.env, signal);
|
|
310
435
|
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
311
436
|
const token = (await resolveGitHubToken(options.env, { cwd: repoRoot, signal })).token;
|
|
312
437
|
|
|
313
438
|
try {
|
|
314
|
-
const
|
|
439
|
+
const resolved = await resolvePullRequestInput(pi, rootCtx, params, branches, [token], signal);
|
|
440
|
+
const headBranch = await ensureGitHubBranchExists(repository, resolved.input.headBranch, "headBranch", token, {
|
|
315
441
|
fetchImpl: options.fetchImpl,
|
|
316
442
|
signal,
|
|
317
443
|
});
|
|
318
|
-
await requireGitHubHeadMatchesLocalBranch(pi, rootCtx,
|
|
319
|
-
await ensureGitHubBranchExists(repository,
|
|
444
|
+
await requireGitHubHeadMatchesLocalBranch(pi, rootCtx, resolved.input.headBranch, headBranch.commitSha, signal);
|
|
445
|
+
await ensureGitHubBranchExists(repository, resolved.input.baseBranch, "baseBranch", token, {
|
|
320
446
|
fetchImpl: options.fetchImpl,
|
|
321
447
|
signal,
|
|
322
448
|
});
|
|
323
|
-
const details = await createGitHubPullRequest(repository,
|
|
449
|
+
const details = await createGitHubPullRequest(repository, resolved.input, token, {
|
|
324
450
|
fetchImpl: options.fetchImpl,
|
|
325
451
|
signal,
|
|
326
452
|
});
|
|
453
|
+
const toolDetails: PullRequestToolDetails = resolved.autofilledFields.length > 0
|
|
454
|
+
? { ...details, autofilledFields: resolved.autofilledFields }
|
|
455
|
+
: details;
|
|
327
456
|
return {
|
|
328
|
-
content: [{ type: "text", text: formatPullRequest(details) }],
|
|
329
|
-
details,
|
|
457
|
+
content: [{ type: "text", text: formatPullRequest(details, resolved.autofilledFields) }],
|
|
458
|
+
details: toolDetails,
|
|
330
459
|
};
|
|
331
460
|
} catch (error) {
|
|
332
461
|
const message = error instanceof Error ? error.message : String(error);
|
package/src/types.ts
CHANGED
|
@@ -67,6 +67,7 @@ export interface BranchStatusDetails {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export interface GitContextDetails extends BranchStatusDetails {
|
|
70
|
+
pullRequestAutofill: boolean | null;
|
|
70
71
|
workingTree: WorkingTreeDetails;
|
|
71
72
|
unstagedChanges: GitFileChangeSummary;
|
|
72
73
|
relatedPullRequest: RelatedPullRequest;
|
|
@@ -145,6 +146,14 @@ export interface PullRequestInput {
|
|
|
145
146
|
draft: boolean;
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
export type PullRequestInputField = keyof PullRequestInput;
|
|
150
|
+
|
|
151
|
+
export type PullRequestToolInput = Partial<PullRequestInput>;
|
|
152
|
+
|
|
153
|
+
export interface PullRequestToolDetails extends PullRequestDetails {
|
|
154
|
+
autofilledFields?: PullRequestInputField[];
|
|
155
|
+
}
|
|
156
|
+
|
|
148
157
|
export interface GitExecResult {
|
|
149
158
|
stdout: string;
|
|
150
159
|
stderr: string;
|