@aivi/tracker-linear 0.0.0-stage → 1.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 +41 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +277 -0
- package/dist/client.js +362 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +171 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +70 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/links.d.ts +48 -0
- package/dist/links.js +83 -0
- package/dist/mcp.d.ts +32 -0
- package/dist/mcp.js +126 -0
- package/dist/module.d.ts +38 -0
- package/dist/module.js +767 -0
- package/dist/projects.d.ts +40 -0
- package/dist/projects.js +119 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +40 -0
- package/dist/runprogress.d.ts +60 -0
- package/dist/runprogress.js +120 -0
- package/dist/setup-project.d.ts +63 -0
- package/dist/setup-project.js +254 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +526 -0
- package/dist/tracker.d.ts +178 -0
- package/dist/tracker.js +435 -0
- package/dist/webhook.d.ts +97 -0
- package/dist/webhook.js +40 -0
- package/dist/work.d.ts +66 -0
- package/dist/work.js +98 -0
- package/docs/linear.md +90 -0
- package/package.json +56 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { errorMessage, getLogger } from '@aivi/core';
|
|
2
|
+
import { MODULE_ID } from './config.js';
|
|
3
|
+
export class LinearApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
/** GraphQL error codes (`extensions.code`), kept apart because the HTTP
|
|
6
|
+
* status says nothing about them — Linear answers entity-not-found with a
|
|
7
|
+
* 200. Empty for transport and token errors. */
|
|
8
|
+
codes;
|
|
9
|
+
constructor(message, status, codes = []) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'LinearApiError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.codes = codes;
|
|
14
|
+
}
|
|
15
|
+
/** Linear's word for "that entity is gone", whatever shape it comes in: a
|
|
16
|
+
* deleted issue takes its agent session with it, and every later call
|
|
17
|
+
* names it. The message is the stable part (`Entity not found: …`); the
|
|
18
|
+
* codes only say *how* Linear refused (`INPUT_ERROR` for a lookup of a
|
|
19
|
+
* dead session, live 2026-10-06), never *what*, so no code may stand in
|
|
20
|
+
* for the words. */
|
|
21
|
+
static isGone(error) {
|
|
22
|
+
return error instanceof LinearApiError && /entity not found/i.test(error.message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const DEFAULT_SCOPE = 'read,write,app:assignable,app:mentionable';
|
|
26
|
+
const ISSUE_FIELDS = `
|
|
27
|
+
id identifier title description branchName url
|
|
28
|
+
state { id name type }
|
|
29
|
+
team { id key }
|
|
30
|
+
labels { nodes { id name } }
|
|
31
|
+
delegate { id }
|
|
32
|
+
assignee { id name }
|
|
33
|
+
archivedAt
|
|
34
|
+
priority
|
|
35
|
+
createdAt
|
|
36
|
+
inverseRelations { nodes { type issue { id state { id name type } } } }
|
|
37
|
+
`;
|
|
38
|
+
const issueOf = ({ labels, inverseRelations, ...rest }) => ({
|
|
39
|
+
...rest,
|
|
40
|
+
labels: labels.nodes,
|
|
41
|
+
// The live Linear schema has no `blockedBy` on Issue: relations are
|
|
42
|
+
// directional, and in `inverseRelations` the relation's source (`issue`) is
|
|
43
|
+
// the blocker of our issue, with `type` the lowercase string `blocks`.
|
|
44
|
+
blockedBy: inverseRelations.nodes.filter(r => r.type === 'blocks').map(r => r.issue),
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* A small GraphQL client for one Linear app. The token is obtained with the
|
|
48
|
+
* `client_credentials` grant (an `app` actor token valid for 30 days), kept in
|
|
49
|
+
* memory, refreshed ahead of its expiry and re-fetched once on a 401. There is
|
|
50
|
+
* no `@linear/sdk`: six operations do not justify it.
|
|
51
|
+
*/
|
|
52
|
+
/** Linear's workflow state groups in board order. `position` is scoped
|
|
53
|
+
* **within a type group** (SDL: "States are displayed in ascending order
|
|
54
|
+
* of position within their type group"), so sorting by position alone
|
|
55
|
+
* interleaves the groups: Done and Canceled each rank near 0 of their own
|
|
56
|
+
* group and would sort before a late `started` lane. Group first, then
|
|
57
|
+
* position — that is the board. */
|
|
58
|
+
const stateGroupOrder = ['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled', 'duplicate'];
|
|
59
|
+
const byBoardOrder = (a, b) => {
|
|
60
|
+
const group = stateGroupOrder.indexOf(a.type) - stateGroupOrder.indexOf(b.type);
|
|
61
|
+
return group !== 0 ? group : a.position - b.position;
|
|
62
|
+
};
|
|
63
|
+
export class LinearClient {
|
|
64
|
+
credentials;
|
|
65
|
+
baseUrl;
|
|
66
|
+
fetchImpl;
|
|
67
|
+
log;
|
|
68
|
+
scope;
|
|
69
|
+
skew;
|
|
70
|
+
token = null;
|
|
71
|
+
refreshing = null;
|
|
72
|
+
constructor(credentials, options = {}) {
|
|
73
|
+
this.credentials = credentials;
|
|
74
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.linear.app').replace(/\/$/, '');
|
|
75
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
76
|
+
this.log = options.log ?? getLogger(['aivi', MODULE_ID]);
|
|
77
|
+
this.scope = options.scope ?? DEFAULT_SCOPE;
|
|
78
|
+
this.skew = options.refreshSkewMs ?? 3_600_000;
|
|
79
|
+
}
|
|
80
|
+
/** A valid token, fetching or refreshing when needed; concurrent callers share one request. */
|
|
81
|
+
async accessToken(force = false) {
|
|
82
|
+
if (!force && this.token && this.token.expiresAt - this.skew > Date.now())
|
|
83
|
+
return this.token.value;
|
|
84
|
+
if (!this.refreshing) {
|
|
85
|
+
this.refreshing = this.fetchToken().finally(() => {
|
|
86
|
+
this.refreshing = null;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return this.refreshing;
|
|
90
|
+
}
|
|
91
|
+
async fetchToken() {
|
|
92
|
+
const body = new URLSearchParams({ grant_type: 'client_credentials', scope: this.scope });
|
|
93
|
+
const basic = Buffer.from(`${this.credentials.clientId}:${this.credentials.clientSecret}`).toString('base64');
|
|
94
|
+
const response = await this.fetchImpl(`${this.baseUrl}/oauth/token`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded' },
|
|
97
|
+
body,
|
|
98
|
+
});
|
|
99
|
+
const text = await response.text();
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new LinearApiError(`Linear token request failed (${response.status}): ${describe(text)}`, response.status);
|
|
102
|
+
}
|
|
103
|
+
const parsed = JSON.parse(text);
|
|
104
|
+
if (!parsed.access_token)
|
|
105
|
+
throw new LinearApiError('Linear token response had no access_token', 502);
|
|
106
|
+
this.token = { value: parsed.access_token, expiresAt: Date.now() + (parsed.expires_in ?? 86_400) * 1000 };
|
|
107
|
+
// A token fetch per API burst is debug-level detail, not something a setup prompt should interleave.
|
|
108
|
+
this.log.debug('linear.token', { expiresIn: parsed.expires_in ?? null });
|
|
109
|
+
return this.token.value;
|
|
110
|
+
}
|
|
111
|
+
/** Run one GraphQL operation; a 401 refreshes the token and retries once. */
|
|
112
|
+
async graphql(query, variables = {}, retried = false) {
|
|
113
|
+
const token = await this.accessToken();
|
|
114
|
+
const response = await this.fetchImpl(`${this.baseUrl}/graphql`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ query, variables }),
|
|
118
|
+
});
|
|
119
|
+
if (response.status === 401 && !retried) {
|
|
120
|
+
await this.accessToken(true);
|
|
121
|
+
return this.graphql(query, variables, true);
|
|
122
|
+
}
|
|
123
|
+
const text = await response.text();
|
|
124
|
+
if (!response.ok)
|
|
125
|
+
throw new LinearApiError(`Linear API ${response.status}: ${describe(text)}`, response.status);
|
|
126
|
+
const parsed = JSON.parse(text);
|
|
127
|
+
if (parsed.errors?.length)
|
|
128
|
+
throw new LinearApiError(parsed.errors.map(e => e.message).join('; '), 200, parsed.errors.map(e => e.extensions?.code ?? ''));
|
|
129
|
+
if (parsed.data === undefined)
|
|
130
|
+
throw new LinearApiError('Linear API returned no data', 502);
|
|
131
|
+
return parsed.data;
|
|
132
|
+
}
|
|
133
|
+
/** The app user's own id and its workspace in one round; also proves the
|
|
134
|
+
* credentials work. The tracker adapter learns `userId`/`orgId` here. */
|
|
135
|
+
async viewer() {
|
|
136
|
+
const data = await this.graphql('query { viewer { id organization { id } } }');
|
|
137
|
+
return { id: data.viewer.id, organizationId: data.viewer.organization.id };
|
|
138
|
+
}
|
|
139
|
+
/** The app user's own id in this workspace; also proves the credentials work. */
|
|
140
|
+
async viewerId() {
|
|
141
|
+
return (await this.viewer()).id;
|
|
142
|
+
}
|
|
143
|
+
/** The teams this app can see with their workflow states, archived excluded:
|
|
144
|
+
* the answer to "which id is PEC?" and "which lanes does PEC have?".
|
|
145
|
+
* Linear returns `states` as a connection; its `nodes` are flattened here. */
|
|
146
|
+
async listTeams() {
|
|
147
|
+
const data = await this.graphql('query { teams { nodes { id key name states { nodes { id name type position } } } } }');
|
|
148
|
+
// The connection's own order is not the board's; the group order and
|
|
149
|
+
// the per-group `position` are.
|
|
150
|
+
return data.teams.nodes.map(team => ({
|
|
151
|
+
id: team.id,
|
|
152
|
+
key: team.key,
|
|
153
|
+
name: team.name,
|
|
154
|
+
states: team.states.nodes.sort(byBoardOrder).map(({ position: _position, ...state }) => state),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
async createActivity(input) {
|
|
158
|
+
const data = await this.graphql(`mutation($input: AgentActivityCreateInput!) { agentActivityCreate(input: $input) { success agentActivity { id } } }`, { input });
|
|
159
|
+
if (!data.agentActivityCreate.success)
|
|
160
|
+
throw new LinearApiError('agentActivityCreate was not successful', 200);
|
|
161
|
+
return data.agentActivityCreate.agentActivity.id;
|
|
162
|
+
}
|
|
163
|
+
/** Move an issue to a workflow state by its id — how a lane move is
|
|
164
|
+
* performed. (schema: `issueUpdate(id: String!, input: IssueUpdateInput!)`,
|
|
165
|
+
* the state carried as `input.stateId`.) */
|
|
166
|
+
async transitionIssue(issueId, stateId) {
|
|
167
|
+
const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`, { id: issueId, input: { stateId } });
|
|
168
|
+
if (!data.issueUpdate.success)
|
|
169
|
+
throw new LinearApiError('issueUpdate was not successful', 200);
|
|
170
|
+
}
|
|
171
|
+
/** The label id a name stands for on a team: the team's own label wins over
|
|
172
|
+
* the workspace's when both carry the name (the API applies any label to
|
|
173
|
+
* any issue, so the disambiguation lives here), and a name no label
|
|
174
|
+
* answers is **created on the team** — the human label must be applicable
|
|
175
|
+
* without a visit to settings first. (schema:
|
|
176
|
+
* `issueLabels(filter: {name: {eq}})`, `issueLabelCreate(input:)`.) */
|
|
177
|
+
async labelIdForTeam(teamId, name) {
|
|
178
|
+
const data = await this.graphql(`query($name: String!) { issueLabels(filter: { name: { eq: $name } }, first: 25) { nodes { id team { id } } } }`, { name });
|
|
179
|
+
const eligible = data.issueLabels.nodes.filter(l => l.team === null || l.team.id === teamId);
|
|
180
|
+
const existing = eligible.find(l => l.team?.id === teamId) ?? eligible[0];
|
|
181
|
+
if (existing)
|
|
182
|
+
return existing.id;
|
|
183
|
+
const created = await this.graphql(`mutation($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { success issueLabel { id } } }`, { input: { name, teamId } });
|
|
184
|
+
if (!created.issueLabelCreate.success)
|
|
185
|
+
throw new LinearApiError(`label "${name}" was not created`, 200);
|
|
186
|
+
return created.issueLabelCreate.issueLabel.id;
|
|
187
|
+
}
|
|
188
|
+
/** Put a label on an issue — how the human label marks a ticket: a stop,
|
|
189
|
+
* a failed closing, a stop the runtime would not answer. (schema:
|
|
190
|
+
* `issueAddLabel(id: String!, labelId: String!)`.) */
|
|
191
|
+
async addLabel(issueId, labelId) {
|
|
192
|
+
const data = await this.graphql(`mutation($id: String!, $labelId: String!) { issueAddLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
|
|
193
|
+
if (!data.issueAddLabel.success)
|
|
194
|
+
throw new LinearApiError('issueAddLabel was not successful', 200);
|
|
195
|
+
}
|
|
196
|
+
/** Take a label off an issue — the lift that lets the walk work a marked
|
|
197
|
+
* ticket again. (schema: `issueRemoveLabel(id: String!, labelId: String!)`.) */
|
|
198
|
+
async removeLabel(issueId, labelId) {
|
|
199
|
+
const data = await this.graphql(`mutation($id: String!, $labelId: String!) { issueRemoveLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
|
|
200
|
+
if (!data.issueRemoveLabel.success)
|
|
201
|
+
throw new LinearApiError('issueRemoveLabel was not successful', 200);
|
|
202
|
+
}
|
|
203
|
+
/** A comment on the **issue** itself, not the agent session's activity:
|
|
204
|
+
* the closing note a person reads without opening the session. Posted
|
|
205
|
+
* with the app's token, so it stands as the app's comment. */
|
|
206
|
+
async createComment(issueId, body) {
|
|
207
|
+
const data = await this.graphql(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, { input: { issueId, body } });
|
|
208
|
+
if (!data.commentCreate.success)
|
|
209
|
+
throw new LinearApiError('commentCreate was not successful', 200);
|
|
210
|
+
}
|
|
211
|
+
/** The ticket's comment trail, oldest first — what `aivi_ticket_comments`
|
|
212
|
+
* reads back. The author is the writer's display name when Linear gives
|
|
213
|
+
* one; the app's own comments carry the app's name like any other. */
|
|
214
|
+
async issueComments(issueId) {
|
|
215
|
+
const data = await this.graphql(`query($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body createdAt user { name } } } } }`, {
|
|
216
|
+
id: issueId,
|
|
217
|
+
});
|
|
218
|
+
if (!data.issue)
|
|
219
|
+
throw new LinearApiError('Entity not found: Issue', 200);
|
|
220
|
+
return data.issue.comments.nodes
|
|
221
|
+
.map(c => ({ author: c.user?.name ?? null, body: c.body, at: c.createdAt }))
|
|
222
|
+
.sort((a, b) => a.at.localeCompare(b.at));
|
|
223
|
+
}
|
|
224
|
+
/** Rewrite the ticket's own words — what `aivi_ticket_edit` performs.
|
|
225
|
+
* Only the fields named are touched; Linear answers with the ticket as
|
|
226
|
+
* it now stands. */
|
|
227
|
+
async issueUpdate(issueId, changes) {
|
|
228
|
+
const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: issueId, input: changes });
|
|
229
|
+
if (!data.issueUpdate.success || !data.issueUpdate.issue)
|
|
230
|
+
throw new LinearApiError('issueUpdate was not successful', 200);
|
|
231
|
+
return issueOf(data.issueUpdate.issue);
|
|
232
|
+
}
|
|
233
|
+
/** The team's label catalogue — the names a worker may add or remove. */
|
|
234
|
+
async teamLabelNames(teamId) {
|
|
235
|
+
const data = await this.graphql(`query($id: String!) { team(id: $id) { labels(first: 50) { nodes { name } } } }`, { id: teamId });
|
|
236
|
+
if (!data.team)
|
|
237
|
+
throw new LinearApiError(`team ${teamId} not found`, 200);
|
|
238
|
+
return data.team.labels.nodes.map(l => l.name);
|
|
239
|
+
}
|
|
240
|
+
/** What a closing note asks Linear for in one round: the issue's comment
|
|
241
|
+
* bodies (the idempotence check — a note already standing is never
|
|
242
|
+
* posted twice) and the agent sessions with their web urls (the note's
|
|
243
|
+
* marker and the link a person clicks to read the whole trail). The
|
|
244
|
+
* comment page is deliberately wide: the check must see a note older
|
|
245
|
+
* than any retry horizon. */
|
|
246
|
+
async closingNoteFacts(issueId) {
|
|
247
|
+
const data = await this.graphql(`query($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body } } agentSessions { nodes { id url } } } }`, { id: issueId });
|
|
248
|
+
if (!data.issue)
|
|
249
|
+
throw new LinearApiError(`issue ${issueId} not found`, 200);
|
|
250
|
+
return { comments: data.issue.comments.nodes, sessions: data.issue.agentSessions.nodes };
|
|
251
|
+
}
|
|
252
|
+
/** Whether Linear has closed this agent session: `endedAt` is its own word
|
|
253
|
+
* for "the result was shown", which is what a follower's retry asks
|
|
254
|
+
* before it would render a result twice. */
|
|
255
|
+
async agentSessionEnded(agentSessionId) {
|
|
256
|
+
const data = await this.graphql('query($id: String!) { agentSession(id: $id) { endedAt } }', { id: agentSessionId });
|
|
257
|
+
return data.agentSession.endedAt !== null;
|
|
258
|
+
}
|
|
259
|
+
/** Replace the session's plan wholesale — Linear takes the full array
|
|
260
|
+
* every time (docs/agent-interaction, Agent Plans: partial updates are
|
|
261
|
+
* explicitly not a thing). `plan` is a JSONObject: the steps array as
|
|
262
|
+
* `{content, status}`. */
|
|
263
|
+
async setPlan(agentSessionId, steps) {
|
|
264
|
+
const data = await this.graphql(`mutation($id: String!, $input: AgentSessionUpdateInput!) { agentSessionUpdate(id: $id, input: $input) { success } }`, { id: agentSessionId, input: { plan: steps } });
|
|
265
|
+
if (!data.agentSessionUpdate.success)
|
|
266
|
+
throw new LinearApiError('agentSessionUpdate was not successful', 200);
|
|
267
|
+
}
|
|
268
|
+
/** Tickets sitting in one state of one team — the eligibility walk's
|
|
269
|
+
* top-to-bottom reading. Linear's public query cannot sort (its `sort`
|
|
270
|
+
* parameter is internal), so the board's order is computed here:
|
|
271
|
+
* Linear's own priority sort — Urgent first, unranked last — and oldest
|
|
272
|
+
* first within a rank. Archived issues never show: the connection
|
|
273
|
+
* leaves them out unless asked. `truncated` says more than 100 sit in
|
|
274
|
+
* the lane; the walk is told rather than shown a partial board in
|
|
275
|
+
* silence. */
|
|
276
|
+
async issuesIn(teamId, stateId) {
|
|
277
|
+
const data = await this.graphql(
|
|
278
|
+
// ID! not String!: Linear's id filters are typed ID and answer a
|
|
279
|
+
// String variable with a 400 (live, 2026-10-02 — the fake board could
|
|
280
|
+
// never catch this, so the walk met it first). Direct id arguments
|
|
281
|
+
// (`issue(id: $id)`) do accept String; filter comparisons do not.
|
|
282
|
+
`query($teamId: ID!, $stateId: ID!) {
|
|
283
|
+
issues(
|
|
284
|
+
filter: { team: { id: { eq: $teamId } }, state: { id: { eq: $stateId } } }
|
|
285
|
+
orderBy: createdAt
|
|
286
|
+
first: 100
|
|
287
|
+
) { nodes { ${ISSUE_FIELDS} } pageInfo { hasNextPage } }
|
|
288
|
+
}`, { teamId, stateId });
|
|
289
|
+
const rank = (priority) => (priority === 0 ? 5 : priority);
|
|
290
|
+
const issues = data.issues.nodes
|
|
291
|
+
.map(issueOf)
|
|
292
|
+
.sort((a, b) => rank(a.priority) - rank(b.priority) || a.createdAt.localeCompare(b.createdAt));
|
|
293
|
+
return { issues, truncated: data.issues.pageInfo.hasNextPage };
|
|
294
|
+
}
|
|
295
|
+
async issue(id) {
|
|
296
|
+
const data = await this.graphql(`query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id });
|
|
297
|
+
// A deleted issue answers as missing, in words the closing already
|
|
298
|
+
// knows: without this a null would crash the caller instead of saying
|
|
299
|
+
// what it means.
|
|
300
|
+
if (!data.issue)
|
|
301
|
+
throw new LinearApiError('Entity not found: Issue', 200);
|
|
302
|
+
return issueOf(data.issue);
|
|
303
|
+
}
|
|
304
|
+
/** Make (or unmake, with `null`) this app the issue's delegate; the human
|
|
305
|
+
* assignee stays. Making the app the delegate is what creates the agent
|
|
306
|
+
* session — Linear returns it in this very answer (live, 2026-09-26). */
|
|
307
|
+
async setDelegate(issueId, delegateId) {
|
|
308
|
+
const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { identifier agentSessions { nodes { id status startedAt url } } } } }`, { id: issueId, input: { delegateId } });
|
|
309
|
+
if (!data.issueUpdate.success)
|
|
310
|
+
throw new LinearApiError('issueUpdate(delegateId) failed', 200);
|
|
311
|
+
return data.issueUpdate;
|
|
312
|
+
}
|
|
313
|
+
/** Create an issue in a team — the installer's throwaway ticket and the
|
|
314
|
+
* worker's escape hatch. `stateId` lands it in a named workflow state;
|
|
315
|
+
* omitted, Linear's own default (the team's triage) takes it. */
|
|
316
|
+
async createIssue(input) {
|
|
317
|
+
const data = await this.graphql(`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input });
|
|
318
|
+
if (!data.issueCreate.success)
|
|
319
|
+
throw new LinearApiError('issueCreate failed', 200);
|
|
320
|
+
return issueOf(data.issueCreate.issue);
|
|
321
|
+
}
|
|
322
|
+
/** Archive an issue — Linear's delete, off the board. The installer tidies
|
|
323
|
+
* its own throwaway ticket with this. */
|
|
324
|
+
async deleteIssue(issueId) {
|
|
325
|
+
const data = await this.graphql(`mutation($id: String!) { issueDelete(id: $id) { success } }`, { id: issueId });
|
|
326
|
+
if (!data.issueDelete.success)
|
|
327
|
+
throw new LinearApiError('issueDelete failed', 200);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Turn what an operator pastes — a team key from a Linear URL or a raw team id —
|
|
332
|
+
* into the ids the config must hold. An exact id matches first, then a key
|
|
333
|
+
* regardless of case; duplicates collapse and the given order is kept. An
|
|
334
|
+
* unknown token is an error naming it and listing the teams the app can see:
|
|
335
|
+
* a private team the app has not joined shows up here as unknown.
|
|
336
|
+
*/
|
|
337
|
+
export function resolveTeams(teams, tokens) {
|
|
338
|
+
const byId = new Map(teams.map(t => [t.id, t.id]));
|
|
339
|
+
const byKey = new Map(teams.map(t => [t.key.toLowerCase(), t.id]));
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const token of tokens) {
|
|
342
|
+
const id = byId.get(token) ?? byKey.get(token.toLowerCase());
|
|
343
|
+
if (!id) {
|
|
344
|
+
const visible = teams.map(t => `${t.key} (${t.name})`).join(', ') || 'none';
|
|
345
|
+
throw new Error(`${token} is not a team this app can see (visible: ${visible}). A private team needs the app added to it.`);
|
|
346
|
+
}
|
|
347
|
+
if (!out.includes(id))
|
|
348
|
+
out.push(id);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
const describe = (text) => {
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(text);
|
|
355
|
+
if (parsed.errors?.length)
|
|
356
|
+
return parsed.errors.map(e => e.message).join('; ');
|
|
357
|
+
return [parsed.error, parsed.error_description].filter(Boolean).join(': ') || text.slice(0, 200);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
return text.slice(0, 200) || errorMessage(error);
|
|
361
|
+
}
|
|
362
|
+
};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AiviPlugin } from '@aivi/plugin';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* The Linear module. One app does the work: it carries the workspace's
|
|
5
|
+
* **Issues** data feed on its webhook route, receives every agent-session
|
|
6
|
+
* event, and its token authorises the Linear MCP. Extra apps are *faces* — a
|
|
7
|
+
* name and icon in Linear's UI, their own credentials (`LINEAR_<APP>_*`) and
|
|
8
|
+
* webhook route, no routing meaning. Lanes are core's
|
|
9
|
+
* own (`projects.<id>.lanes`, ordered); this block maps teams to the project. The block
|
|
10
|
+
* lives at `plugins.tracker-linear` in config.json — the module id is the
|
|
11
|
+
* package name, ruled 2026-09-30 — and the `aivi-plugins` list in
|
|
12
|
+
* app/package.json says whether the module runs.
|
|
13
|
+
*/
|
|
14
|
+
/** The module id, and with it the key of this plugin's block in `config.json`
|
|
15
|
+
* (`plugins.tracker-linear`), its `/status` id and its log category: the module
|
|
16
|
+
* id is the package name (`@aivi/tracker-linear`), ruled 2026-09-30 for every
|
|
17
|
+
* plugin, so a person writes the word they already typed into `aivi add` and
|
|
18
|
+
* never opens a source file to learn what to call a plugin. What deliberately
|
|
19
|
+
* keeps the shorter `linear` is everything naming the **platform** rather than
|
|
20
|
+
* this package: the SQLite prefix and session-id prefix (renaming those would
|
|
21
|
+
* orphan every conversation in an existing database for no gain), the webhook
|
|
22
|
+
* URL Linear's dashboard holds, and the `aivi linear` command.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MODULE_ID = "tracker-linear";
|
|
25
|
+
export declare const linearSchema: z.ZodObject<{
|
|
26
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
27
|
+
primary: z.ZodOptional<z.ZodString>;
|
|
28
|
+
apps: z.ZodRecord<z.ZodString, z.ZodObject<{}, z.core.$strict>>;
|
|
29
|
+
logMisroutes: z.ZodDefault<z.ZodBoolean>;
|
|
30
|
+
humanLabel: z.ZodDefault<z.ZodString>;
|
|
31
|
+
pool: z.ZodDefault<z.ZodString>;
|
|
32
|
+
mcp: z.ZodDefault<z.ZodUnion<readonly [z.ZodObject<{
|
|
33
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
34
|
+
}, z.core.$strict>, z.ZodLiteral<false>]>>;
|
|
35
|
+
progress: z.ZodDefault<z.ZodEnum<{
|
|
36
|
+
silent: "silent";
|
|
37
|
+
status: "status";
|
|
38
|
+
tools: "tools";
|
|
39
|
+
}>>;
|
|
40
|
+
}, z.core.$strict>;
|
|
41
|
+
export type LinearConfig = z.infer<typeof linearSchema>;
|
|
42
|
+
/** The environment names of the one app: the primary's credentials, no app segment. */
|
|
43
|
+
export declare const linearPrimarySecretNames: {
|
|
44
|
+
readonly clientId: 'LINEAR_CLIENT_ID';
|
|
45
|
+
readonly clientSecret: 'LINEAR_CLIENT_SECRET';
|
|
46
|
+
readonly webhookSecret: 'LINEAR_WEBHOOK_SECRET';
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Environment variable names for one app's credentials. The **primary** app
|
|
50
|
+
* uses the bare `linearPrimarySecretNames`; this prefixed convention is for
|
|
51
|
+
* every other app (a face) — `<APP>` = the id upper-cased, `-` → `_`.
|
|
52
|
+
*/
|
|
53
|
+
export declare const linearSecretNames: (app: string) => {
|
|
54
|
+
clientId: string;
|
|
55
|
+
clientSecret: string;
|
|
56
|
+
webhookSecret: string;
|
|
57
|
+
};
|
|
58
|
+
/** The app that carries the workspace data feed and the bare secrets: `linear.primary`, else the one configured app. */
|
|
59
|
+
export declare function primaryLinearApp(linear: LinearConfig | undefined): string | undefined;
|
|
60
|
+
/** The assistant's agent name: `linear.agent`, else the one assistant everyone gets. */
|
|
61
|
+
export declare function assistantAgent(linear: LinearConfig | undefined): string;
|
|
62
|
+
/** The plugin's own `linear` section of a project entry (`projects.<id>.linear`),
|
|
63
|
+
* contributed to the composed schema the way the `plugins` block contributes
|
|
64
|
+
* its own. Core reads none of it. */
|
|
65
|
+
export declare const linearProjectSchema: z.ZodObject<{
|
|
66
|
+
workspaceId: z.ZodOptional<z.ZodString>;
|
|
67
|
+
teams: z.ZodArray<z.ZodString>;
|
|
68
|
+
createLane: z.ZodOptional<z.ZodString>;
|
|
69
|
+
}, z.core.$strict>;
|
|
70
|
+
export type LinearProjectEntry = z.infer<typeof linearProjectSchema>;
|
|
71
|
+
/** The plugin's own section under `projectDefaults`. */
|
|
72
|
+
export declare const linearProjectDefaultsSchema: z.ZodObject<{
|
|
73
|
+
workspaceId: z.ZodOptional<z.ZodString>;
|
|
74
|
+
createLane: z.ZodOptional<z.ZodString>;
|
|
75
|
+
}, z.core.$strict>;
|
|
76
|
+
export type LinearProjectDefaults = z.infer<typeof linearProjectDefaultsSchema>;
|
|
77
|
+
/** The project's Linear routing as it takes effect: which teams speak for it
|
|
78
|
+
* and in which workspace. The lanes are the project's own core fact, read
|
|
79
|
+
* from `Project.lanes` — this type stopped carrying them on purpose. */
|
|
80
|
+
export interface ProjectLinear {
|
|
81
|
+
workspaceId?: string;
|
|
82
|
+
teams: string[];
|
|
83
|
+
/** Where `aivi_ticket_create` lands a new ticket, as it takes effect: the
|
|
84
|
+
* project's own `createLane`, else `projectDefaults`', else absent — and
|
|
85
|
+
* absent means the project's first configured lane, read from the core
|
|
86
|
+
* `Project`, not from this section. */
|
|
87
|
+
createLane?: string;
|
|
88
|
+
}
|
|
89
|
+
/** The registry entry: the block holds no paths (credentials live in the
|
|
90
|
+
* environment, lanes name agents), so `home` finds nothing to resolve here;
|
|
91
|
+
* the module code loads lazily. The `linear` sections of a project and of
|
|
92
|
+
* `projectDefaults` are contributed here — core passes them through and this
|
|
93
|
+
* plugin reads them back. */
|
|
94
|
+
export declare const plugin: AiviPlugin<LinearConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** The Linear plugin's registry declaration, at its `./config` subpath: the
|
|
2
|
+
* module id, the block's own schema (with the cross-field rules that belong to
|
|
3
|
+
* it), and the bridge to the module code. The CLI imports this for every
|
|
4
|
+
* command, so nothing here pulls the Linear client. */
|
|
5
|
+
import { PROJECT_ID } from '@aivi/core';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
const id = z.string().regex(PROJECT_ID);
|
|
8
|
+
/**
|
|
9
|
+
* The Linear module. One app does the work: it carries the workspace's
|
|
10
|
+
* **Issues** data feed on its webhook route, receives every agent-session
|
|
11
|
+
* event, and its token authorises the Linear MCP. Extra apps are *faces* — a
|
|
12
|
+
* name and icon in Linear's UI, their own credentials (`LINEAR_<APP>_*`) and
|
|
13
|
+
* webhook route, no routing meaning. Lanes are core's
|
|
14
|
+
* own (`projects.<id>.lanes`, ordered); this block maps teams to the project. The block
|
|
15
|
+
* lives at `plugins.tracker-linear` in config.json — the module id is the
|
|
16
|
+
* package name, ruled 2026-09-30 — and the `aivi-plugins` list in
|
|
17
|
+
* app/package.json says whether the module runs.
|
|
18
|
+
*/
|
|
19
|
+
/** The module id, and with it the key of this plugin's block in `config.json`
|
|
20
|
+
* (`plugins.tracker-linear`), its `/status` id and its log category: the module
|
|
21
|
+
* id is the package name (`@aivi/tracker-linear`), ruled 2026-09-30 for every
|
|
22
|
+
* plugin, so a person writes the word they already typed into `aivi add` and
|
|
23
|
+
* never opens a source file to learn what to call a plugin. What deliberately
|
|
24
|
+
* keeps the shorter `linear` is everything naming the **platform** rather than
|
|
25
|
+
* this package: the SQLite prefix and session-id prefix (renaming those would
|
|
26
|
+
* orphan every conversation in an existing database for no gain), the webhook
|
|
27
|
+
* URL Linear's dashboard holds, and the `aivi linear` command.
|
|
28
|
+
*/
|
|
29
|
+
export const MODULE_ID = 'tracker-linear';
|
|
30
|
+
export const linearSchema = z
|
|
31
|
+
.strictObject({
|
|
32
|
+
agent: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('The OpenCode agent that answers people on Linear — comment mentions and delegations that no lane claims: the assistant. Default: the aivi name.'),
|
|
37
|
+
primary: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('The app that carries the workspace data feed, signs the bare LINEAR_* secrets and authorises the Linear MCP; default the one app. Required once several apps are configured.'),
|
|
42
|
+
apps: z
|
|
43
|
+
.record(id, z
|
|
44
|
+
.strictObject({})
|
|
45
|
+
.describe('Empty today: credentials come from the environment; the id is the app identity and its webhook route.'))
|
|
46
|
+
.describe('Linear apps by id. The primary (see `primary`) carries the data feed; every other app is a face — a name and icon in Linear’s UI with its own credentials, no routing meaning.'),
|
|
47
|
+
logMisroutes: z
|
|
48
|
+
.boolean()
|
|
49
|
+
.default(true)
|
|
50
|
+
.describe('Log at warn a webhook delivered to the wrong endpoint (a data change on a face’s route); it is dropped either way.'),
|
|
51
|
+
humanLabel: z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.default('needs-human')
|
|
55
|
+
.describe('Issues carrying this label are never worked automatically; a hand delegation is refused with an explanation.'),
|
|
56
|
+
pool: id.default('default').describe('Dispatcher pool a worker turn takes a slot in.'),
|
|
57
|
+
mcp: z
|
|
58
|
+
.union([
|
|
59
|
+
z.strictObject({
|
|
60
|
+
port: z.number().int().min(0).max(65535).default(4101),
|
|
61
|
+
}),
|
|
62
|
+
z.literal(false),
|
|
63
|
+
])
|
|
64
|
+
.default({ port: 4101 })
|
|
65
|
+
.describe("On by default: the module serves Linear's hosted MCP on loopback (default port 4101), authorised with the app-actor token, so agents can act in Linear and writes attribute to the app; OpenCode connects as a remote MCP at http://127.0.0.1:<port>/mcp. `false` disables it. Loopback only."),
|
|
66
|
+
progress: z
|
|
67
|
+
.enum(['silent', 'status', 'tools'])
|
|
68
|
+
.default('tools')
|
|
69
|
+
.describe('What the ephemeral activities in the agent session show while a worker runs.'),
|
|
70
|
+
})
|
|
71
|
+
.superRefine((config, ctx) => {
|
|
72
|
+
// Several apps need a named primary, and the primary must be one of the configured apps.
|
|
73
|
+
const appIds = Object.keys(config.apps);
|
|
74
|
+
if (appIds.length > 1 && !config.primary)
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: 'custom',
|
|
77
|
+
path: ['primary'],
|
|
78
|
+
message: 'Required once several apps are configured: which app carries the data feed and the bare LINEAR_* secrets',
|
|
79
|
+
});
|
|
80
|
+
if (config.primary && !appIds.includes(config.primary))
|
|
81
|
+
ctx.addIssue({ code: 'custom', path: ['primary'], message: 'Not a configured app' });
|
|
82
|
+
});
|
|
83
|
+
/** The environment names of the one app: the primary's credentials, no app segment. */
|
|
84
|
+
export const linearPrimarySecretNames = {
|
|
85
|
+
clientId: 'LINEAR_CLIENT_ID',
|
|
86
|
+
clientSecret: 'LINEAR_CLIENT_SECRET',
|
|
87
|
+
webhookSecret: 'LINEAR_WEBHOOK_SECRET',
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Environment variable names for one app's credentials. The **primary** app
|
|
91
|
+
* uses the bare `linearPrimarySecretNames`; this prefixed convention is for
|
|
92
|
+
* every other app (a face) — `<APP>` = the id upper-cased, `-` → `_`.
|
|
93
|
+
*/
|
|
94
|
+
export const linearSecretNames = (app) => {
|
|
95
|
+
const key = app.toUpperCase().replaceAll('-', '_');
|
|
96
|
+
return {
|
|
97
|
+
clientId: `LINEAR_${key}_CLIENT_ID`,
|
|
98
|
+
clientSecret: `LINEAR_${key}_CLIENT_SECRET`,
|
|
99
|
+
webhookSecret: `LINEAR_${key}_WEBHOOK_SECRET`,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/** The app that carries the workspace data feed and the bare secrets: `linear.primary`, else the one configured app. */
|
|
103
|
+
export function primaryLinearApp(linear) {
|
|
104
|
+
if (!linear)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (linear.primary)
|
|
107
|
+
return linear.primary;
|
|
108
|
+
const ids = Object.keys(linear.apps);
|
|
109
|
+
return ids.length === 1 ? ids[0] : undefined;
|
|
110
|
+
}
|
|
111
|
+
/** The assistant's agent name: `linear.agent`, else the one assistant everyone gets. */
|
|
112
|
+
export function assistantAgent(linear) {
|
|
113
|
+
return linear?.agent ?? 'assistant';
|
|
114
|
+
}
|
|
115
|
+
/*
|
|
116
|
+
* Lanes used to live here, in Linear's own section. They do not anymore
|
|
117
|
+
* (ruled 2026-10-01): **lanes are core's concept** — the ordered workflow of
|
|
118
|
+
* a project, configured at `projects.<id>.lanes` and read by the host
|
|
119
|
+
* orchestrator, which decides every move from the order and asks a tracker
|
|
120
|
+
* only to perform one. What remains in this section is Linear's own routing:
|
|
121
|
+
* which teams feed the project, and which workspace.
|
|
122
|
+
*/
|
|
123
|
+
/** The plugin's own `linear` section of a project entry (`projects.<id>.linear`),
|
|
124
|
+
* contributed to the composed schema the way the `plugins` block contributes
|
|
125
|
+
* its own. Core reads none of it. */
|
|
126
|
+
export const linearProjectSchema = z.strictObject({
|
|
127
|
+
workspaceId: z
|
|
128
|
+
.string()
|
|
129
|
+
.min(1)
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Linear organization id; only needed with more than one workspace.'),
|
|
132
|
+
teams: z
|
|
133
|
+
.array(z.string().min(1))
|
|
134
|
+
.min(1)
|
|
135
|
+
.describe('Linear team ids whose issues belong to this project; a team maps to at most one project.'),
|
|
136
|
+
createLane: z
|
|
137
|
+
.string()
|
|
138
|
+
.min(1)
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Where `aivi_ticket_create` lands a new ticket in this project — a lane name from the team’s states. Default: the project’s first configured lane; with none matching, Linear’s own default state.'),
|
|
141
|
+
});
|
|
142
|
+
/** The plugin's own section under `projectDefaults`. */
|
|
143
|
+
export const linearProjectDefaultsSchema = z.strictObject({
|
|
144
|
+
workspaceId: z
|
|
145
|
+
.string()
|
|
146
|
+
.min(1)
|
|
147
|
+
.optional()
|
|
148
|
+
.describe('Linear organization id every project gets unless it names its own.'),
|
|
149
|
+
createLane: z
|
|
150
|
+
.string()
|
|
151
|
+
.min(1)
|
|
152
|
+
.optional()
|
|
153
|
+
.describe('Where `aivi_ticket_create` lands a new ticket in every project unless it names its own — a lane name from the team’s states.'),
|
|
154
|
+
});
|
|
155
|
+
/** The registry entry: the block holds no paths (credentials live in the
|
|
156
|
+
* environment, lanes name agents), so `home` finds nothing to resolve here;
|
|
157
|
+
* the module code loads lazily. The `linear` sections of a project and of
|
|
158
|
+
* `projectDefaults` are contributed here — core passes them through and this
|
|
159
|
+
* plugin reads them back. */
|
|
160
|
+
export const plugin = {
|
|
161
|
+
id: MODULE_ID,
|
|
162
|
+
configSchema: linearSchema,
|
|
163
|
+
projectSchema: linearProjectSchema,
|
|
164
|
+
projectDefaultsSchema: linearProjectDefaultsSchema,
|
|
165
|
+
// The lazy load is the design: the CLI reads this file's schema without
|
|
166
|
+
// pulling the host's world in, and the module arrives only when the host
|
|
167
|
+
// actually builds. The cycle back into this file (the module imports its
|
|
168
|
+
// own schema) is that design, not a tangle.
|
|
169
|
+
// fallow-ignore-next-line circular-dependency
|
|
170
|
+
createModule: config => import('./module.js').then(m => m.createLinearModule(config)),
|
|
171
|
+
};
|
package/dist/desk.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear's answer to the ticket desk (the `TicketDesk` contract at
|
|
3
|
+
* `@aivi/plugin/tracker`). The host owns the `aivi_ticket_*` tools and the
|
|
4
|
+
* ground truth of the call — this file answers what each one *is* on Linear:
|
|
5
|
+
* which app's feed a plain comment lands on, which team a new ticket is born
|
|
6
|
+
* in, what the team's label catalogue means. The desk speaks through the
|
|
7
|
+
* **primary** app: it holds the credentials, and a comment posted there is
|
|
8
|
+
* the app's own.
|
|
9
|
+
*/
|
|
10
|
+
import type { LoadedConfig } from '@aivi/core';
|
|
11
|
+
import type { Platform, TicketDesk } from '@aivi/plugin/tracker';
|
|
12
|
+
import type { LinearConfig } from './config.ts';
|
|
13
|
+
export declare function createLinearDesk(loaded: LoadedConfig, config: LinearConfig, platform: Platform): TicketDesk;
|