@clipboard-health/groundcrew 3.1.3 → 3.1.4
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 +16 -15
- package/crew.config.example.ts +25 -10
- package/dist/commands/cleaner.d.ts.map +1 -1
- package/dist/commands/cleaner.js +4 -4
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +6 -6
- package/dist/commands/eligibility.d.ts.map +1 -1
- package/dist/commands/eligibility.js +2 -2
- package/dist/commands/orchestrator.d.ts +4 -2
- package/dist/commands/orchestrator.d.ts.map +1 -1
- package/dist/commands/orchestrator.js +6 -105
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +1 -0
- package/dist/commands/ticketDoctor.d.ts.map +1 -1
- package/dist/commands/ticketDoctor.js +28 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/lib/boardSource.d.ts +41 -5
- package/dist/lib/boardSource.d.ts.map +1 -1
- package/dist/lib/boardSource.js +211 -70
- package/dist/lib/config.d.ts +59 -25
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +130 -22
- package/dist/lib/linearIssueStatus.d.ts +3 -1
- package/dist/lib/linearIssueStatus.d.ts.map +1 -1
- package/dist/lib/linearIssueStatus.js +0 -0
- package/dist/lib/util.d.ts +0 -1
- package/dist/lib/util.d.ts.map +1 -1
- package/dist/lib/util.js +0 -4
- package/package.json +1 -1
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
* typed `BoardState` instead of raw nodes.
|
|
5
5
|
*/
|
|
6
6
|
import type { LinearClient } from "@linear/sdk";
|
|
7
|
-
import { type ResolvedConfig } from "./config.ts";
|
|
7
|
+
import { type ResolvedConfig, type ResolvedProjectConfig } from "./config.ts";
|
|
8
8
|
export interface Blocker {
|
|
9
9
|
id: string;
|
|
10
10
|
title: string;
|
|
11
11
|
status: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* SlugId of the project the blocker lives in. `undefined` when Linear
|
|
14
|
+
* returned no project for the blocker (rare — issues can technically
|
|
15
|
+
* exist without a project). Drives `isTerminalStatusForBlocker`'s
|
|
16
|
+
* pick between the blocker's own project terminals and the global
|
|
17
|
+
* union fallback.
|
|
18
|
+
*/
|
|
19
|
+
projectSlugId: string | undefined;
|
|
12
20
|
}
|
|
13
21
|
export interface Issue {
|
|
14
22
|
id: string;
|
|
@@ -23,6 +31,8 @@ export interface Issue {
|
|
|
23
31
|
/** `undefined` when the ticket has no `agent-*` label — i.e. not groundcrew's concern. */
|
|
24
32
|
model: string | undefined;
|
|
25
33
|
teamId: string;
|
|
34
|
+
/** SlugId of the Linear project the issue belongs to — always one of `linear.projects[*].slugId`. */
|
|
35
|
+
projectSlugId: string;
|
|
26
36
|
blockers: Blocker[];
|
|
27
37
|
hasMoreBlockers: boolean;
|
|
28
38
|
}
|
|
@@ -46,10 +56,22 @@ export declare class RepositoryResolutionError extends Error {
|
|
|
46
56
|
repositories: readonly string[];
|
|
47
57
|
});
|
|
48
58
|
}
|
|
59
|
+
export declare class UnknownProjectError extends Error {
|
|
60
|
+
readonly ticket: string;
|
|
61
|
+
readonly projectSlugId: string | undefined;
|
|
62
|
+
readonly configuredSlugIds: readonly string[];
|
|
63
|
+
constructor(arguments_: {
|
|
64
|
+
ticket: string;
|
|
65
|
+
projectSlugId: string | undefined;
|
|
66
|
+
configuredSlugIds: readonly string[];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
49
69
|
export interface BoardSource {
|
|
50
70
|
/**
|
|
51
|
-
* Look up the configured
|
|
52
|
-
*
|
|
71
|
+
* Look up the configured projects and warn loudly on any that aren't
|
|
72
|
+
* there. Throws only when zero projects resolve, so a typo in one of
|
|
73
|
+
* several entries doesn't abort the watch loop. Run once at startup
|
|
74
|
+
* so misconfigurations surface before the first tick.
|
|
53
75
|
*/
|
|
54
76
|
verify(): Promise<void>;
|
|
55
77
|
/** Fetch the current board snapshot. Paginates internally. */
|
|
@@ -60,7 +82,16 @@ interface BoardSourceDeps {
|
|
|
60
82
|
client: LinearClient;
|
|
61
83
|
}
|
|
62
84
|
export declare function createBoardSource(deps: BoardSourceDeps): BoardSource;
|
|
63
|
-
export declare function
|
|
85
|
+
export declare function projectFor(issue: Issue, config: ResolvedConfig): ResolvedProjectConfig;
|
|
86
|
+
export declare function isTerminalStatusForIssue(issue: Issue, config: ResolvedConfig): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Terminal check for a blocker. When the blocker lives in a configured
|
|
89
|
+
* project, we use that project's terminal list directly. Otherwise we
|
|
90
|
+
* fall back to the union of terminals across all configured projects —
|
|
91
|
+
* matches today's single-project "is this name in our terminal list?"
|
|
92
|
+
* behavior so off-config blockers don't regress.
|
|
93
|
+
*/
|
|
94
|
+
export declare function isTerminalStatusForBlocker(blocker: Blocker, config: ResolvedConfig): boolean;
|
|
64
95
|
interface ResolvedIssue {
|
|
65
96
|
uuid: string;
|
|
66
97
|
title: string;
|
|
@@ -68,12 +99,14 @@ interface ResolvedIssue {
|
|
|
68
99
|
repository: string;
|
|
69
100
|
model: string;
|
|
70
101
|
teamId: string;
|
|
102
|
+
projectSlugId: string;
|
|
71
103
|
}
|
|
72
104
|
export interface RawLinearIssue {
|
|
73
105
|
uuid: string;
|
|
74
106
|
title: string;
|
|
75
107
|
description: string;
|
|
76
108
|
teamId: string;
|
|
109
|
+
projectSlugId: string | undefined;
|
|
77
110
|
labels: {
|
|
78
111
|
name: string;
|
|
79
112
|
}[];
|
|
@@ -127,7 +160,10 @@ export declare function resolveModelFor(arguments_: {
|
|
|
127
160
|
/**
|
|
128
161
|
* `agent-any` collapses to `models.default` here — manual setup doesn't run
|
|
129
162
|
* the usage-gated `any` resolver, so the caller gets a concrete model name
|
|
130
|
-
* instead of a sentinel that downstream code can't interpret.
|
|
163
|
+
* instead of a sentinel that downstream code can't interpret. Throws
|
|
164
|
+
* `UnknownProjectError` when the ticket lives in a Linear project that
|
|
165
|
+
* isn't listed in `linear.projects`, so callers can surface the misconfiguration
|
|
166
|
+
* instead of silently using the wrong status names.
|
|
131
167
|
*/
|
|
132
168
|
export declare function fetchResolvedIssue(arguments_: {
|
|
133
169
|
client: LinearClient;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,
|
|
1
|
+
{"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/boardSource.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* snapshot. Owns the GraphQL queries and shape parsing so callers consume a
|
|
4
4
|
* typed `BoardState` instead of raw nodes.
|
|
5
5
|
*/
|
|
6
|
-
import { AGENT_ANY_MODEL, isShippedDefaultDisabled } from "./config.js";
|
|
6
|
+
import { AGENT_ANY_MODEL, findProjectBySlugId, isShippedDefaultDisabled, unionTerminalStatuses, } from "./config.js";
|
|
7
7
|
import { log } from "./util.js";
|
|
8
8
|
const AGENT_LABEL_PREFIX = "agent-";
|
|
9
9
|
const ISSUES_PAGE_SIZE = 250;
|
|
@@ -17,61 +17,115 @@ export class RepositoryResolutionError extends Error {
|
|
|
17
17
|
this.name = "RepositoryResolutionError";
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
export class UnknownProjectError extends Error {
|
|
21
|
+
ticket;
|
|
22
|
+
projectSlugId;
|
|
23
|
+
configuredSlugIds;
|
|
24
|
+
constructor(arguments_) {
|
|
25
|
+
const { ticket, projectSlugId, configuredSlugIds } = arguments_;
|
|
26
|
+
const ticketProjectClause = projectSlugId === undefined
|
|
27
|
+
? "has no associated Linear project"
|
|
28
|
+
: `belongs to Linear project slugId "${projectSlugId}"`;
|
|
29
|
+
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.`);
|
|
30
|
+
this.name = "UnknownProjectError";
|
|
31
|
+
this.ticket = ticket;
|
|
32
|
+
this.projectSlugId = projectSlugId;
|
|
33
|
+
this.configuredSlugIds = configuredSlugIds;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
20
36
|
export function createBoardSource(deps) {
|
|
21
37
|
const { config, client } = deps;
|
|
22
38
|
return {
|
|
23
39
|
async verify() {
|
|
24
|
-
await
|
|
40
|
+
await verifyProjects(client, config);
|
|
25
41
|
},
|
|
26
42
|
async fetch() {
|
|
27
43
|
return await fetchBoard(client, config);
|
|
28
44
|
},
|
|
29
45
|
};
|
|
30
46
|
}
|
|
31
|
-
export function
|
|
32
|
-
|
|
47
|
+
export function projectFor(issue, config) {
|
|
48
|
+
const resolved = findProjectBySlugId(config, issue.projectSlugId);
|
|
49
|
+
/* v8 ignore next 5 @preserve -- fetchBoard's slugId filter and issueStatusBelongsToOwnProject keep production issues from reaching here with an unknown slugId */
|
|
50
|
+
if (resolved === undefined) {
|
|
51
|
+
throw new Error(`Issue ${issue.id} carries projectSlugId "${issue.projectSlugId}" which is not in linear.projects`);
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
export function isTerminalStatusForIssue(issue, config) {
|
|
56
|
+
return projectFor(issue, config).statuses.terminal.includes(issue.status);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Terminal check for a blocker. When the blocker lives in a configured
|
|
60
|
+
* project, we use that project's terminal list directly. Otherwise we
|
|
61
|
+
* fall back to the union of terminals across all configured projects —
|
|
62
|
+
* matches today's single-project "is this name in our terminal list?"
|
|
63
|
+
* behavior so off-config blockers don't regress.
|
|
64
|
+
*/
|
|
65
|
+
export function isTerminalStatusForBlocker(blocker, config) {
|
|
66
|
+
if (blocker.status === undefined) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (blocker.projectSlugId !== undefined) {
|
|
70
|
+
const project = findProjectBySlugId(config, blocker.projectSlugId);
|
|
71
|
+
if (project !== undefined) {
|
|
72
|
+
return project.statuses.terminal.includes(blocker.status);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return unionTerminalStatuses(config).has(blocker.status);
|
|
33
76
|
}
|
|
34
|
-
async function
|
|
35
|
-
const
|
|
36
|
-
|
|
77
|
+
async function verifyProjects(client, config) {
|
|
78
|
+
const slugIds = config.linear.projects.map((project) => project.slugId);
|
|
79
|
+
const response = await client.client.rawRequest(`query VerifyProjects($slugIds: [String!]!) {
|
|
80
|
+
projects(filter: { slugId: { in: $slugIds } }, first: ${slugIds.length}) {
|
|
37
81
|
nodes { id name slugId }
|
|
38
82
|
}
|
|
39
|
-
}`, {
|
|
83
|
+
}`, { slugIds });
|
|
40
84
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
41
85
|
const { projects } = response.data;
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
const resolved = new Map(projects.nodes.map((project) => [project.slugId.toLowerCase(), project]));
|
|
87
|
+
for (const project of config.linear.projects) {
|
|
88
|
+
const found = resolved.get(project.slugId);
|
|
89
|
+
if (found === undefined) {
|
|
90
|
+
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.`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
log(`Resolved Linear project: ${found.name} (slugId ${found.slugId})`);
|
|
94
|
+
}
|
|
95
|
+
if (resolved.size === 0) {
|
|
96
|
+
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.`);
|
|
45
97
|
}
|
|
46
|
-
log(`Resolved Linear project: ${project.name} (slugId ${project.slugId})`);
|
|
47
98
|
}
|
|
48
99
|
async function fetchBoard(client, config) {
|
|
49
100
|
const nodes = [];
|
|
50
101
|
let after = null;
|
|
51
102
|
// Two server-side filters narrow the response to tickets the orchestrator
|
|
52
103
|
// can actually act on:
|
|
53
|
-
// 1. State:
|
|
54
|
-
//
|
|
55
|
-
//
|
|
104
|
+
// 1. State: union of every configured project's
|
|
105
|
+
// {todo, inProgress, done, terminal} state names. Backlog, Triage,
|
|
106
|
+
// and custom columns are dropped server-side. Each issue is
|
|
107
|
+
// post-filtered against ITS OWN project's statuses below so a
|
|
108
|
+
// state name from project A doesn't leak into project B.
|
|
56
109
|
// 2. Labels: at least one `agent-*` label — i.e. someone opted the ticket
|
|
57
110
|
// in to groundcrew. Without this, every human-owned ticket on a shared
|
|
58
111
|
// project would round-trip back just to be filtered out client-side.
|
|
59
112
|
// The client-side `isGroundcrewIssue` guard in dispatcher.ts is now
|
|
60
113
|
// belt-and-suspenders against query drift, not the load-bearing filter.
|
|
114
|
+
const slugIds = config.linear.projects.map((project) => project.slugId);
|
|
61
115
|
const stateNames = [
|
|
62
|
-
...new Set([
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
...
|
|
67
|
-
]),
|
|
116
|
+
...new Set(config.linear.projects.flatMap((project) => [
|
|
117
|
+
project.statuses.todo,
|
|
118
|
+
project.statuses.inProgress,
|
|
119
|
+
project.statuses.done,
|
|
120
|
+
...project.statuses.terminal,
|
|
121
|
+
])),
|
|
68
122
|
];
|
|
69
123
|
for (;;) {
|
|
70
124
|
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
71
|
-
const response = await client.client.rawRequest(`query BoardIssues($
|
|
125
|
+
const response = await client.client.rawRequest(`query BoardIssues($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
|
|
72
126
|
issues(
|
|
73
127
|
filter: {
|
|
74
|
-
project: { slugId: {
|
|
128
|
+
project: { slugId: { in: $slugIds } }
|
|
75
129
|
state: { name: { in: $stateNames } }
|
|
76
130
|
labels: { some: { name: { startsWith: $agentLabelPrefix } } }
|
|
77
131
|
}
|
|
@@ -88,6 +142,7 @@ async function fetchBoard(client, config) {
|
|
|
88
142
|
state { id name }
|
|
89
143
|
team { id key }
|
|
90
144
|
assignee { name }
|
|
145
|
+
project { slugId }
|
|
91
146
|
children { nodes { id } }
|
|
92
147
|
labels {
|
|
93
148
|
nodes {
|
|
@@ -101,6 +156,7 @@ async function fetchBoard(client, config) {
|
|
|
101
156
|
identifier
|
|
102
157
|
title
|
|
103
158
|
state { name }
|
|
159
|
+
project { slugId }
|
|
104
160
|
}
|
|
105
161
|
}
|
|
106
162
|
pageInfo { hasNextPage }
|
|
@@ -109,7 +165,7 @@ async function fetchBoard(client, config) {
|
|
|
109
165
|
pageInfo { hasNextPage endCursor }
|
|
110
166
|
}
|
|
111
167
|
}`, {
|
|
112
|
-
|
|
168
|
+
slugIds,
|
|
113
169
|
stateNames,
|
|
114
170
|
agentLabelPrefix: AGENT_LABEL_PREFIX,
|
|
115
171
|
after,
|
|
@@ -128,44 +184,86 @@ async function fetchBoard(client, config) {
|
|
|
128
184
|
// would abort the whole `crew run` before the Todo filter ever runs.
|
|
129
185
|
const issues = nodes
|
|
130
186
|
.filter((node) => node.children.nodes.length === 0)
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
warnIfDisabledFallback(node.identifier, modelResolution, config);
|
|
134
|
-
const repository = modelResolution.kind === "no-label"
|
|
135
|
-
? undefined
|
|
136
|
-
: parseRepository({
|
|
137
|
-
description: node.description ?? undefined,
|
|
138
|
-
config,
|
|
139
|
-
repositoryRegex,
|
|
140
|
-
ticket: node.identifier,
|
|
141
|
-
});
|
|
142
|
-
let model;
|
|
143
|
-
if (modelResolution.kind === "matched") {
|
|
144
|
-
({ model } = modelResolution);
|
|
145
|
-
}
|
|
146
|
-
else if (modelResolution.kind === "disabled-fallback") {
|
|
147
|
-
model = modelResolution.fallbackModel;
|
|
148
|
-
}
|
|
149
|
-
else if (modelResolution.kind === "agent-any") {
|
|
150
|
-
model = AGENT_ANY_MODEL;
|
|
151
|
-
}
|
|
152
|
-
return {
|
|
153
|
-
id: node.identifier.toLowerCase(),
|
|
154
|
-
uuid: node.id,
|
|
155
|
-
title: node.title,
|
|
156
|
-
status: node.state?.name ?? "Unknown",
|
|
157
|
-
statusId: node.state?.id ?? "",
|
|
158
|
-
assignee: node.assignee?.name ?? "Unassigned",
|
|
159
|
-
updatedAt: node.updatedAt,
|
|
160
|
-
repository,
|
|
161
|
-
model,
|
|
162
|
-
teamId: node.team?.id ?? "",
|
|
163
|
-
blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
|
|
164
|
-
hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
165
|
-
};
|
|
166
|
-
});
|
|
187
|
+
.filter((node) => issueStatusBelongsToOwnProject(node, config))
|
|
188
|
+
.map((node) => issueFromNode(node, config, repositoryRegex));
|
|
167
189
|
return { timestamp: new Date().toISOString(), issues };
|
|
168
190
|
}
|
|
191
|
+
function modelForResolution(resolution) {
|
|
192
|
+
if (resolution.kind === "matched") {
|
|
193
|
+
return resolution.model;
|
|
194
|
+
}
|
|
195
|
+
if (resolution.kind === "disabled-fallback") {
|
|
196
|
+
return resolution.fallbackModel;
|
|
197
|
+
}
|
|
198
|
+
if (resolution.kind === "agent-any") {
|
|
199
|
+
return AGENT_ANY_MODEL;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
function issueFromNode(node, config, repositoryRegex) {
|
|
204
|
+
const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
|
|
205
|
+
warnIfDisabledFallback(node.identifier, modelResolution, config);
|
|
206
|
+
const repository = modelResolution.kind === "no-label"
|
|
207
|
+
? undefined
|
|
208
|
+
: parseRepository({
|
|
209
|
+
description: node.description ?? undefined,
|
|
210
|
+
config,
|
|
211
|
+
repositoryRegex,
|
|
212
|
+
ticket: node.identifier,
|
|
213
|
+
});
|
|
214
|
+
// `issueStatusBelongsToOwnProject` drops nodes whose `state` or `project`
|
|
215
|
+
// is missing, so by the time we land here both are defined. The nullish
|
|
216
|
+
// coalescing on those fields is belt-and-suspenders for type narrowing.
|
|
217
|
+
return {
|
|
218
|
+
id: node.identifier.toLowerCase(),
|
|
219
|
+
uuid: node.id,
|
|
220
|
+
title: node.title,
|
|
221
|
+
/* v8 ignore next @preserve -- post-filter guarantees `state` is defined */
|
|
222
|
+
status: node.state?.name ?? "Unknown",
|
|
223
|
+
/* v8 ignore next @preserve -- post-filter guarantees `state` is defined */
|
|
224
|
+
statusId: node.state?.id ?? "",
|
|
225
|
+
assignee: node.assignee?.name ?? "Unassigned",
|
|
226
|
+
updatedAt: node.updatedAt,
|
|
227
|
+
repository,
|
|
228
|
+
model: modelForResolution(modelResolution),
|
|
229
|
+
teamId: node.team?.id ?? "",
|
|
230
|
+
/* v8 ignore next @preserve -- post-filter guarantees `project` is defined */
|
|
231
|
+
projectSlugId: node.project?.slugId?.toLowerCase() ?? "",
|
|
232
|
+
blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
|
|
233
|
+
hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Drops issues whose status name isn't recognized by their own project's
|
|
238
|
+
* configured statuses. The union `stateNames` filter sent to Linear can
|
|
239
|
+
* pull in an issue from project A whose status name appears in project
|
|
240
|
+
* B's status list but not A's; this guard removes that cross-project
|
|
241
|
+
* leakage so each issue is judged only against its own project's rules.
|
|
242
|
+
*/
|
|
243
|
+
function issueStatusBelongsToOwnProject(node, config) {
|
|
244
|
+
const slugId = node.project?.slugId?.toLowerCase();
|
|
245
|
+
if (slugId === undefined) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
const project = findProjectBySlugId(config, slugId);
|
|
249
|
+
if (project === undefined) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const status = node.state?.name;
|
|
253
|
+
/* 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 */
|
|
254
|
+
if (status === undefined) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return projectStateNames(project).has(status);
|
|
258
|
+
}
|
|
259
|
+
function projectStateNames(project) {
|
|
260
|
+
return new Set([
|
|
261
|
+
project.statuses.todo,
|
|
262
|
+
project.statuses.inProgress,
|
|
263
|
+
project.statuses.done,
|
|
264
|
+
...project.statuses.terminal,
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
169
267
|
function escapeRegex(value) {
|
|
170
268
|
return value.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`);
|
|
171
269
|
}
|
|
@@ -200,6 +298,7 @@ export async function fetchBlockersForTicket(arguments_) {
|
|
|
200
298
|
identifier
|
|
201
299
|
title
|
|
202
300
|
state { name }
|
|
301
|
+
project { slugId }
|
|
203
302
|
}
|
|
204
303
|
}
|
|
205
304
|
pageInfo { hasNextPage endCursor }
|
|
@@ -227,6 +326,7 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
227
326
|
title
|
|
228
327
|
description
|
|
229
328
|
team { id }
|
|
329
|
+
project { slugId }
|
|
230
330
|
state { name }
|
|
231
331
|
labels(first: ${ISSUE_LABEL_PAGE_SIZE}) {
|
|
232
332
|
nodes { name }
|
|
@@ -238,6 +338,7 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
238
338
|
identifier
|
|
239
339
|
title
|
|
240
340
|
state { name }
|
|
341
|
+
project { slugId }
|
|
241
342
|
}
|
|
242
343
|
}
|
|
243
344
|
pageInfo { hasNextPage }
|
|
@@ -254,6 +355,7 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
254
355
|
title: issue.title,
|
|
255
356
|
description: issue.description ?? "",
|
|
256
357
|
teamId: issue.team?.id ?? "",
|
|
358
|
+
projectSlugId: issue.project?.slugId?.toLowerCase(),
|
|
257
359
|
labels: issue.labels.nodes,
|
|
258
360
|
stateName: issue.state?.name ?? "",
|
|
259
361
|
blockers: blockersFromRelations(issue.inverseRelations?.nodes ?? []),
|
|
@@ -262,33 +364,58 @@ export async function fetchRawLinearIssue(arguments_) {
|
|
|
262
364
|
}
|
|
263
365
|
export async function fetchInProgressIssueCount(arguments_) {
|
|
264
366
|
const { client, config } = arguments_;
|
|
367
|
+
const slugIds = config.linear.projects.map((project) => project.slugId);
|
|
368
|
+
// The union state filter is permissive: it can pull in an issue whose state
|
|
369
|
+
// name happens to match a different project's `inProgress`. Post-filter
|
|
370
|
+
// against each issue's OWN project to count only true in-progress tickets.
|
|
371
|
+
const stateNames = [
|
|
372
|
+
...new Set(config.linear.projects.map((project) => project.statuses.inProgress)),
|
|
373
|
+
];
|
|
265
374
|
let after = null;
|
|
266
375
|
let count = 0;
|
|
267
376
|
for (;;) {
|
|
268
377
|
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
269
|
-
const response = await client.client.rawRequest(`query InProgressIssues($
|
|
378
|
+
const response = await client.client.rawRequest(`query InProgressIssues($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
|
|
270
379
|
issues(
|
|
271
380
|
filter: {
|
|
272
|
-
project: { slugId: {
|
|
273
|
-
state: { name: {
|
|
381
|
+
project: { slugId: { in: $slugIds } }
|
|
382
|
+
state: { name: { in: $stateNames } }
|
|
274
383
|
labels: { some: { name: { startsWith: $agentLabelPrefix } } }
|
|
275
384
|
}
|
|
276
385
|
first: ${ISSUES_PAGE_SIZE}
|
|
277
386
|
after: $after
|
|
278
387
|
includeArchived: false
|
|
279
388
|
) {
|
|
280
|
-
nodes {
|
|
389
|
+
nodes {
|
|
390
|
+
id
|
|
391
|
+
project { slugId }
|
|
392
|
+
state { name }
|
|
393
|
+
}
|
|
281
394
|
pageInfo { hasNextPage endCursor }
|
|
282
395
|
}
|
|
283
396
|
}`, {
|
|
284
|
-
|
|
285
|
-
|
|
397
|
+
slugIds,
|
|
398
|
+
stateNames,
|
|
286
399
|
agentLabelPrefix: AGENT_LABEL_PREFIX,
|
|
287
400
|
after,
|
|
288
401
|
});
|
|
289
402
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
290
403
|
const { issues: page } = response.data;
|
|
291
|
-
|
|
404
|
+
for (const node of page.nodes) {
|
|
405
|
+
const slugId = node.project?.slugId?.toLowerCase();
|
|
406
|
+
/* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
|
|
407
|
+
if (slugId === undefined) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const project = findProjectBySlugId(config, slugId);
|
|
411
|
+
/* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
|
|
412
|
+
if (project === undefined) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (node.state?.name === project.statuses.inProgress) {
|
|
416
|
+
count += 1;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
292
419
|
if (!page.pageInfo.hasNextPage) {
|
|
293
420
|
return count;
|
|
294
421
|
}
|
|
@@ -327,19 +454,31 @@ export function resolveModelFor(arguments_) {
|
|
|
327
454
|
/**
|
|
328
455
|
* `agent-any` collapses to `models.default` here — manual setup doesn't run
|
|
329
456
|
* the usage-gated `any` resolver, so the caller gets a concrete model name
|
|
330
|
-
* instead of a sentinel that downstream code can't interpret.
|
|
457
|
+
* instead of a sentinel that downstream code can't interpret. Throws
|
|
458
|
+
* `UnknownProjectError` when the ticket lives in a Linear project that
|
|
459
|
+
* isn't listed in `linear.projects`, so callers can surface the misconfiguration
|
|
460
|
+
* instead of silently using the wrong status names.
|
|
331
461
|
*/
|
|
332
462
|
export async function fetchResolvedIssue(arguments_) {
|
|
333
463
|
const { client, config, ticket } = arguments_;
|
|
464
|
+
const upper = ticket.toUpperCase();
|
|
334
465
|
const raw = await fetchRawLinearIssue({ client, ticket });
|
|
466
|
+
const project = raw.projectSlugId === undefined ? undefined : findProjectBySlugId(config, raw.projectSlugId);
|
|
467
|
+
if (project === undefined) {
|
|
468
|
+
throw new UnknownProjectError({
|
|
469
|
+
ticket: upper,
|
|
470
|
+
projectSlugId: raw.projectSlugId,
|
|
471
|
+
configuredSlugIds: config.linear.projects.map((entry) => entry.slugId),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
335
474
|
const repositoryResolution = resolveRepositoryFor({
|
|
336
475
|
description: raw.description,
|
|
337
476
|
config,
|
|
338
|
-
ticket:
|
|
477
|
+
ticket: upper,
|
|
339
478
|
});
|
|
340
479
|
if (repositoryResolution.kind === "missing") {
|
|
341
480
|
throw new RepositoryResolutionError({
|
|
342
|
-
ticket:
|
|
481
|
+
ticket: upper,
|
|
343
482
|
repositories: config.workspace.knownRepositories,
|
|
344
483
|
});
|
|
345
484
|
}
|
|
@@ -362,6 +501,7 @@ export async function fetchResolvedIssue(arguments_) {
|
|
|
362
501
|
repository: repositoryResolution.repository,
|
|
363
502
|
model,
|
|
364
503
|
teamId: raw.teamId,
|
|
504
|
+
projectSlugId: project.slugId,
|
|
365
505
|
};
|
|
366
506
|
}
|
|
367
507
|
function parseRepository(arguments_) {
|
|
@@ -436,5 +576,6 @@ function blockersFromRelations(relations) {
|
|
|
436
576
|
id: relation.issue?.identifier?.toLowerCase() ?? "unknown",
|
|
437
577
|
title: relation.issue?.title ?? "",
|
|
438
578
|
status: relation.issue?.state?.name,
|
|
579
|
+
projectSlugId: relation.issue?.project?.slugId?.toLowerCase(),
|
|
439
580
|
}));
|
|
440
581
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -94,29 +94,43 @@ interface DisabledUserModelDefinition {
|
|
|
94
94
|
disabled: true;
|
|
95
95
|
}
|
|
96
96
|
type UserModelDefinition = EnabledUserModelDefinition | DisabledUserModelDefinition;
|
|
97
|
+
/**
|
|
98
|
+
* One Linear project the orchestrator should watch. Each project has its
|
|
99
|
+
* own status name overrides so multi-team setups with divergent workflow
|
|
100
|
+
* state names (e.g. "Todo" vs "To Do", "Shipped" vs "Done") can coexist
|
|
101
|
+
* under one `crew` process.
|
|
102
|
+
*/
|
|
103
|
+
export interface ProjectConfig {
|
|
104
|
+
/**
|
|
105
|
+
* Project URL slug as it appears in Linear's URL bar — e.g.
|
|
106
|
+
* `ai-strategy-5152195762f3` from
|
|
107
|
+
* `https://linear.app/<workspace>/project/ai-strategy-5152195762f3`.
|
|
108
|
+
* The trailing 12-character hex `slugId` is what's used for the
|
|
109
|
+
* GraphQL filter; the leading name segment is kept intact in the
|
|
110
|
+
* config so `config.ts` is self-documenting at a glance, and so it
|
|
111
|
+
* survives Linear project renames.
|
|
112
|
+
*/
|
|
113
|
+
projectSlug: string;
|
|
114
|
+
statuses?: {
|
|
115
|
+
todo?: string;
|
|
116
|
+
inProgress?: string;
|
|
117
|
+
done?: string;
|
|
118
|
+
terminal?: string[];
|
|
119
|
+
};
|
|
120
|
+
}
|
|
97
121
|
/**
|
|
98
122
|
* Loose user-facing shape — what a `config.ts` file declares.
|
|
99
|
-
* Fields with defaults are optional; only `linear.
|
|
123
|
+
* Fields with defaults are optional; only `linear.projects` and the
|
|
100
124
|
* `workspace.*` fields are required.
|
|
101
125
|
*/
|
|
102
126
|
export interface Config {
|
|
103
127
|
linear: {
|
|
104
128
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* `
|
|
108
|
-
* The trailing 12-character hex `slugId` is what's used for the
|
|
109
|
-
* GraphQL filter; the leading name segment is kept intact in the
|
|
110
|
-
* config so `config.ts` is self-documenting at a glance, and so it
|
|
111
|
-
* survives Linear project renames.
|
|
129
|
+
* One or more Linear projects to watch. A single `crew` process
|
|
130
|
+
* dispatches across all configured projects under a shared
|
|
131
|
+
* `orchestrator.maximumInProgress` budget.
|
|
112
132
|
*/
|
|
113
|
-
|
|
114
|
-
statuses?: {
|
|
115
|
-
todo?: string;
|
|
116
|
-
inProgress?: string;
|
|
117
|
-
done?: string;
|
|
118
|
-
terminal?: string[];
|
|
119
|
-
};
|
|
133
|
+
projects: ProjectConfig[];
|
|
120
134
|
};
|
|
121
135
|
git?: {
|
|
122
136
|
remote?: string;
|
|
@@ -168,21 +182,24 @@ export interface Config {
|
|
|
168
182
|
file?: string;
|
|
169
183
|
};
|
|
170
184
|
}
|
|
185
|
+
export interface ResolvedProjectConfig {
|
|
186
|
+
/** Original full slug from `ProjectConfig.projectSlug` — for log lines. */
|
|
187
|
+
projectSlug: string;
|
|
188
|
+
/** 12-char hex tail of `projectSlug` — the value Linear filters on. */
|
|
189
|
+
slugId: string;
|
|
190
|
+
statuses: {
|
|
191
|
+
todo: string;
|
|
192
|
+
inProgress: string;
|
|
193
|
+
done: string;
|
|
194
|
+
terminal: string[];
|
|
195
|
+
};
|
|
196
|
+
}
|
|
171
197
|
/**
|
|
172
198
|
* Strict shape after defaults are applied — what scripts work with.
|
|
173
199
|
*/
|
|
174
200
|
export interface ResolvedConfig {
|
|
175
201
|
linear: {
|
|
176
|
-
|
|
177
|
-
projectSlug: string;
|
|
178
|
-
/** 12-char hex tail of `projectSlug` — the value Linear filters on. */
|
|
179
|
-
slugId: string;
|
|
180
|
-
statuses: {
|
|
181
|
-
todo: string;
|
|
182
|
-
inProgress: string;
|
|
183
|
-
done: string;
|
|
184
|
-
terminal: string[];
|
|
185
|
-
};
|
|
202
|
+
projects: ResolvedProjectConfig[];
|
|
186
203
|
};
|
|
187
204
|
git: {
|
|
188
205
|
remote: string;
|
|
@@ -228,5 +245,22 @@ export interface ResolvedConfig {
|
|
|
228
245
|
* Consumers needing to distinguish disabled-by-user from unknown-label use this.
|
|
229
246
|
*/
|
|
230
247
|
export declare function isShippedDefaultDisabled(config: Pick<ResolvedConfig, "models">, name: string): boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Returns the resolved project the issue belongs to, or `undefined` when
|
|
250
|
+
* its slugId isn't in `linear.projects[]`. Callers in the dispatcher
|
|
251
|
+
* path expect a project to always exist (the board fetch only surfaces
|
|
252
|
+
* issues from configured projects); callers in the manual-ticket path
|
|
253
|
+
* (`setupWorkspace`, `ticketDoctor`) use this to detect off-config
|
|
254
|
+
* tickets and surface a clear error.
|
|
255
|
+
*/
|
|
256
|
+
export declare function findProjectBySlugId(config: Pick<ResolvedConfig, "linear">, slugId: string): ResolvedProjectConfig | undefined;
|
|
257
|
+
/**
|
|
258
|
+
* Union of every terminal status name configured across all watched
|
|
259
|
+
* projects. Used for blocker terminal checks when the blocker belongs
|
|
260
|
+
* to a project we don't watch — matches today's single-project "is the
|
|
261
|
+
* status terminal under any configured project?" behavior so off-config
|
|
262
|
+
* blockers don't regress.
|
|
263
|
+
*/
|
|
264
|
+
export declare function unionTerminalStatuses(config: Pick<ResolvedConfig, "linear">): ReadonlySet<string>;
|
|
231
265
|
export declare function loadConfig(): Promise<Readonly<ResolvedConfig>>;
|
|
232
266
|
//# sourceMappingURL=config.d.ts.map
|