Skip to content

Instantly share code, notes, and snippets.

@roninjin10
Created March 9, 2026 21:06
Show Gist options
  • Select an option

  • Save roninjin10/d76b20a26b8e772225795c5a7b7f94f1 to your computer and use it in GitHub Desktop.

Select an option

Save roninjin10/d76b20a26b8e772225795c5a7b7f94f1 to your computer and use it in GitHub Desktop.
Linear smithers script
#!/usr/bin/env bun
/**
* JJHub Smithers Workflow — webhook listener and backfill runner for Linear.
*
* Modes:
* 1. Webhook mode: process issues when the trigger label is added.
* 2. Backfill mode: enqueue existing issues on startup.
* 3. Batch mode: backfill and exit once the queue drains.
*
* Usage:
* bun scripts/smithers.tsx --team JJH --public-url https://abc.ngrok.io
* bun scripts/smithers.tsx --team JJH --backfill --all-open --batch
*
* Env:
* LINEAR_API_KEY — Required for Linear integration
* SMITHERS_PUBLIC_URL — Alternative to --public-url flag
*/
import { Database } from "bun:sqlite";
import { parseArgs } from "node:util";
import { z } from "zod";
import {
createSmithers,
Sequence,
Parallel,
Branch,
Ralph,
Worktree,
ClaudeCodeAgent,
CodexAgent,
GeminiAgent,
runWorkflow,
LinearWebhookListener,
SmithersRenderer,
} from "smithers-orchestrator";
import { getLinearClient } from "smithers-orchestrator/linear";
import type {
LinearIssue,
LinearIssueStatus,
WebhookIssueEvent,
} from "smithers-orchestrator/linear";
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
args: Bun.argv.slice(2),
options: {
team: { type: "string", default: "JJH" },
"public-url": { type: "string" },
port: { type: "string", default: "3456" },
"trigger-label": { type: "string", default: "smithers" },
concurrency: { type: "string", default: "3" },
"dry-run": { type: "boolean", default: false },
backfill: { type: "boolean", default: false },
"all-open": { type: "boolean", default: false },
batch: { type: "boolean", default: false },
limit: { type: "string" },
},
});
const TEAM_KEY = args.team!;
const PUBLIC_URL = args["public-url"] ?? process.env.SMITHERS_PUBLIC_URL;
const PORT = parseInt(args.port!, 10);
const TRIGGER_LABEL = args["trigger-label"]!;
const CONCURRENCY = parseInt(args.concurrency!, 10);
const DRY_RUN = args["dry-run"]!;
const BATCH_MODE = args.batch!;
const BACKFILL = args.backfill! || BATCH_MODE;
const ALL_OPEN = args["all-open"]!;
const BACKFILL_LIMIT = args.limit ? parseInt(args.limit, 10) : null;
const ENABLE_WEBHOOK = !BATCH_MODE;
if (ENABLE_WEBHOOK && !PUBLIC_URL) {
console.error(
"Error: --public-url or SMITHERS_PUBLIC_URL is required (e.g. https://abc.ngrok.io)",
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
const researchSchema = z.object({
ticketId: z.string(),
summary: z.string(),
relevantFiles: z.array(z.string()),
isFrontend: z.boolean(),
complexity: z.enum(["low", "medium", "high"]),
});
const planSchema = z.object({
approach: z.string(),
steps: z.array(z.string()),
risks: z.array(z.string()),
estimatedFileCount: z.number(),
});
const implementSchema = z.object({
filesChanged: z.array(z.string()),
summary: z.string(),
testsAdded: z.boolean(),
});
function makeReviewSchema() {
return z.object({
reviewer: z.string(),
verdict: z.enum(["LGTM", "NEEDS_CHANGES"]),
comments: z.array(z.string()),
});
}
const claudeReviewSchema = makeReviewSchema();
const codexReviewSchema = makeReviewSchema();
const geminiReviewSchema = makeReviewSchema();
const linearUpdateSchema = z.object({
ticketId: z.string(),
newStateId: z.string(),
commentId: z.string(),
success: z.boolean(),
});
// ---------------------------------------------------------------------------
// Smithers setup
// ---------------------------------------------------------------------------
const { Workflow, Task, smithers, outputs } = createSmithers({
research: researchSchema,
plan: planSchema,
implement: implementSchema,
claudeReview: claudeReviewSchema,
codexReview: codexReviewSchema,
geminiReview: geminiReviewSchema,
linearUpdate: linearUpdateSchema,
});
// ---------------------------------------------------------------------------
// Agents
// ---------------------------------------------------------------------------
const CLAUDE_MODEL = "claude-opus-4-6";
const CODEX_MODEL = "gpt-5.4";
const GEMINI_MODEL = "gemini-3.1-pro";
const TASK_RETRIES = 3;
// The implementer agents do NOT get this — they focus on code only.
const LINEAR_API_INSTRUCTIONS = `
## Linear API (for posting progress updates)
You have access to the Linear GraphQL API via curl. Use it to post comments on the issue you're working on.
The API key is in the LINEAR_API_KEY environment variable.
Post a comment:
curl -s -X POST https://api.linear.app/graphql \\
-H "Content-Type: application/json" \\
-H "Authorization: $LINEAR_API_KEY" \\
-d '{"query":"mutation{commentCreate(input:{issueId:\\"ISSUE_ID\\",body:\\"Your markdown comment\\"}){success}}"}'
Update issue state:
curl -s -X POST https://api.linear.app/graphql \\
-H "Content-Type: application/json" \\
-H "Authorization: $LINEAR_API_KEY" \\
-d '{"query":"mutation{issueUpdate(id:\\"ISSUE_ID\\",input:{stateId:\\"STATE_ID\\"}){success}}"}'
Use this to post a brief progress comment when you START your phase (e.g. "Starting research..." or "Reviewing implementation...").
Keep comments concise — 1-3 sentences.`;
const claudeResearcher = new ClaudeCodeAgent({
id: "claude-researcher",
model: CLAUDE_MODEL,
systemPrompt: `You are a senior engineer researching a ticket for JJHub, a jj-native code hosting platform.
Your job is to understand the ticket, find relevant files in the codebase, and classify the work.
The project is a Go API server + Rust CLI/repo-host. Check docs/specs/ for architecture.
Respond with structured JSON matching the output schema.
${LINEAR_API_INSTRUCTIONS}`,
});
const codexPlanner = new CodexAgent({
id: "codex-planner",
model: CODEX_MODEL,
systemPrompt: `You are a senior architect planning implementation for JJHub tickets.
Given research output, create a concrete implementation plan with steps, risks, and file estimates.
The project uses Go (Chi router, sqlc, pgxpool) for the API and Rust (clap, jj-lib) for CLI/repo-host.
Respond with structured JSON matching the output schema.
${LINEAR_API_INSTRUCTIONS}`,
});
const codexImplementer = new CodexAgent({
id: "codex-implementer",
model: CODEX_MODEL,
systemPrompt: `You are a senior Go/Rust engineer implementing JJHub features.
Follow the plan exactly. Write clean, idiomatic code. Use existing patterns from the codebase.
Go API: Chi router, sqlc queries, services layer. Rust: clap CLI, jj-lib.
Read specs in docs/specs/ before writing code. Run tests after changes.`,
});
const geminiImplementer = new GeminiAgent({
id: "gemini-implementer",
model: GEMINI_MODEL,
systemPrompt: `You are a senior frontend/full-stack engineer implementing JJHub features.
Follow the plan exactly. Write clean, idiomatic code. Use existing patterns from the codebase.
Read specs in docs/specs/ before writing code. Run tests after changes.`,
});
const claudeReviewer = new ClaudeCodeAgent({
id: "claude-reviewer",
model: CLAUDE_MODEL,
systemPrompt: `You are a senior code reviewer for JJHub.
Review the implementation changes critically. Check for:
- Correctness and completeness vs the plan
- Code quality, error handling, edge cases
- Security issues (OWASP top 10)
- Adherence to project patterns (Chi, sqlc, services layer)
Set verdict to "LGTM" only if the code is production-ready.
${LINEAR_API_INSTRUCTIONS}`,
});
const codexReviewer = new CodexAgent({
id: "codex-reviewer",
model: CODEX_MODEL,
systemPrompt: `You are a senior code reviewer for JJHub.
Review the implementation changes critically. Check for:
- Logical correctness and edge cases
- Performance implications
- Test coverage
- API design consistency
Set verdict to "LGTM" only if the code is production-ready.
${LINEAR_API_INSTRUCTIONS}`,
});
const geminiReviewer = new GeminiAgent({
id: "gemini-reviewer",
model: GEMINI_MODEL,
systemPrompt: `You are a senior code reviewer for JJHub.
Review the implementation changes critically. Check for:
- Code organization and readability
- Documentation and naming
- Type safety
- Potential regressions
Set verdict to "LGTM" only if the code is production-ready the test cases alone give you confidence it works and you can't think of any ways to improve the implementation documentation or implementation code.
${LINEAR_API_INSTRUCTIONS}`,
});
// ---------------------------------------------------------------------------
// Shared types / helpers
// ---------------------------------------------------------------------------
type ExistingRun = {
runId: string;
status: string;
createdAtMs: number;
finishedAtMs: number | null;
};
type QueuedEvent = {
issueId: string;
identifier: string;
issue?: LinearIssue;
existingRun?: ExistingRun | null;
source: "webhook" | "backfill";
};
type InflightEntry = {
issueId: string;
identifier: string;
abort: AbortController;
promise: Promise<void>;
};
const REPO_ROOT = process.cwd();
const WORKTREE_DIR = `${REPO_ROOT}/.smithers-worktrees`;
const TICKET_WORKFLOW_PREFIX = "ticket-";
const queue: QueuedEvent[] = [];
const inflight = new Map<string, InflightEntry>();
const seen = new Set<string>();
const idleWaiters = new Set<() => void>();
let poolClient: ReturnType<typeof getLinearClient>;
let poolStatuses: LinearIssueStatus[];
function issueHasTriggerLabel(issue: Pick<LinearIssue, "labels">) {
const normalized = TRIGGER_LABEL.trim().toLowerCase();
return issue.labels.some((label) => label.name.trim().toLowerCase() === normalized);
}
function isActiveIssue(issue: Pick<LinearIssue, "state">) {
const stateType = issue.state?.type ?? "";
return stateType !== "completed" && stateType !== "canceled";
}
function ticketNumber(identifier: string) {
const match = identifier.match(/-(\d+)$/);
return match ? parseInt(match[1]!, 10) : Number.MAX_SAFE_INTEGER;
}
function sortIssuesForProcessing(a: LinearIssue, b: LinearIssue) {
const aStarted = a.state?.type === "started" ? 0 : 1;
const bStarted = b.state?.type === "started" ? 0 : 1;
if (aStarted !== bStarted) return aStarted - bStarted;
return ticketNumber(a.identifier) - ticketNumber(b.identifier);
}
function notifyIfIdle() {
if (queue.length > 0 || inflight.size > 0) return;
for (const resolve of idleWaiters) resolve();
idleWaiters.clear();
}
function waitForIdle() {
if (queue.length === 0 && inflight.size === 0) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
idleWaiters.add(resolve);
});
}
async function pruneStaleGitWorktrees() {
const { $ } = await import("bun");
$.cwd(REPO_ROOT);
await $`git worktree prune`.nothrow();
}
function loadLatestRunsByTicket(): Map<string, ExistingRun> {
const db = new Database("./smithers.db");
db.exec("PRAGMA busy_timeout = 5000");
try {
const rows = db.query(
`WITH ranked AS (
SELECT
workflow_name,
run_id,
status,
created_at_ms,
finished_at_ms,
ROW_NUMBER() OVER (
PARTITION BY workflow_name
ORDER BY created_at_ms DESC
) AS rn
FROM _smithers_runs
WHERE workflow_name LIKE '${TICKET_WORKFLOW_PREFIX}%'
)
SELECT workflow_name, run_id, status, created_at_ms, finished_at_ms
FROM ranked
WHERE rn = 1`,
).all() as Array<{
workflow_name: string;
run_id: string;
status: string;
created_at_ms: number;
finished_at_ms: number | null;
}>;
const latestRuns = new Map<string, ExistingRun>();
for (const row of rows) {
latestRuns.set(row.workflow_name.replace(TICKET_WORKFLOW_PREFIX, ""), {
runId: row.run_id,
status: row.status,
createdAtMs: row.created_at_ms,
finishedAtMs: row.finished_at_ms,
});
}
return latestRuns;
} catch {
return new Map();
} finally {
db.close();
}
}
async function fetchBackfillIssues(teamId: string): Promise<LinearIssue[]> {
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) {
throw new Error("LINEAR_API_KEY is required for backfill mode");
}
const query = `
query SmithersBackfillIssues($teamId: ID!, $after: String) {
issues(
first: 50
after: $after
filter: { team: { id: { eq: $teamId } } }
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
identifier
title
description
priority
priorityLabel
state {
id
name
type
}
assignee {
id
name
email
}
labels {
nodes {
id
name
}
}
project {
id
name
}
createdAt
updatedAt
url
}
}
}
`;
const issues: LinearIssue[] = [];
let after: string | null = null;
while (true) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: apiKey,
},
body: JSON.stringify({
query,
variables: { teamId, after },
}),
});
if (!response.ok) {
throw new Error(
`Linear backfill query failed with HTTP ${response.status}: ${await response.text()}`,
);
}
const payload = await response.json() as {
data?: {
issues?: {
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
nodes?: Array<{
id: string;
identifier: string;
title: string;
description: string | null;
priority: number;
priorityLabel: string;
state: { id: string; name: string; type: string } | null;
assignee: { id: string; name: string; email: string } | null;
labels: { nodes: Array<{ id: string; name: string }> };
project: { id: string; name: string } | null;
createdAt: string;
updatedAt: string;
url: string;
}>;
};
};
errors?: Array<{ message?: string }>;
};
if (payload.errors?.length) {
throw new Error(
payload.errors.map((err) => err.message ?? "Unknown Linear error").join("; "),
);
}
const page = payload.data?.issues;
if (!page?.nodes) break;
issues.push(
...page.nodes.map((node) => ({
id: node.id,
identifier: node.identifier,
title: node.title,
description: node.description,
priority: node.priority,
priorityLabel: node.priorityLabel,
state: node.state,
assignee: node.assignee,
labels: node.labels.nodes,
project: node.project,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
url: node.url,
})),
);
if (!page.pageInfo?.hasNextPage) break;
after = page.pageInfo.endCursor;
}
const filtered = issues
.filter((issue) => isActiveIssue(issue))
.filter((issue) => (ALL_OPEN ? true : issueHasTriggerLabel(issue)))
.sort(sortIssuesForProcessing);
if (BACKFILL_LIMIT && Number.isFinite(BACKFILL_LIMIT) && BACKFILL_LIMIT > 0) {
return filtered.slice(0, BACKFILL_LIMIT);
}
return filtered;
}
async function hydrateIssue(node: any): Promise<LinearIssue> {
const [state, assignee, labels, project] = await Promise.all([
node.state,
node.assignee,
node.labels(),
node.project,
]);
return {
id: node.id,
identifier: node.identifier,
title: node.title,
description: node.description ?? null,
priority: node.priority,
priorityLabel: node.priorityLabel,
state: state ? { id: state.id, name: state.name, type: state.type } : null,
assignee: assignee ? { id: assignee.id, name: assignee.name, email: assignee.email } : null,
labels: labels.nodes.map((label: any) => ({ id: label.id, name: label.name })),
project: project ? { id: project.id, name: project.name } : null,
createdAt: node.createdAt.toISOString(),
updatedAt: node.updatedAt.toISOString(),
url: node.url,
};
}
async function enqueueBackfill(teamId: string) {
const issues = await fetchBackfillIssues(teamId);
const latestRuns = loadLatestRunsByTicket();
let resumable = 0;
for (const issue of issues) {
if (seen.has(issue.id)) continue;
const existingRun = latestRuns.get(issue.identifier) ?? null;
if (existingRun) resumable += 1;
seen.add(issue.id);
queue.push({
issueId: issue.id,
identifier: issue.identifier,
issue,
existingRun,
source: "backfill",
});
}
console.log(
`Backfill queued ${issues.length} issue(s) (${resumable} resumable) from ${ALL_OPEN ? "all active tickets" : `label "${TRIGGER_LABEL}"`}.`,
);
drainQueue();
}
// ---------------------------------------------------------------------------
// Workflow builder
// ---------------------------------------------------------------------------
function buildTicketWorkflow(
ticket: LinearIssue,
statuses: LinearIssueStatus[],
) {
const doneState = statuses.find((status) => status.type === "completed");
const branchName = `smithers/${ticket.identifier.toLowerCase()}`;
const worktreePath = `${WORKTREE_DIR}/${ticket.identifier.toLowerCase()}`;
return smithers((ctx) => {
const latestClaudeReview =
ctx.outputs.claudeReview?.[ctx.outputs.claudeReview.length - 1];
const latestCodexReview =
ctx.outputs.codexReview?.[ctx.outputs.codexReview.length - 1];
const latestGeminiReview =
ctx.outputs.geminiReview?.[ctx.outputs.geminiReview.length - 1];
const allLgtm =
latestClaudeReview?.verdict === "LGTM" &&
latestCodexReview?.verdict === "LGTM" &&
latestGeminiReview?.verdict === "LGTM";
const latestResearch =
ctx.outputs.research?.[ctx.outputs.research.length - 1];
const isFrontend = latestResearch?.isFrontend ?? false;
const previousFeedback = [
latestClaudeReview?.verdict === "NEEDS_CHANGES"
? `Claude reviewer: ${latestClaudeReview.comments.join("; ")}`
: null,
latestCodexReview?.verdict === "NEEDS_CHANGES"
? `Codex reviewer: ${latestCodexReview.comments.join("; ")}`
: null,
latestGeminiReview?.verdict === "NEEDS_CHANGES"
? `Gemini reviewer: ${latestGeminiReview.comments.join("; ")}`
: null,
]
.filter(Boolean)
.join("\n");
return (
<Workflow name={`ticket-${ticket.identifier}`}>
<Sequence>
<Task
id="research"
output={outputs.research}
agent={claudeResearcher}
retries={TASK_RETRIES}
>
{`Research this ticket and classify the work required.
Ticket: ${ticket.identifier}
Title: ${ticket.title}
Description: ${ticket.description ?? "(no description)"}
Labels: ${ticket.labels.map((label) => label.name).join(", ") || "(none)"}
Linear Issue ID: ${ticket.id}
Post a brief comment on the Linear issue (using the curl instructions) saying you're starting research.
Explore the codebase to find relevant files. Check docs/specs/ for architecture context.
Determine if this is frontend work (Astro/React) or backend (Go API / Rust CLI/repo-host).
Classify complexity as low (< 3 files), medium (3-8 files), or high (> 8 files).`}
</Task>
<Task
id="plan"
output={outputs.plan}
agent={codexPlanner}
retries={TASK_RETRIES}
>
{`Create an implementation plan for this ticket.
Ticket: ${ticket.identifier} — ${ticket.title}
Description: ${ticket.description ?? "(no description)"}
Linear Issue ID: ${ticket.id}
Research findings:
${JSON.stringify(latestResearch ?? {}, null, 2)}
Post a brief comment on the Linear issue (using the curl instructions) with your planned approach.
Create a concrete step-by-step plan. Identify risks and estimate file count.`}
</Task>
<Worktree
id={`wt-${ticket.identifier.toLowerCase()}`}
path={worktreePath}
branch={branchName}
>
<Ralph
until={allLgtm}
maxIterations={5}
onMaxReached="return-last"
>
<Sequence>
<Branch
if={isFrontend}
then={
<Task
id="implement"
output={outputs.implement}
agent={geminiImplementer}
retries={TASK_RETRIES}
>
{`Implement the following ticket based on the plan.
Ticket: ${ticket.identifier} — ${ticket.title}
Plan: ${JSON.stringify(ctx.outputs.plan?.[ctx.outputs.plan.length - 1] ?? {}, null, 2)}
${previousFeedback ? `PREVIOUS REVIEW FEEDBACK TO ADDRESS:\n${previousFeedback}\n` : ""}
You are working in an isolated jj worktree at ${worktreePath} on branch ${branchName}.
Follow the plan steps. Write clean code matching existing patterns.`}
</Task>
}
else={
<Task
id="implement"
output={outputs.implement}
agent={codexImplementer}
retries={TASK_RETRIES}
>
{`Implement the following ticket based on the plan.
Ticket: ${ticket.identifier} — ${ticket.title}
Plan: ${JSON.stringify(ctx.outputs.plan?.[ctx.outputs.plan.length - 1] ?? {}, null, 2)}
${previousFeedback ? `PREVIOUS REVIEW FEEDBACK TO ADDRESS:\n${previousFeedback}\n` : ""}
You are working in an isolated jj worktree at ${worktreePath} on branch ${branchName}.
Follow the plan steps. Write clean code matching existing patterns.`}
</Task>
}
/>
<Parallel>
<Task
id="claudeReview"
output={outputs.claudeReview}
agent={claudeReviewer}
retries={TASK_RETRIES}
>
{`Review the implementation for ticket ${ticket.identifier}.
Linear Issue ID: ${ticket.id}
Implementation summary:
${JSON.stringify(ctx.outputs.implement?.[ctx.outputs.implement.length - 1] ?? {}, null, 2)}
You are reviewing code in worktree at ${worktreePath} on branch ${branchName}.
Read the changed files and review thoroughly. Set verdict to LGTM or NEEDS_CHANGES.
Post your review verdict and key findings as a comment on the Linear issue.
Review the implementation changes critically. Check for:
- Code organization and readability
- Documentation and naming
- Type safety
- Potential regressions
Set verdict to "LGTM" only if the code is production-ready the test cases alone give you confidence it works and you can't think of any ways to improve the implementation documentation or implementation code.
`}
</Task>
<Task
id="codexReview"
output={outputs.codexReview}
agent={codexReviewer}
retries={TASK_RETRIES}
>
{`Review the implementation for ticket ${ticket.identifier}.
Linear Issue ID: ${ticket.id}
Implementation summary:
${JSON.stringify(ctx.outputs.implement?.[ctx.outputs.implement.length - 1] ?? {}, null, 2)}
You are reviewing code in worktree at ${worktreePath} on branch ${branchName}.
Read the changed files and review thoroughly. Set verdict to LGTM or NEEDS_CHANGES.
Post your review verdict and key findings as a comment on the Linear issue.
Review the implementation changes critically. Check for:
- Code organization and readability
- Documentation and naming
- Type safety
- Potential regressions
Set verdict to "LGTM" only if the code is production-ready the test cases alone give you confidence it works and you can't think of any ways to improve the implementation documentation or implementation code.
`}
</Task>
<Task
id="geminiReview"
output={outputs.geminiReview}
agent={geminiReviewer}
retries={TASK_RETRIES}
>
{`Review the implementation for ticket ${ticket.identifier}.
Linear Issue ID: ${ticket.id}
Implementation summary:
${JSON.stringify(ctx.outputs.implement?.[ctx.outputs.implement.length - 1] ?? {}, null, 2)}
You are reviewing code in worktree at ${worktreePath} on branch ${branchName}.
Read the changed files and review thoroughly. Set verdict to LGTM or NEEDS_CHANGES.
Post your review verdict and key findings as a comment on the Linear issue.
Review the implementation changes critically. Check for:
- Code organization and readability
- Documentation and naming
- Type safety
- Potential regressions
Set verdict to "LGTM" only if the code is production-ready the test cases alone give you confidence it works and you can't think of any ways to improve the implementation documentation or implementation code.
`}
</Task>
</Parallel>
</Sequence>
</Ralph>
</Worktree>
<Task id="applyToMain" output={outputs.linearUpdate}>
{async () => {
if (DRY_RUN) {
return {
ticketId: ticket.identifier,
newStateId: doneState?.id ?? "dry-run",
commentId: "dry-run",
success: true,
};
}
const { $ } = await import("bun");
$.cwd(REPO_ROOT);
await $`jj squash --from ${branchName} --into main -m ${`${ticket.identifier}: ${ticket.title}`}`;
console.log(` Squashed ${branchName} into main`);
await $`jj workspace forget ${`wt-${ticket.identifier.toLowerCase()}`}`.nothrow();
await $`rm -rf ${worktreePath}`.nothrow();
console.log(` Cleaned up worktree ${worktreePath}`);
const linearClient = getLinearClient();
const implementResult =
ctx.outputs.implement?.[ctx.outputs.implement.length - 1];
const reviewSummary = [
`Claude: ${latestClaudeReview?.verdict ?? "N/A"}`,
`Codex: ${latestCodexReview?.verdict ?? "N/A"}`,
`Gemini: ${latestGeminiReview?.verdict ?? "N/A"}`,
].join(" | ");
const body = [
`## Smithers Workflow Complete`,
"",
`**Reviews:** ${reviewSummary}`,
`**Branch:** \`${branchName}\` → squashed into main`,
"",
`**Files changed:** ${implementResult?.filesChanged?.join(", ") ?? "none"}`,
"",
`**Summary:** ${implementResult?.summary ?? "No summary"}`,
].join("\n");
const commentResult = await linearClient.createComment({
issueId: ticket.id,
body,
});
const comment = await commentResult.comment;
const commentId = comment?.id ?? "";
if (doneState) {
await linearClient.updateIssue(ticket.id, { stateId: doneState.id });
}
return {
ticketId: ticket.identifier,
newStateId: doneState?.id ?? "",
commentId,
success: true,
};
}}
</Task>
</Sequence>
</Workflow>
);
});
}
// ---------------------------------------------------------------------------
// Worker pool
// ---------------------------------------------------------------------------
function drainQueue() {
while (queue.length > 0 && inflight.size < CONCURRENCY) {
const event = queue.shift()!;
startWorker(event);
}
if (queue.length > 0) {
console.log(
` [pool] ${inflight.size}/${CONCURRENCY} workers busy, ${queue.length} queued`,
);
}
notifyIfIdle();
}
function startWorker(event: QueuedEvent) {
const abort = new AbortController();
const promise = (async () => {
try {
const issue = event.issue ?? await hydrateIssue(await poolClient.issue(event.issueId));
await processIssue(
poolClient,
issue,
poolStatuses,
abort.signal,
event.existingRun ?? null,
);
} catch (err) {
if (abort.signal.aborted) {
console.log(` ${event.identifier} aborted`);
return;
}
console.error(` Failed ${event.identifier} (${event.source}):`, err);
try {
await poolClient.createComment({
issueId: event.issueId,
body: `## Smithers Workflow Error\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``,
});
} catch (commentErr) {
console.error(` Failed to post error comment for ${event.identifier}:`, commentErr);
}
} finally {
inflight.delete(event.issueId);
drainQueue();
notifyIfIdle();
}
})();
inflight.set(event.issueId, {
issueId: event.issueId,
identifier: event.identifier,
abort,
promise,
});
}
// ---------------------------------------------------------------------------
// Ticket processing
// ---------------------------------------------------------------------------
async function processIssue(
client: ReturnType<typeof getLinearClient>,
issue: LinearIssue,
statuses: LinearIssueStatus[],
signal: AbortSignal,
existingRun: ExistingRun | null,
) {
const inProgressState = statuses.find(
(status) =>
status.type === "started" &&
status.name.toLowerCase().includes("progress"),
);
const resume = Boolean(existingRun);
console.log(
`[worker ${inflight.size}/${CONCURRENCY}] Processing ${issue.identifier}: ${issue.title}${resume ? ` [resume ${existingRun!.runId}]` : ""}`,
);
if (DRY_RUN) {
console.log(
` [DRY RUN] Would process ${issue.identifier}${resume ? ` via resume ${existingRun!.runId}` : ""}`,
);
return;
}
if (inProgressState) {
await client.updateIssue(issue.id, { stateId: inProgressState.id });
console.log(` Marked ${issue.identifier} as "${inProgressState.name}"`);
}
const workflow = buildTicketWorkflow(issue, statuses);
const input = {
ticketId: issue.identifier,
title: issue.title,
description: issue.description ?? "",
};
let result;
try {
result = await runWorkflow(workflow, {
input,
signal,
runId: existingRun?.runId,
resume,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (resume && /Cannot resume without an existing input row|MISSING_INPUT/i.test(message)) {
console.warn(` Resume state missing for ${issue.identifier}; starting a fresh run instead`);
result = await runWorkflow(workflow, {
input,
signal,
});
} else {
throw err;
}
}
console.log(` Completed ${issue.identifier}: ${result.status}`);
}
// ---------------------------------------------------------------------------
// Webhook handling
// ---------------------------------------------------------------------------
function handleIssueEvent(event: WebhookIssueEvent) {
if (seen.has(event.issueId)) {
console.log(` Skipping ${event.identifier} — already seen`);
return;
}
seen.add(event.issueId);
const existingRun = loadLatestRunsByTicket().get(event.identifier) ?? null;
console.log(
`\nWebhook: "${TRIGGER_LABEL}" label added to ${event.identifier} [queued: ${queue.length}, active: ${inflight.size}/${CONCURRENCY}]`,
);
queue.push({
issueId: event.issueId,
identifier: event.identifier,
existingRun,
source: "webhook",
});
drainQueue();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const client = getLinearClient();
console.log("Fetching teams...");
const teamsResult = await client.teams();
const teams = teamsResult.nodes;
const team = teams.find((candidate: any) => candidate.key === TEAM_KEY);
if (!team) {
console.error(
`Team "${TEAM_KEY}" not found. Available: ${teams.map((candidate: any) => candidate.key).join(", ")}`,
);
process.exit(1);
}
console.log(`Found team: ${team.name} (${team.key})`);
console.log(`Fetching statuses for ${team.name}...`);
const statesResult = await (await client.team(team.id)).states();
const statuses: LinearIssueStatus[] = statesResult.nodes.map((status: any) => ({
id: status.id,
name: status.name,
type: status.type,
position: status.position,
}));
await Bun.write(`${WORKTREE_DIR}/.gitkeep`, "");
await pruneStaleGitWorktrees();
poolClient = client;
poolStatuses = statuses;
console.log(`Worker pool: ${CONCURRENCY} concurrent slots\n`);
let shutdownFn: (() => Promise<void>) | null = null;
if (ENABLE_WEBHOOK) {
const listenerWorkflow = smithers(() => (
<Workflow name="smithers-webhook-listener">
<LinearWebhookListener
port={PORT}
publicUrl={PUBLIC_URL!}
teamId={team.id}
triggerLabel={TRIGGER_LABEL}
label={`smithers-${TEAM_KEY.toLowerCase()}`}
onIssue={(event) => handleIssueEvent(event)}
onReady={({ webhookId, shutdown }) => {
shutdownFn = shutdown;
console.log(
`Listening for "${TRIGGER_LABEL}" label on ${TEAM_KEY} issues (webhook: ${webhookId})`,
);
console.log("Ready. Press Ctrl+C to stop.\n");
}}
/>
</Workflow>
));
const renderer = new SmithersRenderer();
await renderer.render(listenerWorkflow.build({} as any));
}
if (BACKFILL) {
await enqueueBackfill(team.id);
}
if (BATCH_MODE) {
await waitForIdle();
console.log("\nBatch processing complete.");
process.exit(0);
}
let shuttingDown = false;
async function gracefulShutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("\nShutting down gracefully...");
for (const [, entry] of inflight) {
console.log(` Aborting ${entry.identifier}...`);
entry.abort.abort();
}
if (inflight.size > 0) {
console.log(`Waiting for ${inflight.size} in-flight workflows...`);
await Promise.allSettled(
Array.from(inflight.values()).map((entry) => entry.promise),
);
}
if (shutdownFn) {
await shutdownFn();
}
process.exit(0);
}
process.on("SIGINT", () => void gracefulShutdown());
process.on("SIGTERM", () => void gracefulShutdown());
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment