@senad-d/branchme 0.3.0 → 0.3.1
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 +11 -3
- package/README.md +62 -24
- package/SECURITY.md +27 -17
- package/docs/PROJECT_DEFINITION_BRIEF.md +30 -19
- package/docs/SMOKE_TEST.md +13 -4
- package/docs/STRUCTURE.md +20 -12
- package/docs/TUI_CAPTURE.md +41 -31
- package/package.json +3 -2
- package/src/commands/branchme-command.ts +13 -3
- package/src/constants.ts +13 -0
- package/src/git-integration.ts +27 -6
- package/src/git-landing.ts +63 -11
- package/src/git-workflow.ts +136 -0
- package/src/git.ts +279 -3
- package/src/github.ts +115 -0
- package/src/tools/branchme-tools.ts +84 -12
- package/src/tools/workflow-tools.ts +86 -0
- package/src/types.ts +55 -0
- package/src/ui/branchme-panel.ts +3 -2
package/src/git.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { lstat, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
3
|
-
import type
|
|
3
|
+
import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
|
+
GIT_BRANCH_ENTRY_LIMIT,
|
|
6
|
+
GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
|
|
5
7
|
GIT_CONTEXT_CHANGE_LIMIT,
|
|
6
8
|
GIT_CONTEXT_RECENT_COMMIT_LIMIT,
|
|
7
9
|
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
@@ -24,6 +26,7 @@ import {
|
|
|
24
26
|
import { redactSecrets } from "./redaction.ts";
|
|
25
27
|
import type {
|
|
26
28
|
AheadBehindCount,
|
|
29
|
+
BranchEntry,
|
|
27
30
|
BranchAncestryDetails,
|
|
28
31
|
BranchStatusAncestryQuery,
|
|
29
32
|
BranchStatusDetails,
|
|
@@ -37,6 +40,8 @@ import type {
|
|
|
37
40
|
GitExecResult,
|
|
38
41
|
GitFileChange,
|
|
39
42
|
GitFileChangeSummary,
|
|
43
|
+
InitRepositoryDetails,
|
|
44
|
+
ListBranchesDetails,
|
|
40
45
|
ListWorktreesDetails,
|
|
41
46
|
PullBranchDetails,
|
|
42
47
|
PushBranchDetails,
|
|
@@ -144,6 +149,196 @@ export async function runGit(
|
|
|
144
149
|
return result;
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
function requireSafeInitializationEnvironment(): void {
|
|
153
|
+
for (const key of [
|
|
154
|
+
"GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY",
|
|
155
|
+
"GIT_INDEX_FILE", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_IMPLICIT_WORK_TREE",
|
|
156
|
+
"GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
157
|
+
]) {
|
|
158
|
+
if (process.env[key] !== undefined) {
|
|
159
|
+
throw new Error(`Git environment override ${key} must be unset before repository initialization.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function requireLosslessRepositoryPath(value: string, label: string): string {
|
|
165
|
+
if (isLosslessGitMetadata(value, GIT_WORKTREE_PATH_LIMIT_CHARS)) return value;
|
|
166
|
+
throw new Error(
|
|
167
|
+
`The canonical ${label} cannot be returned safely and losslessly. ` +
|
|
168
|
+
`Use a path of at most ${GIT_WORKTREE_PATH_LIMIT_CHARS} characters without credential-like token text or control/format characters.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resolveRepositoryInitializationDirectory(ctx: GitCommandContext): Promise<string> {
|
|
173
|
+
let canonicalPath: string;
|
|
174
|
+
try {
|
|
175
|
+
canonicalPath = await realpath(ctx.cwd);
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error("The current working directory does not exist or cannot be resolved.");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let directoryStats;
|
|
181
|
+
try {
|
|
182
|
+
directoryStats = await stat(canonicalPath);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error("The current working directory cannot be inspected.");
|
|
185
|
+
}
|
|
186
|
+
if (!directoryStats.isDirectory()) throw new Error("The current working directory is not a directory.");
|
|
187
|
+
if (dirname(canonicalPath) === canonicalPath) throw new Error("The filesystem root cannot be initialized as a repository.");
|
|
188
|
+
return requireLosslessRepositoryPath(canonicalPath, "repository path");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function gitMetadataEntryExists(path: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await lstat(path);
|
|
194
|
+
return true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
197
|
+
throw new Error("The target directory's .git entry cannot be inspected.");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function requireUninitializedRepositoryDirectory(
|
|
202
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
203
|
+
ctx: GitCommandContext,
|
|
204
|
+
canonicalPath: string,
|
|
205
|
+
signal?: AbortSignal,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
requireSafeInitializationEnvironment();
|
|
208
|
+
const currentPath = await resolveRepositoryInitializationDirectory(ctx);
|
|
209
|
+
if (currentPath !== canonicalPath) throw new Error("The current working directory changed while preparing git init.");
|
|
210
|
+
if (await gitMetadataEntryExists(join(canonicalPath, ".git"))) {
|
|
211
|
+
throw new Error("The current working directory already contains a .git entry; reinitialization is not allowed.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Do not let failed discovery (including invalid metadata or filesystem
|
|
215
|
+
// boundaries) hide an enclosing checkout.
|
|
216
|
+
for (let parent = dirname(canonicalPath); ; parent = dirname(parent)) {
|
|
217
|
+
if (await gitMetadataEntryExists(join(parent, ".git"))) {
|
|
218
|
+
throw new Error("The current working directory is already inside a Git repository; nested initialization is not allowed.");
|
|
219
|
+
}
|
|
220
|
+
if (dirname(parent) === parent) break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const probe = await runGit(pi, { cwd: canonicalPath }, ["rev-parse", "--git-dir"], {
|
|
224
|
+
signal,
|
|
225
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
226
|
+
allowFailure: true,
|
|
227
|
+
});
|
|
228
|
+
if (probe.code === 0) {
|
|
229
|
+
throw new Error("The current working directory is already inside a Git repository; nested initialization is not allowed.");
|
|
230
|
+
}
|
|
231
|
+
// Only Git's explicit absence diagnostic permits initialization. Ownership,
|
|
232
|
+
// permission, configuration and unrecognized/localized failures stay closed.
|
|
233
|
+
if (probe.code !== 128 || probe.stdout ||
|
|
234
|
+
!/^fatal: not a git repository \(or any (?:of the parent directories\): \.git|parent up to mount point [^\r\n]+\))/u.test(probe.stderr)) {
|
|
235
|
+
throw new Error("Git repository discovery failed; inspect the current directory and Git configuration before initialization.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function verifyInitializedRepository(
|
|
240
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
241
|
+
canonicalPath: string,
|
|
242
|
+
initialBranch: string,
|
|
243
|
+
): Promise<InitRepositoryDetails> {
|
|
244
|
+
const verificationCtx = { cwd: canonicalPath };
|
|
245
|
+
const repoRoot = await getCanonicalGitWorktreeRoot(pi, verificationCtx);
|
|
246
|
+
if (repoRoot !== canonicalPath) throw new Error("git init produced an unexpected repository root.");
|
|
247
|
+
|
|
248
|
+
const current = await getCurrentBranch(pi, verificationCtx);
|
|
249
|
+
if (current.detached || current.currentBranch !== initialBranch) {
|
|
250
|
+
throw new Error("git init did not create the requested unborn initial branch.");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const gitDirectoryResult = await runGit(
|
|
254
|
+
pi,
|
|
255
|
+
verificationCtx,
|
|
256
|
+
["rev-parse", "--path-format=absolute", "--git-dir"],
|
|
257
|
+
);
|
|
258
|
+
const gitDirectoryOutput = trimOutput(gitDirectoryResult.stdout);
|
|
259
|
+
let gitDirectory: string;
|
|
260
|
+
try {
|
|
261
|
+
gitDirectory = await realpath(gitDirectoryOutput);
|
|
262
|
+
} catch {
|
|
263
|
+
throw new Error("The initialized Git directory could not be resolved.");
|
|
264
|
+
}
|
|
265
|
+
if (gitDirectory !== join(canonicalPath, ".git")) {
|
|
266
|
+
throw new Error("git init produced an unexpected or separate Git directory.");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const bareResult = await runGit(pi, verificationCtx, ["rev-parse", "--is-bare-repository"]);
|
|
270
|
+
if (trimOutput(bareResult.stdout) !== "false") throw new Error("git init produced an unexpected bare repository.");
|
|
271
|
+
|
|
272
|
+
const headResult = await runGit(pi, verificationCtx, ["rev-parse", "--verify", "HEAD"], {
|
|
273
|
+
allowFailure: true,
|
|
274
|
+
});
|
|
275
|
+
if (headResult.code === 0) throw new Error("The initialized repository unexpectedly contains a commit.");
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
action: "init_repository",
|
|
279
|
+
request: { initialBranch },
|
|
280
|
+
repoRoot,
|
|
281
|
+
gitDirectory: requireLosslessRepositoryPath(gitDirectory, "Git directory path"),
|
|
282
|
+
initialBranch,
|
|
283
|
+
bare: false,
|
|
284
|
+
unborn: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function initializeRepositoryWithinQueue(
|
|
289
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
290
|
+
targetCtx: GitCommandContext,
|
|
291
|
+
canonicalPath: string,
|
|
292
|
+
initialBranch: string,
|
|
293
|
+
signal?: AbortSignal,
|
|
294
|
+
): Promise<InitRepositoryDetails> {
|
|
295
|
+
await requireUninitializedRepositoryDirectory(pi, targetCtx, canonicalPath, signal);
|
|
296
|
+
await validateBranchName(pi, targetCtx, initialBranch, signal);
|
|
297
|
+
try {
|
|
298
|
+
await runGit(pi, targetCtx, ["init", "--no-template", "--initial-branch", initialBranch], {
|
|
299
|
+
signal,
|
|
300
|
+
timeout: GIT_MUTATION_TIMEOUT_MS,
|
|
301
|
+
});
|
|
302
|
+
return await verifyInitializedRepository(pi, canonicalPath, initialBranch);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Repository initialization did not complete with verified postconditions. ${safeOutput(reason)} ` +
|
|
307
|
+
"Inspect the current directory before retrying; no automatic cleanup was attempted.",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function queueRepositoryInitialization(
|
|
313
|
+
canonicalPath: string,
|
|
314
|
+
initialize: () => Promise<InitRepositoryDetails>,
|
|
315
|
+
): Promise<InitRepositoryDetails> {
|
|
316
|
+
return withRepositoryMutationQueue(canonicalPath, initialize);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function initializeRepository(
|
|
320
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
321
|
+
ctx: GitCommandContext,
|
|
322
|
+
initialBranch: unknown = "main",
|
|
323
|
+
signal?: AbortSignal,
|
|
324
|
+
): Promise<InitRepositoryDetails> {
|
|
325
|
+
requireSafeInitializationEnvironment();
|
|
326
|
+
validateBranchNameInput(initialBranch, "Initial branch name");
|
|
327
|
+
requireLosslessWorktreeIdentity(initialBranch, "branch");
|
|
328
|
+
const canonicalPath = await resolveRepositoryInitializationDirectory(ctx);
|
|
329
|
+
const targetCtx = { cwd: canonicalPath };
|
|
330
|
+
const initialize: () => Promise<InitRepositoryDetails> = initializeRepositoryWithinQueue.bind(
|
|
331
|
+
undefined,
|
|
332
|
+
pi,
|
|
333
|
+
targetCtx,
|
|
334
|
+
canonicalPath,
|
|
335
|
+
initialBranch,
|
|
336
|
+
signal,
|
|
337
|
+
);
|
|
338
|
+
const queueRepositoryMutation = queueRepositoryInitialization.bind(undefined, canonicalPath, initialize);
|
|
339
|
+
return withFileMutationQueue<InitRepositoryDetails>(join(canonicalPath, ".git"), queueRepositoryMutation);
|
|
340
|
+
}
|
|
341
|
+
|
|
147
342
|
export async function getGitRoot(
|
|
148
343
|
pi: Pick<ExtensionAPI, "exec">,
|
|
149
344
|
ctx: GitCommandContext,
|
|
@@ -1000,6 +1195,85 @@ export async function validateWorktreeRemovalPath(
|
|
|
1000
1195
|
};
|
|
1001
1196
|
}
|
|
1002
1197
|
|
|
1198
|
+
const GIT_BRANCH_FORMAT = "%(refname)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(symref)%00%(upstream:track)";
|
|
1199
|
+
|
|
1200
|
+
function branchTrackingCounts(upstream: string, track: string): AheadBehindCount {
|
|
1201
|
+
if (!upstream || track === "[gone]") return { ahead: null, behind: null };
|
|
1202
|
+
if (track !== "" && !/^\[(?:ahead \d+(?:, behind \d+)?|behind \d+)\]$/u.test(track)) {
|
|
1203
|
+
throw new TypeError("Unable to parse branches: malformed upstream counts.");
|
|
1204
|
+
}
|
|
1205
|
+
const ahead = Number(/ahead (\d+)/u.exec(track)?.[1] ?? 0);
|
|
1206
|
+
const behind = Number(/behind (\d+)/u.exec(track)?.[1] ?? 0);
|
|
1207
|
+
if (!Number.isSafeInteger(ahead) || !Number.isSafeInteger(behind)) throw new TypeError("Unable to parse branches: invalid upstream counts.");
|
|
1208
|
+
return { ahead, behind };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function parseBranchRecord(record: string): BranchEntry {
|
|
1212
|
+
const fields = record.split(NUL_SEPARATOR);
|
|
1213
|
+
if (fields.length !== 6) throw new TypeError("Unable to parse branches: malformed ref output.");
|
|
1214
|
+
const [fullRef, head, current, upstream, symbolicTarget, track] = fields;
|
|
1215
|
+
const kind = fullRef.startsWith("refs/heads/") ? "local" : "remote-tracking";
|
|
1216
|
+
const prefix = kind === "local" ? "refs/heads/" : "refs/remotes/";
|
|
1217
|
+
if (!fullRef.startsWith(prefix) || fullRef.length === prefix.length ||
|
|
1218
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(head) || !["*", " "].includes(current)) {
|
|
1219
|
+
throw new TypeError("Unable to parse branches: malformed ref output.");
|
|
1220
|
+
}
|
|
1221
|
+
return {
|
|
1222
|
+
name: safeGitContextValue(fullRef.slice(prefix.length)),
|
|
1223
|
+
fullRef: safeGitContextValue(fullRef),
|
|
1224
|
+
kind,
|
|
1225
|
+
head,
|
|
1226
|
+
current: current === "*",
|
|
1227
|
+
upstream: upstream ? safeGitContextValue(upstream) : null,
|
|
1228
|
+
...branchTrackingCounts(upstream, track),
|
|
1229
|
+
symbolicTarget: symbolicTarget ? safeGitContextValue(symbolicTarget) : null,
|
|
1230
|
+
worktreePaths: [],
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export function parseBranchRefs(output: string): Pick<ListBranchesDetails, "branches" | "omitted"> {
|
|
1235
|
+
if (Buffer.byteLength(output, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
1236
|
+
throw new TypeError("Unable to parse branches: ref output exceeded the safety limit.");
|
|
1237
|
+
}
|
|
1238
|
+
const records = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
|
|
1239
|
+
if (records.length === 1 && records[0] === "") return { branches: [], omitted: 0 };
|
|
1240
|
+
const branches: BranchEntry[] = [];
|
|
1241
|
+
const seen = new Set<string>();
|
|
1242
|
+
for (const record of records) {
|
|
1243
|
+
const branch = parseBranchRecord(record);
|
|
1244
|
+
const rawRef = record.split(NUL_SEPARATOR, 1)[0];
|
|
1245
|
+
if (seen.has(rawRef)) throw new TypeError("Unable to parse branches: duplicate ref output.");
|
|
1246
|
+
seen.add(rawRef);
|
|
1247
|
+
if (branches.length < GIT_BRANCH_ENTRY_LIMIT) branches.push(branch);
|
|
1248
|
+
}
|
|
1249
|
+
return { branches, omitted: records.length - branches.length };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export async function listBranches(
|
|
1253
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1254
|
+
ctx: GitCommandContext,
|
|
1255
|
+
signal?: AbortSignal,
|
|
1256
|
+
): Promise<ListBranchesDetails> {
|
|
1257
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1258
|
+
const rootCtx = { cwd: repoRoot };
|
|
1259
|
+
const result = await runGit(pi, rootCtx, [
|
|
1260
|
+
"for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, "refs/heads/", "refs/remotes/",
|
|
1261
|
+
], { signal });
|
|
1262
|
+
const parsed = parseBranchRefs(result.stdout);
|
|
1263
|
+
const inventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1264
|
+
const rawRecords = result.stdout.split("\n");
|
|
1265
|
+
for (const [index, branch] of parsed.branches.entries()) {
|
|
1266
|
+
if (branch.kind !== "local") continue;
|
|
1267
|
+
const rawName = rawRecords[index].split(NUL_SEPARATOR, 1)[0].slice("refs/heads/".length);
|
|
1268
|
+
for (const entry of inventory.entries) {
|
|
1269
|
+
if (entry.record.branch === rawName) {
|
|
1270
|
+
branch.worktreePaths.push(safeWorktreeValue(entry.record.rawPath, GIT_WORKTREE_PATH_LIMIT_CHARS));
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return { action: "list_branches", repoRoot: safeGitContextValue(repoRoot), ...parsed };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1003
1277
|
export async function listWorktrees(
|
|
1004
1278
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1005
1279
|
ctx: GitCommandContext,
|
|
@@ -1487,6 +1761,7 @@ export async function removeWorktree(
|
|
|
1487
1761
|
ctx: GitCommandContext,
|
|
1488
1762
|
worktreePath: unknown,
|
|
1489
1763
|
signal?: AbortSignal,
|
|
1764
|
+
deleteIgnored = false,
|
|
1490
1765
|
): Promise<RemoveWorktreeDetails> {
|
|
1491
1766
|
const requestedWorktreePath = validateWorktreePathInput(worktreePath);
|
|
1492
1767
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
@@ -1494,11 +1769,11 @@ export async function removeWorktree(
|
|
|
1494
1769
|
|
|
1495
1770
|
return withRepositoryMutationQueue(
|
|
1496
1771
|
repoRoot,
|
|
1497
|
-
removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal),
|
|
1772
|
+
removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal, deleteIgnored),
|
|
1498
1773
|
);
|
|
1499
1774
|
}
|
|
1500
1775
|
|
|
1501
|
-
// Caller must hold the repository mutation queue.
|
|
1776
|
+
// Caller must hold the repository mutation queue. Ignored residue requires explicit authorization.
|
|
1502
1777
|
export async function removeWorktreeWithinQueue(
|
|
1503
1778
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1504
1779
|
rootCtx: GitCommandContext,
|
|
@@ -1567,6 +1842,7 @@ export async function removeWorktreeWithinQueue(
|
|
|
1567
1842
|
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1568
1843
|
request: {
|
|
1569
1844
|
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1845
|
+
...(allowIgnored ? { deleteIgnored: true } : {}),
|
|
1570
1846
|
},
|
|
1571
1847
|
verified: {
|
|
1572
1848
|
before: {
|
package/src/github.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getCurrentBranch,
|
|
17
17
|
getGitRoot,
|
|
18
18
|
getOriginUrl,
|
|
19
|
+
requireLosslessWorktreeIdentity,
|
|
19
20
|
validateBranchNameInput,
|
|
20
21
|
type GitCommandContext,
|
|
21
22
|
} from "./git.ts";
|
|
@@ -23,6 +24,8 @@ import type {
|
|
|
23
24
|
GitHubRepository,
|
|
24
25
|
PullRequestDetails,
|
|
25
26
|
PullRequestInput,
|
|
27
|
+
PullRequestStatusDetails,
|
|
28
|
+
PullRequestToolDetails,
|
|
26
29
|
RelatedPullRequest,
|
|
27
30
|
RelatedPullRequestDetails,
|
|
28
31
|
} from "./types.ts";
|
|
@@ -984,6 +987,118 @@ export async function ensureGitHubBranchExists(
|
|
|
984
987
|
return readGitHubBranchDetails(response, options.signal, token, branchName, field, branchLabel);
|
|
985
988
|
}
|
|
986
989
|
|
|
990
|
+
function requirePullRequestCommit(value: unknown): string {
|
|
991
|
+
if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(value)) {
|
|
992
|
+
throw new Error("GitHub pull request has an invalid commit identity.");
|
|
993
|
+
}
|
|
994
|
+
return value.toLowerCase();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function parsePullRequestStatus(repository: GitHubRepository, number: number, payload: Record<string, unknown>, token: string): PullRequestStatusDetails {
|
|
998
|
+
if (pullRequestNumberField(payload.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
999
|
+
if (!["open", "closed"].includes(String(payload.state)) || typeof payload.merged !== "boolean" || typeof payload.draft !== "boolean") {
|
|
1000
|
+
throw new Error("GitHub pull request state is malformed.");
|
|
1001
|
+
}
|
|
1002
|
+
const head = requireGitHubResponseObject(payload.head, "head");
|
|
1003
|
+
const base = requireGitHubResponseObject(payload.base, "base");
|
|
1004
|
+
requireRelatedPullRequestRepository(head.repo, repository);
|
|
1005
|
+
requireRelatedPullRequestRepository(base.repo, repository);
|
|
1006
|
+
const headRef = stringField(head.ref, "head.ref");
|
|
1007
|
+
const baseRef = stringField(base.ref, "base.ref");
|
|
1008
|
+
validatePullRequestBranchRef(headRef, "headBranch");
|
|
1009
|
+
validatePullRequestBranchRef(baseRef, "baseBranch");
|
|
1010
|
+
requireLosslessWorktreeIdentity(headRef, "branch");
|
|
1011
|
+
requireLosslessWorktreeIdentity(baseRef, "branch");
|
|
1012
|
+
if (redactSecrets(headRef, [token]) !== headRef || redactSecrets(baseRef, [token]) !== baseRef) {
|
|
1013
|
+
throw new Error("GitHub branch identities cannot be returned safely.");
|
|
1014
|
+
}
|
|
1015
|
+
const mergedAt = payload.merged_at;
|
|
1016
|
+
if (mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.length > 40 || !Number.isFinite(Date.parse(mergedAt)))) {
|
|
1017
|
+
throw new Error("GitHub pull request merge timestamp is malformed.");
|
|
1018
|
+
}
|
|
1019
|
+
const mergeCommitSha = payload.merge_commit_sha === null ? null : requirePullRequestCommit(payload.merge_commit_sha);
|
|
1020
|
+
if (payload.merged && (payload.state !== "closed" || mergedAt === null || mergeCommitSha === null)) {
|
|
1021
|
+
throw new Error("GitHub merged pull request evidence is incomplete.");
|
|
1022
|
+
}
|
|
1023
|
+
if (!payload.merged && mergedAt !== null) throw new Error("GitHub merge state is contradictory.");
|
|
1024
|
+
return {
|
|
1025
|
+
repository, number, url: requireRelatedPullRequestUrl(payload.html_url, repository, number),
|
|
1026
|
+
title: boundedRelatedPullRequestValue(redactSecrets(stringField(payload.title, "title"), [token])),
|
|
1027
|
+
state: payload.state as "open" | "closed", draft: payload.draft, merged: payload.merged,
|
|
1028
|
+
mergedAt: mergedAt as string | null, head: headRef, base: baseRef,
|
|
1029
|
+
headSha: requirePullRequestCommit(head.sha), baseSha: requirePullRequestCommit(base.sha), mergeCommitSha,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function requestPullRequestJson(repository: GitHubRepository, suffix: string, token: string, options: PullRequestFetchOptions): Promise<unknown> {
|
|
1034
|
+
// Bound both transport and body consumption; the same signal covers the complete request.
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const timer = setTimeout(controller.abort.bind(controller), 10_000);
|
|
1037
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
1038
|
+
try {
|
|
1039
|
+
signal.throwIfAborted();
|
|
1040
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch),
|
|
1041
|
+
`${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls${suffix}`,
|
|
1042
|
+
{ method: "GET", headers: gitHubJsonHeaders(token), signal }, "GitHub pull request lookup failed", token);
|
|
1043
|
+
if (!response.ok) throw new Error(`GitHub pull request lookup returned HTTP ${response.status}.`);
|
|
1044
|
+
const body = await readGitHubResponseBody(response, signal, "GitHub pull request lookup response failed", token);
|
|
1045
|
+
if (body.truncated) throw new Error("GitHub pull request lookup exceeded the response byte limit.");
|
|
1046
|
+
return parseGitHubJson(body.text, "GitHub pull request lookup response", token);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
throw new Error(truncate(redactSecrets(error instanceof Error ? error.message : String(error), [token])).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "));
|
|
1049
|
+
} finally {
|
|
1050
|
+
clearTimeout(timer);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export async function getGitHubPullRequest(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestStatusDetails> {
|
|
1055
|
+
validateGitHubRepository(repository);
|
|
1056
|
+
pullRequestNumberField(number);
|
|
1057
|
+
const payload = await requestPullRequestJson(repository, `/${number}`, token, options);
|
|
1058
|
+
return parsePullRequestStatus(repository, number, requireGitHubResponseObject(payload, "Pull request"), token);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export async function findGitHubPullRequest(repository: GitHubRepository, headBranch: string, token: string, options: PullRequestFetchOptions = {}, state: "open" | "all" = "all"): Promise<PullRequestStatusDetails | null> {
|
|
1062
|
+
validateGitHubRepository(repository);
|
|
1063
|
+
validatePullRequestBranchRef(headBranch, "headBranch");
|
|
1064
|
+
requireLosslessWorktreeIdentity(headBranch, "branch");
|
|
1065
|
+
const query = new URLSearchParams({ state, head: `${repository.owner}:${headBranch}`, sort: "updated", direction: "desc", per_page: state === "open" ? "2" : "1" });
|
|
1066
|
+
const payload = requireGitHubResponseArray(await requestPullRequestJson(repository, `?${query}`, token, options), "Pull request lookup");
|
|
1067
|
+
if (payload.length === 0) return null;
|
|
1068
|
+
if (payload.length > 1) throw new Error("Multiple open pull requests use this head; select a pull request by number.");
|
|
1069
|
+
const row = requireGitHubResponseObject(payload[0], "Pull request lookup item");
|
|
1070
|
+
const result = await getGitHubPullRequest(repository, pullRequestNumberField(row.number), token, options);
|
|
1071
|
+
if (result.head !== headBranch || (state === "open" && result.state !== "open")) {
|
|
1072
|
+
throw new Error("Pull request lookup no longer matches the requested head/state; retry after inspection.");
|
|
1073
|
+
}
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function existingPullRequestDetails(existing: PullRequestStatusDetails, input: PullRequestInput, expectedHead: string): PullRequestToolDetails {
|
|
1078
|
+
if (existing.base !== input.baseBranch || existing.headSha !== expectedHead.toLowerCase()) {
|
|
1079
|
+
throw new Error("Existing pull request base or head commit does not match the requested branches; inspect it before retrying.");
|
|
1080
|
+
}
|
|
1081
|
+
const { repository, number, url, state, head, base, draft } = existing;
|
|
1082
|
+
return { repository, number, url, state, head, base, draft, outcome: "existing" };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export async function createOrReuseGitHubPullRequest(repository: GitHubRepository, input: PullRequestInput, expectedHead: string, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestToolDetails> {
|
|
1086
|
+
validatePullRequestInput(input);
|
|
1087
|
+
requirePullRequestCommit(expectedHead);
|
|
1088
|
+
const existing = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
|
|
1089
|
+
if (existing) return existingPullRequestDetails(existing, input, expectedHead);
|
|
1090
|
+
try {
|
|
1091
|
+
return { ...await createGitHubPullRequest(repository, input, token, options), outcome: "created" };
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
// A concurrent creator may win after our lookup. Recheck only a validation conflict,
|
|
1094
|
+
// never hide network/authentication/cancellation errors or retry the POST.
|
|
1095
|
+
if (!(error instanceof Error) || !error.message.startsWith("GitHub pull request request failed with HTTP 422:")) throw error;
|
|
1096
|
+
const raced = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
|
|
1097
|
+
if (raced) return existingPullRequestDetails(raced, input, expectedHead);
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
987
1102
|
export async function createGitHubPullRequest(
|
|
988
1103
|
repository: GitHubRepository,
|
|
989
1104
|
input: PullRequestInput,
|