@clipboard-health/groundcrew 3.4.0 → 4.0.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/README.md +20 -25
- package/clearance-allow-hosts +10 -0
- package/crew.config.example.ts +14 -33
- package/dist/commands/cleaner.d.ts.map +1 -1
- package/dist/commands/cleaner.js +1 -5
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +5 -5
- package/dist/commands/eligibility.d.ts +1 -1
- package/dist/commands/eligibility.d.ts.map +1 -1
- package/dist/commands/eligibility.js +4 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +1 -2
- package/dist/commands/ticketDoctor.d.ts.map +1 -1
- package/dist/commands/ticketDoctor.js +11 -33
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/lib/adapters/linear/factory.d.ts +10 -14
- package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
- package/dist/lib/adapters/linear/factory.js +23 -63
- package/dist/lib/adapters/linear/schema.d.ts +3 -5
- package/dist/lib/adapters/linear/schema.d.ts.map +1 -1
- package/dist/lib/adapters/linear/schema.js +3 -5
- package/dist/lib/boardSource.d.ts +55 -39
- package/dist/lib/boardSource.d.ts.map +1 -1
- package/dist/lib/boardSource.js +130 -237
- package/dist/lib/config.d.ts +11 -70
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +10 -157
- package/dist/lib/linearIssueStatus.d.ts +0 -4
- package/dist/lib/linearIssueStatus.d.ts.map +1 -1
- package/dist/lib/linearIssueStatus.js +0 -0
- package/dist/lib/ticketSource.d.ts +5 -7
- package/dist/lib/ticketSource.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/lib/boardSource.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Linear adapter — turns the
|
|
2
|
+
* Linear adapter — turns the viewer's GraphQL state into a `BoardState`
|
|
3
3
|
* snapshot. Owns the GraphQL queries and shape parsing so callers consume a
|
|
4
4
|
* typed `BoardState` instead of raw nodes.
|
|
5
|
+
*
|
|
6
|
+
* There is no project / view / status configuration: the only filter is
|
|
7
|
+
* "assigned to the API key's viewer AND carries an `agent-*` label."
|
|
8
|
+
* State classification is driven by Linear's workflow `state.type`
|
|
9
|
+
* (`unstarted` | `started` | `completed` | `canceled` | `duplicate`) —
|
|
10
|
+
* never by status name — so workspaces with renamed columns (Todo → To Do,
|
|
11
|
+
* Done → Shipped, etc.) Just Work.
|
|
5
12
|
*/
|
|
6
|
-
import { AGENT_ANY_MODEL,
|
|
13
|
+
import { AGENT_ANY_MODEL, isShippedDefaultDisabled } from "./config.js";
|
|
7
14
|
import { RepositoryResolutionError } from "./ticketSource.js";
|
|
8
15
|
import { log } from "./util.js";
|
|
9
|
-
const AGENT_LABEL_PREFIX = "agent-";
|
|
10
|
-
const ISSUES_PAGE_SIZE = 250;
|
|
16
|
+
export const AGENT_LABEL_PREFIX = "agent-";
|
|
17
|
+
export const ISSUES_PAGE_SIZE = 250;
|
|
18
|
+
// `state.type` values surfaced by `fetch()`. `backlog` / `triage` are dropped
|
|
19
|
+
// at the GraphQL filter; everything else is post-classified by these names.
|
|
20
|
+
const ACTIONABLE_STATE_TYPES = [
|
|
21
|
+
"unstarted",
|
|
22
|
+
"started",
|
|
23
|
+
"completed",
|
|
24
|
+
"canceled",
|
|
25
|
+
"duplicate",
|
|
26
|
+
];
|
|
11
27
|
export function isGroundcrewIssue(issue) {
|
|
12
28
|
return issue.model !== undefined && issue.repository !== undefined;
|
|
13
29
|
}
|
|
@@ -16,116 +32,69 @@ export function isGroundcrewIssue(issue) {
|
|
|
16
32
|
// boardSource.ts keep compiling until a follow-up PR completes the consumer
|
|
17
33
|
// refactor and deletes this file.
|
|
18
34
|
export { RepositoryResolutionError };
|
|
19
|
-
export class UnknownProjectError extends Error {
|
|
20
|
-
ticket;
|
|
21
|
-
projectSlugId;
|
|
22
|
-
configuredSlugIds;
|
|
23
|
-
constructor(arguments_) {
|
|
24
|
-
const { ticket, projectSlugId, configuredSlugIds } = arguments_;
|
|
25
|
-
const ticketProjectClause = projectSlugId === undefined
|
|
26
|
-
? "has no associated Linear project"
|
|
27
|
-
: `belongs to Linear project slugId "${projectSlugId}"`;
|
|
28
|
-
super(`Ticket ${ticket} ${ticketProjectClause}, which is not in linear.projects (configured: ${configuredSlugIds.join(", ")}). Add the project to your crew config or pick a ticket from a configured project.`);
|
|
29
|
-
this.name = "UnknownProjectError";
|
|
30
|
-
this.ticket = ticket;
|
|
31
|
-
this.projectSlugId = projectSlugId;
|
|
32
|
-
this.configuredSlugIds = configuredSlugIds;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
35
|
export function createBoardSource(deps) {
|
|
36
36
|
const { config, client } = deps;
|
|
37
37
|
return {
|
|
38
38
|
async verify() {
|
|
39
|
-
await
|
|
39
|
+
await verifyViewer(client);
|
|
40
40
|
},
|
|
41
41
|
async fetch() {
|
|
42
42
|
return await fetchBoard(client, config);
|
|
43
43
|
},
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
async function verifyViewer(client) {
|
|
47
|
+
const response = await client.client.rawRequest(`query VerifyViewer { viewer { id name email } }`);
|
|
48
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
49
|
+
const { viewer } = response.data;
|
|
50
|
+
if (viewer === null) {
|
|
51
|
+
throw new Error("Linear API did not return a viewer for this API key. Confirm LINEAR_API_KEY is set and points to a personal API key, not a workspace key.");
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
log(`Resolved Linear viewer: ${viewer.name} (${viewer.email})`);
|
|
54
|
+
}
|
|
55
|
+
export function isIssueInProgress(issue) {
|
|
56
|
+
return issue.stateType === "started";
|
|
53
57
|
}
|
|
54
|
-
export function
|
|
55
|
-
return
|
|
58
|
+
export function isIssueTodo(issue) {
|
|
59
|
+
return issue.stateType === "unstarted";
|
|
60
|
+
}
|
|
61
|
+
export function isTerminalStateType(stateType) {
|
|
62
|
+
return stateType === "completed" || stateType === "canceled" || stateType === "duplicate";
|
|
63
|
+
}
|
|
64
|
+
export function isTerminalStatusForIssue(issue) {
|
|
65
|
+
return isTerminalStateType(issue.stateType);
|
|
56
66
|
}
|
|
57
67
|
/**
|
|
58
|
-
* Terminal check for a blocker.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* matches today's single-project "is this name in our terminal list?"
|
|
62
|
-
* behavior so off-config blockers don't regress.
|
|
68
|
+
* Terminal check for a blocker. Driven by Linear's workflow `state.type` so
|
|
69
|
+
* renamed status columns ("Shipped" instead of "Done") are still classified
|
|
70
|
+
* correctly. An undefined `stateType` falls through to non-terminal.
|
|
63
71
|
*/
|
|
64
|
-
export function isTerminalStatusForBlocker(blocker
|
|
65
|
-
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
if (blocker.projectSlugId !== undefined) {
|
|
69
|
-
const project = findProjectBySlugId(config, blocker.projectSlugId);
|
|
70
|
-
if (project !== undefined) {
|
|
71
|
-
return project.statuses.terminal.includes(blocker.status);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return unionTerminalStatuses(config).has(blocker.status);
|
|
75
|
-
}
|
|
76
|
-
async function verifyProjects(client, config) {
|
|
77
|
-
const slugIds = config.linear.projects.map((project) => project.slugId);
|
|
78
|
-
const response = await client.client.rawRequest(`query VerifyProjects($slugIds: [String!]!) {
|
|
79
|
-
projects(filter: { slugId: { in: $slugIds } }, first: ${slugIds.length}) {
|
|
80
|
-
nodes { id name slugId }
|
|
81
|
-
}
|
|
82
|
-
}`, { slugIds });
|
|
83
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
84
|
-
const { projects } = response.data;
|
|
85
|
-
const resolved = new Map(projects.nodes.map((project) => [project.slugId.toLowerCase(), project]));
|
|
86
|
-
for (const project of config.linear.projects) {
|
|
87
|
-
const found = resolved.get(project.slugId);
|
|
88
|
-
if (found === undefined) {
|
|
89
|
-
log(`WARNING: no Linear project found with slugId "${project.slugId}" (linear.projects entry "${project.projectSlug}"). Check for typos, archived projects, or missing API-key access. Continuing without this project.`);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
log(`Resolved Linear project: ${found.name} (slugId ${found.slugId})`);
|
|
93
|
-
}
|
|
94
|
-
if (resolved.size === 0) {
|
|
95
|
-
throw new Error(`No Linear projects resolved from linear.projects (${config.linear.projects.map((project) => `"${project.projectSlug}"`).join(", ")}). Confirm slugs match the trailing segment of each project's URL and that your Linear API key can access this workspace.`);
|
|
96
|
-
}
|
|
72
|
+
export function isTerminalStatusForBlocker(blocker) {
|
|
73
|
+
return isTerminalStateType(blocker.stateType);
|
|
97
74
|
}
|
|
98
75
|
async function fetchBoard(client, config) {
|
|
99
76
|
const nodes = [];
|
|
100
77
|
let after = null;
|
|
101
|
-
//
|
|
78
|
+
// Three server-side filters narrow the response to tickets the orchestrator
|
|
102
79
|
// can actually act on:
|
|
103
|
-
// 1.
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// The client-side `isGroundcrewIssue` guard in dispatcher.ts is
|
|
80
|
+
// 1. Assignee: the API key's own viewer. groundcrew is a single-user
|
|
81
|
+
// orchestrator — every ticket it dispatches is "this user's work."
|
|
82
|
+
// 2. Label: at least one `agent-*` label — i.e. the user opted the
|
|
83
|
+
// ticket in to groundcrew. Without this, every human-owned ticket
|
|
84
|
+
// would round-trip back just to be filtered out client-side.
|
|
85
|
+
// 3. State type: scoped to actionable values (`unstarted`, `started`,
|
|
86
|
+
// `completed`, `canceled`, `duplicate`) so backlog/triage tickets never
|
|
87
|
+
// make it into the page.
|
|
88
|
+
// The client-side `isGroundcrewIssue` guard in dispatcher.ts is
|
|
112
89
|
// belt-and-suspenders against query drift, not the load-bearing filter.
|
|
113
|
-
const
|
|
114
|
-
const stateNames = [
|
|
115
|
-
...new Set(config.linear.projects.flatMap((project) => [
|
|
116
|
-
project.statuses.todo,
|
|
117
|
-
project.statuses.inProgress,
|
|
118
|
-
project.statuses.done,
|
|
119
|
-
...project.statuses.terminal,
|
|
120
|
-
])),
|
|
121
|
-
];
|
|
90
|
+
const stateTypes = [...ACTIONABLE_STATE_TYPES];
|
|
122
91
|
for (;;) {
|
|
123
92
|
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
124
|
-
const response = await client.client.rawRequest(`query BoardIssues($
|
|
93
|
+
const response = await client.client.rawRequest(`query BoardIssues($stateTypes: [String!]!, $agentLabelPrefix: String!, $after: String) {
|
|
125
94
|
issues(
|
|
126
95
|
filter: {
|
|
127
|
-
|
|
128
|
-
state: {
|
|
96
|
+
assignee: { isMe: { eq: true } }
|
|
97
|
+
state: { type: { in: $stateTypes } }
|
|
129
98
|
labels: { some: { name: { startsWith: $agentLabelPrefix } } }
|
|
130
99
|
}
|
|
131
100
|
first: ${ISSUES_PAGE_SIZE}
|
|
@@ -138,10 +107,9 @@ async function fetchBoard(client, config) {
|
|
|
138
107
|
title
|
|
139
108
|
description
|
|
140
109
|
updatedAt
|
|
141
|
-
state { id name }
|
|
110
|
+
state { id name type }
|
|
142
111
|
team { id key }
|
|
143
112
|
assignee { name }
|
|
144
|
-
project { slugId }
|
|
145
113
|
children { nodes { id } }
|
|
146
114
|
labels {
|
|
147
115
|
nodes {
|
|
@@ -154,8 +122,7 @@ async function fetchBoard(client, config) {
|
|
|
154
122
|
issue {
|
|
155
123
|
identifier
|
|
156
124
|
title
|
|
157
|
-
state { name }
|
|
158
|
-
project { slugId }
|
|
125
|
+
state { name type }
|
|
159
126
|
}
|
|
160
127
|
}
|
|
161
128
|
pageInfo { hasNextPage }
|
|
@@ -164,8 +131,7 @@ async function fetchBoard(client, config) {
|
|
|
164
131
|
pageInfo { hasNextPage endCursor }
|
|
165
132
|
}
|
|
166
133
|
}`, {
|
|
167
|
-
|
|
168
|
-
stateNames,
|
|
134
|
+
stateTypes,
|
|
169
135
|
agentLabelPrefix: AGENT_LABEL_PREFIX,
|
|
170
136
|
after,
|
|
171
137
|
});
|
|
@@ -177,16 +143,12 @@ async function fetchBoard(client, config) {
|
|
|
177
143
|
}
|
|
178
144
|
after = page.pageInfo.endCursor;
|
|
179
145
|
}
|
|
180
|
-
// Only parse `repository` for tickets that opted in via an `agent-*` label.
|
|
181
|
-
// Unlabeled tickets are not groundcrew's concern even when they share a
|
|
182
|
-
// configured state name with one that is.
|
|
183
146
|
const issues = nodes
|
|
184
147
|
.filter((node) => node.children.nodes.length === 0)
|
|
185
|
-
.filter((node) => issueStatusBelongsToOwnProject(node, config))
|
|
186
148
|
.map((node) => issueFromNode(node, config));
|
|
187
149
|
const parentSkips = nodes
|
|
188
150
|
.filter((node) => node.children.nodes.length > 0)
|
|
189
|
-
.filter((node) =>
|
|
151
|
+
.filter((node) => node.state?.type === "unstarted")
|
|
190
152
|
.map((node) => ({
|
|
191
153
|
id: node.identifier.toLowerCase(),
|
|
192
154
|
title: node.title,
|
|
@@ -194,29 +156,7 @@ async function fetchBoard(client, config) {
|
|
|
194
156
|
}));
|
|
195
157
|
return { timestamp: new Date().toISOString(), issues, parentSkips };
|
|
196
158
|
}
|
|
197
|
-
|
|
198
|
-
* Checks whether the node sits in Todo under its own project's configured
|
|
199
|
-
* status names. Used to narrow parent skips (Done / In Progress parents
|
|
200
|
-
* aren't surprising drops, so we don't log them) and to gate the repository
|
|
201
|
-
* parse on the only status the dispatcher acts on — In Progress / Done
|
|
202
|
-
* tickets never read `Issue.repository` so parsing it for them just creates
|
|
203
|
-
* tick-spamming warnings when an operator already-finished ticket can't be
|
|
204
|
-
* re-parsed.
|
|
205
|
-
*/
|
|
206
|
-
function isTodoStatusForOwnProject(node, config) {
|
|
207
|
-
const slugId = node.project?.slugId?.toLowerCase();
|
|
208
|
-
/* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
|
|
209
|
-
if (slugId === undefined) {
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
const project = findProjectBySlugId(config, slugId);
|
|
213
|
-
/* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
|
|
214
|
-
if (project === undefined) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
return node.state?.name === project.statuses.todo;
|
|
218
|
-
}
|
|
219
|
-
function modelForResolution(resolution) {
|
|
159
|
+
export function modelForResolution(resolution) {
|
|
220
160
|
if (resolution.kind === "matched") {
|
|
221
161
|
return resolution.model;
|
|
222
162
|
}
|
|
@@ -225,86 +165,73 @@ function modelForResolution(resolution) {
|
|
|
225
165
|
}
|
|
226
166
|
return AGENT_ANY_MODEL;
|
|
227
167
|
}
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
warnIfDisabledFallback(node.identifier, modelResolution, config);
|
|
168
|
+
export function resolveTodoAgentMetadata(arguments_) {
|
|
169
|
+
const { ticket, description, modelResolution, config, isTodo } = arguments_;
|
|
231
170
|
let repository;
|
|
232
171
|
let model;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Progress (already running) or Done (cleaner only needs the id) would just
|
|
236
|
-
// invite tick-spam warnings on already-finished tickets — e.g. when a
|
|
237
|
-
// description was edited or knownRepositories changed after dispatch. A
|
|
238
|
-
// ticket sitting in Todo with no parseable repo is still the operator's
|
|
239
|
-
// bug, so surface it loudly there and treat it as not-eligible so the rest
|
|
240
|
-
// of the board still ticks instead of the whole watch loop crashing on
|
|
241
|
-
// one bad ticket.
|
|
242
|
-
if (modelResolution.kind !== "no-label" && isTodoStatusForOwnProject(node, config)) {
|
|
243
|
-
const resolution = resolveRepositoryFor({
|
|
244
|
-
description: node.description ?? undefined,
|
|
245
|
-
config,
|
|
246
|
-
ticket: node.identifier,
|
|
247
|
-
});
|
|
172
|
+
if (modelResolution.kind !== "no-label" && isTodo) {
|
|
173
|
+
const resolution = resolveRepositoryFor({ description, config, ticket });
|
|
248
174
|
if (resolution.kind === "ok") {
|
|
249
175
|
({ repository } = resolution);
|
|
250
176
|
model = modelForResolution(modelResolution);
|
|
251
177
|
}
|
|
252
178
|
else {
|
|
253
|
-
log(`WARNING: ${
|
|
179
|
+
log(`WARNING: ${ticket} has an ${AGENT_LABEL_PREFIX}* label but no known repository in its description; skipping dispatch. Add one of workspace.knownRepositories to the description, or remove the ${AGENT_LABEL_PREFIX}* label: ${config.workspace.knownRepositories.join(", ")}`);
|
|
254
180
|
}
|
|
255
181
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
182
|
+
return { repository, model };
|
|
183
|
+
}
|
|
184
|
+
function buildLinearIssue(input) {
|
|
259
185
|
return {
|
|
260
|
-
id:
|
|
186
|
+
id: input.identifier.toLowerCase(),
|
|
187
|
+
uuid: input.uuid,
|
|
188
|
+
title: input.title,
|
|
189
|
+
status: input.status,
|
|
190
|
+
statusId: input.statusId,
|
|
191
|
+
stateType: input.stateType,
|
|
192
|
+
/* v8 ignore next @preserve -- BoardIssues query filters to assignee=isMe so a missing assignee can't occur in practice */
|
|
193
|
+
assignee: input.assigneeName ?? "Unassigned",
|
|
194
|
+
updatedAt: input.updatedAt,
|
|
195
|
+
repository: input.repository,
|
|
196
|
+
model: input.model,
|
|
197
|
+
teamId: input.teamId,
|
|
198
|
+
blockers: blockersFromRelations(input.inverseRelations?.nodes ?? []),
|
|
199
|
+
hasMoreBlockers: input.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function issueFromNode(node, config) {
|
|
203
|
+
const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
|
|
204
|
+
warnIfDisabledFallback(node.identifier, modelResolution, config);
|
|
205
|
+
// Only the dispatcher reads `Issue.repository` / `Issue.model`, and only on
|
|
206
|
+
// tickets in the Todo column it's about to pick up. Resolving them for In
|
|
207
|
+
// Progress (already running) or Done (cleaner only needs the id) would just
|
|
208
|
+
// invite tick-spam warnings on already-finished tickets — e.g. when a
|
|
209
|
+
// description was edited or knownRepositories changed after dispatch.
|
|
210
|
+
const { repository, model } = resolveTodoAgentMetadata({
|
|
211
|
+
ticket: node.identifier,
|
|
212
|
+
/* v8 ignore next @preserve -- BoardIssues query selects description; the ?? guard normalises a null vs undefined edge */
|
|
213
|
+
description: node.description ?? undefined,
|
|
214
|
+
modelResolution,
|
|
215
|
+
config,
|
|
216
|
+
isTodo: node.state?.type === "unstarted",
|
|
217
|
+
});
|
|
218
|
+
return buildLinearIssue({
|
|
219
|
+
identifier: node.identifier,
|
|
261
220
|
uuid: node.id,
|
|
262
221
|
title: node.title,
|
|
263
|
-
/* v8 ignore next @preserve --
|
|
222
|
+
/* v8 ignore next @preserve -- BoardIssues query always returns state */
|
|
264
223
|
status: node.state?.name ?? "Unknown",
|
|
265
|
-
/* v8 ignore next @preserve --
|
|
224
|
+
/* v8 ignore next @preserve -- BoardIssues query always returns state */
|
|
266
225
|
statusId: node.state?.id ?? "",
|
|
267
|
-
|
|
226
|
+
/* v8 ignore next @preserve -- BoardIssues query always returns state */
|
|
227
|
+
stateType: node.state?.type ?? "",
|
|
228
|
+
assigneeName: node.assignee?.name,
|
|
268
229
|
updatedAt: node.updatedAt,
|
|
269
230
|
repository,
|
|
270
231
|
model,
|
|
271
232
|
teamId: node.team?.id ?? "",
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
|
|
275
|
-
hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Drops issues whose status name isn't recognized by their own project's
|
|
280
|
-
* configured statuses. The union `stateNames` filter sent to Linear can
|
|
281
|
-
* pull in an issue from project A whose status name appears in project
|
|
282
|
-
* B's status list but not A's; this guard removes that cross-project
|
|
283
|
-
* leakage so each issue is judged only against its own project's rules.
|
|
284
|
-
*/
|
|
285
|
-
function issueStatusBelongsToOwnProject(node, config) {
|
|
286
|
-
const slugId = node.project?.slugId?.toLowerCase();
|
|
287
|
-
if (slugId === undefined) {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
const project = findProjectBySlugId(config, slugId);
|
|
291
|
-
if (project === undefined) {
|
|
292
|
-
return false;
|
|
293
|
-
}
|
|
294
|
-
const status = node.state?.name;
|
|
295
|
-
/* v8 ignore next 3 @preserve -- GraphQL state filter only returns issues whose state name is in the configured union; an undefined status implies a degenerate Linear response */
|
|
296
|
-
if (status === undefined) {
|
|
297
|
-
return false;
|
|
298
|
-
}
|
|
299
|
-
return projectStateNames(project).has(status);
|
|
300
|
-
}
|
|
301
|
-
function projectStateNames(project) {
|
|
302
|
-
return new Set([
|
|
303
|
-
project.statuses.todo,
|
|
304
|
-
project.statuses.inProgress,
|
|
305
|
-
project.statuses.done,
|
|
306
|
-
...project.statuses.terminal,
|
|
307
|
-
]);
|
|
233
|
+
inverseRelations: node.inverseRelations,
|
|
234
|
+
});
|
|
308
235
|
}
|
|
309
236
|
function escapeRegex(value) {
|
|
310
237
|
return value.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`);
|
|
@@ -339,8 +266,7 @@ export async function fetchBlockersForTicket(arguments_) {
|
|
|
339
266
|
issue {
|
|
340
267
|
identifier
|
|
341
268
|
title
|
|
342
|
-
state { name }
|
|
343
|
-
project { slugId }
|
|
269
|
+
state { name type }
|
|
344
270
|
}
|
|
345
271
|
}
|
|
346
272
|
pageInfo { hasNextPage endCursor }
|
|
@@ -368,8 +294,7 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
368
294
|
title
|
|
369
295
|
description
|
|
370
296
|
team { id }
|
|
371
|
-
|
|
372
|
-
state { name }
|
|
297
|
+
state { name type }
|
|
373
298
|
children { nodes { id } }
|
|
374
299
|
labels(first: ${ISSUE_LABEL_PAGE_SIZE}) {
|
|
375
300
|
nodes { name }
|
|
@@ -380,8 +305,7 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
380
305
|
issue {
|
|
381
306
|
identifier
|
|
382
307
|
title
|
|
383
|
-
state { name }
|
|
384
|
-
project { slugId }
|
|
308
|
+
state { name type }
|
|
385
309
|
}
|
|
386
310
|
}
|
|
387
311
|
pageInfo { hasNextPage }
|
|
@@ -397,33 +321,29 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
397
321
|
uuid: issue.id,
|
|
398
322
|
title: issue.title,
|
|
399
323
|
description: issue.description ?? "",
|
|
324
|
+
/* v8 ignore next @preserve -- ResolveIssue query selects team.id; null only if Linear genuinely returns a teamless ticket */
|
|
400
325
|
teamId: issue.team?.id ?? "",
|
|
401
|
-
projectSlugId: issue.project?.slugId?.toLowerCase(),
|
|
402
326
|
labels: issue.labels.nodes,
|
|
327
|
+
/* v8 ignore next @preserve -- ResolveIssue query selects state; null only if Linear genuinely returns a stateless ticket */
|
|
403
328
|
stateName: issue.state?.name ?? "",
|
|
329
|
+
/* v8 ignore next @preserve -- ResolveIssue query selects state; null only if Linear genuinely returns a stateless ticket */
|
|
330
|
+
stateType: issue.state?.type ?? "",
|
|
404
331
|
blockers: blockersFromRelations(issue.inverseRelations?.nodes ?? []),
|
|
405
332
|
hasMoreBlockers: issue.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
406
333
|
hasChildren: (issue.children?.nodes.length ?? 0) > 0,
|
|
407
334
|
};
|
|
408
335
|
}
|
|
409
336
|
export async function fetchInProgressIssueCount(arguments_) {
|
|
410
|
-
const { client
|
|
411
|
-
const slugIds = config.linear.projects.map((project) => project.slugId);
|
|
412
|
-
// The union state filter is permissive: it can pull in an issue whose state
|
|
413
|
-
// name happens to match a different project's `inProgress`. Post-filter
|
|
414
|
-
// against each issue's OWN project to count only true in-progress tickets.
|
|
415
|
-
const stateNames = [
|
|
416
|
-
...new Set(config.linear.projects.map((project) => project.statuses.inProgress)),
|
|
417
|
-
];
|
|
337
|
+
const { client } = arguments_;
|
|
418
338
|
let after = null;
|
|
419
339
|
let count = 0;
|
|
420
340
|
for (;;) {
|
|
421
341
|
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
422
|
-
const response = await client.client.rawRequest(`query InProgressIssues($
|
|
342
|
+
const response = await client.client.rawRequest(`query InProgressIssues($agentLabelPrefix: String!, $after: String) {
|
|
423
343
|
issues(
|
|
424
344
|
filter: {
|
|
425
|
-
|
|
426
|
-
state: {
|
|
345
|
+
assignee: { isMe: { eq: true } }
|
|
346
|
+
state: { type: { eq: "started" } }
|
|
427
347
|
labels: { some: { name: { startsWith: $agentLabelPrefix } } }
|
|
428
348
|
}
|
|
429
349
|
first: ${ISSUES_PAGE_SIZE}
|
|
@@ -432,31 +352,19 @@ export async function fetchInProgressIssueCount(arguments_) {
|
|
|
432
352
|
) {
|
|
433
353
|
nodes {
|
|
434
354
|
id
|
|
435
|
-
|
|
436
|
-
state { name }
|
|
355
|
+
state { type }
|
|
437
356
|
}
|
|
438
357
|
pageInfo { hasNextPage endCursor }
|
|
439
358
|
}
|
|
440
359
|
}`, {
|
|
441
|
-
slugIds,
|
|
442
|
-
stateNames,
|
|
443
360
|
agentLabelPrefix: AGENT_LABEL_PREFIX,
|
|
444
361
|
after,
|
|
445
362
|
});
|
|
446
363
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
447
364
|
const { issues: page } = response.data;
|
|
448
365
|
for (const node of page.nodes) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (slugId === undefined) {
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
const project = findProjectBySlugId(config, slugId);
|
|
455
|
-
/* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
|
|
456
|
-
if (project === undefined) {
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
if (node.state?.name === project.statuses.inProgress) {
|
|
366
|
+
/* v8 ignore else @preserve -- InProgressIssues query filters server-side to state.type=started; the else branch is unreachable in production */
|
|
367
|
+
if (node.state?.type === "started") {
|
|
460
368
|
count += 1;
|
|
461
369
|
}
|
|
462
370
|
}
|
|
@@ -511,23 +419,12 @@ export function resolveModelFor(arguments_) {
|
|
|
511
419
|
/**
|
|
512
420
|
* `agent-any` collapses to `models.default` here — manual setup doesn't run
|
|
513
421
|
* the usage-gated `any` resolver, so the caller gets a concrete model name
|
|
514
|
-
* instead of a sentinel that downstream code can't interpret.
|
|
515
|
-
* `UnknownProjectError` when the ticket lives in a Linear project that
|
|
516
|
-
* isn't listed in `linear.projects`, so callers can surface the misconfiguration
|
|
517
|
-
* instead of silently using the wrong status names.
|
|
422
|
+
* instead of a sentinel that downstream code can't interpret.
|
|
518
423
|
*/
|
|
519
424
|
export async function fetchResolvedIssue(arguments_) {
|
|
520
425
|
const { client, config, ticket } = arguments_;
|
|
521
426
|
const upper = ticket.toUpperCase();
|
|
522
427
|
const raw = await fetchRawLinearIssue({ client, ticket });
|
|
523
|
-
const project = raw.projectSlugId === undefined ? undefined : findProjectBySlugId(config, raw.projectSlugId);
|
|
524
|
-
if (project === undefined) {
|
|
525
|
-
throw new UnknownProjectError({
|
|
526
|
-
ticket: upper,
|
|
527
|
-
projectSlugId: raw.projectSlugId,
|
|
528
|
-
configuredSlugIds: config.linear.projects.map((entry) => entry.slugId),
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
428
|
const repositoryResolution = resolveRepositoryFor({
|
|
532
429
|
description: raw.description,
|
|
533
430
|
config,
|
|
@@ -539,9 +436,6 @@ export async function fetchResolvedIssue(arguments_) {
|
|
|
539
436
|
repositories: config.workspace.knownRepositories,
|
|
540
437
|
});
|
|
541
438
|
}
|
|
542
|
-
// Manual setup is an explicit per-ticket opt-in by the user, so an
|
|
543
|
-
// unlabeled ticket still resolves to `models.default` — different from
|
|
544
|
-
// the auto-pickup path, where unlabeled tickets are ignored.
|
|
545
439
|
const modelResolution = resolveModelFor({ labels: raw.labels, config });
|
|
546
440
|
warnIfDisabledFallback(ticket, modelResolution, config);
|
|
547
441
|
let model = config.models.default;
|
|
@@ -558,7 +452,6 @@ export async function fetchResolvedIssue(arguments_) {
|
|
|
558
452
|
repository: repositoryResolution.repository,
|
|
559
453
|
model,
|
|
560
454
|
teamId: raw.teamId,
|
|
561
|
-
projectSlugId: project.slugId,
|
|
562
455
|
};
|
|
563
456
|
}
|
|
564
457
|
function parseAgentLabels(labels, config) {
|
|
@@ -588,19 +481,19 @@ function parseAgentLabels(labels, config) {
|
|
|
588
481
|
}
|
|
589
482
|
return fallback;
|
|
590
483
|
}
|
|
591
|
-
function warnIfDisabledFallback(ticket, modelResolution, config) {
|
|
484
|
+
export function warnIfDisabledFallback(ticket, modelResolution, config) {
|
|
592
485
|
if (modelResolution.kind !== "disabled-fallback") {
|
|
593
486
|
return;
|
|
594
487
|
}
|
|
595
488
|
log(`${ticket.toLowerCase()}: agent-${modelResolution.requestedModel} label refers to a disabled model; falling back to models.default (${config.models.default})`);
|
|
596
489
|
}
|
|
597
|
-
function blockersFromRelations(relations) {
|
|
490
|
+
export function blockersFromRelations(relations) {
|
|
598
491
|
return relations
|
|
599
492
|
.filter((relation) => relation.type === "blocks")
|
|
600
493
|
.map((relation) => ({
|
|
601
494
|
id: relation.issue?.identifier?.toLowerCase() ?? "unknown",
|
|
602
495
|
title: relation.issue?.title ?? "",
|
|
603
496
|
status: relation.issue?.state?.name,
|
|
604
|
-
|
|
497
|
+
stateType: relation.issue?.state?.type,
|
|
605
498
|
}));
|
|
606
499
|
}
|