mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-30 03:10:16 +08:00
修复更新器打开卡顿,优化更新中心
This commit is contained in:
996
docs/superpowers/plans/2026-04-14-gitee-issue-bot.md
Normal file
996
docs/superpowers/plans/2026-04-14-gitee-issue-bot.md
Normal file
@@ -0,0 +1,996 @@
|
||||
# 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<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**
|
||||
|
||||
```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>): 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<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),
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<void> => {
|
||||
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<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**
|
||||
|
||||
```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.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user