@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
187 lines (186 loc) • 5.9 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { LinearClient } from "../integrations/linear/client.js";
import { LinearAuthManager } from "../integrations/linear/auth.js";
const TEST_KEYWORDS = [
"test",
"spec",
"unit test",
"integration test",
"e2e",
"end-to-end",
"jest",
"vitest",
"mocha"
];
const VALIDATION_KEYWORDS = [
"validate",
"verify",
"verification",
"acceptance criteria",
"ac:",
"acceptance:",
"given when then",
"criteria:"
];
const QA_KEYWORDS = ["qa", "quality", "regression", "coverage", "assertion"];
const TEST_LABELS = [
"needs-tests",
"test-required",
"qa-review",
"has-ac",
"acceptance-criteria",
"tdd",
"testing"
];
function containsKeywords(text, keywords) {
const lowerText = text.toLowerCase();
return keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
}
function scoreTask(issue, preferTestTasks) {
let score = 0;
const description = issue.description || "";
const title = issue.title || "";
const fullText = `${title} ${description}`;
if (containsKeywords(fullText, TEST_KEYWORDS)) {
score += preferTestTasks ? 10 : 5;
}
if (containsKeywords(fullText, VALIDATION_KEYWORDS)) {
score += preferTestTasks ? 8 : 4;
}
if (containsKeywords(fullText, QA_KEYWORDS)) {
score += preferTestTasks ? 5 : 2;
}
const labelNames = issue.labels?.nodes?.map((l) => l.name.toLowerCase()) || [];
const hasTestLabel = TEST_LABELS.some(
(tl) => labelNames.some((ln) => ln.includes(tl))
);
if (hasTestLabel) {
score += preferTestTasks ? 5 : 3;
}
if (issue.priority === 1) {
score += 5;
} else if (issue.priority === 2) {
score += 3;
} else if (issue.priority === 3) {
score += 1;
}
if (description.includes("## Acceptance") || description.includes("### AC") || description.includes("- [ ]")) {
score += 2;
}
if (issue.estimate) {
score += 1;
}
return score;
}
function getLinearClient() {
const apiKey = process.env["LINEAR_API_KEY"];
if (apiKey && apiKey.startsWith("lin_api_")) {
return new LinearClient({ apiKey });
}
try {
const authManager = new LinearAuthManager();
const tokens = authManager.loadTokens();
if (tokens?.accessToken) {
return new LinearClient({ accessToken: tokens.accessToken });
}
} catch {
}
return null;
}
async function pickNextLinearTask(options = {}) {
const client = getLinearClient();
if (!client) {
return null;
}
const { teamId, preferTestTasks = true, limit = 20 } = options;
try {
const [backlogIssues, unstartedIssues] = await Promise.all([
client.getIssues({ teamId, stateType: "backlog", limit }),
client.getIssues({ teamId, stateType: "unstarted", limit })
]);
const allIssues = [...backlogIssues, ...unstartedIssues];
const unassignedIssues = allIssues.filter((issue) => !issue.assignee);
if (unassignedIssues.length === 0) {
if (allIssues.length === 0) {
return null;
}
}
const issuesToScore = unassignedIssues.length > 0 ? unassignedIssues : allIssues;
const scoredIssues = issuesToScore.map((issue) => ({
issue,
score: scoreTask(issue, preferTestTasks)
}));
scoredIssues.sort((a, b) => b.score - a.score);
const best = scoredIssues[0];
if (!best) {
return null;
}
const description = best.issue.description || "";
const hasTestRequirements = containsKeywords(description, TEST_KEYWORDS) || containsKeywords(description, VALIDATION_KEYWORDS);
return {
id: best.issue.id,
identifier: best.issue.identifier,
title: best.issue.title,
priority: best.issue.priority,
hasTestRequirements,
estimatedPoints: best.issue.estimate,
url: best.issue.url,
score: best.score
};
} catch (error) {
const isAuthError = error instanceof Error && (error.message.includes("401") || error.message.includes("403"));
if (!isAuthError) {
console.error("[linear-task-picker] Error fetching tasks:", error);
}
return null;
}
}
async function getTopTaskSuggestions(options = {}, count = 3) {
const client = getLinearClient();
if (!client) {
return [];
}
const { teamId, preferTestTasks = true, limit = 30 } = options;
try {
const [backlogIssues, unstartedIssues] = await Promise.all([
client.getIssues({ teamId, stateType: "backlog", limit }),
client.getIssues({ teamId, stateType: "unstarted", limit })
]);
const allIssues = [...backlogIssues, ...unstartedIssues];
const unassignedIssues = allIssues.filter((issue) => !issue.assignee);
const issuesToScore = unassignedIssues.length > 0 ? unassignedIssues : allIssues;
const scoredIssues = issuesToScore.map((issue) => ({
issue,
score: scoreTask(issue, preferTestTasks)
}));
scoredIssues.sort((a, b) => b.score - a.score);
return scoredIssues.slice(0, count).map(({ issue, score }) => {
const description = issue.description || "";
const hasTestRequirements = containsKeywords(description, TEST_KEYWORDS) || containsKeywords(description, VALIDATION_KEYWORDS);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
priority: issue.priority,
hasTestRequirements,
estimatedPoints: issue.estimate,
url: issue.url,
score
};
});
} catch (error) {
const isAuthError = error instanceof Error && (error.message.includes("401") || error.message.includes("403"));
if (!isAuthError) {
console.error("[linear-task-picker] Error fetching tasks:", error);
}
return [];
}
}
export {
getTopTaskSuggestions,
pickNextLinearTask
};
//# sourceMappingURL=linear-task-picker.js.map