@tmux-web/ext-git-workflow 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/dist/backend/git.js +214 -0
- package/dist/backend/pane-ready.js +37 -0
- package/dist/backend/routes/commit-push.js +46 -0
- package/dist/backend/routes/handoff.js +85 -0
- package/dist/backend/routes/send-keys.js +31 -0
- package/dist/backend/routes/status.js +10 -0
- package/dist/backend/server.js +25 -0
- package/dist/backend/status-service.js +110 -0
- package/dist/backend/storage.js +32 -0
- package/dist/backend/tmux.js +58 -0
- package/dist/ui/app.js +425 -0
- package/dist/ui/index.html +334 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-client.d.ts +24 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-client.js +177 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-pr.d.ts +20 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-pr.js +43 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-repo.d.ts +14 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-repo.js +58 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/index.d.ts +3 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/index.js +3 -0
- package/node_modules/@tmux-web/ext-gh-workflow/package.json +38 -0
- package/node_modules/@tmux-web/ext-sdk/dist/bridge.d.ts +31 -0
- package/node_modules/@tmux-web/ext-sdk/dist/bridge.js +103 -0
- package/node_modules/@tmux-web/ext-sdk/dist/index.d.ts +7 -0
- package/node_modules/@tmux-web/ext-sdk/dist/index.js +12 -0
- package/node_modules/@tmux-web/ext-sdk/dist/types.d.ts +20 -0
- package/node_modules/@tmux-web/ext-sdk/dist/types.js +1 -0
- package/node_modules/@tmux-web/ext-sdk/package.json +32 -0
- package/package.json +43 -0
- package/tmux-extension.json +13 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<style>
|
|
6
|
+
:root {
|
|
7
|
+
--bg: #11161d;
|
|
8
|
+
--border: #243241;
|
|
9
|
+
--fg: #d0d0d0;
|
|
10
|
+
--muted: #8a97a6;
|
|
11
|
+
--accent: #f3f7fb;
|
|
12
|
+
--success: #73c991;
|
|
13
|
+
--danger: #f14c4c;
|
|
14
|
+
--warn: #e9c46a;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
font-family: 'JetBrains Mono', monospace, ui-monospace;
|
|
21
|
+
background: var(--bg);
|
|
22
|
+
color: var(--fg);
|
|
23
|
+
font-size: 12px;
|
|
24
|
+
line-height: 1.5;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#header {
|
|
28
|
+
padding: 8px 12px 6px;
|
|
29
|
+
border-bottom: 1px solid var(--border);
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: space-between;
|
|
33
|
+
}
|
|
34
|
+
#header-title { font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--accent); }
|
|
35
|
+
#session-label { font-size: 10px; color: var(--muted); }
|
|
36
|
+
|
|
37
|
+
.section {
|
|
38
|
+
padding: 10px 12px;
|
|
39
|
+
border-bottom: 1px solid var(--border);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
43
|
+
.row:last-child { margin-bottom: 0; }
|
|
44
|
+
|
|
45
|
+
.label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; min-width: 72px; }
|
|
46
|
+
|
|
47
|
+
.badge {
|
|
48
|
+
font-size: 10px;
|
|
49
|
+
padding: 2px 8px;
|
|
50
|
+
border-radius: 999px;
|
|
51
|
+
border: 1px solid var(--border);
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
}
|
|
54
|
+
.badge.active { border-color: var(--success); color: var(--success); }
|
|
55
|
+
|
|
56
|
+
.changes { font-size: 11px; }
|
|
57
|
+
.changes .add { color: var(--success); }
|
|
58
|
+
.changes .del { color: var(--danger); }
|
|
59
|
+
|
|
60
|
+
select, input, textarea {
|
|
61
|
+
flex: 1;
|
|
62
|
+
background: rgba(0,0,0,0.3);
|
|
63
|
+
border: 1px solid var(--border);
|
|
64
|
+
border-radius: 4px;
|
|
65
|
+
color: var(--fg);
|
|
66
|
+
font-family: inherit;
|
|
67
|
+
font-size: 11px;
|
|
68
|
+
padding: 5px 7px;
|
|
69
|
+
outline: none;
|
|
70
|
+
}
|
|
71
|
+
select:focus, input:focus, textarea:focus { border-color: rgba(125,211,252,0.4); }
|
|
72
|
+
|
|
73
|
+
textarea { min-height: 64px; resize: vertical; width: 100%; }
|
|
74
|
+
|
|
75
|
+
.path {
|
|
76
|
+
font-size: 10px;
|
|
77
|
+
color: var(--muted);
|
|
78
|
+
word-break: break-all;
|
|
79
|
+
line-height: 1.4;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.btn {
|
|
83
|
+
background: none;
|
|
84
|
+
border: 1px solid var(--border);
|
|
85
|
+
border-radius: 4px;
|
|
86
|
+
color: var(--muted);
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
font-family: inherit;
|
|
89
|
+
font-size: 11px;
|
|
90
|
+
padding: 6px 10px;
|
|
91
|
+
transition: color 0.12s, border-color 0.12s;
|
|
92
|
+
}
|
|
93
|
+
.btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
|
94
|
+
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
95
|
+
.btn.primary { border-color: var(--success); color: var(--success); }
|
|
96
|
+
.btn.danger { border-color: var(--danger); color: var(--danger); }
|
|
97
|
+
.btn-block { width: 100%; text-align: center; margin-top: 8px; }
|
|
98
|
+
|
|
99
|
+
#loading, #empty-state {
|
|
100
|
+
padding: 24px 12px;
|
|
101
|
+
color: var(--muted);
|
|
102
|
+
text-align: center;
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
line-height: 1.7;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.menu-item {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
padding: 8px 0;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
color: var(--fg);
|
|
114
|
+
border: none;
|
|
115
|
+
background: none;
|
|
116
|
+
font-family: inherit;
|
|
117
|
+
font-size: 11px;
|
|
118
|
+
width: 100%;
|
|
119
|
+
text-align: left;
|
|
120
|
+
}
|
|
121
|
+
.menu-item:hover { color: var(--accent); }
|
|
122
|
+
|
|
123
|
+
.modal-backdrop {
|
|
124
|
+
display: none;
|
|
125
|
+
position: fixed;
|
|
126
|
+
inset: 0;
|
|
127
|
+
background: rgba(0,0,0,0.55);
|
|
128
|
+
z-index: 10;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
padding: 12px;
|
|
132
|
+
}
|
|
133
|
+
.modal-backdrop.open { display: flex; }
|
|
134
|
+
|
|
135
|
+
.modal {
|
|
136
|
+
background: var(--bg);
|
|
137
|
+
border: 1px solid var(--border);
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
width: 100%;
|
|
140
|
+
max-width: 320px;
|
|
141
|
+
padding: 12px;
|
|
142
|
+
}
|
|
143
|
+
.modal h3 {
|
|
144
|
+
font-size: 11px;
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
letter-spacing: 0.05em;
|
|
147
|
+
color: var(--accent);
|
|
148
|
+
margin-bottom: 10px;
|
|
149
|
+
}
|
|
150
|
+
.modal .actions {
|
|
151
|
+
display: flex;
|
|
152
|
+
gap: 8px;
|
|
153
|
+
justify-content: flex-end;
|
|
154
|
+
margin-top: 12px;
|
|
155
|
+
}
|
|
156
|
+
.field { margin-bottom: 10px; }
|
|
157
|
+
.field label { display: block; font-size: 10px; color: var(--muted); margin-bottom: 4px; }
|
|
158
|
+
.err { font-size: 10px; color: var(--danger); margin-top: 6px; min-height: 14px; }
|
|
159
|
+
|
|
160
|
+
.pr-link {
|
|
161
|
+
font-size: 11px;
|
|
162
|
+
color: var(--fg);
|
|
163
|
+
text-decoration: none;
|
|
164
|
+
flex: 1;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
text-overflow: ellipsis;
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
}
|
|
169
|
+
.pr-link:hover { color: var(--accent); text-decoration: underline; }
|
|
170
|
+
|
|
171
|
+
.check-row {
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
gap: 6px;
|
|
175
|
+
padding: 4px 0;
|
|
176
|
+
font-size: 11px;
|
|
177
|
+
border-top: 1px solid var(--border);
|
|
178
|
+
}
|
|
179
|
+
.check-name {
|
|
180
|
+
flex: 1;
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
text-overflow: ellipsis;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
color: var(--muted);
|
|
185
|
+
}
|
|
186
|
+
.check-status {
|
|
187
|
+
font-size: 10px;
|
|
188
|
+
padding: 1px 6px;
|
|
189
|
+
border-radius: 999px;
|
|
190
|
+
border: 1px solid var(--border);
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
}
|
|
193
|
+
.check-status.passing { border-color: var(--success); color: var(--success); }
|
|
194
|
+
.check-status.failing { border-color: var(--danger); color: var(--danger); }
|
|
195
|
+
.check-status.pending { border-color: var(--warn); color: var(--warn); }
|
|
196
|
+
.check-status.skipped { opacity: 0.5; }
|
|
197
|
+
|
|
198
|
+
.btn.warn { border-color: var(--warn); color: var(--warn); }
|
|
199
|
+
|
|
200
|
+
#footer {
|
|
201
|
+
padding: 6px 12px;
|
|
202
|
+
border-top: 1px solid var(--border);
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: space-between;
|
|
206
|
+
color: var(--muted);
|
|
207
|
+
font-size: 10px;
|
|
208
|
+
}
|
|
209
|
+
#refresh-btn {
|
|
210
|
+
background: none; border: none;
|
|
211
|
+
color: var(--muted); cursor: pointer;
|
|
212
|
+
font-size: 10px; font-family: inherit;
|
|
213
|
+
}
|
|
214
|
+
#refresh-btn:hover { color: var(--accent); }
|
|
215
|
+
</style>
|
|
216
|
+
</head>
|
|
217
|
+
<body>
|
|
218
|
+
|
|
219
|
+
<div id="header">
|
|
220
|
+
<div>
|
|
221
|
+
<div id="header-title">Environment</div>
|
|
222
|
+
<div id="session-label">session: —</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div id="loading">connecting</div>
|
|
227
|
+
|
|
228
|
+
<div id="empty-state" hidden>
|
|
229
|
+
Works with GitHub repositories only.<br>
|
|
230
|
+
Open a pane in a GitHub-linked git repo.
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div id="panel" hidden>
|
|
234
|
+
<div class="section">
|
|
235
|
+
<div class="row">
|
|
236
|
+
<span class="label">Kind</span>
|
|
237
|
+
<span id="kind-local" class="badge">Local</span>
|
|
238
|
+
<span id="kind-worktree" class="badge">Worktree</span>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="row">
|
|
241
|
+
<span class="label">Changes</span>
|
|
242
|
+
<span id="changes" class="changes">+0 -0</span>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="row">
|
|
245
|
+
<span class="label">Branch</span>
|
|
246
|
+
<span id="branch-display" style="font-size:11px">—</span>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="row" id="branch-select-row">
|
|
249
|
+
<span class="label">Handoff</span>
|
|
250
|
+
<select id="branch-select"></select>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="path" id="path-display"></div>
|
|
253
|
+
<div class="path" id="main-repo-row" style="display:none;margin-top:6px">
|
|
254
|
+
Main: <span id="main-repo-path"></span>
|
|
255
|
+
<button class="btn" id="copy-main-btn" style="margin-left:6px;padding:2px 6px">copy</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="section" id="local-actions">
|
|
260
|
+
<button class="menu-item" id="handoff-btn">↔ Handoff to worktree</button>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="section">
|
|
264
|
+
<button class="btn btn-block primary" id="commit-push-btn" disabled>Commit or push</button>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="section" id="pr-section" hidden>
|
|
268
|
+
<div class="row">
|
|
269
|
+
<span class="label" id="checks-label">PR</span>
|
|
270
|
+
<a id="pr-link" class="pr-link" href="#" target="_blank"></a>
|
|
271
|
+
</div>
|
|
272
|
+
<div id="checks-list"></div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div id="footer">
|
|
277
|
+
<span id="last-refresh">—</span>
|
|
278
|
+
<button id="refresh-btn" onclick="manualRefresh()">↻ refresh</button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<!-- Handoff modal -->
|
|
282
|
+
<div class="modal-backdrop" id="handoff-modal">
|
|
283
|
+
<div class="modal">
|
|
284
|
+
<h3>Handoff to worktree</h3>
|
|
285
|
+
<div class="field">
|
|
286
|
+
<label for="handoff-branch">Branch</label>
|
|
287
|
+
<input id="handoff-branch" type="text" placeholder="feature/my-branch" />
|
|
288
|
+
</div>
|
|
289
|
+
<div class="err" id="handoff-err"></div>
|
|
290
|
+
<div class="actions">
|
|
291
|
+
<button class="btn" id="handoff-cancel">Cancel</button>
|
|
292
|
+
<button class="btn primary" id="handoff-submit">Create worktree</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<!-- Confirm create branch -->
|
|
298
|
+
<div class="modal-backdrop" id="confirm-modal">
|
|
299
|
+
<div class="modal">
|
|
300
|
+
<h3>Create branch?</h3>
|
|
301
|
+
<p id="confirm-text" style="font-size:11px;color:var(--muted);line-height:1.6"></p>
|
|
302
|
+
<div class="actions">
|
|
303
|
+
<button class="btn" id="confirm-cancel">Cancel</button>
|
|
304
|
+
<button class="btn primary" id="confirm-submit">Create branch</button>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<!-- Commit / push modal -->
|
|
310
|
+
<div class="modal-backdrop" id="commit-modal">
|
|
311
|
+
<div class="modal">
|
|
312
|
+
<h3 id="commit-title">Commit or push</h3>
|
|
313
|
+
<div class="field" id="commit-message-field">
|
|
314
|
+
<label for="commit-message">Commit message</label>
|
|
315
|
+
<textarea id="commit-message" placeholder="Describe your changes"></textarea>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="err" id="commit-err"></div>
|
|
318
|
+
<div class="actions">
|
|
319
|
+
<button class="btn" id="commit-cancel">Cancel</button>
|
|
320
|
+
<button class="btn primary" id="commit-submit">Submit</button>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<script>
|
|
326
|
+
function manualRefresh() {
|
|
327
|
+
window.__refresh?.();
|
|
328
|
+
const el = document.getElementById('last-refresh');
|
|
329
|
+
if (el) el.textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
330
|
+
}
|
|
331
|
+
</script>
|
|
332
|
+
<script src="app.js"></script>
|
|
333
|
+
</body>
|
|
334
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
type SpawnFn = typeof nodeSpawn;
|
|
3
|
+
/** @internal test hook */
|
|
4
|
+
export declare function __setExecFileForTests(fn: SpawnFn | null): void;
|
|
5
|
+
export type GhApiResult = {
|
|
6
|
+
status: number;
|
|
7
|
+
body: unknown;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseGhIncludeOutput(stdout: string): GhApiResult;
|
|
10
|
+
export declare function runGh(args: string[], input?: string): Promise<{
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function ghApi(endpoint: string, options?: {
|
|
15
|
+
method?: string;
|
|
16
|
+
body?: unknown;
|
|
17
|
+
}): Promise<GhApiResult>;
|
|
18
|
+
export declare function checkGhAuth(): Promise<{
|
|
19
|
+
ok: true;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
let spawnImpl = nodeSpawn;
|
|
3
|
+
/** @internal test hook */
|
|
4
|
+
export function __setExecFileForTests(fn) {
|
|
5
|
+
spawnImpl = fn ?? nodeSpawn;
|
|
6
|
+
}
|
|
7
|
+
function ghEnv() {
|
|
8
|
+
const env = { ...process.env };
|
|
9
|
+
if (env.GITHUB_PAT && !env.GH_TOKEN && !env.GITHUB_TOKEN) {
|
|
10
|
+
env.GH_TOKEN = env.GITHUB_PAT;
|
|
11
|
+
}
|
|
12
|
+
return env;
|
|
13
|
+
}
|
|
14
|
+
function normalizeEndpoint(endpoint) {
|
|
15
|
+
return endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
|
16
|
+
}
|
|
17
|
+
export function parseGhIncludeOutput(stdout) {
|
|
18
|
+
if (!stdout)
|
|
19
|
+
return { status: 204, body: null };
|
|
20
|
+
const sep = stdout.includes('\r\n\r\n') ? '\r\n\r\n' : '\n\n';
|
|
21
|
+
const splitIdx = stdout.indexOf(sep);
|
|
22
|
+
if (splitIdx === -1) {
|
|
23
|
+
const statusLine = stdout.trim().split(/\r?\n/)[0] ?? '';
|
|
24
|
+
const statusMatch = statusLine.match(/\s(\d{3})\s/);
|
|
25
|
+
if (statusMatch) {
|
|
26
|
+
return { status: Number(statusMatch[1]), body: null };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return { status: 200, body: JSON.parse(stdout.trim()) };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { status: 200, body: stdout.trim() };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const headerBlock = stdout.slice(0, splitIdx);
|
|
36
|
+
const bodyText = stdout.slice(splitIdx + sep.length).trim();
|
|
37
|
+
const statusLine = headerBlock.split(/\r?\n/)[0] ?? '';
|
|
38
|
+
const statusMatch = statusLine.match(/\s(\d{3})\s/);
|
|
39
|
+
const status = statusMatch ? Number(statusMatch[1]) : 200;
|
|
40
|
+
if (!bodyText)
|
|
41
|
+
return { status, body: null };
|
|
42
|
+
try {
|
|
43
|
+
return { status, body: JSON.parse(bodyText) };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { status, body: bodyText };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function authErrorMessage(text) {
|
|
50
|
+
const lower = text.toLowerCase();
|
|
51
|
+
if (lower.includes('gh auth login')
|
|
52
|
+
|| lower.includes('not logged in')
|
|
53
|
+
|| lower.includes('authentication')
|
|
54
|
+
|| lower.includes('no oauth token')
|
|
55
|
+
|| lower.includes('gh_token')) {
|
|
56
|
+
return 'GitHub CLI is not authenticated. Run `gh auth login` or set GH_TOKEN/GITHUB_PAT.';
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function ghNotFoundMessage(err) {
|
|
61
|
+
if (err.code === 'ENOENT') {
|
|
62
|
+
return {
|
|
63
|
+
status: 503,
|
|
64
|
+
body: { error: 'GitHub CLI (gh) not found in PATH. Install gh or set GH_TOKEN/GITHUB_PAT.' },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export async function runGh(args, input) {
|
|
70
|
+
const options = {
|
|
71
|
+
env: ghEnv(),
|
|
72
|
+
};
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const child = spawnImpl('gh', args, options);
|
|
75
|
+
const maxBuffer = 10 * 1024 * 1024;
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
let settled = false;
|
|
79
|
+
function finishWithError(err) {
|
|
80
|
+
if (settled)
|
|
81
|
+
return;
|
|
82
|
+
settled = true;
|
|
83
|
+
err.stdout = stdout;
|
|
84
|
+
err.stderr = stderr;
|
|
85
|
+
reject(err);
|
|
86
|
+
}
|
|
87
|
+
child.stdout.on('data', (chunk) => {
|
|
88
|
+
stdout += String(chunk);
|
|
89
|
+
if (stdout.length + stderr.length > maxBuffer) {
|
|
90
|
+
child.kill();
|
|
91
|
+
finishWithError(Object.assign(new Error('gh output exceeded max buffer'), { code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' }));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
child.stderr.on('data', (chunk) => {
|
|
95
|
+
stderr += String(chunk);
|
|
96
|
+
if (stdout.length + stderr.length > maxBuffer) {
|
|
97
|
+
child.kill();
|
|
98
|
+
finishWithError(Object.assign(new Error('gh output exceeded max buffer'), { code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' }));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.on('error', (err) => finishWithError(err));
|
|
102
|
+
child.on('close', (code) => {
|
|
103
|
+
if (settled)
|
|
104
|
+
return;
|
|
105
|
+
settled = true;
|
|
106
|
+
if (code && code !== 0) {
|
|
107
|
+
const err = Object.assign(new Error(`gh exited with code ${code}`), {
|
|
108
|
+
code,
|
|
109
|
+
stdout,
|
|
110
|
+
stderr,
|
|
111
|
+
});
|
|
112
|
+
reject(err);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
resolve({ stdout, stderr });
|
|
116
|
+
});
|
|
117
|
+
child.stdin.end(input);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export async function ghApi(endpoint, options) {
|
|
121
|
+
const args = ['api', '--include', normalizeEndpoint(endpoint)];
|
|
122
|
+
const method = options?.method?.toUpperCase();
|
|
123
|
+
if (method && method !== 'GET') {
|
|
124
|
+
args.push('-X', method);
|
|
125
|
+
}
|
|
126
|
+
const input = options?.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
127
|
+
if (input !== undefined) {
|
|
128
|
+
args.push('--input', '-');
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const { stdout } = await runGh(args, input);
|
|
132
|
+
return parseGhIncludeOutput(stdout);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const execErr = err;
|
|
136
|
+
const notFound = ghNotFoundMessage(execErr);
|
|
137
|
+
if (notFound)
|
|
138
|
+
return notFound;
|
|
139
|
+
const combined = `${execErr.stdout ?? ''}\n${execErr.stderr ?? ''}`.trim();
|
|
140
|
+
const authMsg = authErrorMessage(combined);
|
|
141
|
+
if (authMsg)
|
|
142
|
+
return { status: 401, body: { error: authMsg } };
|
|
143
|
+
if (execErr.stdout?.trim()) {
|
|
144
|
+
const parsed = parseGhIncludeOutput(execErr.stdout);
|
|
145
|
+
if (parsed.status >= 400)
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const body = combined ? JSON.parse(combined) : { error: combined || 'GitHub CLI request failed' };
|
|
150
|
+
return { status: 502, body };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { status: 502, body: { error: combined || 'GitHub CLI request failed' } };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function checkGhAuth() {
|
|
158
|
+
try {
|
|
159
|
+
await runGh(['auth', 'status']);
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const execErr = err;
|
|
164
|
+
const notFound = ghNotFoundMessage(execErr);
|
|
165
|
+
if (notFound)
|
|
166
|
+
return { ok: false, reason: String(notFound.body.error) };
|
|
167
|
+
if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN || process.env.GITHUB_PAT) {
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
const combined = `${execErr.stdout ?? ''}\n${execErr.stderr ?? ''}`.trim();
|
|
171
|
+
const authMsg = authErrorMessage(combined);
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: authMsg ?? (combined || 'GitHub CLI authentication check failed'),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface PrCheck {
|
|
2
|
+
name: string;
|
|
3
|
+
status: 'queued' | 'in_progress' | 'completed';
|
|
4
|
+
conclusion: string | null;
|
|
5
|
+
url: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PrInfo {
|
|
8
|
+
number: number;
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
headSha: string;
|
|
12
|
+
checks: PrCheck[];
|
|
13
|
+
}
|
|
14
|
+
export declare function fetchPrForBranch(nameWithOwner: string, branch: string): Promise<Omit<PrInfo, 'checks'> | null>;
|
|
15
|
+
export interface BranchHead {
|
|
16
|
+
headSha: string;
|
|
17
|
+
url: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function fetchBranchHead(nameWithOwner: string, branch: string): Promise<BranchHead | null>;
|
|
20
|
+
export declare function fetchPrChecks(nameWithOwner: string, headSha: string): Promise<PrCheck[]>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ghApi } from './gh-client.js';
|
|
2
|
+
export async function fetchPrForBranch(nameWithOwner, branch) {
|
|
3
|
+
const [org] = nameWithOwner.split('/');
|
|
4
|
+
const result = await ghApi(`repos/${nameWithOwner}/pulls?head=${org}:${branch}&state=open&per_page=1`);
|
|
5
|
+
if (result.status !== 200 || !Array.isArray(result.body) || result.body.length === 0) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const pr = result.body[0];
|
|
9
|
+
if (!pr.number || !pr.title)
|
|
10
|
+
return null;
|
|
11
|
+
return {
|
|
12
|
+
number: pr.number,
|
|
13
|
+
title: pr.title,
|
|
14
|
+
url: pr.html_url,
|
|
15
|
+
headSha: pr.head.sha,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function fetchBranchHead(nameWithOwner, branch) {
|
|
19
|
+
const result = await ghApi(`repos/${nameWithOwner}/commits/${branch}`);
|
|
20
|
+
if (result.status !== 200 || !result.body)
|
|
21
|
+
return null;
|
|
22
|
+
const data = result.body;
|
|
23
|
+
if (!data.sha)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
headSha: data.sha,
|
|
27
|
+
url: data.html_url ?? `https://github.com/${nameWithOwner}/tree/${branch}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function fetchPrChecks(nameWithOwner, headSha) {
|
|
31
|
+
const result = await ghApi(`repos/${nameWithOwner}/commits/${headSha}/check-runs?per_page=100`);
|
|
32
|
+
if (result.status !== 200 || !result.body)
|
|
33
|
+
return [];
|
|
34
|
+
const data = result.body;
|
|
35
|
+
if (!Array.isArray(data.check_runs))
|
|
36
|
+
return [];
|
|
37
|
+
return data.check_runs.map((run) => ({
|
|
38
|
+
name: run.name,
|
|
39
|
+
status: run.status,
|
|
40
|
+
conclusion: run.conclusion,
|
|
41
|
+
url: run.html_url,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
2
|
+
export interface GhRepoInfo {
|
|
3
|
+
nameWithOwner: string;
|
|
4
|
+
org: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
}
|
|
7
|
+
type ExecFileFn = typeof nodeExecFile;
|
|
8
|
+
/** @internal test hook */
|
|
9
|
+
export declare function __setExecFileForRepoTests(fn: ExecFileFn | null): void;
|
|
10
|
+
/** Resolve GitHub repo info from a directory (uses git root + gh repo view). */
|
|
11
|
+
export declare function ghRepoView(cwd: string): Promise<GhRepoInfo | null>;
|
|
12
|
+
/** @deprecated use ghRepoView */
|
|
13
|
+
export declare const ghRepoViewFromDir: typeof ghRepoView;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
2
|
+
let execFileImpl = nodeExecFile;
|
|
3
|
+
/** @internal test hook */
|
|
4
|
+
export function __setExecFileForRepoTests(fn) {
|
|
5
|
+
execFileImpl = fn ?? nodeExecFile;
|
|
6
|
+
}
|
|
7
|
+
function gitRepoRoot(cwd) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
execFileImpl('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf-8' }, (err, stdout) => {
|
|
10
|
+
if (err)
|
|
11
|
+
resolve(null);
|
|
12
|
+
else
|
|
13
|
+
resolve(stdout?.trim() || null);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function runGhFromDir(dir, args) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFileImpl('gh', args, { cwd: dir, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
20
|
+
if (err)
|
|
21
|
+
reject(err);
|
|
22
|
+
else
|
|
23
|
+
resolve({
|
|
24
|
+
stdout: stdout == null ? '' : String(stdout),
|
|
25
|
+
stderr: stderr == null ? '' : String(stderr),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Resolve GitHub repo info from a directory (uses git root + gh repo view). */
|
|
31
|
+
export async function ghRepoView(cwd) {
|
|
32
|
+
const repoRoot = await gitRepoRoot(cwd);
|
|
33
|
+
if (!repoRoot)
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await runGhFromDir(repoRoot, [
|
|
37
|
+
'repo', 'view',
|
|
38
|
+
'--json', 'nameWithOwner,name,owner',
|
|
39
|
+
]);
|
|
40
|
+
const jsonText = stdout.trim();
|
|
41
|
+
if (!jsonText)
|
|
42
|
+
return null;
|
|
43
|
+
const data = JSON.parse(jsonText);
|
|
44
|
+
const nameWithOwner = data.nameWithOwner
|
|
45
|
+
?? (data.owner?.login && data.name ? `${data.owner.login}/${data.name}` : null);
|
|
46
|
+
if (!nameWithOwner)
|
|
47
|
+
return null;
|
|
48
|
+
const [org, repo] = nameWithOwner.split('/');
|
|
49
|
+
if (!org || !repo)
|
|
50
|
+
return null;
|
|
51
|
+
return { nameWithOwner, org, repo };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** @deprecated use ghRepoView */
|
|
58
|
+
export const ghRepoViewFromDir = ghRepoView;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { __setExecFileForTests, checkGhAuth, ghApi, parseGhIncludeOutput, runGh, type GhApiResult, } from './gh-client.js';
|
|
2
|
+
export { __setExecFileForRepoTests, ghRepoView, ghRepoViewFromDir, type GhRepoInfo, } from './gh-repo.js';
|
|
3
|
+
export { fetchPrForBranch, fetchPrChecks, fetchBranchHead, type PrCheck, type PrInfo, type BranchHead, } from './gh-pr.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { __setExecFileForTests, checkGhAuth, ghApi, parseGhIncludeOutput, runGh, } from './gh-client.js';
|
|
2
|
+
export { __setExecFileForRepoTests, ghRepoView, ghRepoViewFromDir, } from './gh-repo.js';
|
|
3
|
+
export { fetchPrForBranch, fetchPrChecks, fetchBranchHead, } from './gh-pr.js';
|