@senad-d/branchme 0.1.5 → 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.
@@ -4,18 +4,25 @@ import {
4
4
  BRANCH_STATUS_TOOL_NAME,
5
5
  CHANGE_BRANCH_TOOL_NAME,
6
6
  CREATE_BRANCH_TOOL_NAME,
7
+ FETCH_BRANCH_TOOL_NAME,
7
8
  PULL_BRANCH_TOOL_NAME,
8
9
  PULL_REQUEST_TOOL_NAME,
9
10
  PUSH_BRANCH_TOOL_NAME,
11
+ REBASE_BRANCH_TOOL_NAME,
10
12
  } from "../constants.ts";
11
13
  import {
12
14
  changeExistingLocalBranch,
13
15
  createLocalBranch,
16
+ fetchCurrentBranch,
14
17
  getGitRoot,
15
18
  getLocalBranchCommit,
19
+ getPullRequestCommitSubjects,
20
+ inferPullRequestBaseBranch,
16
21
  localBranchExists,
17
22
  pullCurrentBranch,
18
23
  pushCurrentBranch,
24
+ rebaseCurrentBranch,
25
+ requireCurrentBranch,
19
26
  validateBranchName,
20
27
  withRepositoryMutationQueue,
21
28
  } from "../git.ts";
@@ -27,9 +34,18 @@ import {
27
34
  repositoryLabel,
28
35
  resolveGitHubRepository,
29
36
  resolveGitHubToken,
37
+ resolvePullRequestAutofill,
30
38
  validatePullRequestBranchRef,
31
39
  } from "../github.ts";
32
- 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";
33
49
 
34
50
  const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
35
51
 
@@ -49,11 +65,17 @@ const ChangeBranchParametersSchema = Type.Object(
49
65
 
50
66
  const PullRequestParametersSchema = Type.Object(
51
67
  {
52
- headBranch: Type.String({ minLength: 1, description: "Existing local branch containing the pull request changes." }),
53
- baseBranch: Type.String({ minLength: 1, description: "Existing local target branch for the pull request." }),
54
- title: Type.String({ minLength: 1, description: "Pull request title." }),
55
- body: Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank." }),
56
- 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." })),
57
79
  },
58
80
  { additionalProperties: false },
59
81
  );
@@ -73,8 +95,62 @@ export function formatChangeBranch(details: ChangeBranchDetails): string {
73
95
  return `Changed branch from ${previous} to ${details.currentBranch}.`;
74
96
  }
75
97
 
76
- export function formatPullRequest(details: PullRequestDetails): string {
77
- 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
+ );
78
154
  }
79
155
 
80
156
  async function validateLocalPullRequestBranchName(
@@ -105,6 +181,60 @@ async function requireExistingValidatedLocalPullRequestBranch(
105
181
  throw new Error(`${field} local branch '${redactSecrets(branchName)}' does not exist.`);
106
182
  }
107
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
+
108
238
  function shortCommit(commit: string): string {
109
239
  return commit.slice(0, 12);
110
240
  }
@@ -199,6 +329,26 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
199
329
  },
200
330
  });
201
331
 
332
+ pi.registerTool({
333
+ name: FETCH_BRANCH_TOOL_NAME,
334
+ label: "Fetch Branch",
335
+ description: "fetch_branch fetches the current branch's configured upstream branch into its remote-tracking ref with an explicit git fetch --no-tags --no-recurse-submodules refspec. fetch_branch does not change local branches or working-tree files and never prunes, rebases, merges, stashes, stages, commits, or pushes.",
336
+ promptSnippet: "fetch_branch: fetch the current branch's configured upstream into its remote-tracking ref without changing local branches or files",
337
+ promptGuidelines: [
338
+ "Use fetch_branch only when the user explicitly wants to fetch the configured upstream branch for the current branch.",
339
+ "Use fetch_branch only on a current branch with an upstream; fetch_branch has no branchName, remote, tags, prune, force, or refspec parameters.",
340
+ "Call fetch_branch and wait for it to complete before rebase_branch when the user wants the latest upstream state; do not batch fetch_branch with rebase_branch.",
341
+ ],
342
+ parameters: EmptyParametersSchema,
343
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
344
+ const details = await fetchCurrentBranch(pi, ctx, signal);
345
+ return {
346
+ content: [{ type: "text", text: `Fetched configured upstream remote ${details.remote} for current branch ${details.currentBranch}.` }],
347
+ details,
348
+ };
349
+ },
350
+ });
351
+
202
352
  pi.registerTool({
203
353
  name: PULL_BRANCH_TOOL_NAME,
204
354
  label: "Pull Branch",
@@ -219,6 +369,27 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
219
369
  },
220
370
  });
