@app-vitals/agent-runtime 0.0.1 → 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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/format.ts
|
|
3
|
+
* markdownToSlack: pure function, no mocking needed.
|
|
4
|
+
*
|
|
5
|
+
* Note on conversion order:
|
|
6
|
+
* The pipeline is: tables → italic(*→_) → headings(*) → bold(**→*) → code → links → hr
|
|
7
|
+
* The italic pass runs first (on the original markdown) so that the lookbehind/lookahead
|
|
8
|
+
* can distinguish *italic* from **bold**. Headings and **bold** become *text* (Slack bold),
|
|
9
|
+
* while *italic* becomes _text_ (Slack italic).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
formatPlanLink,
|
|
15
|
+
markdownToBlocks,
|
|
16
|
+
markdownToSlack,
|
|
17
|
+
richTextToMarkdown,
|
|
18
|
+
} from "./format.ts";
|
|
19
|
+
|
|
20
|
+
describe("markdownToSlack", () => {
|
|
21
|
+
// ─── Headings (become *bold* in Slack) ────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
test("converts h1 heading — wraps in asterisks (Slack bold)", () => {
|
|
24
|
+
expect(markdownToSlack("# Hello World")).toBe("*Hello World*");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("converts h2 heading — wraps in asterisks", () => {
|
|
28
|
+
expect(markdownToSlack("## Section Title")).toBe("*Section Title*");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("converts h6 heading — wraps in asterisks", () => {
|
|
32
|
+
expect(markdownToSlack("###### Deep Heading")).toBe("*Deep Heading*");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── Bold (becomes *bold* in Slack) ───────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
test("converts **bold** — becomes *bold* in Slack", () => {
|
|
38
|
+
expect(markdownToSlack("This is **bold** text")).toBe(
|
|
39
|
+
"This is *bold* text",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("handles multiple **bold** spans", () => {
|
|
44
|
+
expect(markdownToSlack("**first** and **second**")).toBe(
|
|
45
|
+
"*first* and *second*",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ─── Italic ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
test("converts *italic* to _italic_", () => {
|
|
52
|
+
expect(markdownToSlack("This is *italic* text")).toBe(
|
|
53
|
+
"This is _italic_ text",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Inline code ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test("preserves inline code backticks unchanged", () => {
|
|
60
|
+
expect(markdownToSlack("Use `npm install` to install")).toBe(
|
|
61
|
+
"Use `npm install` to install",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ─── Links ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
test("converts [text](url) to Slack <url|text> format", () => {
|
|
68
|
+
expect(markdownToSlack("[Click here](https://example.com)")).toBe(
|
|
69
|
+
"<https://example.com|Click here>",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("handles multiple links", () => {
|
|
74
|
+
const input = "[A](https://a.com) and [B](https://b.com)";
|
|
75
|
+
expect(markdownToSlack(input)).toBe(
|
|
76
|
+
"<https://a.com|A> and <https://b.com|B>",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ─── Horizontal rules ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
test("converts --- to unicode line", () => {
|
|
83
|
+
expect(markdownToSlack("---")).toBe("─────────────────────");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("converts longer --- to unicode line", () => {
|
|
87
|
+
expect(markdownToSlack("------")).toBe("─────────────────────");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ─── Tables (require tight separators like |---|---| without spaces) ───────
|
|
91
|
+
|
|
92
|
+
test("converts markdown table with tight separator to code block", () => {
|
|
93
|
+
const table = "| Name | Value |\n|---|---|\n| foo | bar |\n";
|
|
94
|
+
const result = markdownToSlack(table);
|
|
95
|
+
expect(result).toContain("```");
|
|
96
|
+
expect(result).toContain("| Name | Value |");
|
|
97
|
+
expect(result).toContain("| foo | bar |");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("converts markdown table with spaced separator | --- | --- |", () => {
|
|
101
|
+
const table = "| Name | Value |\n| --- | --- |\n| foo | bar |\n";
|
|
102
|
+
const result = markdownToSlack(table);
|
|
103
|
+
expect(result).toContain("```");
|
|
104
|
+
expect(result).toContain("| Name | Value |");
|
|
105
|
+
expect(result).toContain("| foo | bar |");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── Trim ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
test("trims leading and trailing whitespace", () => {
|
|
111
|
+
expect(markdownToSlack(" hello ")).toBe("hello");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Combined ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
test("handles multi-line message with heading, bold, and link", () => {
|
|
117
|
+
const input = [
|
|
118
|
+
"# Report",
|
|
119
|
+
"",
|
|
120
|
+
"This is **important** information.",
|
|
121
|
+
"See [docs](https://docs.example.com) for details.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
|
|
124
|
+
const result = markdownToSlack(input);
|
|
125
|
+
expect(result).toContain("*Report*");
|
|
126
|
+
expect(result).toContain("*important*");
|
|
127
|
+
expect(result).toContain("<https://docs.example.com|docs>");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("passes through plain text unchanged", () => {
|
|
131
|
+
expect(markdownToSlack("plain text with no markdown")).toBe(
|
|
132
|
+
"plain text with no markdown",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("empty string returns empty string", () => {
|
|
137
|
+
expect(markdownToSlack("")).toBe("");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("markdownToBlocks", () => {
|
|
142
|
+
test("returns null when no table is present", () => {
|
|
143
|
+
expect(markdownToBlocks("just some *bold* text")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("builds a table block from a markdown table", () => {
|
|
147
|
+
const result = markdownToBlocks(
|
|
148
|
+
"| Name | Value |\n|---|---|\n| foo | bar |\n",
|
|
149
|
+
);
|
|
150
|
+
expect(result).not.toBeNull();
|
|
151
|
+
const table = result?.blocks.find((b) => b.type === "table") as
|
|
152
|
+
| { type: "table"; rows: Array<Array<{ type: string; text: string }>> }
|
|
153
|
+
| undefined;
|
|
154
|
+
expect(table).toBeDefined();
|
|
155
|
+
expect(table?.rows).toEqual([
|
|
156
|
+
[
|
|
157
|
+
{ type: "raw_text", text: "Name" },
|
|
158
|
+
{ type: "raw_text", text: "Value" },
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
{ type: "raw_text", text: "foo" },
|
|
162
|
+
{ type: "raw_text", text: "bar" },
|
|
163
|
+
],
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("coerces an empty corner/header cell to a non-empty placeholder (Slack rejects 0-char cells)", () => {
|
|
168
|
+
// Blank leading header cell — a common markdown table shape that previously
|
|
169
|
+
// produced text: "" and failed Slack with invalid_blocks.
|
|
170
|
+
const result = markdownToBlocks(
|
|
171
|
+
"| | Col A | Col B |\n|---|---|---|\n| Row 1 | x | y |\n",
|
|
172
|
+
);
|
|
173
|
+
const table = result?.blocks.find((b) => b.type === "table") as
|
|
174
|
+
| { type: "table"; rows: Array<Array<{ type: string; text: string }>> }
|
|
175
|
+
| undefined;
|
|
176
|
+
expect(table).toBeDefined();
|
|
177
|
+
const allCells = table?.rows.flat() ?? [];
|
|
178
|
+
// No cell may have empty text.
|
|
179
|
+
expect(allCells.every((c) => c.text.length > 0)).toBe(true);
|
|
180
|
+
// The blank corner cell specifically became the placeholder.
|
|
181
|
+
expect(table?.rows[0][0]).toEqual({ type: "raw_text", text: " " });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("richTextToMarkdown", () => {
|
|
186
|
+
// ─── Empty / no-op inputs ─────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
test("empty array returns empty string", () => {
|
|
189
|
+
expect(richTextToMarkdown([])).toBe("");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("array with no rich_text block returns empty string", () => {
|
|
193
|
+
expect(richTextToMarkdown([{ type: "section", text: "hello" }])).toBe("");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("null and non-object entries in blocks array are ignored, not thrown", () => {
|
|
197
|
+
expect(
|
|
198
|
+
richTextToMarkdown([
|
|
199
|
+
null,
|
|
200
|
+
"not an object",
|
|
201
|
+
42,
|
|
202
|
+
{
|
|
203
|
+
type: "rich_text",
|
|
204
|
+
elements: [
|
|
205
|
+
{
|
|
206
|
+
type: "rich_text_section",
|
|
207
|
+
elements: [{ type: "text", text: "hello" }],
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
]),
|
|
212
|
+
).toBe("hello");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ─── Plain text section ───────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
test("plain text section returns the text", () => {
|
|
218
|
+
expect(
|
|
219
|
+
richTextToMarkdown([
|
|
220
|
+
{
|
|
221
|
+
type: "rich_text",
|
|
222
|
+
elements: [
|
|
223
|
+
{
|
|
224
|
+
type: "rich_text_section",
|
|
225
|
+
elements: [{ type: "text", text: "hello" }],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
]),
|
|
230
|
+
).toBe("hello");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─── Inline text style flags ──────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
test("bold text wrapped in **", () => {
|
|
236
|
+
expect(
|
|
237
|
+
richTextToMarkdown([
|
|
238
|
+
{
|
|
239
|
+
type: "rich_text",
|
|
240
|
+
elements: [
|
|
241
|
+
{
|
|
242
|
+
type: "rich_text_section",
|
|
243
|
+
elements: [{ type: "text", text: "bold", style: { bold: true } }],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
]),
|
|
248
|
+
).toBe("**bold**");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("italic text wrapped in *", () => {
|
|
252
|
+
expect(
|
|
253
|
+
richTextToMarkdown([
|
|
254
|
+
{
|
|
255
|
+
type: "rich_text",
|
|
256
|
+
elements: [
|
|
257
|
+
{
|
|
258
|
+
type: "rich_text_section",
|
|
259
|
+
elements: [
|
|
260
|
+
{ type: "text", text: "italic", style: { italic: true } },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
]),
|
|
266
|
+
).toBe("*italic*");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("code text wrapped in backticks", () => {
|
|
270
|
+
expect(
|
|
271
|
+
richTextToMarkdown([
|
|
272
|
+
{
|
|
273
|
+
type: "rich_text",
|
|
274
|
+
elements: [
|
|
275
|
+
{
|
|
276
|
+
type: "rich_text_section",
|
|
277
|
+
elements: [{ type: "text", text: "code", style: { code: true } }],
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
]),
|
|
282
|
+
).toBe("`code`");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("strikethrough text wrapped in ~~", () => {
|
|
286
|
+
expect(
|
|
287
|
+
richTextToMarkdown([
|
|
288
|
+
{
|
|
289
|
+
type: "rich_text",
|
|
290
|
+
elements: [
|
|
291
|
+
{
|
|
292
|
+
type: "rich_text_section",
|
|
293
|
+
elements: [
|
|
294
|
+
{ type: "text", text: "strike", style: { strike: true } },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
]),
|
|
300
|
+
).toBe("~~strike~~");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ─── Preformatted ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
test("preformatted block produces fenced code block", () => {
|
|
306
|
+
expect(
|
|
307
|
+
richTextToMarkdown([
|
|
308
|
+
{
|
|
309
|
+
type: "rich_text",
|
|
310
|
+
elements: [
|
|
311
|
+
{
|
|
312
|
+
type: "rich_text_preformatted",
|
|
313
|
+
elements: [{ type: "text", text: "code here" }],
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
]),
|
|
318
|
+
).toBe("```\ncode here\n```");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ─── Lists ────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
test("bullet list produces - items", () => {
|
|
324
|
+
expect(
|
|
325
|
+
richTextToMarkdown([
|
|
326
|
+
{
|
|
327
|
+
type: "rich_text",
|
|
328
|
+
elements: [
|
|
329
|
+
{
|
|
330
|
+
type: "rich_text_list",
|
|
331
|
+
style: "bullet",
|
|
332
|
+
elements: [
|
|
333
|
+
{
|
|
334
|
+
type: "rich_text_section",
|
|
335
|
+
elements: [{ type: "text", text: "item1" }],
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
type: "rich_text_section",
|
|
339
|
+
elements: [{ type: "text", text: "item2" }],
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
]),
|
|
346
|
+
).toBe("- item1\n- item2");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("ordered list produces numbered items", () => {
|
|
350
|
+
expect(
|
|
351
|
+
richTextToMarkdown([
|
|
352
|
+
{
|
|
353
|
+
type: "rich_text",
|
|
354
|
+
elements: [
|
|
355
|
+
{
|
|
356
|
+
type: "rich_text_list",
|
|
357
|
+
style: "ordered",
|
|
358
|
+
elements: [
|
|
359
|
+
{
|
|
360
|
+
type: "rich_text_section",
|
|
361
|
+
elements: [{ type: "text", text: "item1" }],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: "rich_text_section",
|
|
365
|
+
elements: [{ type: "text", text: "item2" }],
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
]),
|
|
372
|
+
).toBe("1. item1\n2. item2");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ─── Link elements ────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
test("link with text renders as [text](url)", () => {
|
|
378
|
+
expect(
|
|
379
|
+
richTextToMarkdown([
|
|
380
|
+
{
|
|
381
|
+
type: "rich_text",
|
|
382
|
+
elements: [
|
|
383
|
+
{
|
|
384
|
+
type: "rich_text_section",
|
|
385
|
+
elements: [
|
|
386
|
+
{ type: "link", text: "click", url: "https://example.com" },
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
]),
|
|
392
|
+
).toBe("[click](https://example.com)");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("link without text renders as bare url", () => {
|
|
396
|
+
expect(
|
|
397
|
+
richTextToMarkdown([
|
|
398
|
+
{
|
|
399
|
+
type: "rich_text",
|
|
400
|
+
elements: [
|
|
401
|
+
{
|
|
402
|
+
type: "rich_text_section",
|
|
403
|
+
elements: [{ type: "link", url: "https://example.com" }],
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
]),
|
|
408
|
+
).toBe("https://example.com");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ─── Emoji and user mention ───────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
test("emoji element renders as :name:", () => {
|
|
414
|
+
expect(
|
|
415
|
+
richTextToMarkdown([
|
|
416
|
+
{
|
|
417
|
+
type: "rich_text",
|
|
418
|
+
elements: [
|
|
419
|
+
{
|
|
420
|
+
type: "rich_text_section",
|
|
421
|
+
elements: [{ type: "emoji", name: "wave" }],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
]),
|
|
426
|
+
).toBe(":wave:");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("user element renders as <@user_id>", () => {
|
|
430
|
+
expect(
|
|
431
|
+
richTextToMarkdown([
|
|
432
|
+
{
|
|
433
|
+
type: "rich_text",
|
|
434
|
+
elements: [
|
|
435
|
+
{
|
|
436
|
+
type: "rich_text_section",
|
|
437
|
+
elements: [{ type: "user", user_id: "U123" }],
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
},
|
|
441
|
+
]),
|
|
442
|
+
).toBe("<@U123>");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ─── Multiple sections joined by newlines ─────────────────────────────────
|
|
446
|
+
|
|
447
|
+
test("multiple rich_text_sections joined by blank line (paragraph break)", () => {
|
|
448
|
+
expect(
|
|
449
|
+
richTextToMarkdown([
|
|
450
|
+
{
|
|
451
|
+
type: "rich_text",
|
|
452
|
+
elements: [
|
|
453
|
+
{
|
|
454
|
+
type: "rich_text_section",
|
|
455
|
+
elements: [{ type: "text", text: "line one" }],
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
type: "rich_text_section",
|
|
459
|
+
elements: [{ type: "text", text: "line two" }],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
},
|
|
463
|
+
]),
|
|
464
|
+
).toBe("line one\n\nline two");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ─── Quote ────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
test("rich_text_quote renders as blockquote", () => {
|
|
470
|
+
expect(
|
|
471
|
+
richTextToMarkdown([
|
|
472
|
+
{
|
|
473
|
+
type: "rich_text",
|
|
474
|
+
elements: [
|
|
475
|
+
{
|
|
476
|
+
type: "rich_text_quote",
|
|
477
|
+
elements: [{ type: "text", text: "quoted text" }],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
]),
|
|
482
|
+
).toBe("> quoted text");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("rich_text_quote with embedded newlines prefixes every line with >", () => {
|
|
486
|
+
expect(
|
|
487
|
+
richTextToMarkdown([
|
|
488
|
+
{
|
|
489
|
+
type: "rich_text",
|
|
490
|
+
elements: [
|
|
491
|
+
{
|
|
492
|
+
type: "rich_text_quote",
|
|
493
|
+
elements: [{ type: "text", text: "line one\nline two" }],
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
]),
|
|
498
|
+
).toBe("> line one\n> line two");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ─── Combined block ───────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
test("combined block: section + preformatted + list", () => {
|
|
504
|
+
expect(
|
|
505
|
+
richTextToMarkdown([
|
|
506
|
+
{
|
|
507
|
+
type: "rich_text",
|
|
508
|
+
elements: [
|
|
509
|
+
{
|
|
510
|
+
type: "rich_text_section",
|
|
511
|
+
elements: [{ type: "text", text: "intro" }],
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
type: "rich_text_preformatted",
|
|
515
|
+
elements: [{ type: "text", text: "code block" }],
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
type: "rich_text_list",
|
|
519
|
+
style: "bullet",
|
|
520
|
+
elements: [
|
|
521
|
+
{
|
|
522
|
+
type: "rich_text_section",
|
|
523
|
+
elements: [{ type: "text", text: "item" }],
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
]),
|
|
530
|
+
).toBe("intro\n\n```\ncode block\n```\n\n- item");
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe("formatPlanLink", () => {
|
|
535
|
+
const url = "https://example.com/p/abc";
|
|
536
|
+
|
|
537
|
+
test("returns exactly one section block", () => {
|
|
538
|
+
const { blocks } = formatPlanLink(url);
|
|
539
|
+
expect(blocks).toHaveLength(1);
|
|
540
|
+
expect(blocks[0].type).toBe("section");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("section mrkdwn contains the url and 'View plan'", () => {
|
|
544
|
+
const { blocks } = formatPlanLink(url);
|
|
545
|
+
const block = blocks[0] as {
|
|
546
|
+
type: "section";
|
|
547
|
+
text: { type: "mrkdwn"; text: string };
|
|
548
|
+
};
|
|
549
|
+
expect(block.text.type).toBe("mrkdwn");
|
|
550
|
+
expect(block.text.text).toContain(url);
|
|
551
|
+
expect(block.text.text).toContain("View plan");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("fallback text contains the url", () => {
|
|
555
|
+
const { text } = formatPlanLink(url);
|
|
556
|
+
expect(text).toContain(url);
|
|
557
|
+
});
|
|
558
|
+
});
|
package/gh-helpers.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/gh-helpers.ts
|
|
3
|
+
*
|
|
4
|
+
* Generic `gh` CLI helpers (ghJson, ghRun, ghGraphql, getCurrentUser),
|
|
5
|
+
* split out of check-helpers.ts (ARD-4.1). Runtime-side: no coding-domain
|
|
6
|
+
* knowledge. check-helpers.ts re-exports these for existing importers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spawn env for a gh call. The agent process has no ambient org, so every
|
|
11
|
+
* helper requires `owner` (the org of the task or PR being handled) and
|
|
12
|
+
* passes it to the `gh` wrapper via SHIPWRIGHT_GH_OWNER.
|
|
13
|
+
*/
|
|
14
|
+
function ghEnv(owner: string): Record<string, string | undefined> {
|
|
15
|
+
return { ...process.env, SHIPWRIGHT_GH_OWNER: owner };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The owner (org) half of an `org/repo` slug. Throws when the slug has no
|
|
20
|
+
* org: callers must not guess one.
|
|
21
|
+
*/
|
|
22
|
+
export function ownerOfRepo(repo: string): string {
|
|
23
|
+
const idx = repo.indexOf("/");
|
|
24
|
+
if (idx < 1) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Cannot derive GitHub owner from repo "${repo}" (expected org/repo)`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return repo.slice(0, idx);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a gh CLI command and return the parsed JSON output.
|
|
34
|
+
* Throws on non-zero exit.
|
|
35
|
+
*/
|
|
36
|
+
export async function ghJson<T>(args: string[], owner: string): Promise<T> {
|
|
37
|
+
const proc = Bun.spawn(["gh", ...args], {
|
|
38
|
+
env: ghEnv(owner),
|
|
39
|
+
stdout: "pipe",
|
|
40
|
+
stderr: "pipe",
|
|
41
|
+
});
|
|
42
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
43
|
+
new Response(proc.stdout).text(),
|
|
44
|
+
new Response(proc.stderr).text(),
|
|
45
|
+
proc.exited,
|
|
46
|
+
]);
|
|
47
|
+
if (status !== 0) {
|
|
48
|
+
throw new Error(`gh ${args.join(" ")} failed (exit ${status}): ${stderr}`);
|
|
49
|
+
}
|
|
50
|
+
return JSON.parse(stdout) as T;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run a gh CLI command without parsing output.
|
|
55
|
+
* Throws on non-zero exit.
|
|
56
|
+
*/
|
|
57
|
+
export async function ghRun(args: string[], owner: string): Promise<void> {
|
|
58
|
+
const proc = Bun.spawn(["gh", ...args], {
|
|
59
|
+
env: ghEnv(owner),
|
|
60
|
+
stdout: "pipe",
|
|
61
|
+
stderr: "pipe",
|
|
62
|
+
});
|
|
63
|
+
const [stderr, status] = await Promise.all([
|
|
64
|
+
new Response(proc.stderr).text(),
|
|
65
|
+
proc.exited,
|
|
66
|
+
]);
|
|
67
|
+
if (status !== 0) {
|
|
68
|
+
throw new Error(`gh ${args.join(" ")} failed (exit ${status}): ${stderr}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const GH_GRAPHQL_MAX_ATTEMPTS = 3;
|
|
73
|
+
const GH_GRAPHQL_BASE_DELAY_MS = 500;
|
|
74
|
+
const GH_TRANSIENT_STDERR = /HTTP 50[234]\b|timed? ?out/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run a gh API graphql command and return the raw response.
|
|
78
|
+
*
|
|
79
|
+
* Retries (exponential backoff) when gh fails with a transient gateway error
|
|
80
|
+
* (HTTP 502/503/504 or a timeout). Any other failure — auth, query errors —
|
|
81
|
+
* throws immediately. After exhausting retries the last error is thrown in
|
|
82
|
+
* the same format as a non-retried failure.
|
|
83
|
+
*
|
|
84
|
+
* `owner` is required and set as SHIPWRIGHT_GH_OWNER in the spawned env for
|
|
85
|
+
* the `gh` wrapper's token routing.
|
|
86
|
+
*/
|
|
87
|
+
export async function ghGraphql<T>(
|
|
88
|
+
query: string,
|
|
89
|
+
owner: string,
|
|
90
|
+
opts: { baseDelayMs?: number } = {},
|
|
91
|
+
): Promise<T> {
|
|
92
|
+
const baseDelayMs = opts.baseDelayMs ?? GH_GRAPHQL_BASE_DELAY_MS;
|
|
93
|
+
const env = ghEnv(owner);
|
|
94
|
+
for (let attempt = 1; ; attempt++) {
|
|
95
|
+
const proc = Bun.spawn(["gh", "api", "graphql", "-f", `query=${query}`], {
|
|
96
|
+
env,
|
|
97
|
+
stdout: "pipe",
|
|
98
|
+
stderr: "pipe",
|
|
99
|
+
});
|
|
100
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
101
|
+
new Response(proc.stdout).text(),
|
|
102
|
+
new Response(proc.stderr).text(),
|
|
103
|
+
proc.exited,
|
|
104
|
+
]);
|
|
105
|
+
if (status === 0) {
|
|
106
|
+
return JSON.parse(stdout) as T;
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
attempt >= GH_GRAPHQL_MAX_ATTEMPTS ||
|
|
110
|
+
!GH_TRANSIENT_STDERR.test(stderr)
|
|
111
|
+
) {
|
|
112
|
+
throw new Error(`gh api graphql failed (exit ${status}): ${stderr}`);
|
|
113
|
+
}
|
|
114
|
+
await Bun.sleep(baseDelayMs * 2 ** (attempt - 1));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the current authenticated GH user login.
|
|
120
|
+
*
|
|
121
|
+
* Uses GraphQL viewer (not REST /user) because installation tokens issued to
|
|
122
|
+
* GitHub Apps are rejected by /user with 403; the viewer query resolves to the
|
|
123
|
+
* app's bot identity under both PAT and installation-token auth.
|
|
124
|
+
*
|
|
125
|
+
* Normalises the bot identity format: GraphQL returns "name[bot]" but
|
|
126
|
+
* pr.author.login (and gh pr list --author) use "app/name", so we convert
|
|
127
|
+
* here once so every call-site sees a consistent format.
|
|
128
|
+
*
|
|
129
|
+
* `owner` is only the token-routing hint: the viewer identity is the same
|
|
130
|
+
* under any installation token, so the caller passes whichever org it has.
|
|
131
|
+
*/
|
|
132
|
+
export async function getCurrentUser(owner: string): Promise<string> {
|
|
133
|
+
const result = await ghGraphql<{ data: { viewer: { login: string } } }>(
|
|
134
|
+
"query { viewer { login } }",
|
|
135
|
+
owner,
|
|
136
|
+
);
|
|
137
|
+
const login = result.data.viewer.login;
|
|
138
|
+
return login.endsWith("[bot]") ? `app/${login.slice(0, -5)}` : login;
|
|
139
|
+
}
|