agent-dealer 1.2.12 → 1.2.13
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/linear-inbox.js +72 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +183 -1
- 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/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/package.json +2 -2
- package/bundle/server/static-ui/assets/index-BzLjMBI9.js +64 -0
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +303 -0
- package/bundle/shared/dist/index.js +47 -0
- package/bundle/shared/dist/linear-intake.d.ts +14 -0
- package/bundle/shared/dist/linear-intake.js +23 -0
- package/bundle/shared/dist/linear-intake.test.js +41 -2
- 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,9 +1,11 @@
|
|
|
1
1
|
import { resolveLinearRepoWithMappings } from "@agent-dealer/shared";
|
|
2
2
|
import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
3
3
|
import { listRepositoryMappings } from "../repository/repository-mappings.js";
|
|
4
|
-
import { linearGraphqlRequest } from "./linear-graphql.js";
|
|
4
|
+
import { LinearApiKeyMissingError, linearGraphqlRequest } from "./linear-graphql.js";
|
|
5
5
|
export { DEFAULT_LINEAR_STATE_FILTER };
|
|
6
|
-
|
|
6
|
+
/** NOT-361: first-page bound for the From Linear picker — never walk further cursors. */
|
|
7
|
+
export const LINEAR_CANDIDATE_PAGE_SIZE = 50;
|
|
8
|
+
const PAGE_SIZE = LINEAR_CANDIDATE_PAGE_SIZE;
|
|
7
9
|
function hasApiKey() {
|
|
8
10
|
return Boolean(process.env.LINEAR_API_KEY);
|
|
9
11
|
}
|
|
@@ -81,35 +83,89 @@ export function parseLinearIssueRef(raw) {
|
|
|
81
83
|
}
|
|
82
84
|
return null;
|
|
83
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* NOT-361: at most one GraphQL page of candidates, most recently updated first.
|
|
88
|
+
* Does not walk `pageInfo` cursors — `hasMore` tells the UI when matches remain.
|
|
89
|
+
*/
|
|
84
90
|
export async function listLinearCandidates() {
|
|
85
91
|
if (!hasApiKey())
|
|
86
|
-
return [];
|
|
92
|
+
return { candidates: [], hasMore: false };
|
|
87
93
|
const settings = getLinearIntakeConfig();
|
|
88
94
|
let viewerId;
|
|
89
95
|
if (settings.assigneeMe) {
|
|
90
96
|
const viewer = await getLinearViewer();
|
|
91
97
|
viewerId = viewer?.id;
|
|
92
98
|
if (!viewerId)
|
|
93
|
-
return [];
|
|
99
|
+
return { candidates: [], hasMore: false };
|
|
94
100
|
}
|
|
95
101
|
const filter = buildIssueFilter(settings, viewerId);
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
const data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter) {
|
|
103
|
+
issues(filter: $filter, first: ${PAGE_SIZE}, orderBy: updatedAt) {
|
|
104
|
+
nodes { ${ISSUE_FIELDS} }
|
|
105
|
+
pageInfo { hasNextPage }
|
|
106
|
+
}
|
|
107
|
+
}`, { filter }));
|
|
108
|
+
// NOT-260: mappings load once per list operation, not once per candidate.
|
|
109
|
+
const mappings = listRepositoryMappings();
|
|
110
|
+
return {
|
|
111
|
+
candidates: data.issues.nodes.map((n) => nodeToCandidate(n, mappings)),
|
|
112
|
+
hasMore: Boolean(data.issues.pageInfo.hasNextPage),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Page size when walking Linear teams for the filter editor metadata. */
|
|
116
|
+
const METADATA_TEAM_PAGE_SIZE = 50;
|
|
117
|
+
/**
|
|
118
|
+
* NOT-361: teams, workflow statuses, and the authenticated Linear viewer for
|
|
119
|
+
* the inline filter editor — never ask the operator for raw UUIDs or CSV text.
|
|
120
|
+
* Teams are walked page-by-page so large workspaces are not truncated at
|
|
121
|
+
* Linear's default first page.
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchLinearIntakeMetadata() {
|
|
124
|
+
if (!hasApiKey())
|
|
125
|
+
throw new LinearApiKeyMissingError();
|
|
126
|
+
const viewerData = (await linearQuery("fetchLinearIntakeMetadataViewer", `query IntakeMetadataViewer { viewer { id name email } }`));
|
|
127
|
+
const teams = [];
|
|
128
|
+
const workflowStates = [];
|
|
129
|
+
let after = null;
|
|
98
130
|
for (;;) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
nodes {
|
|
131
|
+
const page = (await linearQuery("fetchLinearIntakeMetadataTeams", `query IntakeMetadataTeams($first: Int!, $after: String) {
|
|
132
|
+
teams(first: $first, after: $after) {
|
|
133
|
+
nodes {
|
|
134
|
+
id
|
|
135
|
+
name
|
|
136
|
+
key
|
|
137
|
+
states { nodes { name type } }
|
|
138
|
+
}
|
|
102
139
|
pageInfo { hasNextPage endCursor }
|
|
103
140
|
}
|
|
104
|
-
}`, {
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
}`, { first: METADATA_TEAM_PAGE_SIZE, after }));
|
|
142
|
+
for (const team of page.teams?.nodes ?? []) {
|
|
143
|
+
teams.push({ id: team.id, name: team.name, key: team.key });
|
|
144
|
+
for (const state of team.states?.nodes ?? []) {
|
|
145
|
+
const name = state.name?.trim();
|
|
146
|
+
if (!name)
|
|
147
|
+
continue;
|
|
148
|
+
workflowStates.push({ name, type: state.type, teamId: team.id });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!page.teams?.pageInfo?.hasNextPage || !page.teams.pageInfo.endCursor)
|
|
107
152
|
break;
|
|
108
|
-
after =
|
|
153
|
+
after = page.teams.pageInfo.endCursor;
|
|
109
154
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
// Stable team order by name; statuses keep Linear's per-team order, then
|
|
156
|
+
// unique-by-name order is left to the UI when "All teams" is selected.
|
|
157
|
+
teams.sort((a, b) => a.name.localeCompare(b.name));
|
|
158
|
+
return {
|
|
159
|
+
teams,
|
|
160
|
+
workflowStates,
|
|
161
|
+
viewer: viewerData.viewer
|
|
162
|
+
? {
|
|
163
|
+
id: viewerData.viewer.id,
|
|
164
|
+
name: viewerData.viewer.name,
|
|
165
|
+
email: viewerData.viewer.email,
|
|
166
|
+
}
|
|
167
|
+
: null,
|
|
168
|
+
};
|
|
113
169
|
}
|
|
114
170
|
export async function getLinearIssue(issueId) {
|
|
115
171
|
const data = (await linearQuery("getLinearIssue", `query Issue($id: String!) {
|
|
@@ -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, 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,182 @@ 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
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeLinearIntakePickerPatch } from "@agent-dealer/shared";
|
|
1
2
|
import { getDb } from "../db/index.js";
|
|
2
3
|
/** Open workflow states — exclude terminal Done / Canceled. Shared with linear-inbox seed. */
|
|
3
4
|
export const DEFAULT_LINEAR_STATE_FILTER = ["Backlog", "Todo", "In Progress", "In Review"];
|
|
@@ -14,6 +15,11 @@ function getJson(key, fallback) {
|
|
|
14
15
|
return fallback;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
function setJson(key, value) {
|
|
19
|
+
getDb()
|
|
20
|
+
.prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json")
|
|
21
|
+
.run(key, JSON.stringify(value));
|
|
22
|
+
}
|
|
17
23
|
function parseStateFilterEnv(raw) {
|
|
18
24
|
if (!raw?.trim())
|
|
19
25
|
return null;
|
|
@@ -23,6 +29,19 @@ function parseStateFilterEnv(raw) {
|
|
|
23
29
|
.filter(Boolean);
|
|
24
30
|
return parts.length > 0 ? parts : null;
|
|
25
31
|
}
|
|
32
|
+
function linearEnvOverrides() {
|
|
33
|
+
return {
|
|
34
|
+
stateFilter: parseStateFilterEnv(process.env.LINEAR_STATE_FILTER) !== null,
|
|
35
|
+
teamId: process.env.LINEAR_TEAM_ID !== undefined && process.env.LINEAR_TEAM_ID !== "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function toPickerConfig(config) {
|
|
39
|
+
return {
|
|
40
|
+
stateFilter: config.stateFilter,
|
|
41
|
+
teamId: config.teamId,
|
|
42
|
+
assigneeMe: config.assigneeMe,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
26
45
|
/** Persisted-only view of the Linear intake config (env overrides applied separately). */
|
|
27
46
|
export function getPersistedLinearIntakeConfig() {
|
|
28
47
|
return {
|
|
@@ -47,6 +66,37 @@ function applyEnvOverrides(config) {
|
|
|
47
66
|
export function getLinearIntakeConfig() {
|
|
48
67
|
return applyEnvOverrides(getPersistedLinearIntakeConfig());
|
|
49
68
|
}
|
|
69
|
+
/** NOT-361: effective picker filters + persisted values + which env vars win. */
|
|
70
|
+
export function getLinearIntakeConfigView() {
|
|
71
|
+
const persistedFull = getPersistedLinearIntakeConfig();
|
|
72
|
+
const effective = applyEnvOverrides(persistedFull);
|
|
73
|
+
return {
|
|
74
|
+
...toPickerConfig(effective),
|
|
75
|
+
persisted: toPickerConfig(persistedFull),
|
|
76
|
+
envOverrides: linearEnvOverrides(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* NOT-361: persist picker fields only (Team / Assignee / Status).
|
|
81
|
+
* Never touches syncEnabled or the deleted routing/default-agent rows.
|
|
82
|
+
*/
|
|
83
|
+
export function patchLinearIntakeConfig(patch) {
|
|
84
|
+
const normalized = normalizeLinearIntakePickerPatch(patch);
|
|
85
|
+
const current = getPersistedLinearIntakeConfig();
|
|
86
|
+
const nextPicker = {
|
|
87
|
+
stateFilter: normalized.stateFilter ?? current.stateFilter,
|
|
88
|
+
teamId: normalized.teamId !== undefined ? normalized.teamId : current.teamId,
|
|
89
|
+
assigneeMe: normalized.assigneeMe !== undefined ? normalized.assigneeMe : current.assigneeMe,
|
|
90
|
+
};
|
|
91
|
+
// Re-validate the merged picker shape (empty status list is rejected above).
|
|
92
|
+
if (nextPicker.stateFilter.length === 0) {
|
|
93
|
+
throw new Error("Select at least one workflow status");
|
|
94
|
+
}
|
|
95
|
+
setJson("linear.stateFilter", nextPicker.stateFilter);
|
|
96
|
+
setJson("linear.teamId", nextPicker.teamId);
|
|
97
|
+
setJson("linear.assigneeMe", nextPicker.assigneeMe);
|
|
98
|
+
return getLinearIntakeConfigView();
|
|
99
|
+
}
|
|
50
100
|
function parseEnvAgentDeckUrl() {
|
|
51
101
|
const raw = process.env.AGENT_DECK_API_URL;
|
|
52
102
|
if (!raw)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// NOT-361: Linear picker config persistence, env-override precedence, and
|
|
2
|
+
// narrow patch (Team / Assignee / Status only — no routing fields).
|
|
3
|
+
import { test, before, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-intake-settings-"));
|
|
9
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
10
|
+
const { getLinearIntakeConfigView, getPersistedLinearIntakeConfig, patchLinearIntakeConfig, DEFAULT_LINEAR_STATE_FILTER, } = await import("./intake-settings.js");
|
|
11
|
+
before(() => {
|
|
12
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
13
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
14
|
+
migrate();
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
18
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
19
|
+
// Reset picker rows to defaults between cases.
|
|
20
|
+
const db = getDb();
|
|
21
|
+
const upsert = db.prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json");
|
|
22
|
+
upsert.run("linear.stateFilter", JSON.stringify([...DEFAULT_LINEAR_STATE_FILTER]));
|
|
23
|
+
upsert.run("linear.teamId", JSON.stringify(null));
|
|
24
|
+
upsert.run("linear.assigneeMe", JSON.stringify(false));
|
|
25
|
+
});
|
|
26
|
+
test("patchLinearIntakeConfig round-trips Team, Assignee, and Status through SQLite", () => {
|
|
27
|
+
const saved = patchLinearIntakeConfig({
|
|
28
|
+
stateFilter: ["Todo", "In Progress"],
|
|
29
|
+
teamId: "team-abc",
|
|
30
|
+
assigneeMe: true,
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(saved.persisted, {
|
|
33
|
+
stateFilter: ["Todo", "In Progress"],
|
|
34
|
+
teamId: "team-abc",
|
|
35
|
+
assigneeMe: true,
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(saved.stateFilter, ["Todo", "In Progress"]);
|
|
38
|
+
assert.equal(saved.teamId, "team-abc");
|
|
39
|
+
assert.equal(saved.assigneeMe, true);
|
|
40
|
+
assert.equal(saved.envOverrides.stateFilter, false);
|
|
41
|
+
assert.equal(saved.envOverrides.teamId, false);
|
|
42
|
+
const again = getLinearIntakeConfigView();
|
|
43
|
+
assert.deepEqual(again.persisted, saved.persisted);
|
|
44
|
+
// syncEnabled stays untouched when patching picker fields.
|
|
45
|
+
const full = getPersistedLinearIntakeConfig();
|
|
46
|
+
assert.equal(typeof full.syncEnabled, "boolean");
|
|
47
|
+
});
|
|
48
|
+
test("LINEAR_TEAM_ID and LINEAR_STATE_FILTER override effective values and set flags", () => {
|
|
49
|
+
patchLinearIntakeConfig({
|
|
50
|
+
stateFilter: ["Backlog"],
|
|
51
|
+
teamId: "saved-team",
|
|
52
|
+
assigneeMe: false,
|
|
53
|
+
});
|
|
54
|
+
process.env.LINEAR_STATE_FILTER = "Todo, In Review";
|
|
55
|
+
process.env.LINEAR_TEAM_ID = "env-team";
|
|
56
|
+
const view = getLinearIntakeConfigView();
|
|
57
|
+
assert.deepEqual(view.stateFilter, ["Todo", "In Review"]);
|
|
58
|
+
assert.equal(view.teamId, "env-team");
|
|
59
|
+
assert.deepEqual(view.persisted.stateFilter, ["Backlog"]);
|
|
60
|
+
assert.equal(view.persisted.teamId, "saved-team");
|
|
61
|
+
assert.equal(view.envOverrides.stateFilter, true);
|
|
62
|
+
assert.equal(view.envOverrides.teamId, true);
|
|
63
|
+
// A save still writes persisted values; env remains authoritative on read.
|
|
64
|
+
const afterSave = patchLinearIntakeConfig({ stateFilter: ["In Progress"], teamId: "ui-team" });
|
|
65
|
+
assert.deepEqual(afterSave.persisted.stateFilter, ["In Progress"]);
|
|
66
|
+
assert.equal(afterSave.persisted.teamId, "ui-team");
|
|
67
|
+
assert.deepEqual(afterSave.stateFilter, ["Todo", "In Review"]);
|
|
68
|
+
assert.equal(afterSave.teamId, "env-team");
|
|
69
|
+
});
|
|
70
|
+
test("patchLinearIntakeConfig rejects an empty status list", () => {
|
|
71
|
+
assert.throws(() => patchLinearIntakeConfig({ stateFilter: [" ", ""] }), /at least one/i);
|
|
72
|
+
});
|
|
@@ -13,6 +13,7 @@ import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
|
|
|
13
13
|
import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
|
|
14
14
|
import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
|
|
15
15
|
import { registerVersionRoute } from "./version.js";
|
|
16
|
+
import { registerIntakeLinearConfigRoutes } from "./intake-linear-config.js";
|
|
16
17
|
async function resolveDeckName(deckId) {
|
|
17
18
|
if (!deckId)
|
|
18
19
|
return null;
|
|
@@ -35,15 +36,17 @@ export async function registerRoutes(app) {
|
|
|
35
36
|
const { models, source } = await listRuntimeModels(parsed.data, { refresh });
|
|
36
37
|
return { runtime: parsed.data, models, source };
|
|
37
38
|
});
|
|
39
|
+
// NOT-361: picker config + Linear metadata for the inline New issue filter editor.
|
|
40
|
+
await registerIntakeLinearConfigRoutes(app);
|
|
38
41
|
// Linear lookup for the Issues home "New issue" flow — the only intake surface left
|
|
39
42
|
// after NOT-71 removed the Inbox page and its promote-to-run pipeline.
|
|
43
|
+
// NOT-361: bounded page (≤50, most recently updated) with hasMore escape hatch.
|
|
40
44
|
app.get("/api/intake/linear", async (_req, reply) => {
|
|
41
45
|
try {
|
|
42
|
-
|
|
43
|
-
return { candidates };
|
|
46
|
+
return await listLinearCandidates();
|
|
44
47
|
}
|
|
45
48
|
catch (e) {
|
|
46
|
-
return reply.status(502).send({ error: String(e), candidates: [] });
|
|
49
|
+
return reply.status(502).send({ error: String(e), candidates: [], hasMore: false });
|
|
47
50
|
}
|
|
48
51
|
});
|
|
49
52
|
/** Free-form kick lookup: `?q=NOT-103` or a Linear issue URL. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LinearIntakeConfigPatch } from "@agent-dealer/shared";
|
|
2
|
+
import { fetchLinearIntakeMetadata, } from "../adapters/linear-inbox.js";
|
|
3
|
+
import { LinearApiKeyMissingError } from "../adapters/linear-graphql.js";
|
|
4
|
+
import { getLinearIntakeConfigView, patchLinearIntakeConfig, } from "../repository/intake-settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* NOT-361: Linear picker config + metadata for the inline New issue filter editor.
|
|
7
|
+
* Local dealer API (same trust model as repository-mappings); Linear auth is the API key.
|
|
8
|
+
*/
|
|
9
|
+
export async function registerIntakeLinearConfigRoutes(app) {
|
|
10
|
+
app.get("/api/intake/linear/config", async () => getLinearIntakeConfigView());
|
|
11
|
+
app.patch("/api/intake/linear/config", async (req, reply) => {
|
|
12
|
+
try {
|
|
13
|
+
const patch = LinearIntakeConfigPatch.parse(req.body ?? {});
|
|
14
|
+
return patchLinearIntakeConfig(patch);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
app.get("/api/intake/linear/metadata", async (_req, reply) => {
|
|
21
|
+
try {
|
|
22
|
+
return await fetchLinearIntakeMetadata();
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof LinearApiKeyMissingError) {
|
|
26
|
+
return reply.status(503).send({ error: e.message });
|
|
27
|
+
}
|
|
28
|
+
return reply.status(502).send({ error: e instanceof Error ? e.message : String(e) });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -32,19 +32,44 @@ before(() => {
|
|
|
32
32
|
process.env.LINEAR_API_KEY = "test-key";
|
|
33
33
|
globalThis.fetch = (async (_url, init) => {
|
|
34
34
|
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
35
|
-
const
|
|
36
|
-
|
|
35
|
+
const query = body.query ?? "";
|
|
36
|
+
let payload;
|
|
37
|
+
if (typeof body.variables?.id === "string") {
|
|
38
|
+
payload = {
|
|
37
39
|
data: {
|
|
38
40
|
issue: LIST_NODES.find((n) => n.identifier === body.variables?.id) ??
|
|
39
41
|
LIST_NODES.find((n) => n.identifier === "NOT-242") ??
|
|
40
42
|
null,
|
|
41
43
|
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else if (query.includes("IntakeMetadata") || /query\s*\{\s*viewer\s*\{/.test(query)) {
|
|
47
|
+
payload = {
|
|
44
48
|
data: {
|
|
45
|
-
|
|
49
|
+
viewer: { id: "viewer-1", name: "Ada" },
|
|
50
|
+
teams: {
|
|
51
|
+
nodes: [
|
|
52
|
+
{
|
|
53
|
+
id: "team-1",
|
|
54
|
+
name: "Core",
|
|
55
|
+
key: "COR",
|
|
56
|
+
states: { nodes: [{ name: "Todo", type: "unstarted" }] },
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
46
60
|
},
|
|
47
61
|
};
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
payload = {
|
|
65
|
+
data: {
|
|
66
|
+
issues: {
|
|
67
|
+
nodes: LIST_NODES,
|
|
68
|
+
pageInfo: { hasNextPage: listHasMoreForTest, endCursor: null },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
48
73
|
return {
|
|
49
74
|
ok: true,
|
|
50
75
|
status: 200,
|
|
@@ -53,6 +78,8 @@ before(() => {
|
|
|
53
78
|
};
|
|
54
79
|
});
|
|
55
80
|
});
|
|
81
|
+
/** Flipped by hasMore route tests; default false for the existing candidate-list cases. */
|
|
82
|
+
let listHasMoreForTest = false;
|
|
56
83
|
after(() => {
|
|
57
84
|
globalThis.fetch = realFetch;
|
|
58
85
|
delete process.env.LINEAR_API_KEY;
|
|
@@ -63,11 +90,13 @@ async function buildApp() {
|
|
|
63
90
|
return app;
|
|
64
91
|
}
|
|
65
92
|
test("GET /api/intake/linear returns candidates with resolved repo hints", async () => {
|
|
93
|
+
listHasMoreForTest = false;
|
|
66
94
|
const app = await buildApp();
|
|
67
95
|
const res = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
68
96
|
assert.equal(res.statusCode, 200);
|
|
69
97
|
const json = res.json();
|
|
70
98
|
assert.equal(json.candidates.length, 4);
|
|
99
|
+
assert.equal(json.hasMore, false);
|
|
71
100
|
const one = json.candidates.find((c) => c.identifier === "NOT-242");
|
|
72
101
|
assert.equal(one?.repoResolution?.status, "resolved");
|
|
73
102
|
assert.equal(one?.repoResolution?.repository, "github.com/not-so-fat/agent-dealer");
|
|
@@ -143,3 +172,64 @@ test("a mapped Linear label resolves in direct lookup", async () => {
|
|
|
143
172
|
replaceRepositoryMappings({ mappings: [] });
|
|
144
173
|
}
|
|
145
174
|
});
|
|
175
|
+
// NOT-361: config / metadata / hasMore on the intake routes.
|
|
176
|
+
test("GET/PATCH /api/intake/linear/config round-trips picker filters", async () => {
|
|
177
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
178
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
179
|
+
const app = await buildApp();
|
|
180
|
+
const patched = await app.inject({
|
|
181
|
+
method: "PATCH",
|
|
182
|
+
url: "/api/intake/linear/config",
|
|
183
|
+
payload: { stateFilter: ["Todo"], teamId: "team-1", assigneeMe: true },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(patched.statusCode, 200);
|
|
186
|
+
const body = patched.json();
|
|
187
|
+
assert.deepEqual(body.persisted, { stateFilter: ["Todo"], teamId: "team-1", assigneeMe: true });
|
|
188
|
+
const got = await app.inject({ method: "GET", url: "/api/intake/linear/config" });
|
|
189
|
+
assert.equal(got.statusCode, 200);
|
|
190
|
+
assert.deepEqual(got.json().persisted, body.persisted);
|
|
191
|
+
});
|
|
192
|
+
test("env overrides are visible on the config view", async () => {
|
|
193
|
+
process.env.LINEAR_STATE_FILTER = "In Review";
|
|
194
|
+
process.env.LINEAR_TEAM_ID = "env-team";
|
|
195
|
+
try {
|
|
196
|
+
const app = await buildApp();
|
|
197
|
+
const res = await app.inject({ method: "GET", url: "/api/intake/linear/config" });
|
|
198
|
+
assert.equal(res.statusCode, 200);
|
|
199
|
+
const body = res.json();
|
|
200
|
+
assert.deepEqual(body.stateFilter, ["In Review"]);
|
|
201
|
+
assert.equal(body.teamId, "env-team");
|
|
202
|
+
assert.equal(body.envOverrides.stateFilter, true);
|
|
203
|
+
assert.equal(body.envOverrides.teamId, true);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
207
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
test("GET /api/intake/linear/metadata returns teams, statuses, and viewer", async () => {
|
|
211
|
+
const app = await buildApp();
|
|
212
|
+
const res = await app.inject({ method: "GET", url: "/api/intake/linear/metadata" });
|
|
213
|
+
assert.equal(res.statusCode, 200);
|
|
214
|
+
const body = res.json();
|
|
215
|
+
assert.equal(body.teams[0]?.name, "Core");
|
|
216
|
+
assert.ok(body.workflowStates.some((s) => s.name === "Todo"));
|
|
217
|
+
assert.equal(body.viewer?.name, "Ada");
|
|
218
|
+
});
|
|
219
|
+
test("GET /api/intake/linear reports hasMore true and false", async () => {
|
|
220
|
+
// Ensure assigneeMe does not short-circuit the list when viewer lookup fails.
|
|
221
|
+
const app = await buildApp();
|
|
222
|
+
await app.inject({
|
|
223
|
+
method: "PATCH",
|
|
224
|
+
url: "/api/intake/linear/config",
|
|
225
|
+
payload: { assigneeMe: false },
|
|
226
|
+
});
|
|
227
|
+
listHasMoreForTest = true;
|
|
228
|
+
const more = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
229
|
+
assert.equal(more.statusCode, 200);
|
|
230
|
+
assert.equal(more.json().hasMore, true);
|
|
231
|
+
listHasMoreForTest = false;
|
|
232
|
+
const done = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
233
|
+
assert.equal(done.statusCode, 200);
|
|
234
|
+
assert.equal(done.json().hasMore, false);
|
|
235
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-dealer/shared": "1.2.
|
|
25
|
+
"@agent-dealer/shared": "1.2.13",
|
|
26
26
|
"@fastify/cors": "^11.0.1",
|
|
27
27
|
"@fastify/static": "^8.2.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|