221
371
 
372
+ pi.registerTool({
373
+ name: REBASE_BRANCH_TOOL_NAME,
374
+ label: "Rebase Branch",
375
+ description: "rebase_branch rebases the clean current branch onto its configured upstream with git rebase --no-autostash --no-update-refs. rebase_branch rewrites local commits, never pushes or force-pushes, and automatically attempts git rebase --abort if the rebase fails.",
376
+ promptSnippet: "rebase_branch: rebase the clean current branch onto its configured upstream with automatic abort on failure",
377
+ promptGuidelines: [
378
+ "Use rebase_branch only when the user explicitly requests rebasing and accepts that rebase_branch rewrites local commit history.",
379
+ "Use rebase_branch only on a clean current branch with an upstream; rebase_branch has no branchName, base, remote, force, autostash, continue, or abort parameters.",
380
+ "Call fetch_branch and wait for it to complete before rebase_branch when the latest upstream state is required; do not batch rebase_branch with fetch_branch, change_branch, pull_branch, create_branch, push_branch, or pull_request.",
381
+ "If rebase_branch fails or conflicts, rebase_branch automatically attempts git rebase --abort and never leaves conflict resolution to another parallel tool call.",
382
+ ],
383
+ parameters: EmptyParametersSchema,
384
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
385
+ const details = await rebaseCurrentBranch(pi, ctx, signal);
386
+ return {
387
+ content: [{ type: "text", text: `Rebased current branch ${details.currentBranch} onto ${details.upstream}.` }],
388
+ details,
389
+ };
390
+ },
391
+ });
392
+
222
393
  pi.registerTool({
223
394
  name: PUSH_BRANCH_TOOL_NAME,
224
395
  label: "Push Branch",
@@ -243,10 +414,12 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
243
414
  pi.registerTool({
244
415
  name: PULL_REQUEST_TOOL_NAME,
245
416
  label: "Pull Request",
246
- 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.",
247
- 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",
248
419
  promptGuidelines: [
249
- "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.",
250
423
  "Use pull_request only with existing local branches for headBranch and baseBranch; headBranch must match the GitHub-visible branch commit.",
251
424
  "Do not call push_branch and pull_request in the same tool batch; call pull_request only after push_branch has completed.",
252
425
  "Use pull_request only for the resolved current repository; pull_request never accepts owner, repo, or owner-prefixed branch refs.",
@@ -258,30 +431,31 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
258
431
  const rootCtx = { cwd: repoRoot };
259
432
 
260
433
  return withRepositoryMutationQueue(repoRoot, async () => {
261
- await validateLocalPullRequestBranchName(pi, rootCtx, params.headBranch, "headBranch", signal);
262
- await validateLocalPullRequestBranchName(pi, rootCtx, params.baseBranch, "baseBranch", signal);
263
- await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.headBranch, "headBranch", signal);
264
- await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.baseBranch, "baseBranch", signal);
434
+ const branches = await resolvePullRequestBranches(pi, rootCtx, params, options.env, signal);
265
435
  const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
266
436
  const token = (await resolveGitHubToken(options.env, { cwd: repoRoot, signal })).token;
267
437
 
268
438
  try {
269
- 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, {
270
441
  fetchImpl: options.fetchImpl,
271
442
  signal,
272
443
  });
273
- await requireGitHubHeadMatchesLocalBranch(pi, rootCtx, params.headBranch, headBranch.commitSha, signal);
274
- 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, {
275
446
  fetchImpl: options.fetchImpl,
276
447
  signal,
277
448
  });
278
- const details = await createGitHubPullRequest(repository, params, token, {
449
+ const details = await createGitHubPullRequest(repository, resolved.input, token, {
279
450
  fetchImpl: options.fetchImpl,
280
451
  signal,
281
452
  });
453
+ const toolDetails: PullRequestToolDetails = resolved.autofilledFields.length > 0
454
+ ? { ...details, autofilledFields: resolved.autofilledFields }
455
+ : details;
282
456
  return {
283
- content: [{ type: "text", text: formatPullRequest(details) }],
284
- details,
457
+ content: [{ type: "text", text: formatPullRequest(details, resolved.autofilledFields) }],
458
+ details: toolDetails,
285
459
  };
286
460
  } catch (error) {
287
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;
@@ -87,6 +88,17 @@ export interface ChangeBranchDetails {
87
88
  hasChangesBeforeSwitch: false;
88
89
  }
89
90
 
91
+ export interface FetchBranchDetails {
92
+ repoRoot: string;
93
+ currentBranch: string;
94
+ upstream: string;
95
+ remote: string;
96
+ remoteRef: string;
97
+ remoteTrackingRef: string;
98
+ refspec: string;
99
+ output: string;
100
+ }
101
+
90
102
  export interface PullBranchDetails {
91
103
  repoRoot: string;
92
104
  currentBranch: string;
@@ -96,6 +108,15 @@ export interface PullBranchDetails {
96
108
  output: string;
97
109
  }
98
110
 
111
+ export interface RebaseBranchDetails {
112
+ repoRoot: string;
113
+ currentBranch: string;
114
+ upstream: string;
115
+ remote: string;
116
+ remoteRef: string;
117
+ output: string;
118
+ }
119
+
99
120
  export interface PushBranchDetails {
100
121
  repoRoot: string;
101
122
  currentBranch: string;
@@ -125,6 +146,14 @@ export interface PullRequestInput {
125
146
  draft: boolean;
126
147
  }
127
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
+
128
157
  export interface GitExecResult {
129
158
  stdout: string;
130
159
  stderr: string;
@@ -29,8 +29,8 @@ export type BranchMePanelSection = "status" | "workflow";
29
29
  const NARROW_MIN_WIDTH = 24;
30
30
  const WIDE_MIN_WIDTH = 72;
31
31
  const MAX_PANEL_WIDTH = 96;
32
- const MAX_PANEL_HEIGHT = 14;
33
- const BODY_HEIGHT = 7;
32
+ const MAX_PANEL_HEIGHT = 16;
33
+ const BODY_HEIGHT = 9;
34
34
  const PANEL_SECTIONS: BranchMePanelSection[] = ["status", "workflow"];
35
35
  const ANSI_RESET = "\u001b[0m";
36
36
 
@@ -177,7 +177,7 @@ function sectionFooter(section: BranchMePanelSection, data: BranchMePanelData):
177
177
  case "status":
178
178
  return "status • current repository only • tools perform actions";
179
179
  case "workflow":
180
- return "workflow • inspect → change → pull → create → push → PR";
180
+ return "workflow • inspect → change → fetch/pull/rebase → create → push → PR";
181
181
  }
182
182
  }
183
183
 
@@ -197,7 +197,9 @@ function sectionRows(section: BranchMePanelSection, data: BranchMePanelData): Pa
197
197
  heading("WORKFLOW"),
198
198
  workflowDetailRow("branch_status", "inspect"),
199
199
  workflowDetailRow("change_branch", "existing local"),
200
+ workflowDetailRow("fetch_branch", "upstream remote"),
200
201
  workflowDetailRow("pull_branch", "fast-forward"),
202
+ workflowDetailRow("rebase_branch", "onto upstream"),
201
203
  workflowDetailRow("create_branch", "from HEAD"),
202
204
  workflowDetailRow("push_branch", "current branch"),
203
205
  workflowDetailRow("pull_request", "after push"),