@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.
Files changed (37) hide show
  1. package/README.md +20 -25
  2. package/clearance-allow-hosts +10 -0
  3. package/crew.config.example.ts +14 -33
  4. package/dist/commands/cleaner.d.ts.map +1 -1
  5. package/dist/commands/cleaner.js +1 -5
  6. package/dist/commands/dispatcher.d.ts.map +1 -1
  7. package/dist/commands/dispatcher.js +5 -5
  8. package/dist/commands/eligibility.d.ts +1 -1
  9. package/dist/commands/eligibility.d.ts.map +1 -1
  10. package/dist/commands/eligibility.js +4 -4
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/init.js +2 -1
  13. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  14. package/dist/commands/setupWorkspace.js +1 -2
  15. package/dist/commands/ticketDoctor.d.ts.map +1 -1
  16. package/dist/commands/ticketDoctor.js +11 -33
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/lib/adapters/linear/factory.d.ts +10 -14
  21. package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
  22. package/dist/lib/adapters/linear/factory.js +23 -63
  23. package/dist/lib/adapters/linear/schema.d.ts +3 -5
  24. package/dist/lib/adapters/linear/schema.d.ts.map +1 -1
  25. package/dist/lib/adapters/linear/schema.js +3 -5
  26. package/dist/lib/boardSource.d.ts +55 -39
  27. package/dist/lib/boardSource.d.ts.map +1 -1
  28. package/dist/lib/boardSource.js +130 -237
  29. package/dist/lib/config.d.ts +11 -70
  30. package/dist/lib/config.d.ts.map +1 -1
  31. package/dist/lib/config.js +10 -157
  32. package/dist/lib/linearIssueStatus.d.ts +0 -4
  33. package/dist/lib/linearIssueStatus.d.ts.map +1 -1
  34. package/dist/lib/linearIssueStatus.js +0 -0
  35. package/dist/lib/ticketSource.d.ts +5 -7
  36. package/dist/lib/ticketSource.d.ts.map +1 -1
  37. package/package.json +1 -1
@@ -1,13 +1,29 @@
1
1
  /**
2
- * Linear adapter — turns the project's GraphQL state into a `BoardState`
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, findProjectBySlugId, isShippedDefaultDisabled, unionTerminalStatuses, } from "./config.js";
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 verifyProjects(client, config);
39
+ await verifyViewer(client);
40
40
  },
41
41
  async fetch() {
42
42
  return await fetchBoard(client, config);
43
43
  },
44
44
  };
45
45
  }
46
- export function projectFor(issue, config) {
47
- const resolved = findProjectBySlugId(config, issue.projectSlugId);
48
- /* v8 ignore next 5 @preserve -- fetchBoard's slugId filter and issueStatusBelongsToOwnProject keep production issues from reaching here with an unknown slugId */
49
- if (resolved === undefined) {
50
- throw new Error(`Issue ${issue.id} carries projectSlugId "${issue.projectSlugId}" which is not in linear.projects`);
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
- return resolved;
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 isTerminalStatusForIssue(issue, config) {
55
- return projectFor(issue, config).statuses.terminal.includes(issue.status);
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. When the blocker lives in a configured
59
- * project, we use that project's terminal list directly. Otherwise we
60
- * fall back to the union of terminals across all configured projects —
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, config) {
65
- if (blocker.status === undefined) {
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
- // Two server-side filters narrow the response to tickets the orchestrator
78
+ // Three server-side filters narrow the response to tickets the orchestrator
102
79
  // can actually act on:
103
- // 1. State: union of every configured project's
104
- // {todo, inProgress, done, terminal} state names. Backlog, Triage,
105
- // and custom columns are dropped server-side. Each issue is
106
- // post-filtered against ITS OWN project's statuses below so a
107
- // state name from project A doesn't leak into project B.
108
- // 2. Labels: at least one `agent-*` label — i.e. someone opted the ticket
109
- // in to groundcrew. Without this, every human-owned ticket on a shared
110
- // project would round-trip back just to be filtered out client-side.
111
- // The client-side `isGroundcrewIssue` guard in dispatcher.ts is now
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 slugIds = config.linear.projects.map((project) => project.slugId);
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($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
93
+ const response = await client.client.rawRequest(`query BoardIssues($stateTypes: [String!]!, $agentLabelPrefix: String!, $after: String) {
125
94
  issues(
126
95
  filter: {
127
- project: { slugId: { in: $slugIds } }
128
- state: { name: { in: $stateNames } }
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
- slugIds,
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) => isTodoStatusForOwnProject(node, config))
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 issueFromNode(node, config) {
229
- const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
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
- // Only the dispatcher reads `Issue.repository` / `Issue.model`, and only on
234
- // tickets in the Todo column it's about to pick up. Resolving them for In
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: ${node.identifier} 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(", ")}`);
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
- // `issueStatusBelongsToOwnProject` drops nodes whose `state` or `project`
257
- // is missing, so by the time we land here both are defined. The nullish
258
- // coalescing on those fields is belt-and-suspenders for type narrowing.
182
+ return { repository, model };
183
+ }
184
+ function buildLinearIssue(input) {
259
185
  return {
260
- id: node.identifier.toLowerCase(),
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 -- post-filter guarantees `state` is defined */
222
+ /* v8 ignore next @preserve -- BoardIssues query always returns state */
264
223
  status: node.state?.name ?? "Unknown",
265
- /* v8 ignore next @preserve -- post-filter guarantees `state` is defined */
224
+ /* v8 ignore next @preserve -- BoardIssues query always returns state */
266
225
  statusId: node.state?.id ?? "",
267
- assignee: node.assignee?.name ?? "Unassigned",
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
- /* v8 ignore next @preserve -- post-filter guarantees `project` is defined */
273
- projectSlugId: node.project?.slugId?.toLowerCase() ?? "",
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
- project { slugId }
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, config } = arguments_;
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($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
342
+ const response = await client.client.rawRequest(`query InProgressIssues($agentLabelPrefix: String!, $after: String) {
423
343
  issues(
424
344
  filter: {
425
- project: { slugId: { in: $slugIds } }
426
- state: { name: { in: $stateNames } }
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
- project { slugId }
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
- const slugId = node.project?.slugId?.toLowerCase();
450
- /* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
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. Throws
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
- projectSlugId: relation.issue?.project?.slugId?.toLowerCase(),
497
+ stateType: relation.issue?.state?.type,
605
498
  }));
606
499
  }