@henryqw/pi-pr 0.1.0 → 0.3.0

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
@@ -15,11 +15,13 @@ Requires authenticated GitHub CLI access (`gh auth login`) in a GitHub repositor
15
15
  | Surface | Type | Purpose |
16
16
  | --- | --- | --- |
17
17
  | Footer | UI | Show the current branch pull request. |
18
- | `/pr` | command | Open the current branch pull request in a browser. |
18
+ | `/pr` | command | Open current branch PR, or start PR workflow when absent. |
19
19
 
20
- Each entry is one linked `PR #number` plus one plain-language state: `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `merge conflict`, `merged`, or `closed`. Status priority favors action: merge conflict, changes requested, CI failure, then CI progress. Colors support text; they do not carry meaning alone.
20
+ Each entry is one linked `PR #number` plus one plain-language state: `<count> unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `merge conflict`, `merged`, or `closed`. Known unresolved review threads take priority, followed by merge conflict, changes requested, CI failure, then CI progress. Colors support text; they do not carry meaning alone.
21
21
 
22
- The status loads at session start, polls every 30 seconds, and refreshes after an agent successfully runs `gh pr create` or after `/pr`. No pull request leaves the footer blank.
22
+ The status loads at session start, polls every 30 seconds, and refreshes after an agent successfully runs `gh pr create`, `git push`, or `/pr`. Unresolved review threads are checked every 30 seconds for 20 minutes after an open PR is first found. Each new push or remote PR update, including new comments, restarts that window. Footer and warning notification show the unresolved count when first found or increased. Last known footer count remains after review checks stop. No pull request leaves the footer blank.
23
+
24
+ `/pr` finds an open PR for current branch. When absent, it starts bundled `/skill:pi-pr-create` workflow. Agent resolves base, inspects and commits scoped changes, runs relevant validation, pushes branch, and creates or updates PR with live title and body. This workflow handles dirty worktrees; it never silently commits unrelated changes.
23
25
 
24
26
  ## Remove
25
27
 
