@henryqw/pi-pr 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/extensions/pr.ts +183 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# `@henryqw/pi-pr`
|
|
2
|
+
|
|
3
|
+
Show the current branch pull request's lifecycle, CI, mergeability, and review state in the Pi footer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@henryqw/pi-pr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires authenticated GitHub CLI access (`gh auth login`) in a GitHub repository checkout.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
| Surface | Type | Purpose |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Footer | UI | Show the current branch pull request. |
|
|
18
|
+
| `/pr` | command | Open the current branch pull request in a browser. |
|
|
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.
|
|
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.
|
|
23
|
+
|
|
24
|
+
## Remove
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi remove npm:@henryqw/pi-pr
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm test --workspace @henryqw/pi-pr
|
|
34
|
+
npm run typecheck --workspace @henryqw/pi-pr
|
|
35
|
+
npm run pack:check --workspace @henryqw/pi-pr
|
|
36
|
+
```
|
package/extensions/pr.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isBashToolResult,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { hyperlink } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
const POLL_INTERVAL_MS = 30_000;
|
|
9
|
+
const PR_FIELDS = "number,url,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
|
|
10
|
+
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
11
|
+
const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
|
|
12
|
+
const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
|
|
13
|
+
|
|
14
|
+
type Lifecycle = "D" | "O" | "M" | "C";
|
|
15
|
+
type CiStatus = "success" | "running" | "failure" | "none";
|
|
16
|
+
type PullRequest = {
|
|
17
|
+
number: number;
|
|
18
|
+
url: string;
|
|
19
|
+
lifecycle: Lifecycle;
|
|
20
|
+
mergeable: string;
|
|
21
|
+
reviewDecision: string | null;
|
|
22
|
+
statusCheckRollup: unknown[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pullRequestUrl(value: unknown): string | undefined {
|
|
30
|
+
if (typeof value !== "string") return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(value);
|
|
33
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePullRequest(value: unknown): PullRequest | undefined {
|
|
40
|
+
if (!isRecord(value)) return undefined;
|
|
41
|
+
|
|
42
|
+
const number = value.number;
|
|
43
|
+
const url = pullRequestUrl(value.url);
|
|
44
|
+
const state = value.state;
|
|
45
|
+
const isDraft = value.isDraft;
|
|
46
|
+
const mergeable = value.mergeable;
|
|
47
|
+
const reviewDecision = value.reviewDecision;
|
|
48
|
+
const statusCheckRollup = value.statusCheckRollup;
|
|
49
|
+
if (
|
|
50
|
+
typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url || typeof state !== "string" ||
|
|
51
|
+
typeof isDraft !== "boolean" || typeof mergeable !== "string" ||
|
|
52
|
+
(reviewDecision !== null && typeof reviewDecision !== "string") ||
|
|
53
|
+
(statusCheckRollup !== null && !Array.isArray(statusCheckRollup))
|
|
54
|
+
) return undefined;
|
|
55
|
+
|
|
56
|
+
const lifecycle = state === "MERGED" ? "M" : state === "CLOSED" ? "C" : state === "OPEN" ? isDraft ? "D" : "O" : undefined;
|
|
57
|
+
if (!lifecycle) return undefined;
|
|
58
|
+
|
|
59
|
+
return { number, url, lifecycle, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ciStatus(rollup: unknown[]): CiStatus {
|
|
63
|
+
if (!rollup.length) return "none";
|
|
64
|
+
let running = false;
|
|
65
|
+
for (const check of rollup) {
|
|
66
|
+
const record = isRecord(check) ? check : undefined;
|
|
67
|
+
const value = record?.conclusion ?? record?.state ?? record?.status;
|
|
68
|
+
const state = typeof value === "string" ? value.toUpperCase() : undefined;
|
|
69
|
+
if (state && FAILED_CHECK_STATES.has(state)) return "failure";
|
|
70
|
+
if (!state || !SUCCESSFUL_CHECK_STATES.has(state)) running = true;
|
|
71
|
+
}
|
|
72
|
+
return running ? "running" : "success";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Status = {
|
|
76
|
+
text: string;
|
|
77
|
+
color: "accent" | "warning" | "success" | "error" | "dim";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function statusFor(pullRequest: PullRequest, ci: CiStatus): Status {
|
|
81
|
+
if (pullRequest.lifecycle === "M") return { text: "merged", color: "success" };
|
|
82
|
+
if (pullRequest.lifecycle === "C") return { text: "closed", color: "dim" };
|
|
83
|
+
if (pullRequest.mergeable === "CONFLICTING") return { text: "merge conflict", color: "error" };
|
|
84
|
+
if (pullRequest.reviewDecision === "CHANGES_REQUESTED") return { text: "changes requested", color: "error" };
|
|
85
|
+
if (ci === "failure") return { text: "CI failed", color: "error" };
|
|
86
|
+
if (ci === "running") return { text: "CI running", color: "warning" };
|
|
87
|
+
if (pullRequest.lifecycle === "D") return { text: "draft", color: "warning" };
|
|
88
|
+
if (pullRequest.reviewDecision === "APPROVED") return { text: "approved", color: "success" };
|
|
89
|
+
return { text: "open", color: "accent" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"]): string {
|
|
93
|
+
const link = hyperlink(theme.fg("text", `PR #${pullRequest.number}`), pullRequest.url);
|
|
94
|
+
const status = statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
|
|
95
|
+
return `${link} · ${theme.fg(status.color, status.text)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
99
|
+
let context: ExtensionContext | undefined;
|
|
100
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
101
|
+
let active: AbortController | undefined;
|
|
102
|
+
let queued = false;
|
|
103
|
+
|
|
104
|
+
const stop = () => {
|
|
105
|
+
context = undefined;
|
|
106
|
+
queued = false;
|
|
107
|
+
if (timer !== undefined) clearInterval(timer);
|
|
108
|
+
timer = undefined;
|
|
109
|
+
active?.abort();
|
|
110
|
+
active = undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const refresh = async (afterActive = false): Promise<void> => {
|
|
114
|
+
const ctx = context;
|
|
115
|
+
if (!ctx) return;
|
|
116
|
+
if (active) {
|
|
117
|
+
queued ||= afterActive;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
active = controller;
|
|
123
|
+
try {
|
|
124
|
+
const result = await pi.exec(
|
|
125
|
+
"gh",
|
|
126
|
+
["pr", "view", "--json", PR_FIELDS],
|
|
127
|
+
{ cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
|
|
128
|
+
);
|
|
129
|
+
if (controller.signal.aborted || context !== ctx) return;
|
|
130
|
+
if (result.code !== 0) {
|
|
131
|
+
ctx.ui.setStatus("pi-pr", undefined);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const pullRequest = parsePullRequest(JSON.parse(result.stdout));
|
|
136
|
+
ctx.ui.setStatus("pi-pr", pullRequest ? formatPullRequest(pullRequest, ctx.ui.theme) : undefined);
|
|
137
|
+
} catch {
|
|
138
|
+
if (!controller.signal.aborted && context === ctx) ctx.ui.setStatus("pi-pr", undefined);
|
|
139
|
+
} finally {
|
|
140
|
+
if (active !== controller) return;
|
|
141
|
+
active = undefined;
|
|
142
|
+
if (queued) {
|
|
143
|
+
queued = false;
|
|
144
|
+
await refresh();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
150
|
+
if (!ctx.hasUI) return;
|
|
151
|
+
stop();
|
|
152
|
+
context = ctx;
|
|
153
|
+
await refresh();
|
|
154
|
+
if (context === ctx) timer = setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("session_shutdown", stop);
|
|
158
|
+
|
|
159
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
160
|
+
if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
|
|
161
|
+
const command = event.input.command;
|
|
162
|
+
if (typeof command === "string" && GH_PR_CREATE.test(command)) await refresh(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
pi.registerCommand("pr", {
|
|
166
|
+
description: "Open the pull request for the current branch",
|
|
167
|
+
handler: async (_args, ctx) => {
|
|
168
|
+
if (!ctx.hasUI) return;
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
result = await pi.exec("gh", ["pr", "view", "--web"], { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
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
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-pr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Show the current branch pull request status in the Pi footer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"github",
|
|
9
|
+
"pull-request",
|
|
10
|
+
"footer"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
25
|
+
"pack:check": "npm pack --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.84.2"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
34
|
+
"directory": "packages/pi-pr"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./extensions/pr.ts"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|