@senad-d/branchme 0.1.2 → 0.1.4

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/src/github.ts CHANGED
@@ -5,12 +5,26 @@ import { redactSecrets } from "./redaction.ts";
5
5
  import {
6
6
  GITHUB_API_BASE_URL,
7
7
  GITHUB_API_VERSION,
8
+ GITHUB_RELATED_PR_TIMEOUT_MS,
8
9
  GITHUB_RESPONSE_BODY_LIMIT_BYTES,
9
10
  GITHUB_USER_AGENT,
11
+ GIT_CONTEXT_VALUE_LIMIT_CHARS,
10
12
  MAX_SUMMARY_OUTPUT_CHARS,
11
13
  } from "./constants.ts";
12
- import { getGitRoot, getOriginUrl, validateBranchNameInput, type GitCommandContext } from "./git.ts";
13
- import type { GitHubRepository, PullRequestDetails, PullRequestInput } from "./types.ts";
14
+ import {
15
+ getCurrentBranch,
16
+ getGitRoot,
17
+ getOriginUrl,
18
+ validateBranchNameInput,
19
+ type GitCommandContext,
20
+ } from "./git.ts";
21
+ import type {
22
+ GitHubRepository,
23
+ PullRequestDetails,
24
+ PullRequestInput,
25
+ RelatedPullRequest,
26
+ RelatedPullRequestDetails,
27
+ } from "./types.ts";
14
28
 
15
29
  type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
16
30
 
@@ -33,6 +47,11 @@ export interface PullRequestFetchOptions {
33
47
  signal?: AbortSignal;
34
48
  }
35
49
 
50
+ export interface RelatedPullRequestLookupOptions extends PullRequestFetchOptions {
51
+ env?: NodeJS.ProcessEnv;
52
+ timeoutMs?: number;
53
+ }
54
+
36
55
  export interface GitHubBranchDetails {
37
56
  name: string;
38
57
  commitSha: string;
@@ -484,6 +503,11 @@ function requireGitHubResponseObject(payload: unknown, responseContext: string):
484
503
  return payload;
485
504
  }
486
505
 
