254 lines
5.8 KiB
TypeScript
Executable File
254 lines
5.8 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Linear gatherer for status
|
|
*
|
|
* Collects Linear issue data via Claude CLI headless mode (MCP)
|
|
* - Checks if Linear MCP is configured
|
|
* - Queries recent issues via claude --print
|
|
*/
|
|
|
|
import { homedir } from "node:os";
|
|
import { parseArgs } from "node:util";
|
|
import { parseTimeConstraint, toISOPeriod } from "../lib/time";
|
|
import type { GathererResult, LinearData, LinearIssue } from "../lib/types";
|
|
|
|
const { values } = parseArgs({
|
|
args: Bun.argv.slice(2),
|
|
options: {
|
|
time: { type: "string", short: "t", default: "24h" },
|
|
team: { type: "string" },
|
|
help: { type: "boolean", short: "h" },
|
|
},
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(`
|
|
linear-gatherer.ts - Gather Linear issue data
|
|
|
|
Usage:
|
|
./linear-gatherer.ts [options]
|
|
|
|
Options:
|
|
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
|
--team <team-key> Linear team key to filter by
|
|
-h, --help Show this help
|
|
|
|
Output:
|
|
JSON GathererResult with LinearData
|
|
|
|
Note:
|
|
Requires Linear MCP to be configured in Claude settings.
|
|
Uses 'claude --print' headless mode to query via MCP.
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
/**
|
|
* Checks if Linear MCP is configured in Claude settings.
|
|
* @returns True if Linear MCP is configured
|
|
*/
|
|
async function checkLinearMCPConfigured(): Promise<boolean> {
|
|
// Check both user and project settings
|
|
const settingsPaths = [
|
|
`${homedir()}/.claude/settings.json`,
|
|
`${homedir()}/.claude/settings.local.json`,
|
|
".claude/settings.json",
|
|
".claude/settings.local.json",
|
|
];
|
|
|
|
for (const path of settingsPaths) {
|
|
const file = Bun.file(path);
|
|
if (await file.exists()) {
|
|
try {
|
|
const content = await file.json();
|
|
// Check for Linear in mcpServers
|
|
if (content.mcpServers) {
|
|
const servers = Object.keys(content.mcpServers);
|
|
if (servers.some((s) => s.toLowerCase().includes("linear"))) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// Continue checking other files
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if Claude CLI is installed.
|
|
* @returns True if claude is available
|
|
*/
|
|
async function checkClaudeCliAvailable(): Promise<boolean> {
|
|
const proc = Bun.spawn(["which", "claude"], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const exitCode = await proc.exited;
|
|
return exitCode === 0;
|
|
}
|
|
|
|
/**
|
|
* Queries Linear issues via Claude CLI headless mode.
|
|
* @param timeMs - Time window in milliseconds
|
|
* @param team - Optional team key to filter by
|
|
* @returns Array of issues or null on failure
|
|
*/
|
|
async function queryLinearViaClaude(
|
|
timeMs: number,
|
|
team?: string,
|
|
): Promise<LinearIssue[] | null> {
|
|
const _period = toISOPeriod(timeMs);
|
|
|
|
// Build the prompt for Claude
|
|
const teamFilter = team ? ` for team ${team}` : "";
|
|
const prompt = `Use the Linear MCP tools to list issues updated in the last ${Math.round(timeMs / (60 * 60 * 1000))} hours${teamFilter}.
|
|
|
|
Return ONLY a JSON array of issues with this structure (no other text):
|
|
[
|
|
{
|
|
"identifier": "TEAM-123",
|
|
"title": "Issue title",
|
|
"state": { "name": "In Progress", "type": "started" },
|
|
"priority": 2,
|
|
"assignee": { "name": "Person Name" },
|
|
"labels": [{ "name": "label1" }],
|
|
"createdAt": "ISO date",
|
|
"updatedAt": "ISO date",
|
|
"url": "https://linear.app/..."
|
|
}
|
|
]
|
|
|
|
If no issues found, return an empty array [].`;
|
|
|
|
try {
|
|
const proc = Bun.spawn(
|
|
[
|
|
"claude",
|
|
"--print",
|
|
prompt,
|
|
"--output-format",
|
|
"json",
|
|
"--max-turns",
|
|
"3",
|
|
],
|
|
{
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 60000, // 60 second timeout
|
|
},
|
|
);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) {
|
|
return null;
|
|
}
|
|
|
|
// Try to parse the response
|
|
// Claude's JSON output may include a wrapper, extract the issues array
|
|
const parsed = JSON.parse(stdout);
|
|
|
|
// Handle different response formats
|
|
if (Array.isArray(parsed)) {
|
|
return parsed as LinearIssue[];
|
|
}
|
|
|
|
// If wrapped in a result object
|
|
if (parsed.result && Array.isArray(parsed.result)) {
|
|
return parsed.result as LinearIssue[];
|
|
}
|
|
|
|
// If wrapped in content
|
|
if (parsed.content) {
|
|
const content =
|
|
typeof parsed.content === "string"
|
|
? parsed.content
|
|
: JSON.stringify(parsed.content);
|
|
// Try to extract JSON array from content
|
|
const match = content.match(/\[[\s\S]*\]/);
|
|
if (match) {
|
|
return JSON.parse(match[0]) as LinearIssue[];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gathers Linear issue data via MCP.
|
|
* @returns Gatherer result with Linear data
|
|
*/
|
|
async function gatherLinearData(): Promise<GathererResult<LinearData>> {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// Check if Linear MCP is configured
|
|
const mcpConfigured = await checkLinearMCPConfigured();
|
|
if (!mcpConfigured) {
|
|
return {
|
|
source: "linear",
|
|
status: "unavailable",
|
|
reason: "Linear MCP not configured in Claude settings",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Check if Claude CLI is available
|
|
const claudeAvailable = await checkClaudeCliAvailable();
|
|
if (!claudeAvailable) {
|
|
return {
|
|
source: "linear",
|
|
status: "unavailable",
|
|
reason: "Claude CLI not installed",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Parse time constraint
|
|
const timeValue = values.time ?? "24h";
|
|
let timeMs: number;
|
|
try {
|
|
timeMs = parseTimeConstraint(timeValue);
|
|
} catch (e) {
|
|
return {
|
|
source: "linear",
|
|
status: "error",
|
|
error: e instanceof Error ? e.message : "Invalid time constraint",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Query Linear via Claude
|
|
const issues = await queryLinearViaClaude(timeMs, values.team);
|
|
|
|
if (issues === null) {
|
|
return {
|
|
source: "linear",
|
|
status: "error",
|
|
error: "Failed to query Linear via Claude CLI",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
return {
|
|
source: "linear",
|
|
status: "success",
|
|
data: {
|
|
team: values.team,
|
|
issues,
|
|
},
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Main execution
|
|
const result = await gatherLinearData();
|
|
console.log(JSON.stringify(result, null, 2));
|