@aivi/tracker-linear 0.0.0-stage → 0.4.1

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.
@@ -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;
@@ -0,0 +1,3 @@
1
+ import { type PluginSetup } from '@aivi/plugin';
2
+ declare const setup: PluginSetup;
3
+ export default setup;