@senad-d/branchme 0.3.15 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +131 -30
- package/SECURITY.md +45 -9
- package/docs/PROJECT_DEFINITION_BRIEF.md +26 -7
- package/docs/SHELL_FREE_WORKFLOWS.md +74 -0
- package/docs/SMOKE_TEST.md +16 -7
- package/docs/STRUCTURE.md +48 -8
- package/docs/TUI_CAPTURE.md +17 -3
- package/package.json +9 -6
- package/src/blocker-guidance.ts +20 -5
- package/src/commands/branchme-command.ts +17 -3
- package/src/commit-draft.ts +461 -0
- package/src/commit-prompt.ts +409 -0
- package/src/commit-workflow.ts +233 -0
- package/src/constants.ts +138 -0
- package/src/git-commit-context.ts +783 -0
- package/src/git-commit.ts +317 -0
- package/src/git-conflicts.ts +77 -0
- package/src/git-diff.ts +274 -0
- package/src/git-history.ts +112 -0
- package/src/git-index.ts +119 -0
- package/src/git-read.ts +100 -0
- package/src/git-rebase.ts +42 -0
- package/src/git-remotes.ts +129 -0
- package/src/git-restore.ts +53 -0
- package/src/git-revert.ts +77 -0
- package/src/git-status.ts +346 -0
- package/src/git.ts +11 -2
- package/src/github-api.ts +39 -0
- package/src/github-checks.ts +88 -0
- package/src/github-lifecycle.ts +106 -0
- package/src/tools/branchme-tools.ts +159 -126
- package/src/tools/commit-tools.ts +240 -0
- package/src/tools/github-lifecycle-tools.ts +29 -0
- package/src/tools/inspection-tools.ts +39 -0
- package/src/tools/mutation-tools.ts +58 -0
- package/src/tools/workflow-tools.ts +32 -49
- package/src/tools/worktree-routing.ts +34 -0
- package/src/truncation.ts +119 -0
- package/src/types.ts +273 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { getGitHubPullRequest } from "./github.ts";
|
|
2
|
+
import { apiPath, clientOptions, githubRequest, positiveNumber, record, type GitHubClient } from "./github-api.ts";
|
|
3
|
+
import { getPullRequestPolicy } from "./github-checks.ts";
|
|
4
|
+
import { boundedGitText, errorText, OBJECT_ID, safeGitText, validateKeys } from "./git-read.ts";
|
|
5
|
+
|
|
6
|
+
export interface EditPullRequestInput { number: number; title?: string; body?: string; draft?: boolean }
|
|
7
|
+
export interface MergePullRequestInput { number: number; expectedHead: string; mergeMethod: "merge" | "squash" | "rebase" }
|
|
8
|
+
async function rawPullRequest(client: GitHubClient, number: number) {
|
|
9
|
+
const value = record(await githubRequest(client, apiPath(client, `/pulls/${number}`)));
|
|
10
|
+
if (value.number !== number || typeof value.title !== "string" || (value.body !== null && typeof value.body !== "string") || typeof value.draft !== "boolean" || typeof value.node_id !== "string" || !value.node_id || value.node_id.length > 256) throw new Error("PR metadata response is incomplete.");
|
|
11
|
+
return { title: value.title, body: value.body ?? "", draft: value.draft, id: value.node_id, head: record(value.head).sha, base: record(value.base).ref };
|
|
12
|
+
}
|
|
13
|
+
function metadataMatches(actual: Awaited<ReturnType<typeof rawPullRequest>>, original: Awaited<ReturnType<typeof rawPullRequest>>, input: EditPullRequestInput): boolean {
|
|
14
|
+
return actual.title === (input.title ?? original.title) && actual.body === (input.body ?? original.body) && actual.draft === (input.draft ?? original.draft) && actual.head === original.head && actual.base === original.base;
|
|
15
|
+
}
|
|
16
|
+
function validateEditInput(client: GitHubClient, input: EditPullRequestInput): void {
|
|
17
|
+
validateKeys(input, ["number", "title", "body", "draft"]); positiveNumber(input.number);
|
|
18
|
+
if (input.title === undefined && input.body === undefined && input.draft === undefined) throw new TypeError("Supply at least one explicit title, body, or draft change.");
|
|
19
|
+
if (input.title !== undefined && (typeof input.title !== "string" || !input.title.trim() || input.title.length > 512)) throw new TypeError("title must be a nonempty bounded string.");
|
|
20
|
+
if (input.body !== undefined && (typeof input.body !== "string" || input.body.length > 60000)) throw new TypeError("body must be a bounded string; an empty string deliberately clears it.");
|
|
21
|
+
if (input.draft !== undefined && typeof input.draft !== "boolean") throw new TypeError("draft must be boolean.");
|
|
22
|
+
for (const value of [input.title, input.body]) if (value !== undefined && safeGitText(value, [client.token]) !== value) throw new Error("PR metadata must not contain credentials or unsafe control characters.");
|
|
23
|
+
}
|
|
24
|
+
export async function editPullRequest(client: GitHubClient, input: EditPullRequestInput) {
|
|
25
|
+
validateEditInput(client, input);
|
|
26
|
+
const before = await getGitHubPullRequest(client.repository, input.number, client.token, clientOptions(client));
|
|
27
|
+
if (before.state !== "open" || before.merged) throw new Error("Only an open PR can be edited.");
|
|
28
|
+
const original = await rawPullRequest(client, input.number);
|
|
29
|
+
if (original.head !== before.headSha || original.base !== before.base) throw new Error("PR changed during preparation; inspect pull_request_status.");
|
|
30
|
+
let attempted = false;
|
|
31
|
+
try {
|
|
32
|
+
client.signal?.throwIfAborted();
|
|
33
|
+
if (input.title !== undefined || input.body !== undefined) {
|
|
34
|
+
attempted = true;
|
|
35
|
+
await githubRequest(client, apiPath(client, `/pulls/${input.number}`), "PATCH", { ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }) });
|
|
36
|
+
}
|
|
37
|
+
if (input.draft !== undefined && original.draft !== input.draft) {
|
|
38
|
+
attempted = true;
|
|
39
|
+
await updateDraft(client, original.id, input.draft);
|
|
40
|
+
}
|
|
41
|
+
const final = { ...client, signal: undefined };
|
|
42
|
+
const after = await rawPullRequest(final, input.number);
|
|
43
|
+
const status = await getGitHubPullRequest(final.repository, input.number, final.token, clientOptions(final));
|
|
44
|
+
requireEditedMetadata(after, original, input, status, before.headSha);
|
|
45
|
+
return { action: "edit_pull_request", number: input.number, verified: true, changedFields: editedFields(input), head: status.headSha, url: status.url };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!attempted) throw error;
|
|
48
|
+
const reconciled = await reconcileEdit(client, input, original, before.headSha);
|
|
49
|
+
if (reconciled) return reconciled;
|
|
50
|
+
throw new Error(`PR metadata may be partially updated; inspect the PR before retrying. No automatic rollback/retry occurred. ${boundedGitText(String(error), [client.token], 1000).text}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function editedFields(input: EditPullRequestInput): string[] {
|
|
54
|
+
return ["title", "body", "draft"].filter((key) => input[key as keyof EditPullRequestInput] !== undefined);
|
|
55
|
+
}
|
|
56
|
+
function requireEditedMetadata(after: Awaited<ReturnType<typeof rawPullRequest>>, original: Awaited<ReturnType<typeof rawPullRequest>>, input: EditPullRequestInput, status: Awaited<ReturnType<typeof getGitHubPullRequest>>, head: string): void {
|
|
57
|
+
if (!metadataMatches(after, original, input) || status.state !== "open" || status.merged || status.headSha !== head) throw new Error("PR metadata postconditions did not match.");
|
|
58
|
+
}
|
|
59
|
+
async function updateDraft(client: GitHubClient, id: string, draft: boolean): Promise<void> {
|
|
60
|
+
const mutation = draft ? "convertPullRequestToDraft" : "markPullRequestReadyForReview";
|
|
61
|
+
const response = record(await githubRequest(client, "/graphql", "POST", { query: `mutation($id:ID!){${mutation}(input:{pullRequestId:$id}){pullRequest{id isDraft}}}`, variables: { id } }));
|
|
62
|
+
if (response.errors) throw new Error("Draft-state update was not confirmed.");
|
|
63
|
+
}
|
|
64
|
+
async function reconcileEdit(client: GitHubClient, input: EditPullRequestInput, original: Awaited<ReturnType<typeof rawPullRequest>>, head: string) {
|
|
65
|
+
try {
|
|
66
|
+
const final = { ...client, signal: undefined };
|
|
67
|
+
const after = await rawPullRequest(final, input.number);
|
|
68
|
+
const status = await getGitHubPullRequest(final.repository, input.number, final.token, clientOptions(final));
|
|
69
|
+
if (metadataMatches(after, original, input) && status.state === "open" && !status.merged && status.headSha === head) return { action: "edit_pull_request", number: input.number, verified: true, reconciledAfterUncertainResponse: true, head: status.headSha, url: status.url };
|
|
70
|
+
} catch { /* Preserve the uncertain mutation outcome in the caller. */ }
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function requireMergePolicy(policy: Awaited<ReturnType<typeof getPullRequestPolicy>>, before: Awaited<ReturnType<typeof getGitHubPullRequest>>): void {
|
|
74
|
+
if (!policy.available || policy.head !== before.headSha || policy.base !== before.baseSha || policy.mergeable !== "MERGEABLE" || policy.mergeState !== "CLEAN" || (policy.reviewDecision !== null && policy.reviewDecision !== "APPROVED") || (policy.rollup !== null && policy.rollup !== "SUCCESS")) throw new Error("Host readiness is blocked, unknown, or stale; inspect pull_request_checks. No merge/bypass was attempted.");
|
|
75
|
+
}
|
|
76
|
+
export async function mergePullRequest(client: GitHubClient, input: MergePullRequestInput) {
|
|
77
|
+
validateKeys(input, ["number", "expectedHead", "mergeMethod"]); positiveNumber(input.number);
|
|
78
|
+
if (typeof input.expectedHead !== "string" || !OBJECT_ID.test(input.expectedHead) || !["merge", "squash", "rebase"].includes(input.mergeMethod)) throw new TypeError("Require expectedHead and an explicit supported merge method.");
|
|
79
|
+
const before = await getGitHubPullRequest(client.repository, input.number, client.token, clientOptions(client));
|
|
80
|
+
if (before.headSha !== input.expectedHead) throw new Error("PR head changed; inspect pull_request_status before authorizing a merge.");
|
|
81
|
+
if (before.merged) return { action: "merge_pull_request", outcome: "already-merged", pullRequest: before, cleanupPerformed: false };
|
|
82
|
+
if (before.state !== "open" || before.draft) throw new Error("Only an open non-draft PR can be merged.");
|
|
83
|
+
const policy = await getPullRequestPolicy(client, input.number);
|
|
84
|
+
requireMergePolicy(policy, before);
|
|
85
|
+
const repo = record(await githubRequest(client, apiPath(client, "")));
|
|
86
|
+
const setting = { merge: "allow_merge_commit", squash: "allow_squash_merge", rebase: "allow_rebase_merge" }[input.mergeMethod];
|
|
87
|
+
if (repo[setting] !== true) throw new Error("The repository does not explicitly allow the selected merge method.");
|
|
88
|
+
const immediate = await getGitHubPullRequest(client.repository, input.number, client.token, clientOptions(client));
|
|
89
|
+
if (immediate.headSha !== before.headSha || immediate.base !== before.base || immediate.baseSha !== before.baseSha || immediate.state !== "open" || immediate.draft) throw new Error("PR head/base/state changed during preparation; no merge was attempted.");
|
|
90
|
+
client.signal?.throwIfAborted();
|
|
91
|
+
let reported: string | null = null;
|
|
92
|
+
let failure: unknown;
|
|
93
|
+
try {
|
|
94
|
+
const result = record(await githubRequest(client, apiPath(client, `/pulls/${input.number}/merge`), "PUT", { sha: input.expectedHead, merge_method: input.mergeMethod }));
|
|
95
|
+
if (result.merged !== true || typeof result.sha !== "string" || !OBJECT_ID.test(result.sha)) throw new Error("Host did not confirm the requested merge.");
|
|
96
|
+
reported = result.sha;
|
|
97
|
+
} catch (error) { failure = error; }
|
|
98
|
+
try {
|
|
99
|
+
const final = { ...client, signal: undefined };
|
|
100
|
+
const after = await getGitHubPullRequest(final.repository, input.number, final.token, clientOptions(final));
|
|
101
|
+
if (!after.merged || after.headSha !== before.headSha || after.base !== before.base || (reported !== null && after.mergeCommitSha !== reported)) throw new Error("Exact merged identities were not verified.");
|
|
102
|
+
return { action: "merge_pull_request", outcome: "merged", mergeMethod: input.mergeMethod, reconciledAfterUncertainResponse: Boolean(failure), pullRequest: after, cleanupPerformed: false, note: "Host merge verified; local cleanup requires a separate land_branch call. GitHub enforces protections. The merge API leases HEAD but has no atomic base-SHA lease." };
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(`Merge outcome was not verified; inspect pull_request_status before retrying. No local merge, cleanup, or remote branch deletion occurred. ${failure ? boundedGitText(errorText(failure), [client.token], 1000).text : ""}`);
|
|
105
|
+
}
|
|
106
|
+
}
|