@kurokeita/add-skill 2.4.0 → 2.5.1
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/bin/cli.js +1 -1
- package/dist/skills/git-commit/SKILL.md +1 -1
- package/dist/skills/git-commit/hooks/commit-gate.py +1 -1
- package/dist/skills/statusline-setup/.claude-plugin/plugin.json +7 -0
- package/dist/skills/statusline-setup/SKILL.md +451 -15
- package/dist/skills/statusline-setup/mod/hooks/discover.ts +142 -0
- package/dist/skills/statusline-setup/mod/hooks/fetch.ts +327 -0
- package/dist/skills/statusline-setup/mod/hooks/hooks.json +1 -0
- package/dist/skills/statusline-setup/mod/hooks/register.tsx +411 -0
- package/dist/skills/statusline-setup/mod/tests/work.test.ts +306 -0
- package/dist/skills/statusline-setup/mod/types/index.d.ts +35 -0
- package/package.json +1 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import type { EngineInterface, Register } from "claude-code"
|
|
2
|
+
import { atom, read, update } from "claude-code"
|
|
3
|
+
|
|
4
|
+
import type { CheckState, Pr, Step } from "../types"
|
|
5
|
+
import {
|
|
6
|
+
parseSummary,
|
|
7
|
+
platformOf,
|
|
8
|
+
prNumbersIn,
|
|
9
|
+
repoNameOf,
|
|
10
|
+
SUMMARY_SYSTEM,
|
|
11
|
+
transcriptOf,
|
|
12
|
+
} from "./discover"
|
|
13
|
+
import { fetchAzure, fetchGitHub, git, type Run } from "./fetch"
|
|
14
|
+
|
|
15
|
+
const PANE = "work-items"
|
|
16
|
+
const branches = atom({ plugin: "work-items", key: "branches" } as const, [])
|
|
17
|
+
const work = atom({ plugin: "work-items", key: "work" } as const, null)
|
|
18
|
+
const isFetching = atom(
|
|
19
|
+
{ plugin: "work-items", key: "isFetching" } as const,
|
|
20
|
+
false,
|
|
21
|
+
)
|
|
22
|
+
const summary = atom({ plugin: "work-items", key: "summary" } as const, null)
|
|
23
|
+
const isSummarizing = atom(
|
|
24
|
+
{ plugin: "work-items", key: "isSummarizing" } as const,
|
|
25
|
+
false,
|
|
26
|
+
)
|
|
27
|
+
const collapsed = atom({ plugin: "work-items", key: "collapsed" } as const, [])
|
|
28
|
+
|
|
29
|
+
let isFetchRunning = false
|
|
30
|
+
let isAutoOpenWanted = false
|
|
31
|
+
let isAutoOpened = false
|
|
32
|
+
// Which conversation this process shows, and whether a prompt was sent in it here.
|
|
33
|
+
let watchedId = ""
|
|
34
|
+
let hasPromptedHere = false
|
|
35
|
+
let sessionStartedAt = Date.now()
|
|
36
|
+
|
|
37
|
+
const runner =
|
|
38
|
+
($: EngineInterface): Run =>
|
|
39
|
+
(argv) =>
|
|
40
|
+
$.process
|
|
41
|
+
.run(argv, { timeoutMs: 60_000 })
|
|
42
|
+
.catch((error) => ({ exitCode: 1, stdout: "", stderr: String(error) }))
|
|
43
|
+
|
|
44
|
+
const isPaneOpen = async ($: EngineInterface) =>
|
|
45
|
+
(await $.ui.panes()).some((pane) => pane.id === PANE)
|
|
46
|
+
|
|
47
|
+
// The branches this session sat on at the end of a turn, newest first.
|
|
48
|
+
async function noteBranch($: EngineInterface): Promise<string> {
|
|
49
|
+
const branch = await git(runner($), ["branch", "--show-current"])
|
|
50
|
+
if (branch)
|
|
51
|
+
await update($, branches, (list) =>
|
|
52
|
+
[branch, ...list.filter((b) => b !== branch)].slice(0, 10),
|
|
53
|
+
)
|
|
54
|
+
return branch
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The current branch, plus the branches this session committed to. A branch the
|
|
58
|
+
// session only started on or passed through has no commits since it began.
|
|
59
|
+
async function trackedBranches(
|
|
60
|
+
$: EngineInterface,
|
|
61
|
+
current: string,
|
|
62
|
+
): Promise<string[]> {
|
|
63
|
+
const since = `--since=${new Date(sessionStartedAt).toISOString()}`
|
|
64
|
+
const tracked = current ? [current] : []
|
|
65
|
+
for (const branch of await read($, branches)) {
|
|
66
|
+
if (branch === current) continue
|
|
67
|
+
if (await git(runner($), ["log", "-1", "--format=%H", since, branch]))
|
|
68
|
+
tracked.push(branch)
|
|
69
|
+
}
|
|
70
|
+
return tracked
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function refreshWork($: EngineInterface) {
|
|
74
|
+
if (isFetchRunning) return
|
|
75
|
+
isFetchRunning = true
|
|
76
|
+
await update($, isFetching, () => true)
|
|
77
|
+
try {
|
|
78
|
+
const remote = await git(runner($), ["remote", "get-url", "origin"])
|
|
79
|
+
const platform = platformOf(remote)
|
|
80
|
+
if (!platform) {
|
|
81
|
+
await update($, work, () => ({
|
|
82
|
+
prs: [],
|
|
83
|
+
items: [],
|
|
84
|
+
fetchedAt: Date.now(),
|
|
85
|
+
error: "origin is neither GitHub nor Azure DevOps.",
|
|
86
|
+
}))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const current = await noteBranch($)
|
|
90
|
+
const prNumbers = prNumbersIn(
|
|
91
|
+
await $.session.messages(),
|
|
92
|
+
platform,
|
|
93
|
+
repoNameOf(remote),
|
|
94
|
+
)
|
|
95
|
+
const fetch = platform === "github" ? fetchGitHub : fetchAzure
|
|
96
|
+
const result = await fetch(
|
|
97
|
+
runner($),
|
|
98
|
+
await trackedBranches($, current),
|
|
99
|
+
prNumbers,
|
|
100
|
+
)
|
|
101
|
+
await update($, work, () => result)
|
|
102
|
+
} finally {
|
|
103
|
+
isFetchRunning = false
|
|
104
|
+
await update($, isFetching, () => false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function summarize($: EngineInterface) {
|
|
109
|
+
if (await read($, isSummarizing)) return
|
|
110
|
+
await update($, isSummarizing, () => true)
|
|
111
|
+
try {
|
|
112
|
+
const prompt = transcriptOf(await $.session.messages())
|
|
113
|
+
// The API refuses an empty user message with a 400.
|
|
114
|
+
if (!prompt.trim()) {
|
|
115
|
+
await update($, summary, () => ({
|
|
116
|
+
error: "Nothing to summarize yet. Send a prompt first.",
|
|
117
|
+
at: Date.now(),
|
|
118
|
+
}))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const reply = await $.model.complete({
|
|
122
|
+
model: "haiku",
|
|
123
|
+
system: SUMMARY_SYSTEM,
|
|
124
|
+
prompt,
|
|
125
|
+
maxTokens: 1500,
|
|
126
|
+
})
|
|
127
|
+
const parsed = reply.isAnswered ? parseSummary(reply.text) : null
|
|
128
|
+
const error = reply.isAnswered
|
|
129
|
+
? "The summary came back unreadable."
|
|
130
|
+
: reply.reason === "api-error"
|
|
131
|
+
? `Summary failed: api-error ${reply.status ?? ""} ${reply.error}`
|
|
132
|
+
: `Summary failed: ${reply.reason}`
|
|
133
|
+
await update($, summary, () =>
|
|
134
|
+
parsed ? { ...parsed, at: Date.now() } : { error, at: Date.now() },
|
|
135
|
+
)
|
|
136
|
+
} finally {
|
|
137
|
+
await update($, isSummarizing, () => false)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Summarizes once when the pane shows and no summary exists yet, or the last try failed.
|
|
142
|
+
async function summarizeIfMissing($: EngineInterface) {
|
|
143
|
+
const current = await read($, summary)
|
|
144
|
+
if (!current || "error" in current) await summarize($)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Opens the pane once in a resumed session, the first time it has a PR or item.
|
|
148
|
+
async function autoOpen($: EngineInterface) {
|
|
149
|
+
if (!isAutoOpenWanted || isAutoOpened) return
|
|
150
|
+
await refreshWork($)
|
|
151
|
+
const current = await read($, work)
|
|
152
|
+
if (
|
|
153
|
+
isAutoOpened ||
|
|
154
|
+
!current ||
|
|
155
|
+
current.prs.length + current.items.length === 0
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
isAutoOpened = true
|
|
159
|
+
// No focus: the prompt keeps the keyboard. Ctrl+X Tab or a click focuses it to scroll.
|
|
160
|
+
await $.ui.open({ id: PANE, title: "Work", columns: 64 })
|
|
161
|
+
await summarizeIfMissing($)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A conversation is resumed when it has turns no prompt of this process made:
|
|
165
|
+
// --resume and --continue load them before session.start, while the picker and
|
|
166
|
+
// an in-session /resume load them later, under a new session id.
|
|
167
|
+
async function checkResumed($: EngineInterface) {
|
|
168
|
+
const id = await $.session.id()
|
|
169
|
+
if (id !== watchedId) {
|
|
170
|
+
watchedId = id
|
|
171
|
+
hasPromptedHere = false
|
|
172
|
+
isAutoOpenWanted = false
|
|
173
|
+
isAutoOpened = false
|
|
174
|
+
// Another conversation now: its branches start over from here.
|
|
175
|
+
sessionStartedAt = Date.now()
|
|
176
|
+
await update($, branches, () => [])
|
|
177
|
+
}
|
|
178
|
+
if (isAutoOpenWanted || hasPromptedHere || (await $.session.turns()) === 0)
|
|
179
|
+
return
|
|
180
|
+
isAutoOpenWanted = true
|
|
181
|
+
await autoOpen($)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ago(at: number): string {
|
|
185
|
+
const s = Math.max(0, Math.round((Date.now() - at) / 1000))
|
|
186
|
+
if (s < 60) return `${s}s ago`
|
|
187
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
|
188
|
+
return `${Math.round(s / 3600)}h ago`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const STATE_COLOR: Record<string, string> = {
|
|
192
|
+
open: "success",
|
|
193
|
+
draft: "subtle",
|
|
194
|
+
merged: "merged",
|
|
195
|
+
closed: "error",
|
|
196
|
+
done: "success",
|
|
197
|
+
resolved: "success",
|
|
198
|
+
removed: "subtle",
|
|
199
|
+
"to do": "subtle",
|
|
200
|
+
new: "subtle",
|
|
201
|
+
active: "suggestion",
|
|
202
|
+
committed: "suggestion",
|
|
203
|
+
"in progress": "suggestion",
|
|
204
|
+
}
|
|
205
|
+
const CHECKS: Record<CheckState, { text: string; color: string }> = {
|
|
206
|
+
passing: { text: "✓ checks", color: "success" },
|
|
207
|
+
failing: { text: "✗ checks", color: "error" },
|
|
208
|
+
pending: { text: "● checks", color: "warning" },
|
|
209
|
+
none: { text: "no checks", color: "subtle" },
|
|
210
|
+
}
|
|
211
|
+
const STEP: Record<Step["status"], { mark: string; color: string }> = {
|
|
212
|
+
done: { mark: "✓", color: "success" },
|
|
213
|
+
current: { mark: "●", color: "warning" },
|
|
214
|
+
todo: { mark: "○", color: "subtle" },
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const register: Register = (on) => {
|
|
218
|
+
on("session.start", async ($, e, next) => {
|
|
219
|
+
await $.command.register({
|
|
220
|
+
name: "work",
|
|
221
|
+
description: "Show this session's PRs, work items and summary in a pane",
|
|
222
|
+
})
|
|
223
|
+
void checkResumed($)
|
|
224
|
+
$.clock.every(5_000, () => checkResumed($))
|
|
225
|
+
$.clock.every(60_000, async () => {
|
|
226
|
+
if (await isPaneOpen($)) await refreshWork($)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
return next(e)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
on("turn.complete", async ($, e, next) => {
|
|
233
|
+
const result = await next(e)
|
|
234
|
+
if (e.agentId) return result
|
|
235
|
+
// A main-conversation turn is when the session pushes branches and opens PRs.
|
|
236
|
+
void (async () => {
|
|
237
|
+
await noteBranch($)
|
|
238
|
+
if (isAutoOpenWanted && !isAutoOpened) return autoOpen($)
|
|
239
|
+
if (await isPaneOpen($)) await refreshWork($)
|
|
240
|
+
})()
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
on("prompt.submit", async (_$, e, next) => {
|
|
246
|
+
hasPromptedHere = true
|
|
247
|
+
|
|
248
|
+
return next(e)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
on("command.run", { command: "work" }, async ($) => {
|
|
252
|
+
void refreshWork($)
|
|
253
|
+
await $.ui.open({
|
|
254
|
+
id: PANE,
|
|
255
|
+
title: "Work",
|
|
256
|
+
columns: 64,
|
|
257
|
+
focus: true,
|
|
258
|
+
closeOnEscape: true,
|
|
259
|
+
})
|
|
260
|
+
void summarizeIfMissing($)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
text: "Work pane opened. Up/Down or PgUp/PgDn scroll, Esc closes.",
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
|
|
268
|
+
const { Box, Text, Link, Button } = $.ui.resolve(e)
|
|
269
|
+
const current = await read($, work)
|
|
270
|
+
const fetching = await read($, isFetching)
|
|
271
|
+
const sum = await read($, summary)
|
|
272
|
+
const summarizing = await read($, isSummarizing)
|
|
273
|
+
const shut = await read($, collapsed)
|
|
274
|
+
|
|
275
|
+
const toggle = (pr: Pr) =>
|
|
276
|
+
update($, collapsed, (list) =>
|
|
277
|
+
list.includes(pr.number)
|
|
278
|
+
? list.filter((n) => n !== pr.number)
|
|
279
|
+
: [...list, pr.number],
|
|
280
|
+
)
|
|
281
|
+
const prs = current?.prs ?? []
|
|
282
|
+
const items = current?.items ?? []
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<Box flexDirection="column" rowGap={1}>
|
|
286
|
+
<Box flexDirection="column">
|
|
287
|
+
<Text>
|
|
288
|
+
<Text bold color="planMode">
|
|
289
|
+
Summary
|
|
290
|
+
</Text>
|
|
291
|
+
<Text dimColor>
|
|
292
|
+
{summarizing ? " updating…" : sum ? ` ${ago(sum.at)}` : ""}
|
|
293
|
+
</Text>
|
|
294
|
+
</Text>
|
|
295
|
+
{!sum && (
|
|
296
|
+
<Text dimColor>Press Refresh to summarize this session.</Text>
|
|
297
|
+
)}
|
|
298
|
+
{sum && "error" in sum && <Text color="error">{sum.error}</Text>}
|
|
299
|
+
{sum && "goal" in sum && <Text>{sum.goal}</Text>}
|
|
300
|
+
</Box>
|
|
301
|
+
|
|
302
|
+
{sum && "steps" in sum && sum.steps.length > 0 && (
|
|
303
|
+
<Box flexDirection="column">
|
|
304
|
+
<Text bold color="planMode">
|
|
305
|
+
Steps
|
|
306
|
+
</Text>
|
|
307
|
+
{sum.steps.map((step) => (
|
|
308
|
+
<Text
|
|
309
|
+
wrap="truncate-end"
|
|
310
|
+
color={
|
|
311
|
+
step.status === "todo" ? undefined : STEP[step.status].color
|
|
312
|
+
}
|
|
313
|
+
dimColor={step.status === "todo"}
|
|
314
|
+
bold={step.status === "current"}
|
|
315
|
+
>
|
|
316
|
+
{STEP[step.status].mark} {step.text}
|
|
317
|
+
</Text>
|
|
318
|
+
))}
|
|
319
|
+
</Box>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
<Box flexDirection="column">
|
|
323
|
+
<Text>
|
|
324
|
+
<Text bold color="planMode">
|
|
325
|
+
Pull requests
|
|
326
|
+
</Text>
|
|
327
|
+
<Text dimColor>
|
|
328
|
+
{" "}
|
|
329
|
+
{prs.length}
|
|
330
|
+
{fetching ? " · updating…" : ""}
|
|
331
|
+
</Text>
|
|
332
|
+
</Text>
|
|
333
|
+
{current?.error && <Text color="error">{current.error}</Text>}
|
|
334
|
+
{prs.length === 0 && !current?.error && (
|
|
335
|
+
<Text dimColor>None found for this session yet.</Text>
|
|
336
|
+
)}
|
|
337
|
+
{prs.map((pr) => (
|
|
338
|
+
<Box flexDirection="column">
|
|
339
|
+
<Box flexDirection="row" columnGap={1}>
|
|
340
|
+
<Button
|
|
341
|
+
key={`toggle-${pr.number}`}
|
|
342
|
+
plain
|
|
343
|
+
label={shut.includes(pr.number) ? "▸" : "▾"}
|
|
344
|
+
onPress={() => void toggle(pr)}
|
|
345
|
+
/>
|
|
346
|
+
<Text wrap="truncate-end">
|
|
347
|
+
<Link href={pr.url}>#{pr.number}</Link>
|
|
348
|
+
<Text color={STATE_COLOR[pr.state] ?? "text"}>
|
|
349
|
+
{" "}
|
|
350
|
+
{pr.state}
|
|
351
|
+
</Text>
|
|
352
|
+
<Text dimColor> [{pr.branch}]</Text> {pr.title}
|
|
353
|
+
</Text>
|
|
354
|
+
</Box>
|
|
355
|
+
{!shut.includes(pr.number) && (
|
|
356
|
+
<Text>
|
|
357
|
+
{" "}
|
|
358
|
+
{pr.review && (
|
|
359
|
+
<Text
|
|
360
|
+
color={pr.review === "approved" ? "success" : "warning"}
|
|
361
|
+
>
|
|
362
|
+
{pr.review}{" "}
|
|
363
|
+
</Text>
|
|
364
|
+
)}
|
|
365
|
+
<Text color={CHECKS[pr.checks].color}>
|
|
366
|
+
{CHECKS[pr.checks].text}
|
|
367
|
+
</Text>
|
|
368
|
+
<Text dimColor> {pr.comments} comments</Text>
|
|
369
|
+
</Text>
|
|
370
|
+
)}
|
|
371
|
+
</Box>
|
|
372
|
+
))}
|
|
373
|
+
</Box>
|
|
374
|
+
|
|
375
|
+
<Box flexDirection="column">
|
|
376
|
+
<Text>
|
|
377
|
+
<Text bold color="planMode">
|
|
378
|
+
Work items
|
|
379
|
+
</Text>
|
|
380
|
+
<Text dimColor> {items.length}</Text>
|
|
381
|
+
</Text>
|
|
382
|
+
{items.length === 0 && <Text dimColor>None linked yet.</Text>}
|
|
383
|
+
{items.map((item) => (
|
|
384
|
+
<Text wrap="truncate-end">
|
|
385
|
+
<Link href={item.url}>#{item.id}</Link>
|
|
386
|
+
<Text color={STATE_COLOR[item.state] ?? "text"}>
|
|
387
|
+
{" "}
|
|
388
|
+
{item.state}
|
|
389
|
+
</Text>{" "}
|
|
390
|
+
{item.title}
|
|
391
|
+
</Text>
|
|
392
|
+
))}
|
|
393
|
+
</Box>
|
|
394
|
+
|
|
395
|
+
<Box flexDirection="row" columnGap={1}>
|
|
396
|
+
<Button
|
|
397
|
+
key="refresh"
|
|
398
|
+
label="Refresh"
|
|
399
|
+
hotkey="r"
|
|
400
|
+
onPress={() => void Promise.all([refreshWork($), summarize($)])}
|
|
401
|
+
/>
|
|
402
|
+
<Text dimColor>
|
|
403
|
+
{current
|
|
404
|
+
? `PRs updated ${ago(current.fetchedAt)}`
|
|
405
|
+
: "PRs not fetched yet"}
|
|
406
|
+
</Text>
|
|
407
|
+
</Box>
|
|
408
|
+
</Box>
|
|
409
|
+
)
|
|
410
|
+
})
|
|
411
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { On, SessionMessage } from "claude-code"
|
|
2
|
+
import { expect, mock, test } from "claude-code/testing"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
azureChecks,
|
|
6
|
+
itemRefs,
|
|
7
|
+
parseSummary,
|
|
8
|
+
prNumbersIn,
|
|
9
|
+
} from "../hooks/discover"
|
|
10
|
+
|
|
11
|
+
const message = (text: string, output = ""): SessionMessage => ({
|
|
12
|
+
role: "assistant",
|
|
13
|
+
text,
|
|
14
|
+
toolUses: [],
|
|
15
|
+
toolResults: output
|
|
16
|
+
? [{ tool_use_id: "t1", text: output, isError: false }]
|
|
17
|
+
: [],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("PR links count only when they point into this repository", async () => {
|
|
21
|
+
const messages = [
|
|
22
|
+
message(
|
|
23
|
+
"see https://github.com/o/repo/pull/12",
|
|
24
|
+
"https://github.com/o/repo/pull/7\n",
|
|
25
|
+
),
|
|
26
|
+
message("other repo https://github.com/o/elsewhere/pull/99"),
|
|
27
|
+
]
|
|
28
|
+
expect(prNumbersIn(messages, "github", "repo").sort()).toEqual([12, 7].sort())
|
|
29
|
+
const azure = [
|
|
30
|
+
message("https://dev.azure.com/org/proj/_git/My%20Repo/pullrequest/19668"),
|
|
31
|
+
]
|
|
32
|
+
expect(prNumbersIn(azure, "azure", "my repo")).toEqual([19668])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("item refs come from the branch name and the branch commits", async () => {
|
|
36
|
+
expect(
|
|
37
|
+
itemRefs("azure", "user/me/43553/feat/x", "fix AB#44429 and AB#43553"),
|
|
38
|
+
).toEqual([43553, 44429])
|
|
39
|
+
expect(itemRefs("github", "41-fix-v2", "closes #8")).toEqual([41, 8])
|
|
40
|
+
expect(itemRefs("github", "release/2.4", "")).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("checks and summaries parse defensively", async () => {
|
|
44
|
+
expect(azureChecks(["approved", "queued"])).toBe("pending")
|
|
45
|
+
expect(azureChecks(["approved", "rejected"])).toBe("failing")
|
|
46
|
+
expect(azureChecks([])).toBe("none")
|
|
47
|
+
expect(
|
|
48
|
+
parseSummary(
|
|
49
|
+
'```json\n{"goal":"G","steps":[{"text":"a","status":"done"},{"text":"b","status":"weird"}]}\n```',
|
|
50
|
+
),
|
|
51
|
+
).toEqual({
|
|
52
|
+
goal: "G",
|
|
53
|
+
steps: [
|
|
54
|
+
{ text: "a", status: "done" },
|
|
55
|
+
{ text: "b", status: "todo" },
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
expect(parseSummary("no json here")).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const GRAPHQL = JSON.stringify({
|
|
62
|
+
data: {
|
|
63
|
+
repository: {
|
|
64
|
+
b0: {
|
|
65
|
+
nodes: [
|
|
66
|
+
{
|
|
67
|
+
number: 7,
|
|
68
|
+
title: "Add the thing",
|
|
69
|
+
url: "https://github.com/o/repo/pull/7",
|
|
70
|
+
state: "OPEN",
|
|
71
|
+
isDraft: false,
|
|
72
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
73
|
+
headRefName: "feat/x",
|
|
74
|
+
comments: { totalCount: 1 },
|
|
75
|
+
reviewThreads: { totalCount: 2 },
|
|
76
|
+
commits: {
|
|
77
|
+
nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }],
|
|
78
|
+
},
|
|
79
|
+
closingIssuesReferences: {
|
|
80
|
+
nodes: [
|
|
81
|
+
{
|
|
82
|
+
number: 5,
|
|
83
|
+
title: "The bug",
|
|
84
|
+
url: "https://github.com/o/repo/issues/5",
|
|
85
|
+
state: "OPEN",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
p12: {
|
|
93
|
+
number: 12,
|
|
94
|
+
title: "Stacked slice",
|
|
95
|
+
url: "https://github.com/o/repo/pull/12",
|
|
96
|
+
state: "MERGED",
|
|
97
|
+
isDraft: false,
|
|
98
|
+
reviewDecision: "APPROVED",
|
|
99
|
+
headRefName: "feat/y",
|
|
100
|
+
comments: { totalCount: 0 },
|
|
101
|
+
reviewThreads: { totalCount: 0 },
|
|
102
|
+
commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
|
|
103
|
+
closingIssuesReferences: { nodes: [] },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const STDOUT: Record<string, string> = {
|
|
110
|
+
"git remote": "git@github.com:o/repo.git\n",
|
|
111
|
+
"git branch": "feat/x\n",
|
|
112
|
+
"git symbolic-ref": "origin/main\n",
|
|
113
|
+
"git log": "",
|
|
114
|
+
"gh api": GRAPHQL,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fakeWorld(
|
|
118
|
+
on: On,
|
|
119
|
+
opened: string[] = [],
|
|
120
|
+
messages: SessionMessage[] = [
|
|
121
|
+
message("opened https://github.com/o/repo/pull/12"),
|
|
122
|
+
],
|
|
123
|
+
stdout: (argv: readonly string[]) => string | undefined = () => undefined,
|
|
124
|
+
) {
|
|
125
|
+
const prompts: string[] = []
|
|
126
|
+
on("process.run", (_$, e) => ({
|
|
127
|
+
value: {
|
|
128
|
+
exitCode: 0,
|
|
129
|
+
stdout: stdout(e.argv) ?? STDOUT[`${e.argv[0]} ${e.argv[1]}`] ?? "",
|
|
130
|
+
stderr: "",
|
|
131
|
+
isStdoutTruncated: false,
|
|
132
|
+
isStderrTruncated: false,
|
|
133
|
+
},
|
|
134
|
+
}))
|
|
135
|
+
on("session.messages", () => ({ value: messages }))
|
|
136
|
+
on("model.complete", (_$, e) => {
|
|
137
|
+
prompts.push(String(e.prompt))
|
|
138
|
+
return {
|
|
139
|
+
value: {
|
|
140
|
+
isAnswered: true,
|
|
141
|
+
text: '{"goal":"Ship the thing.","steps":[{"text":"Write it","status":"done"},{"text":"Review it","status":"current"},{"text":"Merge it","status":"todo"}]}',
|
|
142
|
+
usage: {
|
|
143
|
+
input_tokens: 1,
|
|
144
|
+
output_tokens: 1,
|
|
145
|
+
cache_read_input_tokens: 0,
|
|
146
|
+
cache_creation_input_tokens: 0,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
on("ui.open", (_$, e) => {
|
|
152
|
+
opened.push(e.id)
|
|
153
|
+
return { value: { isPlaced: true } }
|
|
154
|
+
})
|
|
155
|
+
on("ui.panes", () => ({ value: [] }))
|
|
156
|
+
on("command.register", (_$, e) => ({ value: { command: e.name } }))
|
|
157
|
+
on("session.start", (_$, e) => ({ cwd: e.cwd }))
|
|
158
|
+
return prompts
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const PANE = {
|
|
162
|
+
plugin: "work-items",
|
|
163
|
+
component: "Pane",
|
|
164
|
+
requestId: "work-items",
|
|
165
|
+
props: {
|
|
166
|
+
title: "Work",
|
|
167
|
+
isFocused: true,
|
|
168
|
+
bodyColumns: 64,
|
|
169
|
+
placement: "dock",
|
|
170
|
+
scroll: { offset: 0, bodyRows: 30 },
|
|
171
|
+
view: {},
|
|
172
|
+
},
|
|
173
|
+
} as const
|
|
174
|
+
|
|
175
|
+
test("Refresh fills PRs, items and the summary, each PR linked", async ($, on) => {
|
|
176
|
+
const prompts = fakeWorld(on)
|
|
177
|
+
const first = await $.ui.mount({ ...PANE, surface: "terminal" })
|
|
178
|
+
expect(
|
|
179
|
+
await first.find({ type: "Text", text: /Press Refresh/ }),
|
|
180
|
+
).toBeDefined()
|
|
181
|
+
await first.unmount()
|
|
182
|
+
|
|
183
|
+
for (const surface of ["terminal", "desktop"] as const) {
|
|
184
|
+
const ui = await $.ui.mount({ ...PANE, surface })
|
|
185
|
+
await ui.press({ key: "refresh" })
|
|
186
|
+
|
|
187
|
+
const links = await ui.findAll({ type: "Link" })
|
|
188
|
+
expect(links.map((link) => link.props.href)).toEqual([
|
|
189
|
+
"https://github.com/o/repo/pull/7",
|
|
190
|
+
"https://github.com/o/repo/pull/12",
|
|
191
|
+
"https://github.com/o/repo/issues/5",
|
|
192
|
+
])
|
|
193
|
+
expect(
|
|
194
|
+
await ui.find({ type: "Text", text: /review required/ }),
|
|
195
|
+
).toBeDefined()
|
|
196
|
+
expect(await ui.find({ type: "Text", text: /3 comments/ })).toBeDefined()
|
|
197
|
+
expect(
|
|
198
|
+
await ui.find({ type: "Text", text: /Ship the thing\./ }),
|
|
199
|
+
).toBeDefined()
|
|
200
|
+
expect(await ui.find({ type: "Text", text: /● Review it/ })).toBeDefined()
|
|
201
|
+
expect(await ui.find({ type: "Text", text: /○ Merge it/ })).toBeDefined()
|
|
202
|
+
expect(
|
|
203
|
+
await ui.find({ type: "Text", text: /^PRs updated \d+s ago$/ }),
|
|
204
|
+
).toBeDefined()
|
|
205
|
+
|
|
206
|
+
await ui.press({ key: "toggle-7" })
|
|
207
|
+
expect(await ui.find({ type: "Text", text: /3 comments/ })).toBeUndefined()
|
|
208
|
+
await ui.press({ key: "toggle-7" })
|
|
209
|
+
await ui.unmount()
|
|
210
|
+
}
|
|
211
|
+
expect(prompts.length).toBe(2)
|
|
212
|
+
expect(prompts[0]).toContain("pull/12")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("Refresh skips the model while the session has no messages", async ($, on) => {
|
|
216
|
+
const prompts = fakeWorld(on, [], [])
|
|
217
|
+
const ui = await $.ui.mount({ ...PANE, surface: "terminal" })
|
|
218
|
+
await ui.press({ key: "refresh" })
|
|
219
|
+
expect(prompts).toEqual([])
|
|
220
|
+
expect(
|
|
221
|
+
await ui.find({ type: "Text", text: /Nothing to summarize yet/ }),
|
|
222
|
+
).toBeDefined()
|
|
223
|
+
await ui.unmount()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
function fakeSession(on: On, start: { id: string; turns: number }) {
|
|
227
|
+
const session = { ...start }
|
|
228
|
+
on("session.id", () => ({ value: session.id }))
|
|
229
|
+
on("session.turns", () => ({ value: session.turns }))
|
|
230
|
+
return session
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const START = {
|
|
234
|
+
cwd: "/repo",
|
|
235
|
+
surface: "terminal",
|
|
236
|
+
isInteractive: true,
|
|
237
|
+
} as const
|
|
238
|
+
|
|
239
|
+
test("a branch the session only started on drops off; one it committed to stays", async ($, on) => {
|
|
240
|
+
let branch = "old-pr"
|
|
241
|
+
const queried: string[][] = []
|
|
242
|
+
const committedTo = new Set<string>()
|
|
243
|
+
fakeWorld(on, [], [], (argv) => {
|
|
244
|
+
if (argv[0] === "git" && argv[1] === "branch") return `${branch}\n`
|
|
245
|
+
if (argv[0] === "git" && argv[1] === "log" && argv[2] === "-1")
|
|
246
|
+
return committedTo.has(argv.at(-1) ?? "") ? "abc\n" : ""
|
|
247
|
+
if (argv[0] === "gh") queried.push(argv.filter((arg) => /^b\d+=/.test(arg)))
|
|
248
|
+
return undefined
|
|
249
|
+
})
|
|
250
|
+
const ui = await $.ui.mount({ ...PANE, surface: "terminal" })
|
|
251
|
+
await ui.press({ key: "refresh" })
|
|
252
|
+
expect(queried.at(-1)).toEqual(["b0=old-pr"])
|
|
253
|
+
|
|
254
|
+
branch = "stacked"
|
|
255
|
+
committedTo.add("stacked")
|
|
256
|
+
await ui.press({ key: "refresh" })
|
|
257
|
+
branch = "new-feature"
|
|
258
|
+
await ui.press({ key: "refresh" })
|
|
259
|
+
expect(queried.at(-1)).toEqual(["b0=new-feature", "b1=stacked"])
|
|
260
|
+
await ui.unmount()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test("a new session never opens the pane by itself, even after its own turns", async ($, on) => {
|
|
264
|
+
const clock = mock.clock(on)
|
|
265
|
+
const opened: string[] = []
|
|
266
|
+
fakeWorld(on, opened)
|
|
267
|
+
const session = fakeSession(on, { id: "a", turns: 0 })
|
|
268
|
+
on("prompt.submit", (_$, e) => ({ text: e.text }))
|
|
269
|
+
await $.session.start(START)
|
|
270
|
+
await clock.settle()
|
|
271
|
+
await $.prompt.submit({
|
|
272
|
+
text: "hi",
|
|
273
|
+
wait: false,
|
|
274
|
+
origin: { kind: "composer" },
|
|
275
|
+
})
|
|
276
|
+
session.turns = 1
|
|
277
|
+
await clock.advance(10_000)
|
|
278
|
+
expect(opened).toEqual([])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test("--resume opens the pane at start and summarizes once", async ($, on) => {
|
|
282
|
+
const clock = mock.clock(on)
|
|
283
|
+
const opened: string[] = []
|
|
284
|
+
const prompts = fakeWorld(on, opened)
|
|
285
|
+
fakeSession(on, { id: "a", turns: 4 })
|
|
286
|
+
await $.session.start(START)
|
|
287
|
+
await clock.settle()
|
|
288
|
+
expect(opened).toEqual(["work-items"])
|
|
289
|
+
expect(prompts.length).toBe(1)
|
|
290
|
+
await clock.advance(10_000)
|
|
291
|
+
expect(opened).toEqual(["work-items"])
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("the picker and /resume open the pane once the resumed turns load", async ($, on) => {
|
|
295
|
+
const clock = mock.clock(on)
|
|
296
|
+
const opened: string[] = []
|
|
297
|
+
fakeWorld(on, opened)
|
|
298
|
+
const session = fakeSession(on, { id: "picker", turns: 0 })
|
|
299
|
+
await $.session.start(START)
|
|
300
|
+
await clock.settle()
|
|
301
|
+
expect(opened).toEqual([])
|
|
302
|
+
session.id = "resumed"
|
|
303
|
+
session.turns = 6
|
|
304
|
+
await clock.advance(5_000)
|
|
305
|
+
expect(opened).toEqual(["work-items"])
|
|
306
|
+
})
|