@henryqw/pi-pr 0.1.0 → 0.2.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,12 +15,14 @@ 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
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.
21
21
 
22
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.
23
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.
25
+
24
26
  ## Remove
25
27
 
26
28
  ```bash
package/extensions/pr.ts CHANGED
@@ -8,6 +8,7 @@ import { hyperlink } from "@earendil-works/pi-tui";
8
8
  const POLL_INTERVAL_MS = 30_000;
9
9
  const PR_FIELDS = "number,url,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
10
10
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
11
+ const CREATE_PR_SKILL_COMMAND = "skill:pi-pr-create";
11
12
  const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
12
13
  const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
13
14
 
@@ -59,6 +60,41 @@ export function parsePullRequest(value: unknown): PullRequest | undefined {
59
60
  return { number, url, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
60
61
  }
61
62
 
63
+ function parseRepositoryName(json: string): string {
64
+ let value: unknown;
65
+ try {
66
+ value = JSON.parse(json);
67
+ } catch {
68
+ throw new Error("Read push repository failed: invalid GitHub CLI output");
69
+ }
70
+ const nameWithOwner = isRecord(value) ? value.nameWithOwner : undefined;
71
+ if (typeof nameWithOwner !== "string" || !nameWithOwner) {
72
+ throw new Error("Read push repository failed: invalid GitHub CLI output");
73
+ }
74
+ return nameWithOwner.toLowerCase();
75
+ }
76
+
77
+ function parseOpenPullRequestNumbers(json: string, headRepository: string): number[] {
78
+ let values: unknown;
79
+ try {
80
+ values = JSON.parse(json);
81
+ } catch {
82
+ throw new Error("Find pull requests failed: invalid GitHub CLI output");
83
+ }
84
+ if (!Array.isArray(values)) throw new Error("Find pull requests failed: invalid GitHub CLI output");
85
+ return values.flatMap((value) => {
86
+ const number = isRecord(value) ? value.number : undefined;
87
+ const repository = isRecord(value) ? value.headRepository : undefined;
88
+ if (repository === null) return [];
89
+ const nameWithOwner = isRecord(repository) ? repository.nameWithOwner : undefined;
90
+ if (
91
+ typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 ||
92
+ typeof nameWithOwner !== "string" || !nameWithOwner
93
+ ) throw new Error("Find pull requests failed: invalid GitHub CLI output");
94
+ return nameWithOwner.toLowerCase() === headRepository ? [number] : [];
95
+ });
96
+ }
97
+
62
98
  function ciStatus(rollup: unknown[]): CiStatus {
63
99
  if (!rollup.length) return "none";
64
100
  let running = false;
@@ -163,20 +199,51 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
163
199
  });
164
200
 
165
201
  pi.registerCommand("pr", {
166
- description: "Open the pull request for the current branch",
202
+ description: "Open current branch pull request, or run creation workflow when absent",
167
203
  handler: async (_args, ctx) => {
168
204
  if (!ctx.hasUI) return;
169
- let result;
205
+ const execute = async (action: string, command: string, args: string[]) => {
206
+ let result;
207
+ try {
208
+ result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
209
+ } catch (error) {
210
+ throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ if (result.code !== 0) {
213
+ const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was cancelled" : `exit code ${result.code}`);
214
+ throw new Error(`${action} failed: ${detail}`);
215
+ }
216
+ return result;
217
+ };
218
+
170
219
  try {
171
- result = await pi.exec("gh", ["pr", "view", "--web"], { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
172
- } catch (error) {
220
+ const branch = (await execute("Read current branch", "git", ["branch", "--show-current"])).stdout.trim();
221
+ if (!branch) throw new Error("Create pull request failed: current checkout has no branch");
222
+ const remote = (await execute("Read push remote", "git", ["remote", "get-url", "--push", "origin"])).stdout.trim();
223
+ if (!remote) throw new Error("Read push remote failed: origin has no push URL");
224
+ const repository = await execute("Read push repository", "gh", ["repo", "view", remote, "--json", "nameWithOwner"]);
225
+ const listed = await execute("Find pull requests", "gh", [
226
+ "pr", "list", "--head", branch, "--state", "open", "--limit", "100", "--json", "number,headRepository",
227
+ ]);
228
+ const numbers = parseOpenPullRequestNumbers(listed.stdout, parseRepositoryName(repository.stdout));
229
+ if (numbers.length > 1) throw new Error("Open pull request failed: multiple open pull requests found for current branch");
230
+
231
+ if (numbers.length === 1) {
232
+ await execute("Open pull request", "gh", ["pr", "view", String(numbers[0]), "--web"]);
233
+ return;
234
+ }
235
+
236
+ const workflow = pi.getCommands().find((command) =>
237
+ command.name === CREATE_PR_SKILL_COMMAND && command.source === "skill" && command.sourceInfo.origin === "package",
238
+ );
239
+ if (!workflow) throw new Error("Create pull request failed: bundled workflow is unavailable");
240
+ if (ctx.isIdle()) {
241
+ pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { expandPromptTemplates: true });
242
+ } else {
243
+ pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { deliverAs: "followUp", expandPromptTemplates: true });
244
+ }
245
+ } finally {
173
246
  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
247
  }
181
248
  },
182
249
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.