import * as fs from "fs"; import * as path from "path"; const REPO_ROOT = process.env.REPO_ROOT || path.resolve(__dirname, "../../../.."); const TASKS_ROOT = path.join(REPO_ROOT, "palaces/manglasabang/secretariat/tasks-synced-from-linear-to-git"); const API_URL = "https://api.linear.app/graphql"; type FolderBucket = "active" | "inactive-todo" | "inactive-done"; const STATE_MAP: Record = { "Pushed indefinitely": "inactive-todo", "Could do": "inactive-todo", "Next Up": "inactive-todo", "Ace is working on this": "active", "We are both waiting": "active", "Blocked": "active", "Junwon to reply": "active", "Ace to report progress": "active", "Ace is Waiting for Junwon": "active", "Junwon must unblock": "active", "Junwon to confirm done": "active", "Junwon to git commit": "active", "Done": "inactive-done", "Canceled": "inactive-done", }; interface IssueData { id: string; identifier: string; title: string; url: string; branchName: string; description: string | null; createdAt: string; dueDate: string | null; state: { name: string; type: string }; project: { name: string } | null; parent: { id: string; identifier: string } | null; relations: { nodes: { type: string; relatedIssue: { id: string; identifier: string } }[] }; } let apiKey: string; async function gql(query: string, variables?: Record): Promise { const res = await fetch(API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: apiKey, }, body: JSON.stringify({ query, variables }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Linear API ${res.status}: ${text}`); } const json = await res.json(); if (json.errors?.length) { throw new Error(`Linear GraphQL: ${json.errors.map((e: any) => e.message).join(", ")}`); } return json.data; } const TEAM_ISSUES_QUERY = ` query TeamIssues($teamId: String!, $after: String) { team(id: $teamId) { issues(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id identifier title url branchName description createdAt dueDate state { name type } project { name } parent { id identifier } relations(first: 10) { nodes { type relatedIssue { id identifier } } } } } } } `; const TEAM_QUERY = ` query { teams(first: 1) { nodes { id } } } `; async function fetchAllIssues(): Promise { const teamData = await gql(TEAM_QUERY); const teamId = teamData.teams.nodes[0].id; const issues: IssueData[] = []; let after: string | null = null; while (true) { const data = await gql(TEAM_ISSUES_QUERY, { teamId, after }); const page = data.team.issues; issues.push(...page.nodes); if (!page.pageInfo.hasNextPage) break; after = page.pageInfo.endCursor; } return issues; } function bucketDir(project: string, bucket: FolderBucket): string { return path.join(TASKS_ROOT, project, bucket); } function findExistingFolder(folderName: string, project: string): { dir: string; bucket: FolderBucket } | null { const buckets: FolderBucket[] = ["active", "inactive-todo", "inactive-done"]; for (const b of buckets) { const dir = path.join(bucketDir(project, b), folderName); if (fs.existsSync(dir)) { return { dir, bucket: b }; } } return null; } function generateTaskMd(issue: IssueData, project: string): string { const lines = [ `# ${issue.title}`, "", `**Linear:** ${issue.identifier} — ${issue.url}`, `**Status:** ${issue.state.name}`, `**Project:** ${project}`, ]; if (issue.dueDate) lines.push(`**Due:** ${issue.dueDate}`); lines.push("", "## Description", issue.description || "(no description)", ""); return lines.join("\n"); } function ensureDir(dir: string): void { fs.mkdirSync(dir, { recursive: true }); } function moveFolder(oldPath: string, newPath: string): void { ensureDir(path.dirname(newPath)); fs.renameSync(oldPath, newPath); } export async function syncIssues(linearApiKey: string): Promise { apiKey = linearApiKey; console.log("[sync] starting issue sync"); const rawIssues = await fetchAllIssues(); console.log(`[sync] fetched ${rawIssues.length} total issues`); const allIssues: { issue: IssueData; project: string }[] = []; for (const issue of rawIssues) { const project = issue.project?.name?.toLowerCase() || "default"; allIssues.push({ issue, project }); } const touchedPaths: string[] = []; const changes: string[] = []; for (const { issue, project } of allIssues) { if (!issue.branchName) { console.log(`[sync] skipping ${issue.identifier}: no branchName`); continue; } const stateName = issue.state.name; const targetBucket = STATE_MAP[stateName]; if (!targetBucket) { console.log(`[sync] skipping ${issue.identifier}: unknown state "${stateName}"`); continue; } const folderName = issue.branchName; const targetDir = path.join(bucketDir(project, targetBucket), folderName); const mdFile = path.join(targetDir, `${folderName}.md`); const mdContent = generateTaskMd(issue, project); ensureDir(bucketDir(project, "active")); ensureDir(bucketDir(project, "inactive-todo")); ensureDir(bucketDir(project, "inactive-done")); const existing = findExistingFolder(folderName, project); if (existing && existing.bucket !== targetBucket) { console.log(`[sync] moving ${issue.identifier} from ${existing.bucket} to ${targetBucket}`); moveFolder(existing.dir, targetDir); changes.push(`move ${issue.identifier} to ${targetBucket}`); } else if (!existing) { console.log(`[sync] creating ${issue.identifier} in ${project}/${targetBucket}`); ensureDir(targetDir); changes.push(`add ${issue.identifier}`); } const existingMd = fs.existsSync(mdFile) ? fs.readFileSync(mdFile, "utf-8") : null; if (existingMd !== mdContent) { fs.writeFileSync(mdFile, mdContent); touchedPaths.push(mdFile); } } const validFolders = new Set(); for (const { issue, project } of allIssues) { if (!issue.branchName) continue; const bucket = STATE_MAP[issue.state.name]; if (!bucket) continue; validFolders.add(`${project}/${issue.branchName}`); } const buckets: FolderBucket[] = ["active", "inactive-todo", "inactive-done"]; const projects = fs.existsSync(TASKS_ROOT) ? fs.readdirSync(TASKS_ROOT, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : []; for (const proj of projects) { for (const bucket of buckets) { const dir = bucketDir(proj, bucket); if (!fs.existsSync(dir)) continue; for (const folder of fs.readdirSync(dir, { withFileTypes: true })) { if (!folder.isDirectory()) continue; const key = `${proj}/${folder.name}`; if (!validFolders.has(key)) { const orphanPath = path.join(dir, folder.name); fs.rmSync(orphanPath, { recursive: true, force: true }); console.log(`[sync] removed orphan: ${proj}/${bucket}/${folder.name}`); changes.push(`remove orphan ${folder.name}`); } } } } if (touchedPaths.length > 0 || changes.length > 0) { console.log(`[sync] updated ${touchedPaths.length} files, ${changes.length} changes`); } else { console.log("[sync] no changes detected"); } const tasksIndex = { synced_at: new Date().toISOString(), tasks: rawIssues.map((issue) => { const proj = issue.project?.name?.toLowerCase() || "default"; const bkt = STATE_MAP[issue.state.name] || null; const blocks: { id: string; identifier: string }[] = []; const blockedBy: { id: string; identifier: string }[] = []; for (const rel of issue.relations?.nodes || []) { if (rel.type === "blocks") blocks.push({ id: rel.relatedIssue.id, identifier: rel.relatedIssue.identifier }); else if (rel.type === "blocked_by") blockedBy.push({ id: rel.relatedIssue.id, identifier: rel.relatedIssue.identifier }); } return { id: issue.id, identifier: issue.identifier, title: issue.title, url: issue.url, status: issue.state.name, statusType: issue.state.type, project: proj, bucket: bkt, dueDate: issue.dueDate, parentId: issue.parent?.id || null, parentIdentifier: issue.parent?.identifier || null, blocks, blockedBy, }; }), }; const idMap: Record = {}; for (const t of tasksIndex.tasks) idMap[t.id] = t; for (const t of tasksIndex.tasks) { for (const b of t.blocks) { const bt = idMap[b.id]; if (bt && !bt.blockedBy.some((x) => x.id === t.id)) { bt.blockedBy.push({ id: t.id, identifier: t.identifier }); } } } fs.writeFileSync(path.join(TASKS_ROOT, "tasks-index.json"), JSON.stringify(tasksIndex, null, 2)); console.log(`[sync] wrote tasks-index.json (${tasksIndex.tasks.length} tasks)`); console.log("[sync] sync complete"); } if (require.main === module) { const key = process.env.LINEAR_API_KEY; if (!key) { console.error("[sync] LINEAR_API_KEY not set"); process.exit(1); } syncIssues(key).catch((err) => { console.error("[sync] fatal:", err); process.exit(1); }); }