@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 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, with explicit PR fields and `GITHUB_TOKEN` or `GH_TOKEN` from the process environment or a local `.env` fallback.
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 and fill in one token value:
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 for automatic related-PR lookup, GitHub pull request creation, and optional repository boundary checks, with a local `.env` token fallback when no process token is set. Token lookup checks `process.env.GITHUB_TOKEN`, then `process.env.GH_TOKEN`; if neither is set, BranchMe reads `.env` from the verified git root and checks `GITHUB_TOKEN`, then `GH_TOKEN`.
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 `GH_TOKEN` 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.
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": 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 via `POST /repos/{owner}/{repo}/pulls`; branch refs must exist locally and cannot use `owner:branch`. |
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 explicit PR fields supplied to the tool: title, head branch, base branch, body, and draft flag. All GitHub response reads are bounded.
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 branch inputs are validated as existing local branch-name refs; missing local branches and cross-repository `head` values are rejected before token lookup or any GitHub request.
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
- Only these two token keys are read from `.env`; other `.env` keys are ignored. The fallback uses async file I/O, requires `.env` to be 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, tool content, and tool details.
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 explicit GitHub pull request fields described above; BranchMe does not send diff contents, filenames, commit subjects, or local file contents to GitHub during context collection.
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, diff, or message generation behavior.
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 | Required `headBranch`, `baseBranch`, `title`, `body`, `draft`; repo inferred from current checkout |
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 and hardened git-root `.env` fallback 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.
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 missing, owner-prefixed, or unsafe branch refs before token lookup or the request.
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 non-token `.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.
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
 
@@ -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.6",
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.80.2",
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": "^6.0.3"
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";
@@ -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(formatRelatedPullRequest(details, selection.valueLimit));
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 isTokenEnvironmentKey(value: string): value is TokenEnvironmentKey {
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
- if (value.startsWith("\"") && value.endsWith("\"")) {
190
- return decodeDoubleQuotedDotEnvValue(value.slice(1, -1)).trim();
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 parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey, string>> {
201
- const tokens: Partial<Record<TokenEnvironmentKey, string>> = {};
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 (!isTokenEnvironmentKey(key)) continue;
239
+ if (!isSupportedDotEnvKey(key)) continue;
213
240
 
214
- tokens[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
241
+ values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
215
242
  }
216
243
 
217
- return tokens;
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("GitHub token .env fallback was aborted.");
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 readDotEnvTokens(
261
+ async function readDotEnvValues(
235
262
  cwd: string | undefined,
236
263
  signal?: AbortSignal,
237
- ): Promise<Partial<Record<TokenEnvironmentKey, string>>> {
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 GitHub token fallback: ${errorMessage(error)}`);
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("Unable to read .env file for GitHub token fallback: .env must be a small regular file.");
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 GitHub token fallback: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
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 GitHub token fallback: ${errorMessage(error)}`);
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 GitHub token fallback: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
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 parseDotEnvTokens(contents);
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 readDotEnvTokens(options.cwd, options.signal);
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 { ChangeBranchDetails, GitContextDetails, PullRequestDetails } from "../types.ts";
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.String({ minLength: 1, description: "Existing local branch containing the pull request changes." }),
57
- baseBranch: Type.String({ minLength: 1, description: "Existing local target branch for the pull request." }),
58
- title: Type.String({ minLength: 1, description: "Pull request title." }),
59
- body: Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank." }),
60
- draft: Type.Boolean({ description: "Whether to create the pull request as a draft." }),
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
- return `Created pull request #${details.number} (${details.state}) for ${repositoryLabel(details.repository)}: ${details.url}`;
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. pull_request requires headBranch and baseBranch to exist as safe local branch names, requires headBranch to match the GitHub-visible branch commit, and requires baseBranch to be visible on GitHub. Owner-prefixed refs, owner, and repo are never accepted as inputs.",
292
- promptSnippet: "pull_request: create a GitHub pull request in the current repository with all PR fields explicit",
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 provides explicit headBranch, baseBranch, title, body, and draft values.",
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 validateLocalPullRequestBranchName(pi, rootCtx, params.headBranch, "headBranch", signal);
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 headBranch = await ensureGitHubBranchExists(repository, params.headBranch, "headBranch", token, {
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, params.headBranch, headBranch.commitSha, signal);
319
- await ensureGitHubBranchExists(repository, params.baseBranch, "baseBranch", token, {
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, params, token, {
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;