@henryqw/pi-pr 5.0.2 → 6.0.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.
@@ -92,7 +92,7 @@ type CiSnapshot = {
92
92
  failures: FailureIdentity[];
93
93
  };
94
94
 
95
- export type CiFailureEvidence = {
95
+ type CiFailureEvidence = {
96
96
  checkRuns: Array<{ id: number; name: string; conclusion: string }>;
97
97
  checkSuite: { id: number };
98
98
  run: { id: number; url: string; attempt: number };
@@ -101,20 +101,18 @@ export type CiFailureEvidence = {
101
101
  log: { scope: "job"; text: string; truncated: boolean };
102
102
  };
103
103
 
104
- export type CiEvidence = {
104
+ type CiEvidence = {
105
105
  fingerprint: string;
106
106
  pullRequest: { number: number; url: string; headOid: string };
107
107
  failures: CiFailureEvidence[];
108
108
  };
109
109
 
110
- export type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
111
- export type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
110
+ type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
111
+ type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
112
112
 
113
- export type CiFixState = {
113
+ type CiFixState = {
114
114
  phase: CiFixPhase;
115
115
  pushAttempt: CiPushAttempt;
116
- fingerprint?: string;
117
- repairHead?: string;
118
116
  };
119
117
 
120
118
  export type PullRequestCiFixOptions = {
@@ -126,7 +124,7 @@ export type PullRequestCiFixOptions = {
126
124
  loadCurrentPullRequest?: Load;
127
125
  };
128
126
 
129
- export type CiPublishResult = {
127
+ type CiPublishResult = {
130
128
  kind: "published";
131
129
  head: string;
132
130
  attempt: "applied";
@@ -193,21 +191,11 @@ function actionsUrl(authority: CurrentPullRequest, suffix: string): string {
193
191
  }
194
192
 
195
193
  function tailUtf8(value: string, limit: number): { text: string; truncated: boolean } {
196
- const total = Buffer.byteLength(value, "utf8");
197
- if (total <= limit) return { text: value, truncated: false };
198
- let start = value.length;
199
- let bytes = 0;
200
- while (start > 0) {
201
- let previous = start - 1;
202
- if (previous > 0 && value.charCodeAt(previous) >= 0xdc00 && value.charCodeAt(previous) <= 0xdfff &&
203
- value.charCodeAt(previous - 1) >= 0xd800 && value.charCodeAt(previous - 1) <= 0xdbff) previous -= 1;
204
- const character = value.slice(previous, start);
205
- const size = Buffer.byteLength(character, "utf8");
206
- if (bytes + size > limit) break;
207
- bytes += size;
208
- start = previous;
209
- }
210
- return { text: value.slice(start), truncated: true };
194
+ const bytes = Buffer.from(value, "utf8");
195
+ if (bytes.length <= limit) return { text: value, truncated: false };
196
+ let start = bytes.length - limit;
197
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
198
+ return { text: bytes.subarray(start).toString("utf8"), truncated: true };
211
199
  }
212
200
 
213
201
  function failureEvidence(
@@ -669,7 +657,6 @@ export class PullRequestCiFixer {
669
657
  throw new Error("Failed-CI evidence was replaced or became stale during collection");
670
658
  }
671
659
  this.collectedFingerprint = after.fingerprint;
672
- this.state.fingerprint = after.fingerprint;
673
660
  this.state.phase = "collected";
674
661
  return {
675
662
  fingerprint: after.fingerprint,
@@ -721,7 +708,6 @@ export class PullRequestCiFixer {
721
708
  throw new Error("CI repair HEAD must be a new descendant of the frozen pull request head");
722
709
  }
723
710
  if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
724
- this.state.repairHead = repairHead;
725
711
  return repairHead;
726
712
  }
727
713
 
@@ -18,19 +18,19 @@ import {
18
18
  type RouteIntent,
19
19
  } from "./pr-routing.ts";
20
20
 
21
- export type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
21
+ type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
22
22
 
23
- const WORKFLOWS: Record<WorkflowNextStep, { command: string }> = {
24
- create: { command: "skill:pi-pr-create" },
25
- "update-branch": { command: "skill:pi-pr-update-branch" },
26
- sweep: { command: "skill:pi-pr-comment-sweep" },
27
- "fix-ci": { command: "skill:pi-pr-fix-ci" },
23
+ const WORKFLOWS: Record<WorkflowNextStep, string> = {
24
+ create: "skill:pi-pr-create",
25
+ "update-branch": "skill:pi-pr-update-branch",
26
+ sweep: "skill:pi-pr-comment-sweep",
27
+ "fix-ci": "skill:pi-pr-fix-ci",
28
28
  };
29
- export type WorkflowReservation =
29
+ type WorkflowReservation =
30
30
  | { route: "create"; target: PullRequestTarget; base?: string }
31
31
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
32
- export type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
33
- export type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
32
+ type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
33
+ type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
34
34
 
35
35
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
36
36
  export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
@@ -120,11 +120,11 @@ function workflowReservation(
120
120
  function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) {
121
121
  const workflow = WORKFLOWS[route];
122
122
  const command = pi.getCommands().find((candidate) =>
123
- candidate.name === workflow.command &&
123
+ candidate.name === workflow &&
124
124
  candidate.source === "skill" &&
125
125
  candidate.sourceInfo.origin === "package"
126
126
  );
127
- if (!command) throw new Error(`${workflow.command} failed: bundled workflow is unavailable`);
127
+ if (!command) throw new Error(`${workflow} failed: bundled workflow is unavailable`);
128
128
  return command;
129
129
  }
130
130
 
@@ -52,21 +52,21 @@ const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
52
52
  const ATTEMPT_STATES = new Set<AttemptState>(["none", "attempting", "applied", "blocked", "unknown"]);
53
53
  const DISPOSITIONS = ["addressed", "non-actionable", "blocked"] as const;
54
54
 
55
- export type SweepDisposition = (typeof DISPOSITIONS)[number];
55
+ type SweepDisposition = (typeof DISPOSITIONS)[number];
56
56
  export type SweepLedgerEntry = {
57
57
  id: string;
58
58
  kind: FeedbackKind;
59
59
  disposition: SweepDisposition;
60
60
  note: string;
61
61
  };
62
- export type SweepRunGuard = {
62
+ type SweepRunGuard = {
63
63
  epoch: number;
64
64
  runId: string;
65
65
  generation: number;
66
66
  fingerprint: string;
67
67
  };
68
- export type SweepCheck = { command: string; args: string[] };
69
- export type SweepFinalProjection = {
68
+ type SweepCheck = { command: string; args: string[] };
69
+ type SweepFinalProjection = {
70
70
  generation: number;
71
71
  contentFingerprint: string;
72
72
  items: Array<{ id: string; kind: FeedbackKind }>;
@@ -88,7 +88,7 @@ export type SweepStatus = {
88
88
  finalize: AttemptState;
89
89
  };
90
90
  };
91
- export type SweepPhase = "triage" | "recorded" | "published" | "refresh-pending" | "refreshed" | "resolving" | "resolved";
91
+ type SweepPhase = "triage" | "recorded" | "published" | "refresh-pending" | "refreshed" | "resolving" | "resolved";
92
92
 
93
93
  type SweepAuthority = ReturnType<typeof authorityFromCurrent>;
94
94
  type ResolutionAttempt = {
@@ -26,11 +26,13 @@ import {
26
26
  isAncestor,
27
27
  isRecord,
28
28
  parseNulPaths,
29
+ parseSingleOutputLine,
29
30
  readHead,
30
31
  readRemoteOid,
31
32
  requiredOid,
32
33
  requiredText,
33
34
  runChecked,
35
+ validateResolvedConflictPaths,
34
36
  withWorktreeLock,
35
37
  } from "./pr-execution.ts";
36
38
 
@@ -41,7 +43,7 @@ const CREATE_PULL_REQUEST_MUTATION = "mutation($repositoryId:ID!,$baseRefName:St
41
43
 
42
44
  type Load = typeof loadCurrentPullRequest;
43
45
 
44
- export type CreateBaseAuthority = {
46
+ type CreateBaseAuthority = {
45
47
  host: string;
46
48
  repository: string;
47
49
  ref: string;
@@ -49,7 +51,7 @@ export type CreateBaseAuthority = {
49
51
  fetchSource: string;
50
52
  };
51
53
 
52
- export type CreatePhase = "unprepared" | "prepared" | "conflict-awaiting-user" | "verified" | "pushed" | "published" | "blocked";
54
+ type CreatePhase = "unprepared" | "prepared" | "conflict-awaiting-user" | "verified" | "pushed" | "published" | "blocked";
53
55
 
54
56
  type CrossRepositoryCreateAuthority = {
55
57
  baseRepositoryId: string;
@@ -57,17 +59,15 @@ type CrossRepositoryCreateAuthority = {
57
59
  headOwnerType: "Organization" | "User";
58
60
  };
59
61
 
60
- export type CreatePullRequestState = {
62
+ type CreatePullRequestState = {
61
63
  phase: CreatePhase;
62
64
  base?: CreateBaseAuthority;
63
65
  createAuthority?: CrossRepositoryCreateAuthority;
64
- mergeHead?: string;
65
66
  publicationHead?: string;
66
67
  conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
67
- url?: string;
68
68
  };
69
69
 
70
- export type CreatePullRequestResult =
70
+ type CreatePullRequestResult =
71
71
  | { kind: "prepared"; base: CreateBaseAuthority; mergeBase: string }
72
72
  | { kind: "verified"; head: string; fastForward: boolean }
73
73
  | { kind: "conflict"; paths: string[] }
@@ -89,13 +89,6 @@ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedR
89
89
  left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
90
90
  }
91
91
 
92
- function line(output: string, label: string): string {
93
- const normalized = output.replace(/\r\n/g, "\n");
94
- const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
95
- if (values.length !== 1 || !values[0]) throw new Error(`${label} returned invalid output`);
96
- return values[0];
97
- }
98
-
99
92
  function graphQlResponse(output: string, action: string): Record<string, unknown> {
100
93
  let value: unknown;
101
94
  try {
@@ -166,15 +159,6 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
166
159
  return url;
167
160
  }
168
161
 
169
- function resolvedPaths(paths: readonly string[], expected: readonly string[]): string[] {
170
- if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
171
- const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
172
- if (expected.some((path) => !parsed.includes(path))) {
173
- throw new Error("Resolved paths must include every original conflict path");
174
- }
175
- return parsed;
176
- }
177
-
178
162
  export class PullRequestCreator {
179
163
  readonly state: CreatePullRequestState = { phase: "unprepared" };
180
164
 
@@ -218,7 +202,7 @@ export class PullRequestCreator {
218
202
  if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
219
203
  throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
220
204
  }
221
- const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
205
+ const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
222
206
  if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
223
207
  return discovery;
224
208
  }
@@ -298,7 +282,7 @@ export class PullRequestCreator {
298
282
  private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
299
283
  const base = this.state.base!;
300
284
  const head = await readHead(this.exec, this.options());
301
- const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
285
+ const commits = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
302
286
  .split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
303
287
  if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
304
288
  let fastForward = false;
@@ -308,14 +292,13 @@ export class PullRequestCreator {
308
292
  }
309
293
  await this.requireCleanHead();
310
294
  this.state.phase = "verified";
311
- this.state.mergeHead = head;
312
295
  delete this.state.conflict;
313
296
  return { head, fastForward };
314
297
  }
315
298
 
316
299
  private async captureConflict(originalHead: string): Promise<string[]> {
317
300
  const base = this.state.base!;
318
- const mergeHead = requiredOid(line((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
301
+ const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
319
302
  if (mergeHead !== base.oid) throw new Error("Failed merge did not retain the frozen base");
320
303
  const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
321
304
  if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
@@ -335,7 +318,6 @@ export class PullRequestCreator {
335
318
  const originalHead = await this.requireCleanHead();
336
319
  if (await isAncestor(this.exec, this.options(), this.state.base!.oid, originalHead)) {
337
320
  this.state.phase = "verified";
338
- this.state.mergeHead = originalHead;
339
321
  return { kind: "verified", head: originalHead, fastForward: false };
340
322
  }
341
323
  this.state.phase = "blocked";
@@ -358,12 +340,12 @@ export class PullRequestCreator {
358
340
  if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict || !this.state.base) {
359
341
  throw new Error("PR creation has no conflict awaiting continuation");
360
342
  }
361
- const paths = resolvedPaths(pathsInput, this.state.conflict.paths);
343
+ const paths = validateResolvedConflictPaths(pathsInput, this.state.conflict.paths);
362
344
  return await withWorktreeLock(this.cwd, async () => {
363
345
  await this.freshNone();
364
346
  if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
365
347
  if (await readHead(this.exec, this.options()) !== this.state.conflict!.originalHead) throw new Error("PR creation merge HEAD changed");
366
- const mergeHead = requiredOid(line((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
348
+ const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
367
349
  if (mergeHead !== this.state.base!.oid) throw new Error("PR creation merge context changed");
368
350
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
369
351
  assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
@@ -433,7 +415,7 @@ export class PullRequestCreator {
433
415
  private async publishedAuthority(): Promise<void> {
434
416
  const head = this.state.publicationHead!;
435
417
  if (this.noTarget && !this.noTargetUpstreamConfigured) {
436
- const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
418
+ const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
437
419
  const authority = await readValidatedRemoteAuthority(this.pi(), this.context(), this.target.remote);
438
420
  if (branch !== this.target.branch || authority.host !== this.target.host ||
439
421
  authority.repository.toLowerCase() !== this.target.repository.toLowerCase() ||
@@ -522,7 +504,6 @@ export class PullRequestCreator {
522
504
  throw new Error("Published pull request did not retain canonical identity, title, and body");
523
505
  }
524
506
  this.state.phase = "published";
525
- this.state.url = after.url.href;
526
507
  return { kind: "published", url: after.url.href };
527
508
  }, { agentDir: this.agentDir, signal: this.signal });
528
509
  }
@@ -6,9 +6,9 @@ import { extensionConfigDir } from "@henryqw/pi-config-store";
6
6
  import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
7
7
  import { lock } from "proper-lockfile";
8
8
 
9
- export const MAX_CONFLICT_PATHS = 128;
10
- export const MAX_CONFLICT_PATH_BYTES = 1_024;
11
- export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
9
+ const MAX_CONFLICT_PATHS = 128;
10
+ const MAX_CONFLICT_PATH_BYTES = 1_024;
11
+ const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
12
12
 
13
13
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
14
14
  const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
@@ -34,8 +34,11 @@ export function requiredOid(value: unknown, label: string): string {
34
34
  return parsed;
35
35
  }
36
36
 
37
- export function commandText(command: string, args: readonly string[]): string {
38
- return [command, ...args].join(" ");
37
+ export function parseSingleOutputLine(output: string, label: string): string {
38
+ const normalized = output.replace(/\r\n/g, "\n");
39
+ const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
40
+ if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
41
+ return lines[0];
39
42
  }
40
43
 
41
44
  export function extensionExecApi(exec: Exec, cwd: string, signal?: AbortSignal): Pick<ExtensionAPI, "exec"> {
@@ -58,7 +61,7 @@ export async function runChecked(
58
61
  const result = await exec(command, args, options);
59
62
  if (result.killed || !allowedCodes.includes(result.code)) {
60
63
  const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
61
- throw new Error(`${commandText(command, args)} failed: ${detail}`);
64
+ throw new Error(`${[command, ...args].join(" ")} failed: ${detail}`);
62
65
  }
63
66
  return result;
64
67
  }
@@ -162,6 +165,15 @@ export function parseNulPaths(output: string, label: string): string[] {
162
165
  return paths;
163
166
  }
164
167
 
168
+ export function validateResolvedConflictPaths(paths: readonly string[], expected: readonly string[]): string[] {
169
+ if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
170
+ const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
171
+ if (expected.some((path) => !parsed.includes(path))) {
172
+ throw new Error("Resolved paths must include every original conflict path");
173
+ }
174
+ return parsed;
175
+ }
176
+
165
177
  function statusPath(record: string): { path: string; rename: boolean } {
166
178
  if (record.startsWith("? ") || record.startsWith("! ")) return { path: record.slice(2), rename: false };
167
179
  const fields = record[0] === "1" ? 8 : record[0] === "2" ? 9 : record[0] === "u" ? 10 : 0;
@@ -9,7 +9,7 @@ import {
9
9
  import type { CurrentPullRequest } from "./pr-github.ts";
10
10
 
11
11
  export const FEEDBACK_API_PAGE_MAX_BYTES = 512 * 1024;
12
- export const FEEDBACK_MAX_PAGES = 100;
12
+ const FEEDBACK_MAX_PAGES = 100;
13
13
  export const FEEDBACK_MAX_RECORDS = 1_000;
14
14
  export const FEEDBACK_SNAPSHOT_MAX_BYTES = 1024 * 1024;
15
15
 
@@ -33,15 +33,15 @@ query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on Pull
33
33
  const RESOLVE_THREAD_MUTATION = `
34
34
  mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
35
35
 
36
- export type FeedbackAuthor = { login: string } | null;
37
- export type FeedbackComment = {
36
+ type FeedbackAuthor = { login: string } | null;
37
+ type FeedbackComment = {
38
38
  id: string;
39
39
  url: string;
40
40
  body: string;
41
41
  createdAt: string;
42
42
  author: FeedbackAuthor;
43
43
  };
44
- export type FeedbackReview = {
44
+ type FeedbackReview = {
45
45
  id: string;
46
46
  url: string;
47
47
  state: string;
@@ -49,7 +49,7 @@ export type FeedbackReview = {
49
49
  submittedAt: string | null;
50
50
  author: FeedbackAuthor;
51
51
  };
52
- export type FeedbackThread = {
52
+ type FeedbackThread = {
53
53
  id: string;
54
54
  isResolved: boolean;
55
55
  isOutdated: boolean;
@@ -77,7 +77,7 @@ export type FeedbackSnapshot = {
77
77
  reviewThreads: FeedbackThread[];
78
78
  };
79
79
  export type FeedbackKind = "conversation_comment" | "review" | "thread" | "thread_comment";
80
- export type FeedbackEntry = {
80
+ type FeedbackEntry = {
81
81
  id: string;
82
82
  kind: FeedbackKind;
83
83
  node: FeedbackComment | FeedbackReview | FeedbackThread;
@@ -91,7 +91,7 @@ export type FeedbackItem =
91
91
  kind: "thread_comment";
92
92
  parentThread: { id: string; isResolved: boolean; isOutdated: boolean; path: string | null; line: number | null };
93
93
  });
94
- export type FeedbackClientOptions = {
94
+ type FeedbackClientOptions = {
95
95
  exec: Exec;
96
96
  cwd: string;
97
97
  signal?: AbortSignal;
@@ -78,7 +78,7 @@ export class GitHubRateLimitError extends PullRequestLoadError {
78
78
  }
79
79
  }
80
80
 
81
- export type PullRequestRef = {
81
+ type PullRequestRef = {
82
82
  repository: string;
83
83
  ref: string;
84
84
  oid: string;
@@ -118,7 +118,7 @@ export type PullRequestObservation = {
118
118
 
119
119
  export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
120
120
 
121
- export type PullRequestCreationPreflight = {
121
+ type PullRequestCreationPreflight = {
122
122
  head: string;
123
123
  base: {
124
124
  host: string;
@@ -187,7 +187,7 @@ type LinkConfiguration = {
187
187
  mirror: string[];
188
188
  };
189
189
 
190
- export type PullRequestCandidate = {
190
+ type PullRequestCandidate = {
191
191
  number: number;
192
192
  url: URL;
193
193
  lifecycle: PullRequestLifecycle;
@@ -209,7 +209,7 @@ export type PullRequestPublication = {
209
209
  body: string;
210
210
  };
211
211
 
212
- export type ValidatedRemoteAuthority = {
212
+ type ValidatedRemoteAuthority = {
213
213
  fetchSource: string;
214
214
  host: string;
215
215
  repository: string;
@@ -11,18 +11,18 @@ import type { LocalMergeSafety } from "./pr-routing.ts";
11
11
 
12
12
  const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
13
13
 
14
- export type InspectLocalMergeSafetyInput = {
14
+ type InspectLocalMergeSafetyInput = {
15
15
  exec: Exec;
16
16
  cwd: string;
17
17
  expectedHead: string;
18
18
  headFetchSource: string;
19
19
  };
20
20
 
21
- export type InspectedLocalMergeSafety = LocalMergeSafety & {
21
+ type InspectedLocalMergeSafety = LocalMergeSafety & {
22
22
  headOid: string;
23
23
  };
24
24
 
25
- export type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
25
+ type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
26
26
  pullRequestId: string;
27
27
  hostname: string;
28
28
  expectedBase: {
@@ -1,9 +1,9 @@
1
1
  export type PullRequestLifecycle = "open" | "merged" | "closed";
2
2
  export type CiStatus = "none" | "running" | "success" | "failure" | "failure-blocked";
3
3
  export type ReviewReadiness = "ready" | "pending";
4
- export type PolicyReadiness = "ready" | "pending";
5
- export type LocalWorktree = "clean" | "dirty";
6
- export type LocalHeadRelation = "equal" | "behind" | "ahead" | "diverged";
4
+ type PolicyReadiness = "ready" | "pending";
5
+ type LocalWorktree = "clean" | "dirty";
6
+ type LocalHeadRelation = "equal" | "behind" | "ahead" | "diverged";
7
7
 
8
8
  export type PullRequestConditions = {
9
9
  draft: boolean;
@@ -69,7 +69,7 @@ export type FeedbackRouteBlocker =
69
69
  | { kind: "target-not-configured" }
70
70
  | { kind: "worktree-dirty" }
71
71
  | { kind: "head-not-equal"; relation: Exclude<LocalHeadRelation, "equal"> };
72
- export type RouteDecision =
72
+ type RouteDecision =
73
73
  | { kind: "selected"; nextStep: NextStep }
74
74
  | { kind: "feedback-blocked"; nextStep: "blocked"; blocker: FeedbackRouteBlocker };
75
75
 
@@ -18,7 +18,7 @@ export type PrDisplayInput = PullRequest & {
18
18
 
19
19
  export type PrStatusColor = "accent" | "warning" | "success" | "error" | "dim";
20
20
 
21
- export type PrFooter = {
21
+ type PrFooter = {
22
22
  number?: number;
23
23
  url?: string;
24
24
  text: string;
@@ -12,23 +12,25 @@ import {
12
12
  inspectWorktree,
13
13
  isAncestor,
14
14
  parseNulPaths,
15
+ parseSingleOutputLine,
15
16
  readHead,
16
17
  readRemoteOid,
17
18
  requiredOid,
18
19
  resolveRepositoryFetchSource,
19
20
  runChecked,
21
+ validateResolvedConflictPaths,
20
22
  withWorktreeLock,
21
23
  } from "./pr-execution.ts";
22
24
 
23
- export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
25
+ type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
24
26
 
25
- export type UpdateBranchState = {
27
+ type UpdateBranchState = {
26
28
  phase: UpdateBranchPhase;
27
29
  verifiedHead?: string;
28
30
  conflict?: { paths: string[]; statusBaseline: string };
29
31
  };
30
32
 
31
- export type UpdateBranchResult =
33
+ type UpdateBranchResult =
32
34
  | { kind: "verified"; head: string; fastForward: boolean }
33
35
  | { kind: "conflict"; paths: string[] }
34
36
  | { kind: "published"; head: string };
@@ -49,23 +51,6 @@ function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): b
49
51
  fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
50
52
  }
51
53
 
52
- function singleLine(output: string, label: string): string {
53
- const normalized = output.replace(/\r\n/g, "\n");
54
- const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
55
- if (lines.length !== 1 || !lines[0]) throw new Error(`${label} returned invalid output`);
56
- return lines[0];
57
- }
58
-
59
- function validateDeclaredPaths(paths: readonly string[], expected: readonly string[]): string[] {
60
- if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
61
- const encoded = `${paths.join("\0")}${paths.length ? "\0" : ""}`;
62
- const parsed = parseNulPaths(encoded, "Resolved conflict paths");
63
- if (expected.some((path) => !parsed.includes(path))) {
64
- throw new Error("Resolved paths must include every original conflict path");
65
- }
66
- return parsed;
67
- }
68
-
69
54
  export class PullRequestBranchUpdater {
70
55
  readonly state: UpdateBranchState = { phase: "ready" };
71
56
 
@@ -108,7 +93,7 @@ export class PullRequestBranchUpdater {
108
93
  if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
109
94
  throw new Error("Branch update cancelled: frozen pull request authority changed");
110
95
  }
111
- const branch = singleLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
96
+ const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
112
97
  if (branch !== this.authority.target.branch) throw new Error("Branch update cancelled: current branch changed");
113
98
  if (requireClean && await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
114
99
  throw new Error("Branch update cancelled: worktree is dirty or a Git operation is in progress");
@@ -120,7 +105,7 @@ export class PullRequestBranchUpdater {
120
105
 
121
106
  private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
122
107
  const head = await readHead(this.exec, this.execOptions());
123
- const parentsOutput = singleLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
108
+ const parentsOutput = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
124
109
  const commits = parentsOutput.split(" ").map((value, index) => requiredOid(value, index === 0 ? "merged HEAD" : "merge parent"));
125
110
  if (commits[0] !== head) throw new Error("Branch update merge verification returned a different HEAD");
126
111
  let fastForward = false;
@@ -139,7 +124,7 @@ export class PullRequestBranchUpdater {
139
124
  }
140
125
 
141
126
  private async captureConflict(): Promise<string[]> {
142
- const mergeHead = requiredOid(singleLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
127
+ const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
143
128
  if (mergeHead !== this.authority.base.oid) throw new Error("Failed merge did not retain the frozen base");
144
129
  const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
145
130
  if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
@@ -189,10 +174,10 @@ export class PullRequestBranchUpdater {
189
174
  if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
190
175
  throw new Error("Branch update has no conflict awaiting continuation");
191
176
  }
192
- const paths = validateDeclaredPaths(resolvedPaths, this.state.conflict.paths);
177
+ const paths = validateResolvedConflictPaths(resolvedPaths, this.state.conflict.paths);
193
178
  return await withWorktreeLock(this.cwd, async () => {
194
179
  await this.freshAuthority(this.authority.head.oid, false);
195
- const mergeHead = requiredOid(singleLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
180
+ const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
196
181
  if (mergeHead !== this.authority.base.oid) throw new Error("Branch update merge context changed");
197
182
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
198
183
  assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "5.0.2",
3
+ "version": "6.0.1",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,17 +27,19 @@
27
27
  "pack:check": "npm pack --dry-run"
28
28
  },
29
29
  "peerDependencies": {
30
- "@earendil-works/pi-coding-agent": "^0.85.1",
31
- "@earendil-works/pi-tui": "^0.85.1",
30
+ "@earendil-works/pi-coding-agent": "^0.86.0",
31
+ "@earendil-works/pi-tui": "^0.86.0",
32
32
  "typebox": "^1.3.15"
33
33
  },
34
34
  "dependencies": {
35
- "@henryqw/pi-config-store": "^1.1.0",
35
+ "@henryqw/pi-config-store": "^2.0.0",
36
36
  "@henryqw/pi-herdr": "^0.4.5",
37
37
  "@henryqw/pi-process": "^0.1.0",
38
38
  "proper-lockfile": "^4.1.2"
39
39
  },
40
40
  "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "0.86.0",
42
+ "@earendil-works/pi-tui": "0.86.0",
41
43
  "@types/proper-lockfile": "^4.1.4"
42
44
  },
43
45
  "repository": {