# Gitee Issue Bot Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a user-level `systemd`-driven issue bot that checks Spark Store Gitee issues every 6 hours, stores one ranked candidate locally, and only launches a new opencode window after explicit manual approval. **Architecture:** Keep the implementation outside the Electron runtime by adding a small TypeScript script set under `scripts/issue-bot/`, with focused helpers for Gitee fetching, ranking, local state, approval, and opencode launching. Use user-cache state storage plus `systemd --user` service/timer units, and pass the `~/Desktop/spark-store` + `Erotica`-based worktree requirement into the generated opencode prompt instead of creating worktrees during polling. **Tech Stack:** Node.js 22 with `--experimental-strip-types`, TypeScript strict mode, built-in `fetch`, Vitest, npm scripts, `systemd --user` units. --- ## File Map - Create: `scripts/issue-bot/lib/types.ts` — shared strict TypeScript types for normalized issues, ranking results, and persisted state. - Create: `scripts/issue-bot/lib/state.ts` — state file path resolution, JSON load/save, corruption backup, and default-state initialization. - Create: `scripts/issue-bot/lib/ranking.ts` — issue filtering, heuristic scoring, and candidate selection. - Create: `scripts/issue-bot/lib/gitee.ts` — fetch open issues from Gitee API first and normalize the response. - Create: `scripts/issue-bot/lib/opencode.ts` — build approval prompt and spawn a configured opencode command. - Create: `scripts/issue-bot/check-issues.ts` — one-shot polling entrypoint that updates `currentCandidate`. - Create: `scripts/issue-bot/approve-issue.ts` — manual approval entrypoint that launches opencode and marks the approved issue. - Create: `src/__tests__/unit/issue-bot/state.test.ts` — state initialization, backup, and save/load tests. - Create: `src/__tests__/unit/issue-bot/ranking.test.ts` — scoring, filtering, and candidate selection tests. - Create: `src/__tests__/unit/issue-bot/check-issues.test.ts` — polling orchestration tests using mocked fetch/state. - Create: `src/__tests__/unit/issue-bot/approve-issue.test.ts` — approval and opencode-launch orchestration tests. - Create: `src/__tests__/unit/issue-bot/packaging.test.ts` — npm script and systemd unit smoke tests. - Modify: `package.json` — add `issue-bot:check` and `issue-bot:approve` scripts. - Modify: `tsconfig.node.json` — include `scripts` for type-check coverage in build tooling. - Create: `extras/systemd/spark-store-issue-bot.service` — `oneshot` user service for polling. - Create: `extras/systemd/spark-store-issue-bot.timer` — six-hour persistent timer. ### Task 1: Add Shared Types and Local State Storage **Files:** - Create: `scripts/issue-bot/lib/types.ts` - Create: `scripts/issue-bot/lib/state.ts` - Test: `src/__tests__/unit/issue-bot/state.test.ts` - [ ] **Step 1: Write the failing test** ```ts import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDefaultIssueBotState, getIssueBotStatePath, loadIssueBotState, saveIssueBotState, } from "../../../../scripts/issue-bot/lib/state"; describe("issue-bot state", () => { afterEach(() => { vi.restoreAllMocks(); delete process.env.XDG_CACHE_HOME; }); it("uses the XDG cache directory when available", () => { process.env.XDG_CACHE_HOME = "/tmp/spark-cache"; expect(getIssueBotStatePath()).toBe( "/tmp/spark-cache/spark-store/issue-bot/state.json", ); }); it("returns a default state when the file does not exist", () => { vi.spyOn(fs, "existsSync").mockReturnValue(false); expect(loadIssueBotState()).toEqual(createDefaultIssueBotState()); }); it("backs up invalid JSON and resets to the default state", () => { vi.spyOn(fs, "existsSync").mockReturnValue(true); vi.spyOn(fs, "readFileSync").mockReturnValue("not-json"); const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => {}); expect(loadIssueBotState()).toEqual(createDefaultIssueBotState()); expect(renameSync).toHaveBeenCalledWith( expect.stringContaining("state.json"), expect.stringContaining("state.json.bak-"), ); }); it("creates parent directories before saving state", () => { const mkdirSync = vi .spyOn(fs, "mkdirSync") .mockImplementation(() => undefined); const writeFileSync = vi .spyOn(fs, "writeFileSync") .mockImplementation(() => undefined); saveIssueBotState({ ...createDefaultIssueBotState(), lastRunStatus: "success", lastRunMessage: "candidate updated", }); expect(mkdirSync).toHaveBeenCalledWith( path.dirname(getIssueBotStatePath()), { recursive: true }, ); expect(writeFileSync).toHaveBeenCalledWith( getIssueBotStatePath(), expect.stringContaining('"lastRunStatus": "success"'), "utf8", ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts` Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/state'`. - [ ] **Step 3: Write minimal implementation** ```ts // scripts/issue-bot/lib/types.ts export interface NormalizedIssue { id: number; number: string; title: string; url: string; state: "open" | "closed"; createdAt: string; updatedAt: string; labels: string[]; bodyPreview: string; } export interface RankedIssue extends NormalizedIssue { score: number; rankingReasons: string[]; } export interface ApprovedIssue { id: number; title: string; url: string; approvedAt: string; } export interface IssueBotState { currentCandidate: RankedIssue | null; approvedIssue: ApprovedIssue | null; seenIssueIds: number[]; lastRunAt: string | null; lastRunStatus: "idle" | "success" | "network-error" | "parse-error"; lastRunMessage: string | null; } ``` ```ts // scripts/issue-bot/lib/state.ts import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { IssueBotState } from "./types"; export const createDefaultIssueBotState = (): IssueBotState => ({ currentCandidate: null, approvedIssue: null, seenIssueIds: [], lastRunAt: null, lastRunStatus: "idle", lastRunMessage: null, }); export const getIssueBotStatePath = (): string => { const cacheRoot = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); return path.join(cacheRoot, "spark-store", "issue-bot", "state.json"); }; export const loadIssueBotState = (): IssueBotState => { const filePath = getIssueBotStatePath(); if (!fs.existsSync(filePath)) return createDefaultIssueBotState(); try { const raw = fs.readFileSync(filePath, "utf8"); return { ...createDefaultIssueBotState(), ...(JSON.parse(raw) as Partial), }; } catch { const backupPath = `${filePath}.bak-${Date.now()}`; fs.renameSync(filePath, backupPath); return createDefaultIssueBotState(); } }; export const saveIssueBotState = (state: IssueBotState): void => { const filePath = getIssueBotStatePath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts` Expected: PASS with 4 tests passed. - [ ] **Step 5: Commit** ```bash git add scripts/issue-bot/lib/types.ts scripts/issue-bot/lib/state.ts src/__tests__/unit/issue-bot/state.test.ts git commit -m "feat(issue-bot): add local state storage" ``` ### Task 2: Add Ranking Rules and Candidate Selection **Files:** - Create: `scripts/issue-bot/lib/ranking.ts` - Test: `src/__tests__/unit/issue-bot/ranking.test.ts` - [ ] **Step 1: Write the failing test** ```ts import { describe, expect, it } from "vitest"; import { rankIssues, selectTopIssueCandidate, } from "../../../../scripts/issue-bot/lib/ranking"; import type { NormalizedIssue } from "../../../../scripts/issue-bot/lib/types"; const makeIssue = (overrides: Partial): NormalizedIssue => ({ id: 1, number: "I123", title: "示例 issue", url: "https://gitee.com/spark-store-project/spark-store/issues/I123", state: "open", createdAt: "2026-04-14T00:00:00.000Z", updatedAt: "2026-04-14T00:00:00.000Z", labels: [], bodyPreview: "用户反馈应用无法安装,并附上了复现步骤和日志。", ...overrides, }); describe("issue-bot ranking", () => { it("prioritizes install failures with actionable details", () => { const ranked = rankIssues([ makeIssue({ id: 1, title: "应用无法安装,附日志" }), makeIssue({ id: 2, title: "建议增加分类筛选", bodyPreview: "功能建议" }), ]); expect(ranked[0].id).toBe(1); expect(ranked[0].score).toBeGreaterThan(ranked[1].score); expect(ranked[0].rankingReasons).toContain( "contains high-impact keyword: 无法安装", ); }); it("filters out closed issues and already-approved issues", () => { const candidate = selectTopIssueCandidate( [ makeIssue({ id: 3, state: "closed", title: "已关闭问题" }), makeIssue({ id: 4, title: "白屏并卡死" }), ], { approvedIssueId: 4 }, ); expect(candidate).toBeNull(); }); it("prefers more recently updated issues when scores otherwise match", () => { const candidate = selectTopIssueCandidate( [ makeIssue({ id: 5, title: "启动白屏", updatedAt: "2026-04-14T08:00:00.000Z", }), makeIssue({ id: 6, title: "启动白屏", updatedAt: "2026-04-14T09:00:00.000Z", }), ], { approvedIssueId: null }, ); expect(candidate?.id).toBe(6); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts` Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/ranking'`. - [ ] **Step 3: Write minimal implementation** ```ts // scripts/issue-bot/lib/ranking.ts import type { NormalizedIssue, RankedIssue } from "./types"; const HIGH_IMPACT_KEYWORDS = [ "崩溃", "打不开", "无法安装", "升级失败", "卡死", "白屏", "闪退", ]; const CORE_FLOW_KEYWORDS = ["安装", "卸载", "更新", "启动", "搜索", "加载"]; const hasActionableDetail = (issue: NormalizedIssue): boolean => /复现|日志|截图|error|错误/i.test(issue.bodyPreview); const scoreIssue = (issue: NormalizedIssue): RankedIssue => { const reasons: string[] = []; let score = 0; const haystack = `${issue.title}\n${issue.bodyPreview}`; for (const keyword of HIGH_IMPACT_KEYWORDS) { if (haystack.includes(keyword)) { score += 10; reasons.push(`contains high-impact keyword: ${keyword}`); } } for (const keyword of CORE_FLOW_KEYWORDS) { if (haystack.includes(keyword)) { score += 4; reasons.push(`touches core flow: ${keyword}`); break; } } if (hasActionableDetail(issue)) { score += 6; reasons.push("includes actionable detail"); } if (/建议|需求|希望/.test(haystack)) { score -= 4; reasons.push("looks like feature discussion"); } return { ...issue, score, rankingReasons: reasons, }; }; export const rankIssues = (issues: NormalizedIssue[]): RankedIssue[] => [...issues] .filter((issue) => issue.state === "open") .map(scoreIssue) .sort((left, right) => { if (right.score !== left.score) return right.score - left.score; return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); }); export const selectTopIssueCandidate = ( issues: NormalizedIssue[], options: { approvedIssueId: number | null }, ): RankedIssue | null => { const ranked = rankIssues(issues).filter( (issue) => issue.id !== options.approvedIssueId, ); return ranked[0] ?? null; }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts` Expected: PASS with 3 tests passed. - [ ] **Step 5: Commit** ```bash git add scripts/issue-bot/lib/ranking.ts src/__tests__/unit/issue-bot/ranking.test.ts git commit -m "feat(issue-bot): rank candidate issues" ``` ### Task 3: Add Gitee Fetching and Polling Entrypoint **Files:** - Create: `scripts/issue-bot/lib/gitee.ts` - Create: `scripts/issue-bot/check-issues.ts` - Test: `src/__tests__/unit/issue-bot/check-issues.test.ts` - [ ] **Step 1: Write the failing test** ```ts import { beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueBotState, NormalizedIssue, } from "../../../../scripts/issue-bot/lib/types"; const loadState = vi.fn(); const saveState = vi.fn(); const listOpenIssues = vi.fn(); vi.mock("../../../../scripts/issue-bot/lib/state", () => ({ createDefaultIssueBotState: () => ({ currentCandidate: null, approvedIssue: null, seenIssueIds: [], lastRunAt: null, lastRunStatus: "idle", lastRunMessage: null, }), loadIssueBotState: loadState, saveIssueBotState: saveState, })); vi.mock("../../../../scripts/issue-bot/lib/gitee", () => ({ listOpenIssues, })); describe("check-issues", () => { beforeEach(() => { vi.resetModules(); loadState.mockReset(); saveState.mockReset(); listOpenIssues.mockReset(); }); it("stores the top-ranked issue candidate", async () => { const baseState: IssueBotState = { currentCandidate: null, approvedIssue: null, seenIssueIds: [], lastRunAt: null, lastRunStatus: "idle", lastRunMessage: null, }; loadState.mockReturnValue(baseState); listOpenIssues.mockResolvedValue([ { id: 10, number: "I10", title: "应用无法安装并白屏", url: "https://gitee.com/spark-store-project/spark-store/issues/I10", state: "open", createdAt: "2026-04-14T00:00:00.000Z", updatedAt: "2026-04-14T09:00:00.000Z", labels: ["bug"], bodyPreview: "复现步骤:1. 打开商店 2. 点击安装。附日志。", }, ] satisfies NormalizedIssue[]); const { runIssueBotCheck } = await import("../../../../scripts/issue-bot/check-issues"); await runIssueBotCheck(); expect(saveState).toHaveBeenCalledWith( expect.objectContaining({ currentCandidate: expect.objectContaining({ id: 10, title: "应用无法安装并白屏", }), lastRunStatus: "success", }), ); }); it("keeps the previous candidate when fetching issues fails", async () => { loadState.mockReturnValue({ currentCandidate: { id: 99, number: "I99", title: "旧候选", url: "https://gitee.com/spark-store-project/spark-store/issues/I99", state: "open", createdAt: "2026-04-14T00:00:00.000Z", updatedAt: "2026-04-14T00:00:00.000Z", labels: [], bodyPreview: "旧摘要", score: 12, rankingReasons: ["legacy candidate"], }, approvedIssue: null, seenIssueIds: [], lastRunAt: null, lastRunStatus: "idle", lastRunMessage: null, }); listOpenIssues.mockRejectedValue(new Error("network down")); const { runIssueBotCheck } = await import("../../../../scripts/issue-bot/check-issues"); await runIssueBotCheck(); expect(saveState).toHaveBeenCalledWith( expect.objectContaining({ currentCandidate: expect.objectContaining({ id: 99 }), lastRunStatus: "network-error", lastRunMessage: "network down", }), ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts` Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/check-issues'`. - [ ] **Step 3: Write minimal implementation** ```ts // scripts/issue-bot/lib/gitee.ts import type { NormalizedIssue } from "./types"; interface GiteeIssueApiResponse { id: number; number: string; title: string; state: "open" | "closed"; created_at: string; updated_at: string; body?: string; html_url: string; labels?: Array<{ name?: string }>; } const GITEE_ISSUES_API_URL = "https://gitee.com/api/v5/repos/spark-store-project/spark-store/issues?state=open&sort=updated&direction=desc&page=1&per_page=50"; export const listOpenIssues = async (): Promise => { const response = await fetch(GITEE_ISSUES_API_URL); if (!response.ok) { throw new Error(`Gitee request failed: ${response.status}`); } const payload = (await response.json()) as GiteeIssueApiResponse[]; return payload.map((issue) => ({ id: issue.id, number: issue.number, title: issue.title, url: issue.html_url, state: issue.state, createdAt: issue.created_at, updatedAt: issue.updated_at, labels: (issue.labels || []) .map((label) => label.name?.trim() || "") .filter((label) => label.length > 0), bodyPreview: (issue.body || "").slice(0, 500), })); }; ``` ```ts // scripts/issue-bot/check-issues.ts import { listOpenIssues } from "./lib/gitee"; import { selectTopIssueCandidate } from "./lib/ranking"; import { loadIssueBotState, saveIssueBotState } from "./lib/state"; export const runIssueBotCheck = async (): Promise => { const state = loadIssueBotState(); const now = new Date().toISOString(); try { const issues = await listOpenIssues(); const candidate = selectTopIssueCandidate(issues, { approvedIssueId: state.approvedIssue?.id ?? null, }); saveIssueBotState({ ...state, currentCandidate: candidate, seenIssueIds: candidate ? Array.from(new Set([...state.seenIssueIds, candidate.id])) : state.seenIssueIds, lastRunAt: now, lastRunStatus: "success", lastRunMessage: candidate ? `candidate updated: ${candidate.title}` : "no candidate issues found", }); } catch (error) { saveIssueBotState({ ...state, lastRunAt: now, lastRunStatus: "network-error", lastRunMessage: error instanceof Error ? error.message : String(error), }); } }; if (import.meta.url === `file://${process.argv[1]}`) { runIssueBotCheck().catch((error) => { console.error(error); process.exitCode = 1; }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts` Expected: PASS with 2 tests passed. - [ ] **Step 5: Commit** ```bash git add scripts/issue-bot/lib/gitee.ts scripts/issue-bot/check-issues.ts src/__tests__/unit/issue-bot/check-issues.test.ts git commit -m "feat(issue-bot): poll gitee issues" ``` ### Task 4: Add Opencode Prompt Generation and Manual Approval **Files:** - Create: `scripts/issue-bot/lib/opencode.ts` - Create: `scripts/issue-bot/approve-issue.ts` - Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts` - [ ] **Step 1: Write the failing test** ```ts import { beforeEach, describe, expect, it, vi } from "vitest"; const loadState = vi.fn(); const saveState = vi.fn(); const launchOpencodeForIssue = vi.fn(); vi.mock("../../../../scripts/issue-bot/lib/state", () => ({ loadIssueBotState: loadState, saveIssueBotState: saveState, })); vi.mock("../../../../scripts/issue-bot/lib/opencode", () => ({ launchOpencodeForIssue, })); describe("approve-issue", () => { beforeEach(() => { vi.resetModules(); loadState.mockReset(); saveState.mockReset(); launchOpencodeForIssue.mockReset(); }); it("marks the current candidate as approved and launches opencode", async () => { loadState.mockReturnValue({ currentCandidate: { id: 42, number: "I42", title: "应用升级失败并白屏", url: "https://gitee.com/spark-store-project/spark-store/issues/I42", state: "open", createdAt: "2026-04-14T00:00:00.000Z", updatedAt: "2026-04-14T00:00:00.000Z", labels: ["bug"], bodyPreview: "更新后白屏,附日志。", score: 20, rankingReasons: ["contains high-impact keyword: 升级失败"], }, approvedIssue: null, seenIssueIds: [42], lastRunAt: "2026-04-14T09:00:00.000Z", lastRunStatus: "success", lastRunMessage: "candidate updated", }); const { runIssueBotApproval } = await import("../../../../scripts/issue-bot/approve-issue"); await runIssueBotApproval(); expect(launchOpencodeForIssue).toHaveBeenCalledWith( expect.objectContaining({ id: 42, title: "应用升级失败并白屏" }), ); expect(saveState).toHaveBeenCalledWith( expect.objectContaining({ currentCandidate: null, approvedIssue: expect.objectContaining({ id: 42 }), }), ); }); it("throws when there is no candidate to approve", async () => { loadState.mockReturnValue({ currentCandidate: null, approvedIssue: null, seenIssueIds: [], lastRunAt: null, lastRunStatus: "idle", lastRunMessage: null, }); const { runIssueBotApproval } = await import("../../../../scripts/issue-bot/approve-issue"); await expect(runIssueBotApproval()).rejects.toThrow( "No current issue candidate to approve.", ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts` Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/approve-issue'`. - [ ] **Step 3: Write minimal implementation** ```ts // scripts/issue-bot/lib/opencode.ts import { spawn } from "node:child_process"; import type { RankedIssue } from "./types"; export const buildOpencodePrompt = ( issue: RankedIssue, ): string => `请处理以下 Spark Store issue: 标题:${issue.title} 链接:${issue.url} 摘要:${issue.bodyPreview} 优先级原因:${issue.rankingReasons.join(";")} 要求:先分析根因,再开始修复。默认基仓库必须使用 ~/Desktop/spark-store。 如果开始修改代码,必须先使用 git worktree,从 Erotica 分支开出新的工作分支,并在该 worktree 中实施改动,不要直接在主工作区修改。`; export const launchOpencodeForIssue = async ( issue: RankedIssue, ): Promise => { const configuredCommand = process.env.SPARK_STORE_OPENCODE_CMD || "opencode"; const child = spawn(configuredCommand, [buildOpencodePrompt(issue)], { detached: true, stdio: "ignore", shell: true, }); child.unref(); }; ``` ```ts // scripts/issue-bot/approve-issue.ts import { launchOpencodeForIssue } from "./lib/opencode"; import { loadIssueBotState, saveIssueBotState } from "./lib/state"; export const runIssueBotApproval = async (): Promise => { const state = loadIssueBotState(); const candidate = state.currentCandidate; if (!candidate) { throw new Error("No current issue candidate to approve."); } await launchOpencodeForIssue(candidate); saveIssueBotState({ ...state, currentCandidate: null, approvedIssue: { id: candidate.id, title: candidate.title, url: candidate.url, approvedAt: new Date().toISOString(), }, }); }; if (import.meta.url === `file://${process.argv[1]}`) { runIssueBotApproval().catch((error) => { console.error(error); process.exitCode = 1; }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts` Expected: PASS with 2 tests passed. - [ ] **Step 5: Commit** ```bash git add scripts/issue-bot/lib/opencode.ts scripts/issue-bot/approve-issue.ts src/__tests__/unit/issue-bot/approve-issue.test.ts git commit -m "feat(issue-bot): approve candidates and launch opencode" ``` ### Task 5: Wire npm Scripts and systemd Units **Files:** - Modify: `package.json` - Modify: `tsconfig.node.json` - Create: `extras/systemd/spark-store-issue-bot.service` - Create: `extras/systemd/spark-store-issue-bot.timer` - Create: `src/__tests__/unit/issue-bot/packaging.test.ts` - [ ] **Step 1: Write the failing test** ```ts import { describe, expect, it } from "vitest"; import pkg from "../../../../package.json"; import serviceUnit from "../../../../extras/systemd/spark-store-issue-bot.service?raw"; import timerUnit from "../../../../extras/systemd/spark-store-issue-bot.timer?raw"; describe("issue-bot packaging", () => { it("adds npm scripts for polling and approval", () => { expect(pkg.scripts["issue-bot:check"]).toBe( "node --experimental-strip-types scripts/issue-bot/check-issues.ts", ); expect(pkg.scripts["issue-bot:approve"]).toBe( "node --experimental-strip-types scripts/issue-bot/approve-issue.ts", ); }); it("installs a six-hour persistent user timer", () => { expect(serviceUnit).toContain("Type=oneshot"); expect(serviceUnit).toContain( "ExecStart=/usr/bin/env npm run issue-bot:check", ); expect(timerUnit).toContain("OnUnitActiveSec=6h"); expect(timerUnit).toContain("Persistent=true"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts` Expected: FAIL with `Failed to resolve import '../../../../extras/systemd/spark-store-issue-bot.service?raw'` and missing package scripts. - [ ] **Step 3: Write minimal implementation** ```json // package.json { "scripts": { "issue-bot:check": "node --experimental-strip-types scripts/issue-bot/check-issues.ts", "issue-bot:approve": "node --experimental-strip-types scripts/issue-bot/approve-issue.ts" } } ``` ```json // tsconfig.node.json { "include": ["vite.config.ts", "package.json", "electron", "scripts"] } ``` ```ini ; extras/systemd/spark-store-issue-bot.service [Unit] Description=Spark Store issue bot poller [Service] Type=oneshot WorkingDirectory=%h/Desktop/spark-store ExecStart=/usr/bin/env npm run issue-bot:check ``` ```ini ; extras/systemd/spark-store-issue-bot.timer [Unit] Description=Run Spark Store issue bot every 6 hours [Timer] OnBootSec=15m OnUnitActiveSec=6h Persistent=true Unit=spark-store-issue-bot.service [Install] WantedBy=timers.target ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts` Expected: PASS with 2 tests passed. - [ ] **Step 5: Commit** ```bash git add package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot/packaging.test.ts git commit -m "chore(issue-bot): wire scripts and timer units" ``` ### Task 6: Run End-to-End Verification **Files:** - Modify: `scripts/issue-bot/check-issues.ts` - Modify: `scripts/issue-bot/approve-issue.ts` - Modify: `scripts/issue-bot/lib/gitee.ts` - Modify: `scripts/issue-bot/lib/opencode.ts` - Modify: `scripts/issue-bot/lib/ranking.ts` - Modify: `scripts/issue-bot/lib/state.ts` - Modify: `package.json` - Modify: `tsconfig.node.json` - Create: `extras/systemd/spark-store-issue-bot.service` - Create: `extras/systemd/spark-store-issue-bot.timer` - Test: `src/__tests__/unit/issue-bot/state.test.ts` - Test: `src/__tests__/unit/issue-bot/ranking.test.ts` - Test: `src/__tests__/unit/issue-bot/check-issues.test.ts` - Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts` - Test: `src/__tests__/unit/issue-bot/packaging.test.ts` - [ ] **Step 1: Run focused issue-bot tests** Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts src/__tests__/unit/issue-bot/ranking.test.ts src/__tests__/unit/issue-bot/check-issues.test.ts src/__tests__/unit/issue-bot/approve-issue.test.ts src/__tests__/unit/issue-bot/packaging.test.ts` Expected: PASS with all issue-bot tests green. - [ ] **Step 2: Run lint** Run: `npm run lint` Expected: PASS with no ESLint errors in `scripts/issue-bot`, `src/__tests__/unit/issue-bot`, and touched config files. - [ ] **Step 3: Run build verification** Run: `npm run build:vite` Expected: PASS with Electron/Vite bundles generated and no TypeScript errors after adding `scripts` to `tsconfig.node.json`. - [ ] **Step 4: Manually verify CLI entrypoints** Run: `npm run issue-bot:check` Expected: `~/.cache/spark-store/issue-bot/state.json` exists and contains either a populated `currentCandidate` or a `lastRunMessage` of `no candidate issues found`. Run: `SPARK_STORE_OPENCODE_CMD='printf' npm run issue-bot:approve` Expected: command exits successfully and prints the generated prompt containing both `~/Desktop/spark-store` and `Erotica`. - [ ] **Step 5: Manually verify systemd units** Run: `systemctl --user start spark-store-issue-bot.service` Expected: service runs once without unit-file syntax errors. Run: `systemctl --user enable --now spark-store-issue-bot.timer` Expected: timer is enabled, active, and reports the next run roughly 6 hours later. - [ ] **Step 6: Commit** ```bash git add scripts/issue-bot package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot git commit -m "feat(issue-bot): add automated issue polling workflow" ``` ## Self-Review ### Spec coverage - `systemd --user` timer requirement: covered by Task 5 and Task 6. - One-candidate ranking with explainable reasons: covered by Task 2 and Task 3. - Manual approval before opencode launch: covered by Task 4. - Local cache-backed state with failure retention: covered by Task 1 and Task 3. - `~/Desktop/spark-store` + `Erotica` worktree rule in the launch prompt: covered by Task 4 and manual verification in Task 6. ### Placeholder scan - No `TBD`, `TODO`, or “implement later” placeholders remain. - All code-changing steps include concrete code blocks. - All verification steps include exact commands and expected outcomes. ### Type consistency - `NormalizedIssue`, `RankedIssue`, `ApprovedIssue`, and `IssueBotState` are defined in Task 1 and reused consistently in Tasks 2-4. - `runIssueBotCheck`, `runIssueBotApproval`, and `launchOpencodeForIssue` names stay unchanged across tests and implementation steps.