30 KiB
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 updatescurrentCandidate. - 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— addissue-bot:checkandissue-bot:approvescripts. - Modify:
tsconfig.node.json— includescriptsfor type-check coverage in build tooling. - Create:
extras/systemd/spark-store-issue-bot.service—oneshotuser 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
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
// 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;
}
// 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<IssueBotState>),
};
} 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
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
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>): 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
// 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
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
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
// 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<NormalizedIssue[]> => {
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),
}));
};
// 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<void> => {
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
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
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
// 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<void> => {
const configuredCommand = process.env.SPARK_STORE_OPENCODE_CMD || "opencode";
const child = spawn(configuredCommand, [buildOpencodePrompt(issue)], {
detached: true,
stdio: "ignore",
shell: true,
});
child.unref();
};
// scripts/issue-bot/approve-issue.ts
import { launchOpencodeForIssue } from "./lib/opencode";
import { loadIssueBotState, saveIssueBotState } from "./lib/state";
export const runIssueBotApproval = async (): Promise<void> => {
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
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
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
// 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"
}
}
// tsconfig.node.json
{
"include": ["vite.config.ts", "package.json", "electron", "scripts"]
}
; 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
; 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
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
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 --usertimer 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+Eroticaworktree 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, andIssueBotStateare defined in Task 1 and reused consistently in Tasks 2-4.runIssueBotCheck,runIssueBotApproval, andlaunchOpencodeForIssuenames stay unchanged across tests and implementation steps.