agent-dealer 1.2.12 → 1.2.14
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/bundle/server/dist/adapters/git-worktree.js +15 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
- package/bundle/server/dist/adapters/linear-inbox.js +169 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +481 -1
- package/bundle/server/dist/adapters/linear-sync.js +33 -10
- package/bundle/server/dist/coordinator/auto-merge.js +10 -0
- package/bundle/server/dist/coordinator/branch-progress.js +5 -4
- package/bundle/server/dist/coordinator/commands.js +37 -3
- package/bundle/server/dist/coordinator/developer-effect.js +33 -6
- package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
- package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
- package/bundle/server/dist/coordinator/prompts.js +9 -1
- package/bundle/server/dist/coordinator/prompts.test.js +93 -0
- package/bundle/server/dist/coordinator/source-attachments.js +473 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +284 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/schema.sql +28 -0
- package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
- package/bundle/server/dist/paths.js +12 -0
- package/bundle/server/dist/repository/intake-settings.js +50 -0
- package/bundle/server/dist/repository/intake-settings.test.js +72 -0
- package/bundle/server/dist/repository/source-attachments.js +56 -0
- package/bundle/server/dist/routes/human-actions.js +17 -0
- package/bundle/server/dist/routes/index.js +6 -3
- package/bundle/server/dist/routes/intake-linear-config.js +31 -0
- package/bundle/server/dist/routes/intake-linear.test.js +95 -5
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +411 -0
- package/bundle/server/dist/routes/issues.js +89 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/assets/index-DDbJ9ZU7.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +411 -2
- package/bundle/shared/dist/index.js +59 -1
- package/bundle/shared/dist/issues.d.ts +43 -2
- package/bundle/shared/dist/issues.js +10 -0
- package/bundle/shared/dist/linear-intake.d.ts +99 -0
- package/bundle/shared/dist/linear-intake.js +79 -0
- package/bundle/shared/dist/linear-intake.test.js +95 -2
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +7 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CBdkhTI_.js +0 -64
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { buildIssueFilter, getLinearIssue, LINEAR_CANDIDATE_PAGE_SIZE, listLinearCandidates, fetchLinearIntakeMetadata, nodeToCandidate, parseLinearIssueRef, } from "./linear-inbox.js";
|
|
4
7
|
test("parseLinearIssueRef accepts identifier, URL, and UUID", () => {
|
|
5
8
|
assert.equal(parseLinearIssueRef("NOT-103"), "NOT-103");
|
|
6
9
|
assert.equal(parseLinearIssueRef(" not-90 "), "NOT-90");
|
|
@@ -81,3 +84,480 @@ test("buildIssueFilter includes assignee when assigneeMe is true", () => {
|
|
|
81
84
|
assignee: { id: { eq: "viewer-1" } },
|
|
82
85
|
});
|
|
83
86
|
});
|
|
87
|
+
// NOT-361: bounded candidate fetch — AND filters, updatedAt order, first page only.
|
|
88
|
+
test("listLinearCandidates requests AND filters, updatedAt order, and a single page", async () => {
|
|
89
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-linear-inbox-"));
|
|
90
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
91
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
92
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
93
|
+
const { migrate } = await import("../db/index.js");
|
|
94
|
+
migrate();
|
|
95
|
+
const { patchLinearIntakeConfig } = await import("../repository/intake-settings.js");
|
|
96
|
+
patchLinearIntakeConfig({
|
|
97
|
+
stateFilter: ["Todo", "In Progress"],
|
|
98
|
+
teamId: "team-9",
|
|
99
|
+
assigneeMe: true,
|
|
100
|
+
});
|
|
101
|
+
const requests = [];
|
|
102
|
+
const realFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = (async (_url, init) => {
|
|
104
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
105
|
+
requests.push({ query: body.query ?? "", variables: body.variables });
|
|
106
|
+
if ((body.query ?? "").includes("viewer {")) {
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: new Headers(),
|
|
111
|
+
text: async () => JSON.stringify({ data: { viewer: { id: "viewer-1", name: "Ada" } } }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
headers: new Headers(),
|
|
118
|
+
text: async () => JSON.stringify({
|
|
119
|
+
data: {
|
|
120
|
+
issues: {
|
|
121
|
+
nodes: [
|
|
122
|
+
{
|
|
123
|
+
id: "i1",
|
|
124
|
+
identifier: "NOT-1",
|
|
125
|
+
title: "one",
|
|
126
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
127
|
+
state: { name: "Todo" },
|
|
128
|
+
team: { id: "team-9" },
|
|
129
|
+
labels: { nodes: [] },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
pageInfo: { hasNextPage: true },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
const page = await listLinearCandidates();
|
|
140
|
+
assert.equal(page.candidates.length, 1);
|
|
141
|
+
assert.equal(page.hasMore, true);
|
|
142
|
+
// viewer (assigneeMe) + one issues page — never a second cursor page.
|
|
143
|
+
assert.equal(requests.length, 2);
|
|
144
|
+
const issuesReq = requests.find((r) => r.query.includes("issues("));
|
|
145
|
+
assert.ok(issuesReq, "issues query sent");
|
|
146
|
+
assert.match(issuesReq.query, /orderBy:\s*updatedAt/);
|
|
147
|
+
assert.match(issuesReq.query, new RegExp(`first:\\s*${LINEAR_CANDIDATE_PAGE_SIZE}`));
|
|
148
|
+
assert.ok(!issuesReq.query.includes("$after"), "no cursor variable");
|
|
149
|
+
assert.deepEqual(issuesReq.variables?.filter, {
|
|
150
|
+
state: { name: { in: ["Todo", "In Progress"] } },
|
|
151
|
+
team: { id: { eq: "team-9" } },
|
|
152
|
+
assignee: { id: { eq: "viewer-1" } },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
globalThis.fetch = realFetch;
|
|
157
|
+
delete process.env.LINEAR_API_KEY;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("fetchLinearIntakeMetadata returns teams, statuses, and viewer", async () => {
|
|
161
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
162
|
+
const realFetch = globalThis.fetch;
|
|
163
|
+
const bodies = [];
|
|
164
|
+
globalThis.fetch = (async (_input, init) => {
|
|
165
|
+
bodies.push(JSON.parse(String(init?.body ?? "{}")));
|
|
166
|
+
const op = bodies[bodies.length - 1].query ?? "";
|
|
167
|
+
if (op.includes("IntakeMetadataViewer")) {
|
|
168
|
+
return new Response(JSON.stringify({
|
|
169
|
+
data: { viewer: { id: "v1", name: "Ada", email: "ada@example.com" } },
|
|
170
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
171
|
+
}
|
|
172
|
+
return new Response(JSON.stringify({
|
|
173
|
+
data: {
|
|
174
|
+
teams: {
|
|
175
|
+
nodes: [
|
|
176
|
+
{
|
|
177
|
+
id: "t1",
|
|
178
|
+
name: "Core",
|
|
179
|
+
key: "COR",
|
|
180
|
+
states: {
|
|
181
|
+
nodes: [
|
|
182
|
+
{ name: "Todo", type: "unstarted" },
|
|
183
|
+
{ name: "Done", type: "completed" },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const meta = await fetchLinearIntakeMetadata();
|
|
195
|
+
assert.equal(meta.viewer?.name, "Ada");
|
|
196
|
+
assert.equal(meta.teams[0]?.name, "Core");
|
|
197
|
+
assert.ok(meta.workflowStates.some((s) => s.name === "Todo" && s.teamId === "t1"));
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
globalThis.fetch = realFetch;
|
|
201
|
+
delete process.env.LINEAR_API_KEY;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("fetchLinearIntakeMetadata walks team pages beyond Linear's default first page", async () => {
|
|
205
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
206
|
+
const realFetch = globalThis.fetch;
|
|
207
|
+
let teamPageCalls = 0;
|
|
208
|
+
globalThis.fetch = (async (_input, init) => {
|
|
209
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
210
|
+
if ((body.query ?? "").includes("IntakeMetadataViewer")) {
|
|
211
|
+
return new Response(JSON.stringify({ data: { viewer: { id: "v1", name: "Ada" } } }), {
|
|
212
|
+
status: 200,
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
teamPageCalls += 1;
|
|
217
|
+
assert.equal(body.variables?.first, 50, "requests a bounded team page");
|
|
218
|
+
if (teamPageCalls === 1) {
|
|
219
|
+
assert.equal(body.variables?.after ?? null, null, "first page has no cursor");
|
|
220
|
+
return new Response(JSON.stringify({
|
|
221
|
+
data: {
|
|
222
|
+
teams: {
|
|
223
|
+
nodes: [
|
|
224
|
+
{
|
|
225
|
+
id: "t1",
|
|
226
|
+
name: "Alpha",
|
|
227
|
+
states: { nodes: [{ name: "Todo", type: "unstarted" }] },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
234
|
+
}
|
|
235
|
+
assert.equal(body.variables?.after, "cursor-1", "follows endCursor");
|
|
236
|
+
return new Response(JSON.stringify({
|
|
237
|
+
data: {
|
|
238
|
+
teams: {
|
|
239
|
+
nodes: [
|
|
240
|
+
{
|
|
241
|
+
id: "t2",
|
|
242
|
+
name: "Zeta",
|
|
243
|
+
states: { nodes: [{ name: "In Progress", type: "started" }] },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
const meta = await fetchLinearIntakeMetadata();
|
|
253
|
+
assert.equal(teamPageCalls, 2, "walks a second team page");
|
|
254
|
+
assert.deepEqual(meta.teams.map((t) => t.name), ["Alpha", "Zeta"]);
|
|
255
|
+
assert.ok(meta.workflowStates.some((s) => s.name === "In Progress" && s.teamId === "t2"));
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
globalThis.fetch = realFetch;
|
|
259
|
+
delete process.env.LINEAR_API_KEY;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
test("fetchLinearIntakeMetadata fails clearly without LINEAR_API_KEY", async () => {
|
|
263
|
+
delete process.env.LINEAR_API_KEY;
|
|
264
|
+
await assert.rejects(() => fetchLinearIntakeMetadata(), /LINEAR_API_KEY/);
|
|
265
|
+
});
|
|
266
|
+
// NOT-364: issue attachment metadata on candidates — hosted files and external
|
|
267
|
+
// links, without regressing labels, repo resolution, or the 50-item page bound.
|
|
268
|
+
test("nodeToCandidate carries file and link attachments with stable ids", () => {
|
|
269
|
+
const c = nodeToCandidate({
|
|
270
|
+
id: "uuid-1",
|
|
271
|
+
identifier: "NOT-364",
|
|
272
|
+
title: "t",
|
|
273
|
+
url: "https://linear.app/x/issue/NOT-364/t",
|
|
274
|
+
labels: { nodes: [{ name: "repo:github.com/not-so-fat/agent-dealer" }] },
|
|
275
|
+
attachments: {
|
|
276
|
+
nodes: [
|
|
277
|
+
{
|
|
278
|
+
id: "att-file-1",
|
|
279
|
+
title: "repro.tar.gz",
|
|
280
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "att-link-1",
|
|
284
|
+
title: "Design doc",
|
|
285
|
+
url: "https://docs.example.com/x",
|
|
286
|
+
subtitle: "Spec",
|
|
287
|
+
source: "google-docs",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
assert.equal(c.attachments?.length, 2);
|
|
293
|
+
assert.deepEqual(c.attachments?.[0], {
|
|
294
|
+
id: "att-file-1",
|
|
295
|
+
title: "repro.tar.gz",
|
|
296
|
+
url: "https://uploads.linear.app/abc/repro.tar.gz",
|
|
297
|
+
});
|
|
298
|
+
assert.deepEqual(c.attachments?.[1], {
|
|
299
|
+
id: "att-link-1",
|
|
300
|
+
title: "Design doc",
|
|
301
|
+
url: "https://docs.example.com/x",
|
|
302
|
+
subtitle: "Spec",
|
|
303
|
+
source: "google-docs",
|
|
304
|
+
});
|
|
305
|
+
// Labels and repo resolution are untouched by the attachment connection.
|
|
306
|
+
assert.deepEqual(c.labels, ["repo:github.com/not-so-fat/agent-dealer"]);
|
|
307
|
+
assert.equal(c.repoResolution?.status, "resolved");
|
|
308
|
+
});
|
|
309
|
+
test("nodeToCandidate drops malformed attachments and coerces source metadata", () => {
|
|
310
|
+
const c = nodeToCandidate({
|
|
311
|
+
id: "uuid-1",
|
|
312
|
+
identifier: "NOT-364",
|
|
313
|
+
title: "t",
|
|
314
|
+
url: "https://linear.app/x/issue/NOT-364/t",
|
|
315
|
+
labels: { nodes: [] },
|
|
316
|
+
attachments: {
|
|
317
|
+
nodes: [
|
|
318
|
+
{ id: "", title: "no id", url: "https://uploads.linear.app/x" },
|
|
319
|
+
{ id: "att-no-url", title: "no url", url: "" },
|
|
320
|
+
{ id: "att-obj-source", title: "f", url: "https://uploads.linear.app/f", source: { kind: "slack" } },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
assert.equal(c.attachments?.length, 1);
|
|
325
|
+
assert.equal(c.attachments?.[0]?.id, "att-obj-source");
|
|
326
|
+
assert.equal(c.attachments?.[0]?.source, `{"kind":"slack"}`);
|
|
327
|
+
});
|
|
328
|
+
test("getLinearIssue carries attachment metadata on exact lookup", async () => {
|
|
329
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
330
|
+
const realFetch = globalThis.fetch;
|
|
331
|
+
globalThis.fetch = (async (_url) => ({
|
|
332
|
+
ok: true,
|
|
333
|
+
status: 200,
|
|
334
|
+
headers: new Headers(),
|
|
335
|
+
text: async () => JSON.stringify({
|
|
336
|
+
data: {
|
|
337
|
+
issue: {
|
|
338
|
+
id: "linear-uuid-9",
|
|
339
|
+
identifier: "NOT-9",
|
|
340
|
+
title: "exact",
|
|
341
|
+
url: "https://linear.app/x/issue/NOT-9/exact",
|
|
342
|
+
state: { name: "Todo" },
|
|
343
|
+
team: { id: "team-1" },
|
|
344
|
+
labels: { nodes: [] },
|
|
345
|
+
attachments: {
|
|
346
|
+
nodes: [
|
|
347
|
+
{ id: "a1", title: "repro.tar.gz", url: "https://uploads.linear.app/a/repro.tar.gz" },
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
}));
|
|
354
|
+
try {
|
|
355
|
+
const found = await getLinearIssue("NOT-9");
|
|
356
|
+
assert.equal(found?.identifier, "NOT-9");
|
|
357
|
+
assert.equal(found?.attachments?.length, 1);
|
|
358
|
+
assert.equal(found?.attachments?.[0]?.title, "repro.tar.gz");
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
globalThis.fetch = realFetch;
|
|
362
|
+
delete process.env.LINEAR_API_KEY;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
test("nodeToCandidate defaults missing attachments to an empty list", () => {
|
|
366
|
+
const c = nodeToCandidate({
|
|
367
|
+
id: "uuid-1",
|
|
368
|
+
identifier: "NOT-1",
|
|
369
|
+
title: "t",
|
|
370
|
+
url: "https://linear.app/x/issue/NOT-1/t",
|
|
371
|
+
labels: { nodes: [] },
|
|
372
|
+
});
|
|
373
|
+
assert.deepEqual(c.attachments, []);
|
|
374
|
+
});
|
|
375
|
+
// NOT-364: the candidate page still requests one bounded page and now carries
|
|
376
|
+
// the attachments connection on every issue selection.
|
|
377
|
+
test("listLinearCandidates queries attachments within the single 50-item page", async () => {
|
|
378
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-linear-inbox-att-"));
|
|
379
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
380
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
381
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
382
|
+
const { migrate } = await import("../db/index.js");
|
|
383
|
+
migrate();
|
|
384
|
+
const { patchLinearIntakeConfig } = await import("../repository/intake-settings.js");
|
|
385
|
+
patchLinearIntakeConfig({ stateFilter: ["Todo"], teamId: null, assigneeMe: false });
|
|
386
|
+
const requests = [];
|
|
387
|
+
const realFetch = globalThis.fetch;
|
|
388
|
+
globalThis.fetch = (async (_url, init) => {
|
|
389
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
390
|
+
requests.push({ query: body.query ?? "" });
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
status: 200,
|
|
394
|
+
headers: new Headers(),
|
|
395
|
+
text: async () => JSON.stringify({
|
|
396
|
+
data: {
|
|
397
|
+
issues: {
|
|
398
|
+
nodes: [
|
|
399
|
+
{
|
|
400
|
+
id: "i1",
|
|
401
|
+
identifier: "NOT-1",
|
|
402
|
+
title: "one",
|
|
403
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
404
|
+
state: { name: "Todo" },
|
|
405
|
+
team: { id: "team-9" },
|
|
406
|
+
labels: { nodes: [] },
|
|
407
|
+
attachments: {
|
|
408
|
+
nodes: [
|
|
409
|
+
{ id: "a1", title: "repro.tar.gz", url: "https://uploads.linear.app/a/repro.tar.gz" },
|
|
410
|
+
{ id: "a2", title: "Doc", url: "https://docs.example.com/x", subtitle: "s" },
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
pageInfo: { hasNextPage: false },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
const page = await listLinearCandidates();
|
|
423
|
+
assert.equal(page.candidates.length, 1);
|
|
424
|
+
assert.equal(requests.length, 1, "still a single page — no cursor walk");
|
|
425
|
+
assert.match(requests[0].query, /attachments\s*\{\s*nodes\s*\{\s*id title url subtitle source\s*\}\s*\}/);
|
|
426
|
+
assert.match(requests[0].query, new RegExp(`first:\\s*${LINEAR_CANDIDATE_PAGE_SIZE}`));
|
|
427
|
+
assert.equal(page.candidates[0].attachments?.length, 2);
|
|
428
|
+
assert.equal(page.candidates[0].attachments?.[0]?.id, "a1");
|
|
429
|
+
assert.equal(page.candidates[0].attachments?.[1]?.subtitle, "s");
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
globalThis.fetch = realFetch;
|
|
433
|
+
delete process.env.LINEAR_API_KEY;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
// NOT-362: intake persists Linear's branchName as the Dealer issue's branch.
|
|
437
|
+
async function stubGetLinearIssue(node) {
|
|
438
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
439
|
+
const { migrate } = await import("../db/index.js");
|
|
440
|
+
migrate();
|
|
441
|
+
const realFetch = globalThis.fetch;
|
|
442
|
+
globalThis.__not362RestoreFetch = realFetch;
|
|
443
|
+
globalThis.fetch = (async (_url) => {
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
status: 200,
|
|
447
|
+
headers: new Headers(),
|
|
448
|
+
text: async () => JSON.stringify({ data: { issue: node } }),
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function restoreFetch() {
|
|
453
|
+
const saved = globalThis.__not362RestoreFetch;
|
|
454
|
+
if (saved)
|
|
455
|
+
globalThis.fetch = saved;
|
|
456
|
+
delete process.env.LINEAR_API_KEY;
|
|
457
|
+
}
|
|
458
|
+
async function makeStoredIssue(source, externalId) {
|
|
459
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
460
|
+
const { createIssue } = await import("../repository/issues.js");
|
|
461
|
+
return createIssue({
|
|
462
|
+
title: "branch seed",
|
|
463
|
+
repo: "acme/app",
|
|
464
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
465
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
466
|
+
baseBranch: "main",
|
|
467
|
+
maxReviewRounds: 3,
|
|
468
|
+
maxInfraAttempts: 3,
|
|
469
|
+
source,
|
|
470
|
+
...(externalId ? { externalId } : {}),
|
|
471
|
+
}).id;
|
|
472
|
+
}
|
|
473
|
+
test("persistLinearBranchForIssue stores Linear's branchName on the issue", async () => {
|
|
474
|
+
const { getLinearIssue, persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
475
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
476
|
+
await stubGetLinearIssue({
|
|
477
|
+
id: "lin-1",
|
|
478
|
+
identifier: "NOT-1",
|
|
479
|
+
title: "one",
|
|
480
|
+
url: "https://linear.app/x/issue/NOT-1/one",
|
|
481
|
+
branchName: "yusukemuraoka/ate-4-some-work",
|
|
482
|
+
state: { name: "Todo" },
|
|
483
|
+
team: { id: "team-9" },
|
|
484
|
+
labels: { nodes: [] },
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
const candidate = await getLinearIssue("lin-1");
|
|
488
|
+
assert.equal(candidate?.branchName, "yusukemuraoka/ate-4-some-work");
|
|
489
|
+
const issueId = await makeStoredIssue("linear", "lin-1");
|
|
490
|
+
assert.equal(getIssue(issueId).branch, null);
|
|
491
|
+
await persistLinearBranchForIssue(issueId);
|
|
492
|
+
assert.equal(getIssue(issueId).branch, "yusukemuraoka/ate-4-some-work");
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
restoreFetch();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
test("persistLinearBranchForIssue falls back to issue-<id> with no Linear branch name", async () => {
|
|
499
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
500
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
501
|
+
await stubGetLinearIssue({
|
|
502
|
+
id: "lin-2",
|
|
503
|
+
identifier: "NOT-2",
|
|
504
|
+
title: "two",
|
|
505
|
+
url: "https://linear.app/x/issue/NOT-2/two",
|
|
506
|
+
state: { name: "Todo" },
|
|
507
|
+
team: { id: "team-9" },
|
|
508
|
+
labels: { nodes: [] },
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
const issueId = await makeStoredIssue("linear", "lin-2");
|
|
512
|
+
await persistLinearBranchForIssue(issueId);
|
|
513
|
+
assert.equal(getIssue(issueId).branch, `issue-${issueId}`);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
restoreFetch();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
test("persistLinearBranchForIssue seeds issue-<id> on a second pass for the same ticket", async () => {
|
|
520
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
521
|
+
const { getIssue, transitionIssue } = await import("../repository/issues.js");
|
|
522
|
+
await stubGetLinearIssue({
|
|
523
|
+
id: "lin-3",
|
|
524
|
+
identifier: "NOT-3",
|
|
525
|
+
title: "three",
|
|
526
|
+
url: "https://linear.app/x/issue/NOT-3/three",
|
|
527
|
+
branchName: "owner/ate-3-shared-linear-branch",
|
|
528
|
+
state: { name: "Todo" },
|
|
529
|
+
team: { id: "team-9" },
|
|
530
|
+
labels: { nodes: [] },
|
|
531
|
+
});
|
|
532
|
+
try {
|
|
533
|
+
const firstId = await makeStoredIssue("linear", "lin-3");
|
|
534
|
+
await persistLinearBranchForIssue(firstId);
|
|
535
|
+
assert.equal(getIssue(firstId).branch, "owner/ate-3-shared-linear-branch");
|
|
536
|
+
// The first pass ran to completion (NOT-141): the ticket is re-imported as
|
|
537
|
+
// a fresh Dealer issue. Reusing the Linear branch would check out the old
|
|
538
|
+
// branch (still holding pre-squash commits) and `gh pr view <branch>` could
|
|
539
|
+
// return the old MERGED PR — so the second pass must not reuse it.
|
|
540
|
+
transitionIssue(firstId, "closed");
|
|
541
|
+
const secondId = await makeStoredIssue("linear", "lin-3");
|
|
542
|
+
await persistLinearBranchForIssue(secondId);
|
|
543
|
+
assert.equal(getIssue(secondId).branch, `issue-${secondId}`);
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
restoreFetch();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
test("persistLinearBranchForIssue leaves manual issues unchanged", async () => {
|
|
550
|
+
const { persistLinearBranchForIssue } = await import("./linear-inbox.js");
|
|
551
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
552
|
+
process.env.LINEAR_API_KEY = "test-key";
|
|
553
|
+
const { migrate } = await import("../db/index.js");
|
|
554
|
+
migrate();
|
|
555
|
+
try {
|
|
556
|
+
const issueId = await makeStoredIssue("manual");
|
|
557
|
+
await persistLinearBranchForIssue(issueId);
|
|
558
|
+
assert.equal(getIssue(issueId).branch, null);
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
delete process.env.LINEAR_API_KEY;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
@@ -7,9 +7,17 @@ const STATE_BY_EVENT = {
|
|
|
7
7
|
done: "Done",
|
|
8
8
|
};
|
|
9
9
|
const workflowStateCache = new Map();
|
|
10
|
+
/** Test hook — drop cached team workflow states between cases. */
|
|
11
|
+
export function clearWorkflowStateCacheForTests() {
|
|
12
|
+
workflowStateCache.clear();
|
|
13
|
+
}
|
|
10
14
|
function webBaseUrl() {
|
|
11
15
|
return process.env.AGENT_DEALER_WEB_URL ?? "http://localhost:2222";
|
|
12
16
|
}
|
|
17
|
+
/** Link back to a Dealer issue for Linear comments. */
|
|
18
|
+
export function dealerIssueUrl(issueId) {
|
|
19
|
+
return `${webBaseUrl()}/?issue=${issueId}`;
|
|
20
|
+
}
|
|
13
21
|
async function linearMutate(operation, query, variables) {
|
|
14
22
|
return linearGraphqlRequest({ operation, query, variables });
|
|
15
23
|
}
|
|
@@ -29,25 +37,41 @@ async function getWorkflowStates(teamId) {
|
|
|
29
37
|
workflowStateCache.set(teamId, map);
|
|
30
38
|
return map;
|
|
31
39
|
}
|
|
32
|
-
|
|
40
|
+
/** Post a comment on a Linear issue — shared by the run-scoped delivery path and
|
|
41
|
+
* NOT-362's post-merge fallback (which reuses this writer instead of adding a second). */
|
|
42
|
+
export async function postLinearIssueComment(issueId, body) {
|
|
33
43
|
await linearMutate("commentCreate", `mutation Comment($issueId: String!, $body: String!) {
|
|
34
44
|
commentCreate(input: { issueId: $issueId, body: $body }) { success }
|
|
35
45
|
}`, { issueId, body });
|
|
36
46
|
}
|
|
37
|
-
|
|
47
|
+
/** Move a Linear issue to a workflow state by id — shared writer, see above. */
|
|
48
|
+
export async function setLinearIssueState(issueId, stateId) {
|
|
38
49
|
await linearMutate("issueUpdateState", `mutation UpdateIssue($issueId: String!, $stateId: String!) {
|
|
39
50
|
issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
|
|
40
51
|
}`, { issueId, stateId });
|
|
41
52
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the team's completed-state id for the terminal event — the one
|
|
55
|
+
* STATE_BY_EVENT mapping (NOT-362 reuses this resolution for the fallback
|
|
56
|
+
* instead of adding a second writer). Null when the team has no matching state.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveCompletedStateId(teamId) {
|
|
59
|
+
const states = await getWorkflowStates(teamId);
|
|
60
|
+
return states.get(STATE_BY_EVENT.done.toLowerCase()) ?? null;
|
|
61
|
+
}
|
|
62
|
+
/** The Dealer comment posted alongside the terminal write — one voice for the
|
|
63
|
+
* run-scoped delivery path and NOT-362's post-merge fallback. */
|
|
64
|
+
export function buildDoneComment(label, link, linkLabel = "View run") {
|
|
45
65
|
return [
|
|
46
66
|
`**agent-dealer** — approved and marked done (${label})`,
|
|
47
67
|
``,
|
|
48
|
-
`[
|
|
68
|
+
`[${linkLabel}](${link})`,
|
|
49
69
|
].join("\n");
|
|
50
70
|
}
|
|
71
|
+
function buildComment(run) {
|
|
72
|
+
const label = run.externalLabel ?? run.externalId ?? run.id;
|
|
73
|
+
return buildDoneComment(label, `${webBaseUrl()}/?run=${run.id}`);
|
|
74
|
+
}
|
|
51
75
|
function recordSyncAttempt(run, event, ok, detail) {
|
|
52
76
|
addArtifact(run.id, "linear_sync", { event, ok, at: new Date().toISOString(), ...detail }, "system");
|
|
53
77
|
}
|
|
@@ -63,12 +87,11 @@ export async function syncLinearForRun(run, event) {
|
|
|
63
87
|
return;
|
|
64
88
|
}
|
|
65
89
|
const targetStateName = STATE_BY_EVENT[event];
|
|
66
|
-
const
|
|
67
|
-
const stateId = states.get(targetStateName.toLowerCase());
|
|
90
|
+
const stateId = await resolveCompletedStateId(issue.teamId);
|
|
68
91
|
try {
|
|
69
|
-
await
|
|
92
|
+
await postLinearIssueComment(run.externalId, buildComment(run));
|
|
70
93
|
if (stateId) {
|
|
71
|
-
await
|
|
94
|
+
await setLinearIssueState(run.externalId, stateId);
|
|
72
95
|
}
|
|
73
96
|
else {
|
|
74
97
|
recordSyncAttempt(run, event, false, {
|
|
@@ -30,6 +30,7 @@ import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstanc
|
|
|
30
30
|
import { createHumanAction, findOpenHumanAction, findOpenHumanActionByRequestId, resolveHumanAction, } from "../repository/human-actions.js";
|
|
31
31
|
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
|
|
32
32
|
import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
|
|
33
|
+
import { triggerLinearPostMerge } from "./linear-merge-verify.js";
|
|
33
34
|
import { startBaseAdvancedScan } from "./base-advanced-scan.js";
|
|
34
35
|
import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
|
|
35
36
|
const run = promisify(execFile);
|
|
@@ -313,6 +314,15 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
313
314
|
triggerReflect: true,
|
|
314
315
|
};
|
|
315
316
|
})();
|
|
317
|
+
// NOT-362: the merge landed — confirm the Linear source issue advanced (or
|
|
318
|
+
// advance/flag it). Fire-and-forget: the check's bounded retry window (tens of
|
|
319
|
+
// seconds) must not hold this finalize, which runs while holding the reviewer
|
|
320
|
+
// session and slot. Never touches this issue's status, so it cannot change
|
|
321
|
+
// the merge's result. Runs on every `done` landing, including the
|
|
322
|
+
// already-merged recovery path.
|
|
323
|
+
if (merged.issueStatus === "done") {
|
|
324
|
+
triggerLinearPostMerge(issueId);
|
|
325
|
+
}
|
|
316
326
|
// NOT-356: the base just moved under every other open Dealer PR on this repo +
|
|
317
327
|
// base — probe them and resolve the idle conflicting ones. Background and
|
|
318
328
|
// self-contained: nothing it does can change this merge's result. Only for a
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// here; wrongly re-running is only the status quo.
|
|
16
16
|
import { branchExists, countCommitsBetween, fetchRef, refExists, } from "../adapters/git-worktree.js";
|
|
17
17
|
/**
|
|
18
|
-
* The branch a developer round works on. `issue.branch` is
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* to match
|
|
18
|
+
* The branch a developer round works on. `issue.branch` is the branch NAME (written
|
|
19
|
+
* on a verified clean_handoff, or seeded from Linear's branchName at intake —
|
|
20
|
+
* NOT-362), so an attempt that died before any handoff still left its commits on
|
|
21
|
+
* the conventional name — which is why the fallback here has to match
|
|
22
|
+
* developer-effect.ts's.
|
|
22
23
|
*/
|
|
23
24
|
export function developerBranchName(issue) {
|
|
24
25
|
return issue.branch ?? `issue-${issue.id}`;
|