@henryqw/pi-pr 4.0.9 → 4.0.10

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/README.md CHANGED
@@ -36,6 +36,8 @@ GitHub, use the network, or write files.
36
36
 
37
37
  **Requires.** [`@henryqw/pi-herdr`](https://pi.henry.wang/extensions/pi-herdr) is the shared Herdr CLI client. It installs with this package.
38
38
 
39
+ **Uses.** [`@henryqw/pi-process`](https://pi.henry.wang/packages/pi-process) runs bounded child processes. It installs with this package.
40
+
39
41
  **Improves.** [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) shows current-branch pull-request status in the footer.
40
42
 
41
43
  ## Use
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
3
4
  import {
4
5
  loadCurrentPullRequest,
5
6
  readValidatedRemoteAuthority,
@@ -15,10 +16,7 @@ import {
15
16
  requiredOid,
16
17
  requiredText,
17
18
  runChecked,
18
- spawnBounded,
19
19
  withWorktreeLock,
20
- type Exec,
21
- type ExecOptions,
22
20
  } from "./pr-execution.ts";
23
21
 
24
22
  const PAGE_BYTES = 512 * 1024;
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { realpath, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
5
6
  import {
6
7
  extensionConfigDir,
7
8
  readTextFileBounded,
@@ -36,11 +37,8 @@ import {
36
37
  requiredOid,
37
38
  requiredText,
38
39
  runChecked,
39
- spawnBounded,
40
40
  withWorktreeLock,
41
41
  type AttemptState,
42
- type Exec,
43
- type ExecOptions,
44
42
  } from "./pr-execution.ts";
45
43
 
46
44
  export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
3
  import {
3
4
  branchTrackingRef,
4
5
  fetchBranchTrackingRef,
@@ -29,10 +30,7 @@ import {
29
30
  requiredOid,
30
31
  requiredText,
31
32
  runChecked,
32
- spawnBounded,
33
33
  withWorktreeLock,
34
- type Exec,
35
- type ExecOptions,
36
34
  } from "./pr-execution.ts";
37
35
 
38
36
  const MAX_TITLE_BYTES = 256;
@@ -1,49 +1,21 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat, mkdir, realpath } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
- import { spawn } from "node:child_process";
5
4
  import { extensionConfigDir } from "@henryqw/pi-config-store";
5
+ import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
6
6
  import { lock } from "proper-lockfile";
7
7
 
8
- export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
9
- export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
10
8
  export const MAX_CONFLICT_PATHS = 128;
11
9
  export const MAX_CONFLICT_PATH_BYTES = 1_024;
12
10
  export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
13
11
 
14
12
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
15
13
  const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
16
- const PROCESS_TERMINATION_GRACE_MS = 250;
17
14
  const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 } as const;
18
15
 
19
- export type ExecResult = {
20
- stdout: string;
21
- stderr: string;
22
- code: number;
23
- killed: boolean;
24
- stdoutTruncated?: boolean;
25
- };
26
-
27
- export type ExecOptions = {
28
- cwd: string;
29
- signal?: AbortSignal;
30
- timeoutMs?: number;
31
- stdoutLimitBytes?: number;
32
- stdoutTailBytes?: number;
33
- stderrLimitBytes?: number;
34
- stdin?: string;
35
- };
36
-
37
- export type Exec = (command: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
38
-
39
16
  export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
40
17
  export type GitWorktreeState = "clean" | "dirty" | "operation";
41
18
 
42
- function positiveInteger(value: number, label: string): number {
43
- if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`);
44
- return value;
45
- }
46
-
47
19
  export function requiredText(value: unknown, label: string): string {
48
20
  if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
49
21
  throw new TypeError(`${label} must be a non-empty string`);
@@ -57,167 +29,6 @@ export function requiredOid(value: unknown, label: string): string {
57
29
  return parsed;
58
30
  }
59
31
 
60
- function validatedArguments(args: string[]): string[] {
61
- if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
62
- throw new TypeError("command arguments must be an array of strings without NUL bytes");
63
- }
64
- return args;
65
- }
66
-
67
- function decode(chunks: Buffer[], bytes: number, label: string): string {
68
- try {
69
- return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes));
70
- } catch {
71
- throw new Error(`${label} was not valid UTF-8`);
72
- }
73
- }
74
-
75
- /** Run one argv-only child process with bounded streaming output, cancellation, and a deadline. */
76
- export const spawnBounded: Exec = async (command, args, options) => {
77
- requiredText(command, "command");
78
- validatedArguments(args);
79
- requiredText(options.cwd, "cwd");
80
- options.signal?.throwIfAborted();
81
- const timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, "timeoutMs");
82
- const stdoutLimit = positiveInteger(options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stdoutLimitBytes");
83
- const stdoutTailLimit = options.stdoutTailBytes === undefined
84
- ? undefined
85
- : positiveInteger(options.stdoutTailBytes, "stdoutTailBytes");
86
- const stderrLimit = positiveInteger(options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stderrLimitBytes");
87
- if (options.stdin !== undefined && typeof options.stdin !== "string") throw new TypeError("stdin must be a string");
88
-
89
- return await new Promise<ExecResult>((resolve, reject) => {
90
- const child = spawn(command, args, {
91
- cwd: options.cwd,
92
- detached: process.platform !== "win32",
93
- env: process.env,
94
- stdio: ["pipe", "pipe", "pipe"],
95
- });
96
- const childPid = child.pid;
97
- const processGroup = process.platform !== "win32" && typeof childPid === "number" &&
98
- Number.isSafeInteger(childPid) && childPid > 0 ? childPid : null;
99
- const stdout: Buffer[] = [];
100
- const stderr: Buffer[] = [];
101
- const stdoutDecoder = stdoutTailLimit === undefined
102
- ? undefined
103
- : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
104
- let stdoutBytes = 0;
105
- let stdoutTruncated = false;
106
- let stderrBytes = 0;
107
- let failure: Error | undefined;
108
- let killed = false;
109
- let settled = false;
110
- let killTimer: ReturnType<typeof setTimeout> | undefined;
111
-
112
- const signalChild = (signal: NodeJS.Signals): void => {
113
- if (processGroup !== null) {
114
- try {
115
- process.kill(-processGroup, signal);
116
- killed = true;
117
- return;
118
- } catch (error) {
119
- if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH") return;
120
- }
121
- }
122
- killed = child.kill(signal) || killed;
123
- };
124
- const stop = (error: Error): void => {
125
- if (failure) return;
126
- failure = error;
127
- signalChild("SIGTERM");
128
- killTimer = setTimeout(() => signalChild("SIGKILL"), PROCESS_TERMINATION_GRACE_MS);
129
- };
130
- const appendTail = (text: string): void => {
131
- if (!text || stdoutTailLimit === undefined) return;
132
- const chunk = Buffer.from(text, "utf8");
133
- stdout.push(chunk);
134
- stdoutBytes += chunk.length;
135
- if (stdoutBytes <= stdoutTailLimit) return;
136
- stdoutTruncated = true;
137
- let remove = stdoutBytes - stdoutTailLimit;
138
- while (remove > 0) {
139
- const first = stdout[0]!;
140
- if (first.length <= remove) {
141
- stdout.shift();
142
- stdoutBytes -= first.length;
143
- remove -= first.length;
144
- continue;
145
- }
146
- let start = remove;
147
- while (start < first.length && (first[start]! & 0xc0) === 0x80) start += 1;
148
- stdout[0] = Buffer.from(first.subarray(start));
149
- stdoutBytes -= start;
150
- remove = 0;
151
- }
152
- };
153
- const decodeTail = (chunk?: Buffer): void => {
154
- if (!stdoutDecoder) return;
155
- try {
156
- appendTail(chunk === undefined ? stdoutDecoder.decode() : stdoutDecoder.decode(chunk, { stream: true }));
157
- } catch {
158
- stop(new Error(`${command} stdout was not valid UTF-8`));
159
- }
160
- };
161
- const timer = setTimeout(() => stop(new Error(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
162
- const abort = (): void => stop(options.signal?.reason instanceof Error
163
- ? options.signal.reason
164
- : new DOMException("The operation was aborted", "AbortError"));
165
- options.signal?.addEventListener("abort", abort, { once: true });
166
- if (options.signal?.aborted) abort();
167
-
168
- child.stdout.on("data", (chunk: Buffer) => {
169
- if (failure) return;
170
- if (stdoutDecoder) {
171
- decodeTail(chunk);
172
- return;
173
- }
174
- stdoutBytes += chunk.length;
175
- if (stdoutBytes > stdoutLimit) {
176
- stop(new Error(`${command} stdout exceeded ${stdoutLimit} bytes`));
177
- return;
178
- }
179
- stdout.push(chunk);
180
- });
181
- child.stderr.on("data", (chunk: Buffer) => {
182
- if (failure) return;
183
- stderrBytes += chunk.length;
184
- if (stderrBytes > stderrLimit) {
185
- stop(new Error(`${command} stderr exceeded ${stderrLimit} bytes`));
186
- return;
187
- }
188
- stderr.push(chunk);
189
- });
190
- child.once("error", stop);
191
- child.once("close", (code, signal) => {
192
- if (settled) return;
193
- settled = true;
194
- clearTimeout(timer);
195
- if (killTimer !== undefined && processGroup === null) clearTimeout(killTimer);
196
- options.signal?.removeEventListener("abort", abort);
197
- if (!failure && stdoutDecoder) decodeTail();
198
- if (failure) {
199
- reject(failure);
200
- return;
201
- }
202
- try {
203
- resolve({
204
- stdout: stdoutDecoder
205
- ? Buffer.concat(stdout, stdoutBytes).toString("utf8")
206
- : decode(stdout, stdoutBytes, `${command} stdout`),
207
- stderr: decode(stderr, stderrBytes, `${command} stderr`),
208
- code: code ?? 1,
209
- killed: killed || signal !== null,
210
- ...(stdoutDecoder ? { stdoutTruncated } : {}),
211
- });
212
- } catch (error) {
213
- reject(error);
214
- }
215
- });
216
- if (options.stdin === undefined) child.stdin.end();
217
- else child.stdin.end(options.stdin, "utf8");
218
- });
219
- };
220
-
221
32
  export function commandText(command: string, args: readonly string[]): string {
222
33
  return [command, ...args].join(" ");
223
34
  }
@@ -1,10 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import type { Exec, ExecOptions } from "@henryqw/pi-process";
2
3
  import {
3
4
  runChecked,
4
5
  requiredOid,
5
6
  requiredText,
6
- type Exec,
7
- type ExecOptions,
8
7
  } from "./pr-execution.ts";
9
8
  import type { CurrentPullRequest } from "./pr-github.ts";
10
9
 
@@ -1,10 +1,10 @@
1
+ import type { Exec } from "@henryqw/pi-process";
1
2
  import {
2
3
  inspectWorktree,
3
4
  readHead,
4
5
  requiredOid,
5
6
  requiredText,
6
7
  runChecked,
7
- type Exec,
8
8
  } from "./pr-execution.ts";
9
9
  import type { LocalMergeSafety } from "./pr-routing.ts";
10
10
 
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
3
  import {
3
4
  loadCurrentPullRequest,
4
5
  samePullRequestSnapshot,
@@ -15,10 +16,7 @@ import {
15
16
  requiredOid,
16
17
  resolveRepositoryFetchSource,
17
18
  runChecked,
18
- spawnBounded,
19
19
  withWorktreeLock,
20
- type Exec,
21
- type ExecOptions,
22
20
  } from "./pr-execution.ts";
23
21
 
24
22
  export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
package/extensions/pr.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { spawnBounded } from "@henryqw/pi-process";
2
3
  import { realpath } from "node:fs/promises";
3
4
  import {
4
5
  isBashToolResult,
@@ -26,7 +27,7 @@ import {
26
27
  samePullRequestObservation,
27
28
  type PullRequestObservation,
28
29
  } from "./pr-github.ts";
29
- import { runChecked, spawnBounded } from "./pr-execution.ts";
30
+ import { runChecked } from "./pr-execution.ts";
30
31
  import {
31
32
  discoveryIssueKey,
32
33
  discoveryIssueMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "4.0.9",
3
+ "version": "4.0.10",
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",
@@ -34,6 +34,7 @@
34
34
  "dependencies": {
35
35
  "@henryqw/pi-config-store": "^1.1.0",
36
36
  "@henryqw/pi-herdr": "^0.4.5",
37
+ "@henryqw/pi-process": "^0.1.0",
37
38
  "proper-lockfile": "^4.1.2"
38
39
  },
39
40
  "devDependencies": {
@@ -17,10 +17,10 @@ import {
17
17
  loadCurrentPullRequest,
18
18
  samePullRequestSnapshot,
19
19
  } from "../../../extensions/pr-github.ts";
20
+ import { spawnBounded } from "@henryqw/pi-process";
20
21
  import {
21
22
  inspectWorktree,
22
23
  readHead,
23
- spawnBounded,
24
24
  } from "../../../extensions/pr-execution.ts";
25
25
 
26
26
  class UsageError extends Error {}