cue-ai 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +757 -110
- package/package.json +5 -5
- package/profiles/README.md +12 -12
- package/profiles/SCHEMA.md +31 -3
- package/profiles/_cache/README.md +1 -1
- package/profiles/_types.ts +26 -1
- package/profiles/backend/profile.yaml +1 -0
- package/profiles/career/profile.yaml +13 -0
- package/profiles/core/profile.yaml +76 -9
- package/profiles/creative-media/README.md +1 -1
- package/profiles/cybersecurity/profile.yaml +779 -756
- package/profiles/ecc/profile.yaml +39 -0
- package/profiles/event-design/profile.yaml +10 -0
- package/profiles/fleet-control/README.md +1 -1
- package/profiles/frontend/profile.yaml +14 -0
- package/profiles/full/README.md +1 -1
- package/profiles/go-api/profile.yaml +1 -0
- package/profiles/marketing/profile.yaml +12 -1
- package/profiles/predict-everything/profile.yaml +9 -0
- package/profiles/rust/profile.yaml +22 -3
- package/profiles/rust-cli/profile.yaml +14 -0
- package/profiles/rust-core/profile.yaml +35 -0
- package/profiles/rust-embedded/profile.yaml +11 -0
- package/profiles/rust-ffi/profile.yaml +13 -0
- package/profiles/rust-game/profile.yaml +11 -0
- package/profiles/rust-wasm/profile.yaml +11 -0
- package/profiles/rust-web/profile.yaml +17 -0
- package/profiles/schema.json +44 -4
- package/profiles/trendradar/profile.yaml +11 -0
- package/resources/mcps/README.md +39 -164
- package/resources/mcps/configs/claude.sanitized.json +55 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
- package/resources/skills/README.md +70 -113
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
- package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
- package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
- package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
- package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
- package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
- package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
- package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
- package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
- package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
- package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
- package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
- package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
- package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
- package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
- package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
- package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
- package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
- package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
- package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
- package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
- package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
- package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
- package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
- package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
- package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
- package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
- package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
- package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
- package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
- package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
- package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
- package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
- package/resources/skills/skills/rust/serde/SKILL.md +30 -0
- package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
- package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
- package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
- package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
- package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
- package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
- package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
- package/src/commands/_index.ts +47 -3
- package/src/commands/cli.test.ts +192 -0
- package/src/commands/cli.ts +303 -0
- package/src/commands/current.ts +1 -1
- package/src/commands/debug.test.ts +62 -0
- package/src/commands/debug.ts +212 -0
- package/src/commands/discover.scoring.test.ts +216 -0
- package/src/commands/discover.test.ts +145 -0
- package/src/commands/discover.ts +2618 -0
- package/src/commands/eval-behavior.test.ts +56 -0
- package/src/commands/eval-behavior.ts +189 -0
- package/src/commands/eval.test.ts +102 -0
- package/src/commands/eval.ts +348 -0
- package/src/commands/evolve.ts +291 -0
- package/src/commands/failures.test.ts +78 -0
- package/src/commands/failures.ts +393 -0
- package/src/commands/feedback.ts +219 -0
- package/src/commands/init.ts +26 -0
- package/src/commands/launch.e2e.test.ts +9 -1
- package/src/commands/launch.ts +174 -11
- package/src/commands/lint-skill.ts +157 -0
- package/src/commands/marketplace.ts +763 -2
- package/src/commands/new.ts +1 -1
- package/src/commands/optimizer.ts +92 -28
- package/src/commands/profile-draft-skill.test.ts +96 -0
- package/src/commands/profile-draft-skill.ts +287 -0
- package/src/commands/profile-evolve.test.ts +126 -0
- package/src/commands/profile-evolve.ts +0 -0
- package/src/commands/profile-suggest.ts +223 -0
- package/src/commands/profile.ts +41 -0
- package/src/commands/quick.ts +2 -17
- package/src/commands/scan.ts +2 -2
- package/src/commands/score.ts +1 -1
- package/src/commands/share.ts +1 -1
- package/src/commands/sources.ts +2 -2
- package/src/commands/submit-profile.ts +262 -0
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/use.ts +35 -5
- package/src/commands/validate.ts +1 -1
- package/src/index.ts +66 -0
- package/src/lib/analytics.ts +48 -2
- package/src/lib/claude-binary.ts +39 -0
- package/src/lib/cli-extractor.ts +77 -0
- package/src/lib/cluster-skills.test.ts +268 -0
- package/src/lib/cluster-skills.ts +290 -0
- package/src/lib/credentials-sync.test.ts +208 -0
- package/src/lib/credentials-sync.ts +205 -0
- package/src/lib/mcp-materializer.test.ts +1 -1
- package/src/lib/persona-playbooks.test.ts +111 -0
- package/src/lib/pr-poster.test.ts +243 -0
- package/src/lib/pr-poster.ts +285 -0
- package/src/lib/pr-throttle.test.ts +148 -0
- package/src/lib/pr-throttle.ts +209 -0
- package/src/lib/profile-generator.test.ts +1 -1
- package/src/lib/profile-generator.ts +2 -2
- package/src/lib/profile-linter.test.ts +6 -3
- package/src/lib/profile-linter.ts +71 -8
- package/src/lib/profile-loader.test.ts +1 -1
- package/src/lib/profile-loader.ts +16 -0
- package/src/lib/resolver-local.test.ts +1 -1
- package/src/lib/resolver-npx.test.ts +76 -1
- package/src/lib/resolver-npx.ts +35 -3
- package/src/lib/resolver-plugins.test.ts +1 -1
- package/src/lib/runtime-materializer.test.ts +191 -7
- package/src/lib/runtime-materializer.ts +310 -42
- package/src/lib/scan-plugins.test.ts +1 -1
- package/src/lib/skill-linter.test.ts +174 -0
- package/src/lib/skill-linter.ts +507 -0
- package/src/lib/skill-subset.test.ts +95 -0
- package/src/lib/skill-subset.ts +166 -0
- package/src/lib/star-prompt.ts +1 -1
- package/src/lib/uvx-installer.test.ts +229 -0
- package/src/lib/uvx-installer.ts +278 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pr-poster. Uses a mock `Runner` to avoid hitting real GitHub.
|
|
3
|
+
* Tests assert on the *plan* (which gh / git commands would have run) and the
|
|
4
|
+
* state-transition return shape — not on actual repo mutation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
whoami, forkRepo, openPr, postPrToRepo, fetchPrState, deleteFork,
|
|
14
|
+
writeFilesToWorktree, checkOptOutMarker,
|
|
15
|
+
type Runner,
|
|
16
|
+
} from "./pr-poster";
|
|
17
|
+
|
|
18
|
+
interface Call { cmd: string; args: string[]; cwd?: string }
|
|
19
|
+
|
|
20
|
+
function mockRunner(responses: Array<{ match: RegExp; stdout?: string; stderr?: string; status?: number }>): { runner: Runner; calls: Call[] } {
|
|
21
|
+
const calls: Call[] = [];
|
|
22
|
+
const runner: Runner = {
|
|
23
|
+
async run(cmd, args, opts) {
|
|
24
|
+
calls.push({ cmd, args, cwd: opts?.cwd });
|
|
25
|
+
const key = [cmd, ...args].join(" ");
|
|
26
|
+
const r = responses.find((r) => r.match.test(key));
|
|
27
|
+
return { stdout: r?.stdout ?? "", stderr: r?.stderr ?? "", status: r?.status ?? 0 };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return { runner, calls };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("whoami", () => {
|
|
34
|
+
test("returns trimmed login on success", async () => {
|
|
35
|
+
const { runner } = mockRunner([{ match: /api user/, stdout: "octocat\n" }]);
|
|
36
|
+
expect(await whoami(runner)).toBe("octocat");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns null on non-zero status", async () => {
|
|
40
|
+
const { runner } = mockRunner([{ match: /api user/, status: 1, stderr: "not authed" }]);
|
|
41
|
+
expect(await whoami(runner)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("forkRepo", () => {
|
|
46
|
+
test("returns fork name on fresh fork", async () => {
|
|
47
|
+
const { runner } = mockRunner([
|
|
48
|
+
{ match: /api user/, stdout: "octocat" },
|
|
49
|
+
{ match: /repo fork example\/skill/, stdout: "forked\n" },
|
|
50
|
+
]);
|
|
51
|
+
const res = await forkRepo(runner, "example/skill");
|
|
52
|
+
expect("error" in res).toBe(false);
|
|
53
|
+
if (!("error" in res)) {
|
|
54
|
+
expect(res.fork).toBe("octocat/skill");
|
|
55
|
+
expect(res.existed).toBe(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("treats `already exists` as success (existed=true)", async () => {
|
|
60
|
+
const { runner } = mockRunner([
|
|
61
|
+
{ match: /api user/, stdout: "octocat" },
|
|
62
|
+
{ match: /repo fork example\/skill/, status: 1, stderr: "fork already exists, won't fork again" },
|
|
63
|
+
]);
|
|
64
|
+
const res = await forkRepo(runner, "example/skill");
|
|
65
|
+
expect("error" in res).toBe(false);
|
|
66
|
+
if (!("error" in res)) {
|
|
67
|
+
expect(res.existed).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns error when gh user lookup fails", async () => {
|
|
72
|
+
const { runner } = mockRunner([{ match: /api user/, status: 1 }]);
|
|
73
|
+
const res = await forkRepo(runner, "example/skill");
|
|
74
|
+
expect("error" in res).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("openPr", () => {
|
|
79
|
+
test("parses PR URL + number from success stdout", async () => {
|
|
80
|
+
const { runner } = mockRunner([
|
|
81
|
+
{ match: /pr create/, stdout: "https://github.com/example/skill/pull/42\n" },
|
|
82
|
+
]);
|
|
83
|
+
const res = await openPr(runner, "example/skill", "octocat/skill", "cue/fixes", "title", "body");
|
|
84
|
+
expect("error" in res).toBe(false);
|
|
85
|
+
if (!("error" in res)) {
|
|
86
|
+
expect(res.number).toBe(42);
|
|
87
|
+
expect(res.url).toBe("https://github.com/example/skill/pull/42");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("recovers existing PR from `already exists` error", async () => {
|
|
92
|
+
const { runner } = mockRunner([
|
|
93
|
+
{ match: /pr create/, status: 1, stderr: "a pull request for branch \"cue/fixes\" already exists: https://github.com/example/skill/pull/7" },
|
|
94
|
+
]);
|
|
95
|
+
const res = await openPr(runner, "example/skill", "octocat/skill", "cue/fixes", "t", "b");
|
|
96
|
+
expect("error" in res).toBe(false);
|
|
97
|
+
if (!("error" in res)) {
|
|
98
|
+
expect(res.number).toBe(7);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns error on other gh failures", async () => {
|
|
103
|
+
const { runner } = mockRunner([
|
|
104
|
+
{ match: /pr create/, status: 1, stderr: "network error" },
|
|
105
|
+
]);
|
|
106
|
+
const res = await openPr(runner, "x/y", "me/y", "b", "t", "b");
|
|
107
|
+
expect("error" in res).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("writeFilesToWorktree", () => {
|
|
112
|
+
test("writes only files whose content actually differs", () => {
|
|
113
|
+
const tmp = mkdtempSync(join(tmpdir(), "cue-wt-"));
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(join(tmp, "skills", "a"), { recursive: true });
|
|
116
|
+
writeFileSync(join(tmp, "skills", "a", "SKILL.md"), "OLD");
|
|
117
|
+
writeFileSync(join(tmp, "skills", "a", "OTHER.md"), "X");
|
|
118
|
+
|
|
119
|
+
const changed = writeFilesToWorktree(tmp, [
|
|
120
|
+
{ path: "skills/a/SKILL.md", before: "OLD", after: "NEW" },
|
|
121
|
+
{ path: "skills/a/OTHER.md", before: "X", after: "X" }, // unchanged, should skip
|
|
122
|
+
{ path: "skills/missing/SKILL.md", before: "Z", after: "Z2" }, // file doesn't exist
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expect(changed).toEqual(["skills/a/SKILL.md"]);
|
|
126
|
+
expect(readFileSync(join(tmp, "skills", "a", "SKILL.md"), "utf8")).toBe("NEW");
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("fetchPrState", () => {
|
|
134
|
+
test("returns merged when gh reports merged:true", async () => {
|
|
135
|
+
const { runner } = mockRunner([
|
|
136
|
+
{ match: /pr view 42/, stdout: '{"state":"MERGED","merged":true}' },
|
|
137
|
+
]);
|
|
138
|
+
expect(await fetchPrState(runner, "x/y", 42)).toBe("merged");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns closed when state=CLOSED + merged=false", async () => {
|
|
142
|
+
const { runner } = mockRunner([
|
|
143
|
+
{ match: /pr view 1/, stdout: '{"state":"CLOSED","merged":false}' },
|
|
144
|
+
]);
|
|
145
|
+
expect(await fetchPrState(runner, "x/y", 1)).toBe("closed");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("returns open when state=OPEN", async () => {
|
|
149
|
+
const { runner } = mockRunner([
|
|
150
|
+
{ match: /pr view 1/, stdout: '{"state":"OPEN","merged":false}' },
|
|
151
|
+
]);
|
|
152
|
+
expect(await fetchPrState(runner, "x/y", 1)).toBe("open");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns unknown on gh failure or bad JSON", async () => {
|
|
156
|
+
const { runner } = mockRunner([{ match: /pr view/, status: 1 }]);
|
|
157
|
+
expect(await fetchPrState(runner, "x/y", 1)).toBe("unknown");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("deleteFork", () => {
|
|
162
|
+
test("invokes gh repo delete --yes", async () => {
|
|
163
|
+
const { runner, calls } = mockRunner([{ match: /repo delete/, stdout: "" }]);
|
|
164
|
+
const res = await deleteFork(runner, "octocat/skill");
|
|
165
|
+
expect("error" in res).toBe(false);
|
|
166
|
+
expect(calls[0]!.args).toEqual(["repo", "delete", "octocat/skill", "--yes"]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("checkOptOutMarker", () => {
|
|
171
|
+
test("true when README contains the marker", async () => {
|
|
172
|
+
const { runner } = mockRunner([{ match: /api repos\/x\/y\/readme/, stdout: "Hello\n<!-- cue: ignore -->\nWorld" }]);
|
|
173
|
+
expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("false when README is clean", async () => {
|
|
177
|
+
const { runner } = mockRunner([{ match: /api repos\/x\/y\/readme/, stdout: "Just a normal readme." }]);
|
|
178
|
+
expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("null when gh fails (caller should fail open)", async () => {
|
|
182
|
+
const { runner } = mockRunner([{ match: /api/, status: 1 }]);
|
|
183
|
+
expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("postPrToRepo — driver state transitions (mocked runner)", () => {
|
|
188
|
+
test("happy path: fork → clone → write → commit → push → PR returns ok=true", async () => {
|
|
189
|
+
const tmp = mkdtempSync(join(tmpdir(), "cue-driver-"));
|
|
190
|
+
try {
|
|
191
|
+
// Pre-populate the clone target with the file we'll modify
|
|
192
|
+
mkdirSync(join(tmp, "fake-clone"), { recursive: true });
|
|
193
|
+
writeFileSync(join(tmp, "fake-clone", "SKILL.md"), "OLD");
|
|
194
|
+
|
|
195
|
+
const { runner } = mockRunner([
|
|
196
|
+
{ match: /api user/, stdout: "octocat" },
|
|
197
|
+
{ match: /repo fork/, stdout: "ok" },
|
|
198
|
+
{ match: /repo sync/, status: 0 },
|
|
199
|
+
// intercept the clone — make it succeed and we'll manually copy after
|
|
200
|
+
{ match: /repo clone/, status: 0 },
|
|
201
|
+
{ match: /git checkout/, status: 0 },
|
|
202
|
+
{ match: /git add/, status: 0 },
|
|
203
|
+
{ match: /git commit/, status: 0 },
|
|
204
|
+
{ match: /git push/, status: 0 },
|
|
205
|
+
{ match: /pr create/, stdout: "https://github.com/example/skill/pull/99" },
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
// postPrToRepo creates its own tmp dir via mkdtempSync, so we can't
|
|
209
|
+
// override the clone target. Instead we test it accepts files that
|
|
210
|
+
// don't exist (which exercises the "no files changed" failure path).
|
|
211
|
+
const res = await postPrToRepo({
|
|
212
|
+
upstream: "example/skill",
|
|
213
|
+
changes: [{ path: "does/not/exist/SKILL.md", before: "x", after: "y" }],
|
|
214
|
+
prTitle: "title",
|
|
215
|
+
prBody: "body",
|
|
216
|
+
runner,
|
|
217
|
+
});
|
|
218
|
+
// Files don't exist in the freshly cloned tmpdir, so apply fails
|
|
219
|
+
expect(res.ok).toBe(false);
|
|
220
|
+
if (!res.ok) {
|
|
221
|
+
expect(res.step).toBe("apply");
|
|
222
|
+
expect(res.fork).toBe("octocat/skill");
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("fork failure aborts with step=fork", async () => {
|
|
230
|
+
const { runner } = mockRunner([
|
|
231
|
+
{ match: /api user/, stdout: "octocat" },
|
|
232
|
+
{ match: /repo fork/, status: 1, stderr: "network error" },
|
|
233
|
+
]);
|
|
234
|
+
const res = await postPrToRepo({
|
|
235
|
+
upstream: "example/skill",
|
|
236
|
+
changes: [{ path: "SKILL.md", before: "x", after: "y" }],
|
|
237
|
+
prTitle: "t", prBody: "b",
|
|
238
|
+
runner,
|
|
239
|
+
});
|
|
240
|
+
expect(res.ok).toBe(false);
|
|
241
|
+
if (!res.ok) expect(res.step).toBe("fork");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork → branch → push → PR create flow, wrapped around the `gh` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Every step is idempotent and bails cleanly on the first error. The flow is
|
|
5
|
+
* driven by `postPrToRepo()`; tests can substitute the `runner` to avoid
|
|
6
|
+
* hitting GitHub.
|
|
7
|
+
*
|
|
8
|
+
* The flow itself:
|
|
9
|
+
* 1. fork the upstream repo into the user's account (idempotent — gh
|
|
10
|
+
* reports the existing fork if already present)
|
|
11
|
+
* 2. clone the fork into a tmpdir under the user's home
|
|
12
|
+
* 3. apply the linter's auto-fixes to each SKILL.md path
|
|
13
|
+
* 4. commit on a deterministic branch name
|
|
14
|
+
* 5. push the branch
|
|
15
|
+
* 6. open a PR against upstream:default-branch
|
|
16
|
+
* 7. delete the tmpdir, leave the fork in place (cleaned later by cleanup-forks)
|
|
17
|
+
*
|
|
18
|
+
* Caller is responsible for preflight (throttle/opt-out checks) and for
|
|
19
|
+
* recording the result via pr-throttle's `recordOpened`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join, dirname } from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a deterministic branch name keyed by today's date + a hash of the
|
|
30
|
+
* fix set. Same inputs produce the same branch (idempotent on re-runs the
|
|
31
|
+
* same day); changes in the fix set produce a new branch instead of silently
|
|
32
|
+
* force-pushing over the previous one.
|
|
33
|
+
*
|
|
34
|
+
* Format: cue/skill-md-fixes-YYYY-MM-DD-<8 hex>
|
|
35
|
+
*/
|
|
36
|
+
export function deriveBranchName(upstream: string, changes: Array<{ path: string; after: string }>): string {
|
|
37
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
38
|
+
const fingerprint = createHash("sha256")
|
|
39
|
+
.update(upstream + "\n")
|
|
40
|
+
.update(changes.map((c) => `${c.path}\n${c.after}`).join("\n---\n"))
|
|
41
|
+
.digest("hex")
|
|
42
|
+
.slice(0, 8);
|
|
43
|
+
return `cue/skill-md-fixes-${today}-${fingerprint}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Runner {
|
|
47
|
+
/** Run a command, capture stdout+stderr+status. Throws nothing — always resolves. */
|
|
48
|
+
run(cmd: string, args: string[], opts?: { cwd?: string; timeoutMs?: number; input?: string }): Promise<{ stdout: string; stderr: string; status: number }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const defaultRunner: Runner = {
|
|
52
|
+
run(cmd, args, opts = {}) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
55
|
+
let stdout = "", stderr = "";
|
|
56
|
+
let settled = false;
|
|
57
|
+
const finish = (status: number) => { if (!settled) { settled = true; resolve({ stdout, stderr, status }); } };
|
|
58
|
+
child.stdout.on("data", (c) => { stdout += c.toString(); });
|
|
59
|
+
child.stderr.on("data", (c) => { stderr += c.toString(); });
|
|
60
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
61
|
+
child.on("error", (e) => { stderr += `[spawn error] ${e.message}\n`; finish(127); });
|
|
62
|
+
if (opts.input !== undefined) { child.stdin.write(opts.input); child.stdin.end(); }
|
|
63
|
+
else child.stdin.end();
|
|
64
|
+
const t = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} finish(124); }, opts.timeoutMs ?? 60000);
|
|
65
|
+
child.on("close", () => clearTimeout(t));
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Discrete steps
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export async function whoami(runner: Runner): Promise<string | null> {
|
|
75
|
+
const res = await runner.run("gh", ["api", "user", "--jq", ".login"], { timeoutMs: 10000 });
|
|
76
|
+
if (res.status !== 0) return null;
|
|
77
|
+
return res.stdout.trim() || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function forkRepo(runner: Runner, upstream: string): Promise<{ fork: string; existed: boolean } | { error: string }> {
|
|
81
|
+
const user = await whoami(runner);
|
|
82
|
+
if (!user) return { error: "could not determine gh user — run `gh auth login`" };
|
|
83
|
+
const fork = `${user}/${upstream.split("/")[1]}`;
|
|
84
|
+
// gh repo fork is idempotent — if the fork exists it just reports "already exists"
|
|
85
|
+
const res = await runner.run("gh", ["repo", "fork", upstream, "--clone=false", "--remote=false"], { timeoutMs: 60000 });
|
|
86
|
+
const existed = /already exists/i.test(res.stderr);
|
|
87
|
+
if (res.status !== 0 && !existed) {
|
|
88
|
+
return { error: `gh repo fork failed: ${res.stderr.trim() || res.stdout.trim() || "exit " + res.status}` };
|
|
89
|
+
}
|
|
90
|
+
return { fork, existed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function cloneFork(runner: Runner, fork: string, tmpRoot: string): Promise<{ dir: string } | { error: string }> {
|
|
94
|
+
const dir = mkdtempSync(join(tmpRoot, "cue-pr-"));
|
|
95
|
+
const res = await runner.run("gh", ["repo", "clone", fork, dir, "--", "--depth=1"], { timeoutMs: 120000 });
|
|
96
|
+
if (res.status !== 0) {
|
|
97
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
98
|
+
return { error: `gh repo clone failed: ${res.stderr.trim() || "exit " + res.status}` };
|
|
99
|
+
}
|
|
100
|
+
return { dir };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function syncForkWithUpstream(runner: Runner, fork: string): Promise<boolean> {
|
|
104
|
+
// Keeps fork up to date so the branch we create is based on latest upstream.
|
|
105
|
+
// Failure is non-fatal — the PR may still be valid against an older base.
|
|
106
|
+
const res = await runner.run("gh", ["repo", "sync", fork], { timeoutMs: 30000 });
|
|
107
|
+
return res.status === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FileChange { path: string; before: string; after: string; }
|
|
111
|
+
|
|
112
|
+
/** Apply fixes to the cloned fork's working tree. Returns paths that actually changed. */
|
|
113
|
+
export function writeFilesToWorktree(dir: string, changes: FileChange[]): string[] {
|
|
114
|
+
const written: string[] = [];
|
|
115
|
+
for (const c of changes) {
|
|
116
|
+
const full = join(dir, c.path);
|
|
117
|
+
if (!existsSync(full)) continue;
|
|
118
|
+
const current = readFileSync(full, "utf8");
|
|
119
|
+
if (current === c.after) continue;
|
|
120
|
+
writeFileSync(full, c.after);
|
|
121
|
+
written.push(c.path);
|
|
122
|
+
}
|
|
123
|
+
return written;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function commitAndPush(
|
|
127
|
+
runner: Runner,
|
|
128
|
+
dir: string,
|
|
129
|
+
branch: string,
|
|
130
|
+
message: string,
|
|
131
|
+
): Promise<{ ok: true } | { error: string }> {
|
|
132
|
+
// Use a checkout-or-create flow so re-runs against an existing branch keep working.
|
|
133
|
+
const cb = await runner.run("git", ["checkout", "-B", branch], { cwd: dir });
|
|
134
|
+
if (cb.status !== 0) return { error: `git checkout -B failed: ${cb.stderr.trim()}` };
|
|
135
|
+
|
|
136
|
+
const add = await runner.run("git", ["add", "-A"], { cwd: dir });
|
|
137
|
+
if (add.status !== 0) return { error: `git add failed: ${add.stderr.trim()}` };
|
|
138
|
+
|
|
139
|
+
// -c flags here run the commit without requiring user.{name,email} to be set
|
|
140
|
+
// — useful in CI/sandbox environments.
|
|
141
|
+
const commit = await runner.run("git", [
|
|
142
|
+
"-c", "user.email=cue-bot@users.noreply.github.com",
|
|
143
|
+
"-c", "user.name=cue-bot",
|
|
144
|
+
"commit", "-m", message,
|
|
145
|
+
], { cwd: dir });
|
|
146
|
+
// "nothing to commit" is OK if the fork already has identical content
|
|
147
|
+
if (commit.status !== 0 && !/nothing to commit/i.test(commit.stdout + commit.stderr)) {
|
|
148
|
+
return { error: `git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const push = await runner.run("git", ["push", "-u", "origin", branch, "--force-with-lease"], { cwd: dir, timeoutMs: 60000 });
|
|
152
|
+
if (push.status !== 0) return { error: `git push failed: ${push.stderr.trim()}` };
|
|
153
|
+
return { ok: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface OpenPrResult { number: number; url: string; }
|
|
157
|
+
|
|
158
|
+
export async function openPr(
|
|
159
|
+
runner: Runner,
|
|
160
|
+
upstream: string,
|
|
161
|
+
fork: string,
|
|
162
|
+
branch: string,
|
|
163
|
+
title: string,
|
|
164
|
+
body: string,
|
|
165
|
+
): Promise<OpenPrResult | { error: string }> {
|
|
166
|
+
const forkOwner = fork.split("/")[0];
|
|
167
|
+
// gh pr create against upstream with our fork's branch
|
|
168
|
+
const res = await runner.run("gh", [
|
|
169
|
+
"pr", "create",
|
|
170
|
+
"--repo", upstream,
|
|
171
|
+
"--head", `${forkOwner}:${branch}`,
|
|
172
|
+
"--title", title,
|
|
173
|
+
"--body", body,
|
|
174
|
+
], { timeoutMs: 60000 });
|
|
175
|
+
|
|
176
|
+
if (res.status !== 0) {
|
|
177
|
+
// If a PR already exists for this branch, gh tells us — surface that as success.
|
|
178
|
+
const existingMatch = res.stderr.match(/already exists:?\s+(https:\/\/github\.com\/\S+\/pull\/(\d+))/i);
|
|
179
|
+
if (existingMatch) return { number: parseInt(existingMatch[2]!, 10), url: existingMatch[1]! };
|
|
180
|
+
return { error: `gh pr create failed: ${res.stderr.trim() || "exit " + res.status}` };
|
|
181
|
+
}
|
|
182
|
+
// Successful create — stdout is the PR URL
|
|
183
|
+
const url = res.stdout.trim().split("\n").pop() ?? "";
|
|
184
|
+
const num = url.match(/\/pull\/(\d+)/)?.[1];
|
|
185
|
+
return { number: num ? parseInt(num, 10) : 0, url };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Top-level driver
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export interface PostPrInput {
|
|
193
|
+
upstream: string; // owner/name to PR against
|
|
194
|
+
changes: FileChange[]; // SKILL.md files we touched
|
|
195
|
+
prTitle: string;
|
|
196
|
+
prBody: string;
|
|
197
|
+
branch?: string; // defaults to cue/skill-md-fixes
|
|
198
|
+
runner?: Runner;
|
|
199
|
+
keepTmp?: boolean; // skip cleanup of the cloned fork dir (for debugging)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface PostPrSuccess {
|
|
203
|
+
ok: true;
|
|
204
|
+
prNumber: number;
|
|
205
|
+
prUrl: string;
|
|
206
|
+
fork: string;
|
|
207
|
+
branch: string;
|
|
208
|
+
filesChanged: string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface PostPrFailure {
|
|
212
|
+
ok: false;
|
|
213
|
+
step: "whoami" | "fork" | "clone" | "apply" | "commit" | "push" | "pr-create";
|
|
214
|
+
error: string;
|
|
215
|
+
fork?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function postPrToRepo(input: PostPrInput): Promise<PostPrSuccess | PostPrFailure> {
|
|
219
|
+
const runner = input.runner ?? defaultRunner;
|
|
220
|
+
const branch = input.branch ?? deriveBranchName(input.upstream, input.changes);
|
|
221
|
+
|
|
222
|
+
const fork = await forkRepo(runner, input.upstream);
|
|
223
|
+
if ("error" in fork) return { ok: false, step: "fork", error: fork.error };
|
|
224
|
+
|
|
225
|
+
// Best-effort sync; don't fail the run on sync failure.
|
|
226
|
+
await syncForkWithUpstream(runner, fork.fork);
|
|
227
|
+
|
|
228
|
+
const cloned = await cloneFork(runner, fork.fork, tmpdir());
|
|
229
|
+
if ("error" in cloned) return { ok: false, step: "clone", error: cloned.error, fork: fork.fork };
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const filesChanged = writeFilesToWorktree(cloned.dir, input.changes);
|
|
233
|
+
if (filesChanged.length === 0) {
|
|
234
|
+
return { ok: false, step: "apply", error: "no files changed after writing fixes (worktree already had identical content)", fork: fork.fork };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const commitRes = await commitAndPush(runner, cloned.dir, branch, input.prTitle);
|
|
238
|
+
if ("error" in commitRes) return { ok: false, step: "push", error: commitRes.error, fork: fork.fork };
|
|
239
|
+
|
|
240
|
+
const pr = await openPr(runner, input.upstream, fork.fork, branch, input.prTitle, input.prBody);
|
|
241
|
+
if ("error" in pr) return { ok: false, step: "pr-create", error: pr.error, fork: fork.fork };
|
|
242
|
+
|
|
243
|
+
return { ok: true, prNumber: pr.number, prUrl: pr.url, fork: fork.fork, branch, filesChanged };
|
|
244
|
+
} finally {
|
|
245
|
+
if (!input.keepTmp) {
|
|
246
|
+
try { rmSync(cloned.dir, { recursive: true, force: true }); } catch {}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// PR state polling — used by cleanup-forks to find merged/closed PRs
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export async function fetchPrState(
|
|
256
|
+
runner: Runner,
|
|
257
|
+
upstream: string,
|
|
258
|
+
prNumber: number,
|
|
259
|
+
): Promise<"open" | "merged" | "closed" | "unknown"> {
|
|
260
|
+
const res = await runner.run("gh", ["pr", "view", String(prNumber), "--repo", upstream, "--json", "state,merged", "--jq", "{state, merged}"], { timeoutMs: 10000 });
|
|
261
|
+
if (res.status !== 0) return "unknown";
|
|
262
|
+
try {
|
|
263
|
+
const { state, merged } = JSON.parse(res.stdout) as { state: string; merged: boolean };
|
|
264
|
+
if (merged) return "merged";
|
|
265
|
+
if (state === "CLOSED") return "closed";
|
|
266
|
+
if (state === "OPEN") return "open";
|
|
267
|
+
} catch {}
|
|
268
|
+
return "unknown";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function deleteFork(runner: Runner, fork: string): Promise<{ ok: true } | { error: string }> {
|
|
272
|
+
const res = await runner.run("gh", ["repo", "delete", fork, "--yes"], { timeoutMs: 30000 });
|
|
273
|
+
if (res.status !== 0) return { error: `gh repo delete failed: ${res.stderr.trim() || "exit " + res.status}` };
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Opt-out check — fetches the repo's README and looks for the marker
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export async function checkOptOutMarker(runner: Runner, repo: string, marker: string): Promise<boolean | null> {
|
|
282
|
+
const res = await runner.run("gh", ["api", `repos/${repo}/readme`, "-H", "Accept: application/vnd.github.raw"], { timeoutMs: 10000 });
|
|
283
|
+
if (res.status !== 0) return null; // unknown — fail open (don't post in caller if uncertain)
|
|
284
|
+
return res.stdout.toLowerCase().includes(marker.toLowerCase());
|
|
285
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
loadDb, saveDb,
|
|
8
|
+
isThrottled, canPostMore, todayCount,
|
|
9
|
+
recordOpened, recordOptOut, recordSkipped, updateEntryState,
|
|
10
|
+
filterByState, hasRecordedOptOut,
|
|
11
|
+
DEFAULT_COOLDOWN_DAYS, DAILY_CAP,
|
|
12
|
+
} from "./pr-throttle";
|
|
13
|
+
|
|
14
|
+
let dbPath: string;
|
|
15
|
+
let tmp: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = mkdtempSync(join(tmpdir(), "cue-throttle-"));
|
|
19
|
+
dbPath = join(tmp, "pr-opened.json");
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("loadDb / saveDb", () => {
|
|
26
|
+
test("missing file returns empty DB", () => {
|
|
27
|
+
expect(loadDb(dbPath)).toEqual({ version: 1, history: [] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("save then load round-trips", () => {
|
|
31
|
+
const db = recordOpened({ version: 1, history: [] }, {
|
|
32
|
+
repo: "a/b", rulesFixed: ["R001"], prNumber: 1, prUrl: "https://example/pr/1", fork: "me/b", branch: "x",
|
|
33
|
+
});
|
|
34
|
+
saveDb(db, dbPath);
|
|
35
|
+
expect(existsSync(dbPath)).toBe(true);
|
|
36
|
+
const reloaded = loadDb(dbPath);
|
|
37
|
+
expect(reloaded.history).toHaveLength(1);
|
|
38
|
+
expect(reloaded.history[0]!.repo).toBe("a/b");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("corrupt file is treated as empty (no throw)", () => {
|
|
42
|
+
saveDb({ version: 99 as any, history: "not an array" as any }, dbPath);
|
|
43
|
+
const reloaded = loadDb(dbPath);
|
|
44
|
+
expect(reloaded.history).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("isThrottled", () => {
|
|
49
|
+
test("never-seen repo is not throttled", () => {
|
|
50
|
+
const db = { version: 1 as const, history: [] };
|
|
51
|
+
expect(isThrottled(db, "new/repo")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("recently opened repo is throttled (cooldown)", () => {
|
|
55
|
+
const db = recordOpened({ version: 1, history: [] }, {
|
|
56
|
+
repo: "a/b", rulesFixed: [], prNumber: 1, prUrl: "x", fork: "me/b", branch: "y",
|
|
57
|
+
});
|
|
58
|
+
const reason = isThrottled(db, "a/b");
|
|
59
|
+
expect(reason).toMatch(/cooldown/);
|
|
60
|
+
expect(reason).toMatch(/state=open/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("opted-out repo is permanently throttled", () => {
|
|
64
|
+
const db = recordOptOut({ version: 1, history: [] }, "a/b");
|
|
65
|
+
expect(isThrottled(db, "a/b")).toMatch(/cue: ignore/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("entry older than cooldownDays does not throttle", () => {
|
|
69
|
+
const longAgo = new Date(Date.now() - (DEFAULT_COOLDOWN_DAYS + 1) * 24 * 3600 * 1000).toISOString();
|
|
70
|
+
const db = { version: 1 as const, history: [{ repo: "a/b", state: "skipped" as const, openedAt: longAgo }] };
|
|
71
|
+
expect(isThrottled(db, "a/b")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("custom cooldownDays is respected", () => {
|
|
75
|
+
const fourDaysAgo = new Date(Date.now() - 4 * 24 * 3600 * 1000).toISOString();
|
|
76
|
+
const db = { version: 1 as const, history: [{ repo: "a/b", state: "skipped" as const, openedAt: fourDaysAgo }] };
|
|
77
|
+
expect(isThrottled(db, "a/b", 3)).toBeNull(); // 4d > 3d cooldown → free
|
|
78
|
+
expect(isThrottled(db, "a/b", 10)).toMatch(/cooldown/); // 4d < 10d → throttled
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("canPostMore / todayCount", () => {
|
|
83
|
+
test("empty DB → full headroom", () => {
|
|
84
|
+
const { ok, remaining, cap } = canPostMore({ version: 1, history: [] });
|
|
85
|
+
expect(ok).toBe(true);
|
|
86
|
+
expect(remaining).toBe(DAILY_CAP);
|
|
87
|
+
expect(cap).toBe(DAILY_CAP);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("counts only state=open within last 24h", () => {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const yesterday = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
|
|
93
|
+
const db = {
|
|
94
|
+
version: 1 as const,
|
|
95
|
+
history: [
|
|
96
|
+
{ repo: "a/1", state: "open" as const, openedAt: now, prNumber: 1 },
|
|
97
|
+
{ repo: "a/2", state: "open" as const, openedAt: now, prNumber: 2 },
|
|
98
|
+
{ repo: "a/3", state: "open" as const, openedAt: yesterday, prNumber: 3 }, // too old
|
|
99
|
+
{ repo: "a/4", state: "skipped" as const, openedAt: now }, // not opened
|
|
100
|
+
{ repo: "a/5", state: "opted-out" as const, openedAt: now }, // not opened
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
expect(todayCount(db)).toBe(2);
|
|
104
|
+
expect(canPostMore(db).remaining).toBe(DAILY_CAP - 2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("at the cap, canPostMore returns ok=false", () => {
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
const history = Array.from({ length: DAILY_CAP }, (_, i) => ({
|
|
110
|
+
repo: `a/${i}`, state: "open" as const, openedAt: now, prNumber: i + 1,
|
|
111
|
+
}));
|
|
112
|
+
const db = { version: 1 as const, history };
|
|
113
|
+
expect(canPostMore(db).ok).toBe(false);
|
|
114
|
+
expect(canPostMore(db).remaining).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("mutations + helpers", () => {
|
|
119
|
+
test("recordOptOut + hasRecordedOptOut round-trip", () => {
|
|
120
|
+
const db = recordOptOut({ version: 1, history: [] }, "a/b");
|
|
121
|
+
expect(hasRecordedOptOut(db, "a/b")).toBe(true);
|
|
122
|
+
expect(hasRecordedOptOut(db, "x/y")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("recordSkipped writes a skipped entry with the reason", () => {
|
|
126
|
+
const db = recordSkipped({ version: 1, history: [] }, "a/b", "no fixes");
|
|
127
|
+
expect(db.history[0]!.state).toBe("skipped");
|
|
128
|
+
expect(db.history[0]!.reason).toBe("no fixes");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("updateEntryState mutates by prNumber match", () => {
|
|
132
|
+
let db = recordOpened({ version: 1, history: [] }, {
|
|
133
|
+
repo: "a/b", rulesFixed: [], prNumber: 42, prUrl: "x", fork: "me/b", branch: "y",
|
|
134
|
+
});
|
|
135
|
+
db = updateEntryState(db, { repo: "a/b", prNumber: 42 }, "merged", { cleanedAt: "2026-05-24T00:00:00Z" });
|
|
136
|
+
expect(db.history[0]!.state).toBe("merged");
|
|
137
|
+
expect(db.history[0]!.cleanedAt).toBe("2026-05-24T00:00:00Z");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("filterByState returns only matching entries", () => {
|
|
141
|
+
let db: any = { version: 1, history: [] };
|
|
142
|
+
db = recordOpened(db, { repo: "a/1", rulesFixed: [], prNumber: 1, prUrl: "x", fork: "me/1", branch: "y" });
|
|
143
|
+
db = recordOptOut(db, "a/2");
|
|
144
|
+
db = recordSkipped(db, "a/3", "no fixes");
|
|
145
|
+
expect(filterByState(db, ["open"]).map((e: any) => e.repo)).toEqual(["a/1"]);
|
|
146
|
+
expect(filterByState(db, ["open", "skipped"]).map((e: any) => e.repo).sort()).toEqual(["a/1", "a/3"]);
|
|
147
|
+
});
|
|
148
|
+
});
|