Skip to content

Commit b363302

Browse files
feat(linear): Add issue management and task picker
- Add assignIssue() and updateIssueState() methods to LinearClient - Add linear-task-picker for prioritizing tasks with test requirements - Support scoring tasks based on keywords and priority
1 parent 86a3f8e commit b363302

2 files changed

Lines changed: 443 additions & 0 deletions

File tree

src/hooks/linear-task-picker.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* Linear Task Picker
3+
* Picks the next best task from Linear queue, prioritizing tasks with test/validation requirements
4+
*/
5+
6+
import { LinearClient, LinearIssue } from '../integrations/linear/client.js';
7+
import { LinearAuthManager } from '../integrations/linear/auth.js';
8+
9+
export interface TaskSuggestion {
10+
id: string;
11+
identifier: string; // e.g., "STA-123"
12+
title: string;
13+
priority: number;
14+
hasTestRequirements: boolean;
15+
estimatedPoints?: number;
16+
url: string;
17+
score: number;
18+
}
19+
20+
export interface PickerOptions {
21+
teamId?: string;
22+
preferTestTasks?: boolean;
23+
limit?: number;
24+
}
25+
26+
// Keywords indicating test/validation requirements
27+
const TEST_KEYWORDS = [
28+
'test',
29+
'spec',
30+
'unit test',
31+
'integration test',
32+
'e2e',
33+
'end-to-end',
34+
'jest',
35+
'vitest',
36+
'mocha',
37+
];
38+
39+
const VALIDATION_KEYWORDS = [
40+
'validate',
41+
'verify',
42+
'verification',
43+
'acceptance criteria',
44+
'ac:',
45+
'acceptance:',
46+
'given when then',
47+
'criteria:',
48+
];
49+
50+
const QA_KEYWORDS = ['qa', 'quality', 'regression', 'coverage', 'assertion'];
51+
52+
// Labels that indicate test requirements
53+
const TEST_LABELS = [
54+
'needs-tests',
55+
'test-required',
56+
'qa-review',
57+
'has-ac',
58+
'acceptance-criteria',
59+
'tdd',
60+
'testing',
61+
];
62+
63+
/**
64+
* Check if text contains any of the keywords (case-insensitive)
65+
*/
66+
function containsKeywords(text: string, keywords: string[]): boolean {
67+
const lowerText = text.toLowerCase();
68+
return keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
69+
}
70+
71+
/**
72+
* Score a task based on test/validation requirements
73+
*/
74+
function scoreTask(issue: LinearIssue, preferTestTasks: boolean): number {
75+
let score = 0;
76+
const description = issue.description || '';
77+
const title = issue.title || '';
78+
const fullText = `${title} ${description}`;
79+
80+
// +10 if has test/validation keywords in description
81+
if (containsKeywords(fullText, TEST_KEYWORDS)) {
82+
score += preferTestTasks ? 10 : 5;
83+
}
84+
85+
if (containsKeywords(fullText, VALIDATION_KEYWORDS)) {
86+
score += preferTestTasks ? 8 : 4;
87+
}
88+
89+
if (containsKeywords(fullText, QA_KEYWORDS)) {
90+
score += preferTestTasks ? 5 : 2;
91+
}
92+
93+
// +5 if has test-related labels
94+
const labelNames =
95+
issue.labels?.nodes?.map((l: { name: string }) => l.name.toLowerCase()) ||
96+
[];
97+
const hasTestLabel = TEST_LABELS.some((tl) =>
98+
labelNames.some((ln: string) => ln.includes(tl))
99+
);
100+
if (hasTestLabel) {
101+
score += preferTestTasks ? 5 : 3;
102+
}
103+
104+
// +3 for higher priority (urgent=1, high=2)
105+
if (issue.priority === 1) {
106+
score += 5; // Urgent
107+
} else if (issue.priority === 2) {
108+
score += 3; // High
109+
} else if (issue.priority === 3) {
110+
score += 1; // Medium
111+
}
112+
113+
// +2 if has acceptance criteria pattern
114+
if (
115+
description.includes('## Acceptance') ||
116+
description.includes('### AC') ||
117+
description.includes('- [ ]')
118+
) {
119+
score += 2;
120+
}
121+
122+
// +1 if has estimate (indicates well-scoped)
123+
if (issue.estimate) {
124+
score += 1;
125+
}
126+
127+
return score;
128+
}
129+
130+
/**
131+
* Get Linear client instance
132+
*/
133+
function getLinearClient(): LinearClient | null {
134+
// Try API key first
135+
const apiKey = process.env['LINEAR_API_KEY'];
136+
if (apiKey) {
137+
return new LinearClient({ apiKey });
138+
}
139+
140+
// Fall back to OAuth
141+
try {
142+
const authManager = new LinearAuthManager();
143+
const tokens = authManager.loadTokens();
144+
if (tokens?.accessToken) {
145+
return new LinearClient({ accessToken: tokens.accessToken });
146+
}
147+
} catch {
148+
// Auth not available
149+
}
150+
151+
return null;
152+
}
153+
154+
/**
155+
* Pick the next best task from Linear
156+
*/
157+
export async function pickNextLinearTask(
158+
options: PickerOptions = {}
159+
): Promise<TaskSuggestion | null> {
160+
const client = getLinearClient();
161+
if (!client) {
162+
return null;
163+
}
164+
165+
const { teamId, preferTestTasks = true, limit = 20 } = options;
166+
167+
try {
168+
// Fetch backlog and unstarted issues
169+
const [backlogIssues, unstartedIssues] = await Promise.all([
170+
client.getIssues({ teamId, stateType: 'backlog', limit }),
171+
client.getIssues({ teamId, stateType: 'unstarted', limit }),
172+
]);
173+
174+
const allIssues = [...backlogIssues, ...unstartedIssues];
175+
176+
// Filter out assigned issues (we want unassigned ones)
177+
const unassignedIssues = allIssues.filter((issue) => !issue.assignee);
178+
179+
if (unassignedIssues.length === 0) {
180+
// If no unassigned, consider all
181+
if (allIssues.length === 0) {
182+
return null;
183+
}
184+
}
185+
186+
const issuesToScore =
187+
unassignedIssues.length > 0 ? unassignedIssues : allIssues;
188+
189+
// Score and sort
190+
const scoredIssues = issuesToScore.map((issue) => ({
191+
issue,
192+
score: scoreTask(issue, preferTestTasks),
193+
}));
194+
195+
scoredIssues.sort((a, b) => b.score - a.score);
196+
197+
const best = scoredIssues[0];
198+
if (!best) {
199+
return null;
200+
}
201+
202+
const description = best.issue.description || '';
203+
const hasTestRequirements =
204+
containsKeywords(description, TEST_KEYWORDS) ||
205+
containsKeywords(description, VALIDATION_KEYWORDS);
206+
207+
return {
208+
id: best.issue.id,
209+
identifier: best.issue.identifier,
210+
title: best.issue.title,
211+
priority: best.issue.priority,
212+
hasTestRequirements,
213+
estimatedPoints: best.issue.estimate,
214+
url: best.issue.url,
215+
score: best.score,
216+
};
217+
} catch (error) {
218+
console.error('[linear-task-picker] Error fetching tasks:', error);
219+
return null;
220+
}
221+
}
222+
223+
/**
224+
* Get multiple task suggestions (for showing options)
225+
*/
226+
export async function getTopTaskSuggestions(
227+
options: PickerOptions = {},
228+
count: number = 3
229+
): Promise<TaskSuggestion[]> {
230+
const client = getLinearClient();
231+
if (!client) {
232+
return [];
233+
}
234+
235+
const { teamId, preferTestTasks = true, limit = 30 } = options;
236+
237+
try {
238+
const [backlogIssues, unstartedIssues] = await Promise.all([
239+
client.getIssues({ teamId, stateType: 'backlog', limit }),
240+
client.getIssues({ teamId, stateType: 'unstarted', limit }),
241+
]);
242+
243+
const allIssues = [...backlogIssues, ...unstartedIssues];
244+
const unassignedIssues = allIssues.filter((issue) => !issue.assignee);
245+
const issuesToScore =
246+
unassignedIssues.length > 0 ? unassignedIssues : allIssues;
247+
248+
const scoredIssues = issuesToScore.map((issue) => ({
249+
issue,
250+
score: scoreTask(issue, preferTestTasks),
251+
}));
252+
253+
scoredIssues.sort((a, b) => b.score - a.score);
254+
255+
return scoredIssues.slice(0, count).map(({ issue, score }) => {
256+
const description = issue.description || '';
257+
const hasTestRequirements =
258+
containsKeywords(description, TEST_KEYWORDS) ||
259+
containsKeywords(description, VALIDATION_KEYWORDS);
260+
261+
return {
262+
id: issue.id,
263+
identifier: issue.identifier,
264+
title: issue.title,
265+
priority: issue.priority,
266+
hasTestRequirements,
267+
estimatedPoints: issue.estimate,
268+
url: issue.url,
269+
score,
270+
};
271+
});
272+
} catch (error) {
273+
console.error('[linear-task-picker] Error fetching tasks:', error);
274+
return [];
275+
}
276+
}

0 commit comments

Comments
 (0)