@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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/** Linear's project-setup contributor, at its `./setupProject` subpath: it
|
|
2
|
+
* maps a new project to Linear teams and offers the project's lane array —
|
|
3
|
+
* the board's states in board order, each asked what kind of lane it is
|
|
4
|
+
* (agent, human, or none). It clones
|
|
5
|
+
* nothing (a tracker has no checkout to give): it hands back the `linear`
|
|
6
|
+
* section core writes under the project, and the **core** `lanes` array
|
|
7
|
+
* core writes on the project. Everything Linear-specific — apps, teams,
|
|
8
|
+
* workflow states — lives here; core only knows this plugin serves the
|
|
9
|
+
* `tracker` role. */
|
|
10
|
+
import { getLogger } from '@aivi/core';
|
|
11
|
+
import { LANE_KIND, laneKindLines, PluginSetupCancelled, } from '@aivi/plugin';
|
|
12
|
+
import { resolveTeams } from './client.js';
|
|
13
|
+
import { clientFor } from './tracker.js';
|
|
14
|
+
const log = getLogger(['aivi', 'projects']);
|
|
15
|
+
/** The states the wizard asks about, in board order: deduped by name across
|
|
16
|
+
* the picked teams (two mapped teams can share a state name, and one name
|
|
17
|
+
* gets one lane). Nothing is dropped — **every state is asked**: what a
|
|
18
|
+
* state is for is the person's answer to the lane question, not aivi's
|
|
19
|
+
* guess from the state's type. Linear's `type` is
|
|
20
|
+
* real API data, so it only sets which answer the question starts on. The
|
|
21
|
+
* client already returns each team's states in board order (type group,
|
|
22
|
+
* then position). Exported pure for the test that pins the dedupe. */
|
|
23
|
+
export function boardStates(states) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
return states.filter(state => {
|
|
26
|
+
if (seen.has(state.name))
|
|
27
|
+
return false;
|
|
28
|
+
seen.add(state.name);
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Which answer the lane question starts on, from Linear's state type (real
|
|
33
|
+
* API data): a `completed` state starts on Human — a person takes finished
|
|
34
|
+
* work; `canceled` and `duplicate` start on None — they end out of the
|
|
35
|
+
* workflow; everything else starts on Agent. A start is a highlight, not a
|
|
36
|
+
* ruling: the person's answer wins. */
|
|
37
|
+
export function laneKindDefault(type) {
|
|
38
|
+
if (type === 'completed')
|
|
39
|
+
return 'human';
|
|
40
|
+
if (type === 'canceled' || type === 'duplicate')
|
|
41
|
+
return 'none';
|
|
42
|
+
return 'agent';
|
|
43
|
+
}
|
|
44
|
+
/** The lanes the queue question may offer (ruled 2026-10-02): **one**
|
|
45
|
+
* question after all lanes are configured, and its candidates are the
|
|
46
|
+
* lanes that could legally hold the queue — a queue lane must feed a
|
|
47
|
+
* worker lane, so a lane whose next works nobody is no candidate, and the
|
|
48
|
+
* last lane feeds nothing at all. And a lane that has an agent is **never**
|
|
49
|
+
* an option (ruled 2026-10-02): a queue is where people wait for a worker,
|
|
50
|
+
* not a worker lane itself — the queue runs between the human lanes and
|
|
51
|
+
* the working ones, like a real board's waiting column. A **human** lane is
|
|
52
|
+
* a legal queue: that is what "used as a queue" means in the lane answer's
|
|
53
|
+
* own words. Offering anything else would write a config the operator did
|
|
54
|
+
* not mean. Exported pure, so the wizard's rules are testable apart from
|
|
55
|
+
* prompts. */
|
|
56
|
+
export function queueCandidates(lanes) {
|
|
57
|
+
return lanes.filter(lane => lane.agent === undefined && lanes[lanes.indexOf(lane) + 1]?.agent !== undefined);
|
|
58
|
+
}
|
|
59
|
+
/** Mark the chosen queue lane. The queue answer is the wizard's later
|
|
60
|
+
* word, so a lane that just chose an agent gives it up — and says so out
|
|
61
|
+
* loud, because a lane silently losing its worker is not a wizard. The
|
|
62
|
+
* returned lines are what the caller logs; the marking itself follows
|
|
63
|
+
* core's load rules: a queue lane works none and writes nothing. */
|
|
64
|
+
export function markQueue(lanes, chosen) {
|
|
65
|
+
const lane = lanes.find(l => l.name === chosen);
|
|
66
|
+
if (!lane)
|
|
67
|
+
return [];
|
|
68
|
+
const said = [];
|
|
69
|
+
if (lane.agent !== undefined) {
|
|
70
|
+
delete lane.agent;
|
|
71
|
+
said.push(`"${chosen}" works nobody then — it waits work instead`);
|
|
72
|
+
}
|
|
73
|
+
delete lane.worktree;
|
|
74
|
+
lane.queue = true;
|
|
75
|
+
return said;
|
|
76
|
+
}
|
|
77
|
+
/** The app to ask for teams: one app is never asked of the person,
|
|
78
|
+
* several are. */
|
|
79
|
+
async function pickApp(ctx, appIds) {
|
|
80
|
+
if (appIds.length <= 1)
|
|
81
|
+
return undefined;
|
|
82
|
+
const picked = await ctx.prompts.select({
|
|
83
|
+
message: 'Ask which Linear app for teams',
|
|
84
|
+
options: appIds.map(name => ({ value: name, label: name })),
|
|
85
|
+
});
|
|
86
|
+
if (ctx.prompts.isCancel(picked))
|
|
87
|
+
throw new PluginSetupCancelled('no app chosen');
|
|
88
|
+
return picked;
|
|
89
|
+
}
|
|
90
|
+
/** The teams the app can see: the spinner says so, and a refusal ends the
|
|
91
|
+
* setup — a wizard that went on after a dead app would be writing blind. */
|
|
92
|
+
async function askedTeams(ctx, makeClient, config, app) {
|
|
93
|
+
const spinner = ctx.prompts.spinner();
|
|
94
|
+
spinner.start('Asking the app which teams it can see');
|
|
95
|
+
let teams;
|
|
96
|
+
try {
|
|
97
|
+
teams = await makeClient(config, app, log).listTeams();
|
|
98
|
+
spinner.stop(`The app can see ${teams.length} team${teams.length === 1 ? '' : 's'}`);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
spinner.error(error instanceof Error ? error.message : String(error));
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!teams.length)
|
|
105
|
+
throw new Error('that Linear app sees no teams — a private team needs the app added to it');
|
|
106
|
+
return teams;
|
|
107
|
+
}
|
|
108
|
+
/** Which of the visible teams may work here: a pick-list, never typing. */
|
|
109
|
+
async function pickTeams(ctx, teams) {
|
|
110
|
+
const pickedTeams = await ctx.prompts.multiselect({
|
|
111
|
+
message: 'Which teams may work in this project?',
|
|
112
|
+
options: teams.map(team => ({ value: team.id, label: `${team.key} — ${team.name}`, hint: team.id })),
|
|
113
|
+
required: true,
|
|
114
|
+
});
|
|
115
|
+
if (ctx.prompts.isCancel(pickedTeams))
|
|
116
|
+
throw new PluginSetupCancelled('no teams picked');
|
|
117
|
+
return pickedTeams;
|
|
118
|
+
}
|
|
119
|
+
/** One question per state: what kind of lane it is, in the kit's
|
|
120
|
+
* `LANE_KIND` words. The question **starts on** the answer Linear's state
|
|
121
|
+
* type suggests (`laneKindDefault`); the person's
|
|
122
|
+
* answer wins. An `agent` answer is followed by the agent pick and, when a
|
|
123
|
+
* forge is configured, the branch question. A `human` answer writes a bare
|
|
124
|
+
* lane: the orchestrator's silence, and a legal queue or destination. A
|
|
125
|
+
* `none` answer writes nothing: the state is not in the array, so aivi
|
|
126
|
+
* neither reads it nor moves a ticket into it. */
|
|
127
|
+
async function askLanes(ctx, states, agentNames) {
|
|
128
|
+
const lanes = [];
|
|
129
|
+
let explained = false;
|
|
130
|
+
for (const state of states) {
|
|
131
|
+
// One log entry before the first question, never repeated per state.
|
|
132
|
+
if (!explained) {
|
|
133
|
+
explained = true;
|
|
134
|
+
await ctx.prompts.log.message(laneKindLines('Linear names what a state is, so each question starts on the answer its type suggests. Your answer wins.').join('\n'));
|
|
135
|
+
}
|
|
136
|
+
const first = laneKindDefault(state.type);
|
|
137
|
+
const rest = ['agent', 'human', 'none'].filter(kind => kind !== first);
|
|
138
|
+
const picked = await ctx.prompts.select({
|
|
139
|
+
message: `What kind of lane is the "${state.name}" state?`,
|
|
140
|
+
options: [first, ...rest].map(kind => LANE_KIND[kind]),
|
|
141
|
+
});
|
|
142
|
+
if (ctx.prompts.isCancel(picked))
|
|
143
|
+
throw new PluginSetupCancelled('lane setup incomplete');
|
|
144
|
+
const kind = String(picked);
|
|
145
|
+
if (kind === 'none')
|
|
146
|
+
continue;
|
|
147
|
+
let agent = '';
|
|
148
|
+
if (kind === 'agent') {
|
|
149
|
+
const answer = await ctx.prompts.autocomplete({
|
|
150
|
+
message: `Which OpenCode agent works the "${state.name}" lane?`,
|
|
151
|
+
options: agentNames.map(name => ({ value: name, label: name })),
|
|
152
|
+
maxItems: 12,
|
|
153
|
+
});
|
|
154
|
+
if (ctx.prompts.isCancel(answer))
|
|
155
|
+
throw new PluginSetupCancelled('lane setup incomplete');
|
|
156
|
+
agent = String(answer).trim();
|
|
157
|
+
}
|
|
158
|
+
// A worked lane with git possible: does it work on the ticket's branch?
|
|
159
|
+
// The worktree belongs to the branch, not to writing: a review lane
|
|
160
|
+
// changes nothing yet must read the pull request's branch, and that
|
|
161
|
+
// branch lives nowhere but a worktree — a run on a branch reuses the
|
|
162
|
+
// worktree already holding it. Only a yes is written — core's default
|
|
163
|
+
// is false, and a key stating the default is noise. "No" means the
|
|
164
|
+
// project's own checkout; it is never a promise of read-only: that is
|
|
165
|
+
// the agent file's own deny, never aivi's. Without a forge there are
|
|
166
|
+
// no worktrees to promise, so nothing is asked.
|
|
167
|
+
let worktree = false;
|
|
168
|
+
if (agent && ctx.forgeConfigured) {
|
|
169
|
+
const onBranch = await ctx.prompts.select({
|
|
170
|
+
message: `Does work in the "${state.name}" lane happen on the ticket's branch (git worktree)?`,
|
|
171
|
+
options: [
|
|
172
|
+
{ value: 'yes', label: "yes — on the ticket's branch, in its own worktree" },
|
|
173
|
+
{ value: 'no', label: 'no — in the project checkout itself' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
if (ctx.prompts.isCancel(onBranch))
|
|
177
|
+
throw new PluginSetupCancelled('lane setup incomplete');
|
|
178
|
+
worktree = onBranch === 'yes';
|
|
179
|
+
}
|
|
180
|
+
lanes.push({ name: state.name, ...(agent ? { agent } : {}), ...(worktree ? { worktree } : {}) });
|
|
181
|
+
}
|
|
182
|
+
return lanes;
|
|
183
|
+
}
|
|
184
|
+
/** The queue: ONE question for the whole workflow (ruled 2026-10-02,
|
|
185
|
+
* after every lane is configured — never a per-lane ask). "-- None --"
|
|
186
|
+
* leaves the workflow without a queue lane, which is the old shape. */
|
|
187
|
+
async function askQueue(ctx, lanes) {
|
|
188
|
+
const candidates = queueCandidates(lanes);
|
|
189
|
+
if (candidates.length) {
|
|
190
|
+
const answer = await ctx.prompts.select({
|
|
191
|
+
message: 'Which lane waits with work while the working lanes are full (the queue)?',
|
|
192
|
+
options: [
|
|
193
|
+
...candidates.map(lane => ({ value: lane.name, label: lane.name })),
|
|
194
|
+
{ value: '', label: '-- None --' },
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
if (ctx.prompts.isCancel(answer))
|
|
198
|
+
throw new PluginSetupCancelled('lane setup incomplete');
|
|
199
|
+
const chosen = String(answer).trim();
|
|
200
|
+
for (const line of chosen ? markQueue(lanes, chosen) : [])
|
|
201
|
+
await ctx.prompts.log.message(line);
|
|
202
|
+
}
|
|
203
|
+
else if (lanes.length)
|
|
204
|
+
await ctx.prompts.log.message('no lane sits before a working lane, so nothing can wait for a slot — a queue lane comes later with a worker lane');
|
|
205
|
+
}
|
|
206
|
+
/** The wizard, in its order: app, teams, the project's name, what kind of
|
|
207
|
+
* lane each board state is (agent, human, or none), then the queue over
|
|
208
|
+
* the no-agent lanes. Each step is its own question and its own honest
|
|
209
|
+
* failure; the whole is the ruling. */
|
|
210
|
+
function makeContributor(makeClient = clientFor) {
|
|
211
|
+
return {
|
|
212
|
+
role: 'tracker',
|
|
213
|
+
async setup(ctx) {
|
|
214
|
+
const config = ctx.config;
|
|
215
|
+
const app = await pickApp(ctx, Object.keys(config.apps ?? {}));
|
|
216
|
+
const teams = await askedTeams(ctx, makeClient, config, app);
|
|
217
|
+
const teamIds = await pickTeams(ctx, teams);
|
|
218
|
+
const chosen = teams.filter(team => teamIds.includes(team.id));
|
|
219
|
+
// The name: a tracker offers the first team's key, lower-cased, unless an
|
|
220
|
+
// earlier role (a forge) already settled it. A defaultValue, not just a
|
|
221
|
+
// placeholder: Enter takes it *and says so* — a hint is display-only,
|
|
222
|
+
// and an answered prompt that renders empty leaves the person guessing
|
|
223
|
+
// what was taken.
|
|
224
|
+
const suggested = (ctx.projectId ?? chosen[0].key).toLowerCase();
|
|
225
|
+
const idAnswer = await ctx.prompts.text({
|
|
226
|
+
message: "Project id — aivi's name for it; Linear never sees it.",
|
|
227
|
+
placeholder: suggested,
|
|
228
|
+
defaultValue: suggested,
|
|
229
|
+
});
|
|
230
|
+
if (ctx.prompts.isCancel(idAnswer))
|
|
231
|
+
throw new PluginSetupCancelled('no project id given');
|
|
232
|
+
const id = (String(idAnswer).trim() || suggested).toLowerCase();
|
|
233
|
+
// Lanes: core's ordered workflow (`projects.<id>.lanes`), and the board
|
|
234
|
+
// is the order — the client returns a team's states in board order
|
|
235
|
+
// (type group, then position).
|
|
236
|
+
// A pick-list, never typing: the agents OpenCode can run as a primary
|
|
237
|
+
// in this project's checkout. A service that answers with no agents
|
|
238
|
+
// means something is broken — aivi always ships at least two — so the
|
|
239
|
+
// setup says so and stops.
|
|
240
|
+
const agentNames = await ctx.agents(id);
|
|
241
|
+
if (!agentNames.length)
|
|
242
|
+
throw new Error('Unable to configure lanes: there appear to be no agents available.');
|
|
243
|
+
const lanes = await askLanes(ctx, boardStates(chosen.flatMap(team => team.states)), agentNames);
|
|
244
|
+
await askQueue(ctx, lanes);
|
|
245
|
+
if (!lanes.length)
|
|
246
|
+
await ctx.prompts.log.message('no lanes were written — the boards had no states, or every state was answered “None” — so aivi works no tickets until lanes exist');
|
|
247
|
+
const section = { teams: resolveTeams(teams, teamIds) };
|
|
248
|
+
return { id, section, ...(lanes.length ? { lanes } : {}), cloned: false };
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export const contributor = makeContributor();
|
|
253
|
+
export { makeContributor };
|
|
254
|
+
export default contributor;
|
package/dist/setup.d.ts
ADDED