506
+ function requireGitHubResponseArray(payload: unknown, responseContext: string): unknown[] {
507
+ if (!Array.isArray(payload)) throw new Error(`${responseContext} was not an array.`);
508
+ return payload;
509
+ }
510
+
487
511
  async function readGitHubJsonObject(
488
512
  response: Response,
489
513
  signal: AbortSignal | undefined,
@@ -496,6 +520,18 @@ async function readGitHubJsonObject(
496
520
  return requireGitHubResponseObject(parseGitHubJson(body.text, responseContext, token), responseContext);
497
521
  }
498
522
 
523
+ async function readGitHubJsonArray(
524
+ response: Response,
525
+ signal: AbortSignal | undefined,
526
+ token: string,
527
+ responseContext: string,
528
+ readFailurePrefix: string,
529
+ ): Promise<unknown[]> {
530
+ const body = await readGitHubResponseBody(response, signal, readFailurePrefix, token);
531
+ if (body.truncated) throw new Error(`${responseContext} exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
532
+ return requireGitHubResponseArray(parseGitHubJson(body.text, responseContext, token), responseContext);
533
+ }
534
+
499
535
  function parseGitHubBranchDetails(payload: Record<string, unknown>, branchName: string): GitHubBranchDetails {
500
536
  const commit = isRecord(payload.commit) ? stringField(payload.commit.sha, "commit.sha") : "";
501
537
  if (!/^[0-9a-f]{40,64}$/iu.test(commit)) throw new Error("GitHub branch preflight response is missing commit.sha.");
@@ -604,6 +640,273 @@ function pullRequestDetailsFromPayload(
604
640
  };
605
641
  }
606
642
 
643
+ function escapeRelatedPullRequestControlCharacter(character: string): string {
644
+ const codePoint = character.codePointAt(0);
645
+ return codePoint === undefined ? "" : String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
646
+ }
647
+
648
+ function boundedRelatedPullRequestValue(value: string): string {
649
+ const safeValue = redactSecrets(value).replace(
650
+ /[\p{Cc}\p{Cf}\u2028\u2029]/gu,
651
+ escapeRelatedPullRequestControlCharacter,
652
+ );
653
+ if (safeValue.length <= GIT_CONTEXT_VALUE_LIMIT_CHARS) return safeValue;
654
+
655
+ let end = GIT_CONTEXT_VALUE_LIMIT_CHARS - 1;
656
+ const lastCodePoint = safeValue.codePointAt(end - 1);
657
+ if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
658
+ return `${safeValue.slice(0, end)}…`;
659
+ }
660
+
661
+ function requireRelatedPullRequestString(value: unknown, field: string): string {
662
+ const text = stringField(value, field);
663
+ if (text.trim().length === 0) throw new Error(`GitHub response ${field} cannot be blank.`);
664
+ return text;
665
+ }
666
+
667
+ function requireRelatedPullRequestUrl(
668
+ value: unknown,
669
+ repository: GitHubRepository,
670
+ number: number,
671
+ ): string {
672
+ const rawUrl = requireRelatedPullRequestString(value, "html_url");
673
+ if (rawUrl.length > GIT_CONTEXT_VALUE_LIMIT_CHARS || /[\p{Cc}\p{Cf}\s]/u.test(rawUrl)) {
674
+ throw new Error("GitHub response html_url was invalid.");
675
+ }
676
+
677
+ let parsed: URL;
678
+ try {
679
+ parsed = new URL(rawUrl);
680
+ } catch {
681
+ throw new Error("GitHub response html_url was invalid.");
682
+ }
683
+
684
+ const expectedPath = `/${repository.owner}/${repository.repo}/pull/${number}`.toLowerCase();
685
+ if (
686
+ parsed.protocol !== "https:" ||
687
+ parsed.hostname.toLowerCase() !== "github.com" ||
688
+ parsed.port !== "" ||
689
+ parsed.username !== "" ||
690
+ parsed.password !== "" ||
691
+ parsed.search !== "" ||
692
+ parsed.hash !== "" ||
693
+ parsed.pathname.toLowerCase() !== expectedPath
694
+ ) {
695
+ throw new Error("GitHub response html_url did not match the resolved repository and pull request number.");
696
+ }
697
+
698
+ return rawUrl;
699
+ }
700
+
701
+ function requireRelatedPullRequestRepository(
702
+ value: unknown,
703
+ repository: GitHubRepository,
704
+ ): void {
705
+ const headRepository = requireGitHubResponseObject(value, "GitHub related pull request head.repo");
706
+ const fullName = requireRelatedPullRequestString(headRepository.full_name, "head.repo.full_name");
707
+ if (fullName.toLowerCase() !== repositoryLabel(repository).toLowerCase()) {
708
+ throw new Error("GitHub related pull request head repository did not match the resolved repository.");
709
+ }
710
+ }
711
+
712
+ function relatedPullRequestDetailsFromPayload(
713
+ repository: GitHubRepository,
714
+ currentBranch: string,
715
+ payload: unknown,
716
+ ): RelatedPullRequestDetails {
717
+ const response = requireGitHubResponseObject(payload, "GitHub related pull request response item");
718
+ const number = pullRequestNumberField(response.number);
719
+ const state = requireRelatedPullRequestString(response.state, "state");
720
+ if (state !== "open") throw new Error("GitHub related pull request response state was not open.");
721
+ if (typeof response.draft !== "boolean") throw new Error("GitHub response draft must be a boolean.");
722
+
723
+ const head = requireGitHubResponseObject(response.head, "GitHub related pull request head");
724
+ const headRef = requireRelatedPullRequestString(head.ref, "head.ref");
725
+ if (headRef !== currentBranch) {
726
+ throw new Error("GitHub related pull request head did not match the current branch.");
727
+ }
728
+ requireRelatedPullRequestRepository(head.repo, repository);
729
+
730
+ const base = requireGitHubResponseObject(response.base, "GitHub related pull request base");
731
+ const baseRef = requireRelatedPullRequestString(base.ref, "base.ref");
732
+ const title = requireRelatedPullRequestString(response.title, "title");
733
+
734
+ return {
735
+ repository: {
736
+ owner: boundedRelatedPullRequestValue(repository.owner),
737
+ repo: boundedRelatedPullRequestValue(repository.repo),
738
+ },
739
+ number,
740
+ url: requireRelatedPullRequestUrl(response.html_url, repository, number),
741
+ title: boundedRelatedPullRequestValue(title),
742
+ state,
743
+ draft: response.draft,
744
+ head: boundedRelatedPullRequestValue(headRef),
745
+ base: boundedRelatedPullRequestValue(baseRef),
746
+ };
747
+ }
748
+
749
+ function unavailableRelatedPullRequest(reason: string): RelatedPullRequest {
750
+ return { status: "unavailable", reason };
751
+ }
752
+
753
+ function abortedRelatedPullRequest(
754
+ callerSignal: AbortSignal | undefined,
755
+ timeoutSignal: AbortSignal,
756
+ ): RelatedPullRequest | null {
757
+ if (callerSignal?.aborted) return unavailableRelatedPullRequest("Related pull request lookup was cancelled.");
758
+ if (timeoutSignal.aborted) return unavailableRelatedPullRequest("Related pull request lookup timed out.");
759
+ return null;
760
+ }
761
+
762
+ function validateRelatedPullRequestLookupBounds(repository: GitHubRepository, branch: string): void {
763
+ if (
764
+ repository.owner.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
765
+ repository.repo.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
766
+ branch.length > GIT_CONTEXT_VALUE_LIMIT_CHARS
767
+ ) {
768
+ throw new Error("GitHub related pull request lookup metadata exceeded its value limit.");
769
+ }
770
+ }
771
+
772
+ function relatedPullRequestLookupUrl(repository: GitHubRepository, currentBranch: string): string {
773
+ const query = new URLSearchParams({
774
+ state: "open",
775
+ head: `${repository.owner}:${currentBranch}`,
776
+ per_page: "1",
777
+ });
778
+ return `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls?${query.toString()}`;
779
+ }
780
+
781
+ async function lookupRelatedPullRequestWithSignal(
782
+ pi: Pick<ExtensionAPI, "exec">,
783
+ ctx: GitCommandContext,
784
+ options: RelatedPullRequestLookupOptions,
785
+ signal: AbortSignal,
786
+ timeoutSignal: AbortSignal,
787
+ ): Promise<RelatedPullRequest> {
788
+ const initialAbort = abortedRelatedPullRequest(options.signal, timeoutSignal);
789
+ if (initialAbort) return initialAbort;
790
+
791
+ let repoRoot: string;
792
+ let currentBranch: string;
793
+ let repository: GitHubRepository;
794
+ try {
795
+ repoRoot = await getGitRoot(pi, ctx, signal);
796
+ const rootCtx = { cwd: repoRoot };
797
+ const branch = await getCurrentBranch(pi, rootCtx, signal);
798
+ if (branch.detached || !branch.currentBranch) {
799
+ return unavailableRelatedPullRequest("Related pull request lookup is unavailable for detached HEAD.");
800
+ }
801
+ currentBranch = branch.currentBranch;
802
+ validatePullRequestBranchRef(currentBranch, "headBranch");
803
+ repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env ?? process.env);
804
+ validateRelatedPullRequestLookupBounds(repository, currentBranch);
805
+ } catch {
806
+ return (
807
+ abortedRelatedPullRequest(options.signal, timeoutSignal) ??
808
+ unavailableRelatedPullRequest("The current GitHub repository or branch could not be resolved.")
809
+ );
810
+ }
811
+
812
+ let token: string;
813
+ try {
814
+ token = (await resolveGitHubToken(options.env ?? process.env, { cwd: repoRoot, signal })).token;
815
+ } catch {
816
+ return (
817
+ abortedRelatedPullRequest(options.signal, timeoutSignal) ??
818
+ unavailableRelatedPullRequest("GitHub authentication is unavailable.")
819
+ );
820
+ }
821
+
822
+ let response: Response;
823
+ try {
824
+ const fetchImpl = requireFetchImplementation(options.fetchImpl ?? globalThis.fetch);
825
+ response = await fetchGitHubResponse(
826
+ fetchImpl,
827
+ relatedPullRequestLookupUrl(repository, currentBranch),
828
+ { method: "GET", headers: gitHubJsonHeaders(token), signal },
829
+ "GitHub related pull request request failed",
830
+ token,
831
+ );
832
+ } catch {
833
+ return (
834
+ abortedRelatedPullRequest(options.signal, timeoutSignal) ??
835
+ unavailableRelatedPullRequest("The GitHub related pull request request failed.")
836
+ );
837
+ }
838
+
839
+ if (!response.ok) {
840
+ try {
841
+ await readGitHubResponseBody(
842
+ response,
843
+ signal,
844
+ `GitHub related pull request request failed with HTTP ${response.status}: unable to read error response`,
845
+ token,
846
+ );
847
+ } catch {
848
+ return (
849
+ abortedRelatedPullRequest(options.signal, timeoutSignal) ??
850
+ unavailableRelatedPullRequest("The GitHub related pull request response could not be read.")
851
+ );
852
+ }
853
+ return unavailableRelatedPullRequest(`GitHub related pull request lookup returned HTTP ${response.status}.`);
854
+ }
855
+
856
+ let payload: unknown[];
857
+ try {
858
+ payload = await readGitHubJsonArray(
859
+ response,
860
+ signal,
861
+ token,
862
+ "GitHub related pull request response",
863
+ "GitHub related pull request response could not be read",
864
+ );
865
+ } catch {
866
+ return (
867
+ abortedRelatedPullRequest(options.signal, timeoutSignal) ??
868
+ unavailableRelatedPullRequest("The GitHub related pull request response was invalid.")
869
+ );
870
+ }
871
+
872
+ if (payload.length === 0) return { status: "none" };
873
+
874
+ try {
875
+ return {
876
+ status: "found",
877
+ pullRequest: relatedPullRequestDetailsFromPayload(repository, currentBranch, payload[0]),
878
+ };
879
+ } catch {
880
+ return unavailableRelatedPullRequest("The GitHub related pull request response was invalid.");
881
+ }
882
+ }
883
+
884
+ export async function lookupRelatedPullRequest(
885
+ pi: Pick<ExtensionAPI, "exec">,
886
+ ctx: GitCommandContext,
887
+ options: RelatedPullRequestLookupOptions = {},
888
+ ): Promise<RelatedPullRequest> {
889
+ const timeoutMs = options.timeoutMs ?? GITHUB_RELATED_PR_TIMEOUT_MS;
890
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
891
+ return unavailableRelatedPullRequest("Related pull request lookup timeout is invalid.");
892
+ }
893
+ if (options.signal?.aborted) {
894
+ return unavailableRelatedPullRequest("Related pull request lookup was cancelled.");
895
+ }
896
+
897
+ // AbortSignal.timeout() uses an unref'ed timer, which can let Node exit while fetch is still pending.
898
+ const timeoutController = new AbortController();
899
+ const timeoutHandle = setTimeout(timeoutController.abort.bind(timeoutController), timeoutMs);
900
+ const timeoutSignal = timeoutController.signal;
901
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
902
+
903
+ try {
904
+ return await lookupRelatedPullRequestWithSignal(pi, ctx, options, signal, timeoutSignal);
905
+ } finally {
906
+ clearTimeout(timeoutHandle);
907
+ }
908
+ }
909
+
607
910
  export async function ensureGitHubBranchExists(
608
911
  repository: GitHubRepository,
609
912
  branchName: string,
@@ -10,7 +10,6 @@ import {
10
10
  import {
11
11
  changeExistingLocalBranch,
12
12
  createLocalBranch,
13
- getBranchStatus,
14
13
  getGitRoot,
15
14
  getLocalBranchCommit,
16
15
  localBranchExists,
@@ -18,6 +17,7 @@ import {
18
17
  validateBranchName,
19
18
  withRepositoryMutationQueue,
20
19
  } from "../git.ts";
20
+ import { collectGitContext, formatGitContext } from "../git-context.ts";
21
21
  import {
22
22
  createGitHubPullRequest,
23
23
  ensureGitHubBranchExists,
@@ -27,7 +27,7 @@ import {
27
27
  resolveGitHubToken,
28
28
  validatePullRequestBranchRef,
29
29
  } from "../github.ts";
30
- import type { BranchStatusDetails, ChangeBranchDetails, PullRequestDetails } from "../types.ts";
30
+ import type { ChangeBranchDetails, GitContextDetails, PullRequestDetails } from "../types.ts";
31
31
 
32
32
  const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
33
33
 
@@ -62,17 +62,8 @@ export interface BranchMeToolOptions {
62
62
  fetchImpl?: typeof fetch;
63
63
  }
64
64
 
65
- function repositoryText(details: BranchStatusDetails): string {
66
- return details.githubRepository ? repositoryLabel(details.githubRepository) : "not resolved";
67
- }
68
-
69
- export function formatBranchStatus(details: BranchStatusDetails): string {
70
- const branch = details.detached ? "detached HEAD" : details.currentBranch ?? "unknown branch";
71
- const tree = details.hasChanges ? "dirty" : "clean";
72
- const upstream = details.upstream ? `upstream ${details.upstream}` : "no upstream";
73
- const counts = details.ahead === null || details.behind === null ? "ahead/behind unavailable" : `ahead ${details.ahead}, behind ${details.behind}`;
74
- const warning = details.warnings?.length ? `; warning: ${details.warnings.join("; ")}` : "";
75
- return `BranchMe status: ${branch}; ${tree}; ${upstream}; ${counts}; GitHub ${repositoryText(details)}${warning}.`;
65
+ export function formatBranchStatus(details: GitContextDetails): string {
66
+ return formatGitContext(details, "refresh");
76
67
  }
77
68
 
78
69
  export function formatChangeBranch(details: ChangeBranchDetails): string {
@@ -134,20 +125,25 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
134
125
  pi.registerTool({
135
126
  name: BRANCH_STATUS_TOOL_NAME,
136
127
  label: "Branch Status",
137
- description: "branch_status inspects the current git repository, branch, upstream, dirty state, ahead/behind counts, and GitHub repository if available. branch_status is read-only.",
138
- promptSnippet: "branch_status: inspect current-repository git branch status without mutating files or git state",
128
+ description: "branch_status explicitly refreshes the current Git repository snapshot, including branch, working tree, unstaged changes, related pull request, and recent commits. branch_status is read-only and never mutates files, Git state, or GitHub state.",
129
+ promptSnippet: "branch_status: explicitly refresh current-repository branch, working tree, changes, related PR, and recent commits without mutation",
139
130
  promptGuidelines: [
140
- "Use branch_status before change_branch, create_branch, push_branch, or pull_request when the user asks about the current branch state.",
141
- "Use branch_status only for read-only inspection; branch_status never creates branches, pushes, commits, stages, or edits files.",
131
+ "Use the automatic Git context for start-of-run questions; call branch_status only for an explicit refresh or after Git state changes during the current run.",
132
+ "Use branch_status as a read-only refresh; branch_status never mutates files, Git state, or GitHub state.",
142
133
  ],
143
134
  parameters: EmptyParametersSchema,
144
135
  async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
145
- const details = await getBranchStatus(pi, ctx, signal);
136
+ const details = await collectGitContext(pi, ctx, {
137
+ signal,
138
+ env: options.env,
139
+ fetchImpl: options.fetchImpl,
140
+ });
146
141
 
147
142
  try {
148
- details.githubRepository = await resolveGitHubRepository(pi, ctx, signal, options.env);
149
- } catch {
150
- // Repository resolution is optional for branch_status; mutation tools fail closed.
143
+ details.githubRepository ??= await resolveGitHubRepository(pi, ctx, signal, options.env);
144
+ } catch (error) {
145
+ if (signal?.aborted) throw error;
146
+ // Repository resolution is optional compatibility metadata; context collection is authoritative.
151
147
  }
152
148
 
153
149
  return {
package/src/types.ts CHANGED
@@ -13,6 +13,47 @@ export interface AheadBehindCount {
13
13
  behind: number | null;
14
14
  }
15
15
 
16
+ export interface WorkingTreeDetails {
17
+ state: "clean" | "dirty";
18
+ staged: number;
19
+ unstaged: number;
20
+ untracked: number;
21
+ }
22
+
23
+ export interface GitFileChange {
24
+ status: string;
25
+ path: string;
26
+ originalPath?: string;
27
+ }
28
+
29
+ export interface GitFileChangeSummary {
30
+ entries: GitFileChange[];
31
+ omitted: number;
32
+ }
33
+
34
+ export interface RecentCommit {
35
+ hash: string;
36
+ shortHash: string;
37
+ date: string;
38
+ subject: string;
39
+ }
40
+
41
+ export interface RelatedPullRequestDetails {
42
+ repository: GitHubRepository;
43
+ number: number;
44
+ url: string;
45
+ title: string;
46
+ state: string;
47
+ draft: boolean;
48
+ head: string;
49
+ base: string;
50
+ }
51
+
52
+ export type RelatedPullRequest =
53
+ | { status: "found"; pullRequest: RelatedPullRequestDetails }
54
+ | { status: "none" }
55
+ | { status: "unavailable"; reason: string };
56
+
16
57
  export interface BranchStatusDetails {
17
58
  repoRoot: string;
18
59
  currentBranch: string | null;
@@ -25,6 +66,13 @@ export interface BranchStatusDetails {
25
66
  githubRepository?: GitHubRepository;
26
67
  }
27
68
 
69
+ export interface GitContextDetails extends BranchStatusDetails {
70
+ workingTree: WorkingTreeDetails;
71
+ unstagedChanges: GitFileChangeSummary;
72
+ relatedPullRequest: RelatedPullRequest;
73
+ recentCommits: RecentCommit[];
74
+ }
75
+
28
76
  export interface CreateBranchDetails {
29
77
  repoRoot: string;
30
78
  previousBranch: string;