package/extensions/pr.ts CHANGED
@@ -6,16 +6,23 @@ import {
6
6
  import { hyperlink } from "@earendil-works/pi-tui";
7
7
 
8
8
  const POLL_INTERVAL_MS = 30_000;
9
- const PR_FIELDS = "number,url,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
9
+ const REVIEW_POLL_WINDOW_MS = 20 * 60_000;
10
+ const PR_FIELDS = "id,number,url,headRefOid,updatedAt,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
11
+ const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
10
12
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
13
+ const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
14
+ const CREATE_PR_SKILL_COMMAND = "skill:pi-pr-create";
11
15
  const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
12
16
  const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
13
17
 
14
18
  type Lifecycle = "D" | "O" | "M" | "C";
15
19
  type CiStatus = "success" | "running" | "failure" | "none";
16
20
  type PullRequest = {
21
+ id: string;
17
22
  number: number;
18
23
  url: string;
24
+ headRefOid: string;
25
+ updatedAt: string;
19
26
  lifecycle: Lifecycle;
20
27
  mergeable: string;
21
28
  reviewDecision: string | null;
@@ -39,15 +46,21 @@ function pullRequestUrl(value: unknown): string | undefined {
39
46
  export function parsePullRequest(value: unknown): PullRequest | undefined {
40
47
  if (!isRecord(value)) return undefined;
41
48
 
49
+ const id = value.id;
42
50
  const number = value.number;
43
51
  const url = pullRequestUrl(value.url);
52
+ const headRefOid = value.headRefOid;
53
+ const updatedAt = value.updatedAt;
44
54
  const state = value.state;
45
55
  const isDraft = value.isDraft;
46
56
  const mergeable = value.mergeable;
47
57
  const reviewDecision = value.reviewDecision;
48
58
  const statusCheckRollup = value.statusCheckRollup;
49
59
  if (
50
- typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url || typeof state !== "string" ||
60
+ typeof id !== "string" || !id ||
61
+ typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url ||
62
+ typeof headRefOid !== "string" || !headRefOid ||
63
+ typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt)) || typeof state !== "string" ||
51
64
  typeof isDraft !== "boolean" || typeof mergeable !== "string" ||
52
65
  (reviewDecision !== null && typeof reviewDecision !== "string") ||
53
66
  (statusCheckRollup !== null && !Array.isArray(statusCheckRollup))
@@ -56,7 +69,52 @@ export function parsePullRequest(value: unknown): PullRequest | undefined {
56
69
  const lifecycle = state === "MERGED" ? "M" : state === "CLOSED" ? "C" : state === "OPEN" ? isDraft ? "D" : "O" : undefined;
57
70
  if (!lifecycle) return undefined;
58
71
 
59
- return { number, url, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
72
+ return { id, number, url, headRefOid, updatedAt, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
73
+ }
74
+
75
+ function parseUnresolvedReviewCount(value: string): number {
76
+ if (!value.trim()) throw new Error("Read review comments failed: invalid GitHub CLI output");
77
+ const pages = value.trim().split(/\s+/).map(Number);
78
+ const count = pages.reduce((total, page) => total + page, 0);
79
+ if (pages.some((page) => !Number.isSafeInteger(page) || page < 0) || !Number.isSafeInteger(count)) {
80
+ throw new Error("Read review comments failed: invalid GitHub CLI output");
81
+ }
82
+ return count;
83
+ }
84
+
85
+ function parseRepositoryName(json: string): string {
86
+ let value: unknown;
87
+ try {
88
+ value = JSON.parse(json);
89
+ } catch {
90
+ throw new Error("Read push repository failed: invalid GitHub CLI output");
91
+ }
92
+ const nameWithOwner = isRecord(value) ? value.nameWithOwner : undefined;
93
+ if (typeof nameWithOwner !== "string" || !nameWithOwner) {
94
+ throw new Error("Read push repository failed: invalid GitHub CLI output");
95
+ }
96
+ return nameWithOwner.toLowerCase();
97
+ }
98
+
99
+ function parseOpenPullRequestNumbers(json: string, headRepository: string): number[] {
100
+ let values: unknown;
101
+ try {
102
+ values = JSON.parse(json);
103
+ } catch {
104
+ throw new Error("Find pull requests failed: invalid GitHub CLI output");
105
+ }
106
+ if (!Array.isArray(values)) throw new Error("Find pull requests failed: invalid GitHub CLI output");
107
+ return values.flatMap((value) => {
108
+ const number = isRecord(value) ? value.number : undefined;
109
+ const repository = isRecord(value) ? value.headRepository : undefined;
110
+ if (repository === null) return [];
111
+ const nameWithOwner = isRecord(repository) ? repository.nameWithOwner : undefined;
112
+ if (
113
+ typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 ||
114
+ typeof nameWithOwner !== "string" || !nameWithOwner
115
+ ) throw new Error("Find pull requests failed: invalid GitHub CLI output");
116
+ return nameWithOwner.toLowerCase() === headRepository ? [number] : [];
117
+ });
60
118
  }
61
119
 
62
120
  function ciStatus(rollup: unknown[]): CiStatus {
@@ -89,9 +147,11 @@ function statusFor(pullRequest: PullRequest, ci: CiStatus): Status {
89
147
  return { text: "open", color: "accent" };
90
148
  }
91
149
 
92
- export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"]): string {
150
+ export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"], unresolved = 0): string {
93
151
  const link = hyperlink(theme.fg("text", `PR #${pullRequest.number}`), pullRequest.url);
94
- const status = statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
152
+ const status = unresolved > 0
153
+ ? { text: `${unresolved} unresolved`, color: "warning" as const }
154
+ : statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
95
155
  return `${link} · ${theme.fg(status.color, status.text)}`;
96
156
  }
97
157
 
@@ -100,10 +160,14 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
100
160
  let timer: ReturnType<typeof setInterval> | undefined;
101
161
  let active: AbortController | undefined;
102
162
  let queued = false;
163
+ let reviewState: { id: string; unresolved: number } | undefined;
164
+ let reviewWindow: { id: string; headRefOid: string; updatedAt: string; until: number } | undefined;
103
165
 
104
166
  const stop = () => {
105
167
  context = undefined;
106
168
  queued = false;
169
+ reviewState = undefined;
170
+ reviewWindow = undefined;
107
171
  if (timer !== undefined) clearInterval(timer);
108
172
  timer = undefined;
109
173
  active?.abort();
@@ -133,7 +197,59 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
133
197
  }
134
198
 
135
199
  const pullRequest = parsePullRequest(JSON.parse(result.stdout));
136
- ctx.ui.setStatus("pi-pr", pullRequest ? formatPullRequest(pullRequest, ctx.ui.theme) : undefined);
200
+ if (!pullRequest) {
201
+ reviewState = undefined;
202
+ reviewWindow = undefined;
203
+ ctx.ui.setStatus("pi-pr", undefined);
204
+ return;
205
+ }
206
+ if (pullRequest.lifecycle === "M" || pullRequest.lifecycle === "C") {
207
+ reviewState = undefined;
208
+ reviewWindow = undefined;
209
+ ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme));
210
+ return;
211
+ }
212
+ const changedPullRequest = reviewWindow?.id !== pullRequest.id;
213
+ if (
214
+ changedPullRequest ||
215
+ reviewWindow?.headRefOid !== pullRequest.headRefOid ||
216
+ reviewWindow?.updatedAt !== pullRequest.updatedAt
217
+ ) {
218
+ if (changedPullRequest) reviewState = undefined;
219
+ reviewWindow = {
220
+ id: pullRequest.id,
221
+ headRefOid: pullRequest.headRefOid,
222
+ updatedAt: pullRequest.updatedAt,
223
+ until: Date.now() + REVIEW_POLL_WINDOW_MS,
224
+ };
225
+ }
226
+ ctx.ui.setStatus("pi-pr", formatPullRequest(
227
+ pullRequest,
228
+ ctx.ui.theme,
229
+ reviewState?.id === pullRequest.id ? reviewState.unresolved : 0,
230
+ ));
231
+ if (Date.now() >= reviewWindow.until) return;
232
+
233
+ try {
234
+ const reviews = await pi.exec(
235
+ "gh",
236
+ [
237
+ "api", "graphql", "--hostname", new URL(pullRequest.url).hostname, "--paginate",
238
+ "-f", `query=${REVIEW_THREADS_QUERY}`, "-F", `id=${pullRequest.id}`,
239
+ "--jq", "[.data.node.reviewThreads.nodes[] | select(.isResolved == false)] | length",
240
+ ],
241
+ { cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
242
+ );
243
+ if (controller.signal.aborted || context !== ctx || reviews.code !== 0) return;
244
+ const unresolved = parseUnresolvedReviewCount(reviews.stdout);
245
+ if (unresolved > 0 && (reviewState?.id !== pullRequest.id || unresolved > reviewState.unresolved)) {
246
+ ctx.ui.notify(`PR #${pullRequest.number} has ${unresolved} unresolved review thread${unresolved === 1 ? "" : "s"}`, "warning");
247
+ }
248
+ reviewState = { id: pullRequest.id, unresolved };
249
+ ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme, unresolved));
250
+ } catch {
251
+ // Keep known PR status when review lookup fails.
252
+ }
137
253
  } catch {
138
254
  if (!controller.signal.aborted && context === ctx) ctx.ui.setStatus("pi-pr", undefined);
139
255
  } finally {
@@ -159,24 +275,55 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
159
275
  pi.on("tool_result", async (event, ctx) => {
160
276
  if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
161
277
  const command = event.input.command;
162
- if (typeof command === "string" && GH_PR_CREATE.test(command)) await refresh(true);
278
+ if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) await refresh(true);
163
279
  });
164
280
 
165
281
  pi.registerCommand("pr", {
166
- description: "Open the pull request for the current branch",
282
+ description: "Open current branch pull request, or run creation workflow when absent",
167
283
  handler: async (_args, ctx) => {
168
284
  if (!ctx.hasUI) return;
169
- let result;
285
+ const execute = async (action: string, command: string, args: string[]) => {
286
+ let result;
287
+ try {
288
+ result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
289
+ } catch (error) {
290
+ throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
291
+ }
292
+ if (result.code !== 0) {
293
+ const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was cancelled" : `exit code ${result.code}`);
294
+ throw new Error(`${action} failed: ${detail}`);
295
+ }
296
+ return result;
297
+ };
298
+
170
299
  try {
171
- result = await pi.exec("gh", ["pr", "view", "--web"], { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
172
- } catch (error) {
300
+ const branch = (await execute("Read current branch", "git", ["branch", "--show-current"])).stdout.trim();
301
+ if (!branch) throw new Error("Create pull request failed: current checkout has no branch");
302
+ const remote = (await execute("Read push remote", "git", ["remote", "get-url", "--push", "origin"])).stdout.trim();
303
+ if (!remote) throw new Error("Read push remote failed: origin has no push URL");
304
+ const repository = await execute("Read push repository", "gh", ["repo", "view", remote, "--json", "nameWithOwner"]);
305
+ const listed = await execute("Find pull requests", "gh", [
306
+ "pr", "list", "--head", branch, "--state", "open", "--limit", "100", "--json", "number,headRepository",
307
+ ]);
308
+ const numbers = parseOpenPullRequestNumbers(listed.stdout, parseRepositoryName(repository.stdout));
309
+ if (numbers.length > 1) throw new Error("Open pull request failed: multiple open pull requests found for current branch");
310
+
311
+ if (numbers.length === 1) {
312
+ await execute("Open pull request", "gh", ["pr", "view", String(numbers[0]), "--web"]);
313
+ return;
314
+ }
315
+
316
+ const workflow = pi.getCommands().find((command) =>
317
+ command.name === CREATE_PR_SKILL_COMMAND && command.source === "skill" && command.sourceInfo.origin === "package",
318
+ );
319
+ if (!workflow) throw new Error("Create pull request failed: bundled workflow is unavailable");
320
+ if (ctx.isIdle()) {
321
+ pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { expandPromptTemplates: true });
322
+ } else {
323
+ pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { deliverAs: "followUp", expandPromptTemplates: true });
324
+ }
325
+ } finally {
173
326
  void refresh(true);
174
- throw new Error(`Open pull request failed: ${error instanceof Error ? error.message : String(error)}`);
175
- }
176
- void refresh(true);
177
- if (result.code !== 0) {
178
- const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was cancelled" : `exit code ${result.code}`);
179
- throw new Error(`Open pull request failed: ${detail}`);
180
327
  }
181
328
  },
182
329
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Show the current branch pull request status in the Pi footer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -16,6 +16,7 @@
16
16
  "license": "MIT",
17
17
  "files": [
18
18
  "extensions",
19
+ "skills",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],
@@ -42,6 +43,9 @@
42
43
  "pi": {
43
44
  "extensions": [
44
45
  "./extensions/pr.ts"
46
+ ],
47
+ "skills": [
48
+ "./skills"
45
49
  ]
46
50
  }
47
51
  }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: pi-pr-create
3
+ description: Create or update a GitHub pull request from current branch. Used by `/pr` when no open current-branch pull request exists.
4
+ ---
5
+
6
+ # Pi PR Create
7
+
8
+ Create current branch GitHub pull request.
9
+
10
+ 1. Resolve `<base>` from explicit input, current PR base, or repository default branch. Stop if ambiguous. Inspect `git status --short`, staged and unstaged diffs, and `git diff "$(git merge-base HEAD <base>)"`. Never commit `.context/` or unrelated changes.
11
+ 2. Commit each coherent pending change with a scoped Conventional Commit. Preserve existing coherent staging; stop when changes cannot be separated safely.
12
+ 3. Run smallest relevant non-destructive validation for current `HEAD`; state when none exists.
13
+ 4. Derive Conventional Commit PR title plus Summary and Testing body from live diff and validation.
14
+ 5. Push `HEAD` to `origin`, setting upstream when absent. Query `gh pr list --head <branch> --state open --limit 100 --json number,url,baseRefName`; reuse exactly one only when its base matches, refreshing title and body when needed. Stop on a different base or multiple PRs. Otherwise create with `gh pr create --base <base> --title <title> --body-file <file>`.
15
+ 6. Reply only with PR URL.