@gethmy/agent 1.2.0 → 1.4.1

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.
Files changed (51) hide show
  1. package/dist/cli.js +6 -2
  2. package/dist/config-validation.d.ts +5 -0
  3. package/dist/config-validation.js +16 -5
  4. package/dist/http-server.js +0 -1
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +59 -19
  7. package/dist/merge-monitor.js +0 -1
  8. package/dist/pool.js +0 -2
  9. package/dist/reconcile.js +0 -1
  10. package/dist/recovery.js +0 -1
  11. package/dist/review-worker.js +25 -0
  12. package/dist/review-worktree.js +12 -0
  13. package/dist/startup-banner.d.ts +29 -0
  14. package/dist/startup-banner.js +143 -0
  15. package/dist/watcher.d.ts +15 -0
  16. package/dist/watcher.js +41 -4
  17. package/dist/worktree-gc.js +1 -2
  18. package/dist/worktree.js +24 -2
  19. package/package.json +2 -2
  20. package/dist/__tests__/budget.test.d.ts +0 -1
  21. package/dist/__tests__/budget.test.js +0 -94
  22. package/dist/__tests__/config-validation.test.d.ts +0 -1
  23. package/dist/__tests__/config-validation.test.js +0 -65
  24. package/dist/__tests__/dev-server-readiness.test.d.ts +0 -1
  25. package/dist/__tests__/dev-server-readiness.test.js +0 -26
  26. package/dist/__tests__/http-server.test.d.ts +0 -1
  27. package/dist/__tests__/http-server.test.js +0 -115
  28. package/dist/__tests__/log.test.d.ts +0 -1
  29. package/dist/__tests__/log.test.js +0 -115
  30. package/dist/__tests__/merge-monitor.test.d.ts +0 -1
  31. package/dist/__tests__/merge-monitor.test.js +0 -107
  32. package/dist/__tests__/process-group.test.d.ts +0 -1
  33. package/dist/__tests__/process-group.test.js +0 -88
  34. package/dist/__tests__/progress-tracker.test.d.ts +0 -1
  35. package/dist/__tests__/progress-tracker.test.js +0 -247
  36. package/dist/__tests__/reconcile-heartbeat.test.d.ts +0 -1
  37. package/dist/__tests__/reconcile-heartbeat.test.js +0 -116
  38. package/dist/__tests__/recovery.test.d.ts +0 -1
  39. package/dist/__tests__/recovery.test.js +0 -126
  40. package/dist/__tests__/review-parser.test.d.ts +0 -1
  41. package/dist/__tests__/review-parser.test.js +0 -65
  42. package/dist/__tests__/state-store.test.d.ts +0 -1
  43. package/dist/__tests__/state-store.test.js +0 -132
  44. package/dist/__tests__/stream-parser-selftest.test.d.ts +0 -1
  45. package/dist/__tests__/stream-parser-selftest.test.js +0 -23
  46. package/dist/__tests__/stream-parser.test.d.ts +0 -1
  47. package/dist/__tests__/stream-parser.test.js +0 -199
  48. package/dist/__tests__/transitions.test.d.ts +0 -1
  49. package/dist/__tests__/transitions.test.js +0 -130
  50. package/dist/__tests__/worktree-gc.test.d.ts +0 -1
  51. package/dist/__tests__/worktree-gc.test.js +0 -137
package/dist/worktree.js CHANGED
@@ -20,8 +20,10 @@ export function createWorktree(basePath, baseBranch, branchName) {
20
20
  // Prune stale worktree metadata. If a previous daemon crashed or its
21
21
  // worktree dir was deleted externally, git may still think the branch is
22
22
  // checked out, which blocks `git branch -D` and `git worktree add`.
23
+ // `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
24
+ // freshly-orphaned entries are removed immediately.
23
25
  try {
24
- execFileSync("git", ["worktree", "prune"], {
26
+ execFileSync("git", ["worktree", "prune", "--expire=now"], {
25
27
  cwd: repoRoot,
26
28
  stdio: "pipe",
27
29
  });
@@ -58,6 +60,26 @@ export function createWorktree(basePath, baseBranch, branchName) {
58
60
  // another registered worktree), force-delete the branch and retry.
59
61
  const msg = err instanceof Error ? err.message : String(err);
60
62
  log.warn(TAG, `worktree add failed, attempting forced recovery: ${msg}`);
63
+ // Remove any registered worktree at this path (phantom or otherwise).
64
+ try {
65
+ execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
66
+ cwd: repoRoot,
67
+ stdio: "pipe",
68
+ });
69
+ }
70
+ catch {
71
+ // best-effort
72
+ }
73
+ // Force-prune any stale worktree admin entries referencing this branch.
74
+ try {
75
+ execFileSync("git", ["worktree", "prune", "--expire=now"], {
76
+ cwd: repoRoot,
77
+ stdio: "pipe",
78
+ });
79
+ }
80
+ catch {
81
+ // best-effort
82
+ }
61
83
  try {
62
84
  execFileSync("git", ["branch", "-D", branchName], {
63
85
  cwd: repoRoot,
@@ -112,7 +134,7 @@ export function cleanupWorktree(worktreePath, branchName) {
112
134
  }
113
135
  // Prune stale worktree entries
114
136
  try {
115
- execFileSync("git", ["worktree", "prune"], {
137
+ execFileSync("git", ["worktree", "prune", "--expire=now"], {
116
138
  cwd: repoRoot,
117
139
  stdio: "pipe",
118
140
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.2.0",
3
+ "version": "1.4.1",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,7 +38,7 @@
38
38
  "scripts": {
39
39
  "start": "node dist/index.js",
40
40
  "prebuild": "cd ../harmony-shared && bun run build && cd ../memory && bun run build",
41
- "build": "tsc",
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
42
42
  "typecheck": "tsc --noEmit",
43
43
  "prepublishOnly": "npm run build",
44
44
  "test": "vitest run"
@@ -1 +0,0 @@
1
- export {};
@@ -1,94 +0,0 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { BudgetGuard } from "../budget.js";
6
- import { StateStore } from "../state-store.js";
7
- import { DEFAULT_AGENT_CONFIG } from "../types.js";
8
- describe("BudgetGuard", () => {
9
- let dir;
10
- let store;
11
- beforeEach(() => {
12
- dir = mkdtempSync(join(tmpdir(), "budget-"));
13
- store = new StateStore(join(dir, "state.json"));
14
- });
15
- afterEach(() => {
16
- rmSync(dir, { recursive: true, force: true });
17
- });
18
- function makeGuard(overrides = {}) {
19
- return new BudgetGuard({ ...DEFAULT_AGENT_CONFIG.budget, ...overrides }, store);
20
- }
21
- it("allows a fresh card with no history", () => {
22
- const guard = makeGuard();
23
- expect(guard.check("c1")).toEqual({ allow: true });
24
- });
25
- it("blocks a card already marked DLQ", async () => {
26
- await store.markDlq("c1", "upstream test");
27
- const guard = makeGuard();
28
- const decision = guard.check("c1");
29
- expect(decision).toMatchObject({ allow: false, reason: "dlq" });
30
- });
31
- it("blocks after attempts >= max", async () => {
32
- await store.incrementAttempt("c1");
33
- await store.incrementAttempt("c1");
34
- await store.incrementAttempt("c1");
35
- const guard = makeGuard({ maxAttemptsPerCard: 3 });
36
- const decision = guard.check("c1");
37
- expect(decision).toMatchObject({
38
- allow: false,
39
- reason: "max_attempts",
40
- });
41
- });
42
- it("allows again after recordOutcome('success') resets attempts", async () => {
43
- await store.incrementAttempt("c1");
44
- await store.incrementAttempt("c1");
45
- await store.recordOutcome("c1", "success");
46
- const guard = makeGuard({ maxAttemptsPerCard: 2 });
47
- expect(guard.check("c1")).toEqual({ allow: true });
48
- });
49
- it("blocks on per-card cost cap", async () => {
50
- await store.addCost("c1", 600);
51
- const guard = makeGuard({ maxCentsPerCard: 500 });
52
- expect(guard.check("c1")).toMatchObject({
53
- allow: false,
54
- reason: "card_cost_cap",
55
- });
56
- });
57
- it("blocks on daily budget exhaustion", async () => {
58
- await store.addCost("c1", 5000);
59
- const guard = makeGuard({ dailyBudgetCents: 5000 });
60
- // Different card, but daily aggregate already at cap.
61
- expect(guard.check("c2")).toMatchObject({
62
- allow: false,
63
- reason: "daily_budget",
64
- });
65
- });
66
- it("treats DLQ, max_attempts, card_cost_cap as terminal (daily_budget is not)", () => {
67
- const guard = makeGuard();
68
- expect(guard.isTerminal("dlq")).toBe(true);
69
- expect(guard.isTerminal("max_attempts")).toBe(true);
70
- expect(guard.isTerminal("card_cost_cap")).toBe(true);
71
- expect(guard.isTerminal("daily_budget")).toBe(false);
72
- });
73
- it("markDlq persists state + applies label via runTransition", async () => {
74
- const guard = makeGuard();
75
- const client = {
76
- getBoard: vi.fn().mockResolvedValue({
77
- columns: [{ id: "col-1", name: "To Do" }],
78
- labels: [],
79
- }),
80
- createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-dlq" } }),
81
- addLabelToCard: vi.fn().mockResolvedValue({}),
82
- };
83
- const card = {
84
- id: "c1",
85
- short_id: 99,
86
- project_id: "p",
87
- column_id: "col-1",
88
- labelIds: [],
89
- };
90
- await guard.markDlq(client, card, "max_attempts", "3 attempts");
91
- expect(store.isDlq("c1")).toBe(true);
92
- expect(client.addLabelToCard).toHaveBeenCalledWith("c1", "lbl-dlq");
93
- });
94
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,65 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { ConfigValidationError, validateColumnReferences, } from "../config-validation.js";
3
- import { DEFAULT_AGENT_CONFIG } from "../types.js";
4
- function makeClient(columns) {
5
- return {
6
- getBoard: vi.fn().mockResolvedValue({
7
- columns: columns.map((name, i) => ({ id: `col-${i}`, name })),
8
- labels: [],
9
- }),
10
- };
11
- }
12
- describe("validateColumnReferences", () => {
13
- it("passes when all configured columns exist (case-insensitive)", async () => {
14
- const client = makeClient([
15
- "to do",
16
- "In Progress",
17
- "REVIEW",
18
- "Done",
19
- "Needs Fix",
20
- ]);
21
- await expect(validateColumnReferences(client, "proj", DEFAULT_AGENT_CONFIG)).resolves.toBeUndefined();
22
- });
23
- it("throws a typed error listing every missing column", async () => {
24
- const client = makeClient(["To Do", "Review", "Done"]);
25
- const config = {
26
- ...DEFAULT_AGENT_CONFIG,
27
- verification: {
28
- ...DEFAULT_AGENT_CONFIG.verification,
29
- failColumn: "Needs Fix",
30
- },
31
- };
32
- try {
33
- await validateColumnReferences(client, "proj", config);
34
- throw new Error("expected rejection");
35
- }
36
- catch (err) {
37
- expect(err).toBeInstanceOf(ConfigValidationError);
38
- const issues = err.issues;
39
- expect(issues.some((i) => i.includes("Needs Fix"))).toBe(true);
40
- }
41
- });
42
- it("skips review column checks when review is disabled", async () => {
43
- const client = makeClient(["To Do", "Review", "Needs Fix"]);
44
- const config = {
45
- ...DEFAULT_AGENT_CONFIG,
46
- completion: {
47
- ...DEFAULT_AGENT_CONFIG.completion,
48
- moveToColumn: "Review",
49
- },
50
- review: { ...DEFAULT_AGENT_CONFIG.review, enabled: false },
51
- };
52
- await expect(validateColumnReferences(client, "proj", config)).resolves.toBeUndefined();
53
- });
54
- it("reports the missing column along with the config path", async () => {
55
- const client = makeClient(["To Do", "In Progress", "Review", "Done"]);
56
- const config = {
57
- ...DEFAULT_AGENT_CONFIG,
58
- verification: {
59
- ...DEFAULT_AGENT_CONFIG.verification,
60
- failColumn: "DOES NOT EXIST",
61
- },
62
- };
63
- await expect(validateColumnReferences(client, "proj", config)).rejects.toThrow(/verification\.failColumn: column "DOES NOT EXIST"/);
64
- });
65
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,26 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { describe, expect, it } from "vitest";
3
- import { DevServerReadinessError, waitForDevServer } from "../verification.js";
4
- describe("waitForDevServer", () => {
5
- it("resolves when readiness signal appears on stdout", async () => {
6
- // node -e "console.log('server ready on localhost:4200'); setTimeout(()=>{}, 1000)"
7
- const proc = spawn(process.execPath, [
8
- "-e",
9
- "console.log('server ready on localhost:4200'); setTimeout(() => {}, 1000);",
10
- ], { stdio: ["ignore", "pipe", "pipe"] });
11
- await expect(waitForDevServer(proc, 2000)).resolves.toBeUndefined();
12
- proc.kill("SIGKILL");
13
- });
14
- it("rejects (does NOT resolve) on timeout", async () => {
15
- // Silent child; no readiness signal.
16
- const proc = spawn(process.execPath, ["-e", "setTimeout(() => {}, 5000);"], { stdio: ["ignore", "pipe", "pipe"] });
17
- await expect(waitForDevServer(proc, 100)).rejects.toBeInstanceOf(DevServerReadinessError);
18
- proc.kill("SIGKILL");
19
- });
20
- it("rejects if the process exits before becoming ready", async () => {
21
- const proc = spawn(process.execPath, ["-e", "process.exit(7);"], {
22
- stdio: ["ignore", "pipe", "pipe"],
23
- });
24
- await expect(waitForDevServer(proc, 5000)).rejects.toBeInstanceOf(DevServerReadinessError);
25
- });
26
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,115 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { HttpServer, } from "../http-server.js";
3
- function baseStatus() {
4
- return {
5
- daemonId: "d-1",
6
- daemonPid: 1234,
7
- startedAt: Date.now(),
8
- uptimeMs: 0,
9
- workers: [],
10
- implQueue: [],
11
- reviewQueue: [],
12
- dlq: [],
13
- budget: { todayCents: 0, dailyCapCents: 5000 },
14
- };
15
- }
16
- describe("HttpServer", () => {
17
- let server;
18
- let port;
19
- let getHealth;
20
- let handleCommand;
21
- let clearDlq;
22
- beforeEach(async () => {
23
- // Ephemeral port via 0 doesn't round-trip through the options — use
24
- // a random high port and retry on collision (rare in CI).
25
- port = 40000 + Math.floor(Math.random() * 10000);
26
- getHealth = () => ({ healthy: true, checks: { watcher: true } });
27
- handleCommand = vi.fn().mockResolvedValue(undefined);
28
- clearDlq = vi.fn().mockResolvedValue(undefined);
29
- server = new HttpServer({
30
- port,
31
- bindAddr: "127.0.0.1",
32
- getHealth: () => getHealth(),
33
- getStatus: () => baseStatus(),
34
- handleCommand: (c, id) => handleCommand(c, id),
35
- clearDlq: (id) => clearDlq(id),
36
- });
37
- await server.start();
38
- });
39
- afterEach(async () => {
40
- await server.stop();
41
- });
42
- async function get(path) {
43
- return fetch(`http://127.0.0.1:${port}${path}`);
44
- }
45
- async function post(path) {
46
- return fetch(`http://127.0.0.1:${port}${path}`, { method: "POST" });
47
- }
48
- it("GET /health returns 200 when healthy", async () => {
49
- const res = await get("/health");
50
- expect(res.status).toBe(200);
51
- const body = await res.json();
52
- expect(body.healthy).toBe(true);
53
- });
54
- it("GET /health returns 503 when unhealthy", async () => {
55
- getHealth = () => ({
56
- healthy: false,
57
- checks: { watcher: false, reconciler: true },
58
- });
59
- const res = await get("/health");
60
- expect(res.status).toBe(503);
61
- const body = await res.json();
62
- expect(body.checks.watcher).toBe(false);
63
- });
64
- it("GET /status returns the snapshot shape", async () => {
65
- const res = await get("/status");
66
- expect(res.status).toBe(200);
67
- const body = await res.json();
68
- expect(body).toHaveProperty("daemonId", "d-1");
69
- expect(body).toHaveProperty("workers");
70
- expect(body).toHaveProperty("implQueue");
71
- expect(body).toHaveProperty("dlq");
72
- expect(body).toHaveProperty("budget");
73
- });
74
- it("POST /pause/:cardId dispatches to handleCommand", async () => {
75
- const res = await post("/pause/abc-123");
76
- expect(res.status).toBe(200);
77
- expect(handleCommand).toHaveBeenCalledWith("pause", "abc-123");
78
- });
79
- it("POST /resume/:cardId dispatches correctly", async () => {
80
- const res = await post("/resume/xyz");
81
- expect(res.status).toBe(200);
82
- expect(handleCommand).toHaveBeenCalledWith("resume", "xyz");
83
- });
84
- it("POST /stop/:cardId dispatches correctly", async () => {
85
- const res = await post("/stop/c1");
86
- expect(res.status).toBe(200);
87
- expect(handleCommand).toHaveBeenCalledWith("stop", "c1");
88
- });
89
- it("returns 404 for unknown paths", async () => {
90
- const res = await get("/does-not-exist");
91
- expect(res.status).toBe(404);
92
- });
93
- it("POST /dlq/clear/:cardId routes through the daemon", async () => {
94
- const res = await post("/dlq/clear/card-42");
95
- expect(res.status).toBe(200);
96
- expect(clearDlq).toHaveBeenCalledWith("card-42");
97
- const body = await res.json();
98
- expect(body.ok).toBe(true);
99
- expect(body.cardId).toBe("card-42");
100
- });
101
- it("POST /dlq/clear returns 500 with detail on failure", async () => {
102
- clearDlq = vi.fn().mockRejectedValue(new Error("state store locked"));
103
- const res = await post("/dlq/clear/card-99");
104
- expect(res.status).toBe(500);
105
- const body = await res.json();
106
- expect(body.detail).toContain("state store locked");
107
- });
108
- it("returns 500 with detail when handleCommand throws", async () => {
109
- handleCommand = vi.fn().mockRejectedValue(new Error("worker gone"));
110
- const res = await post("/stop/c1");
111
- expect(res.status).toBe(500);
112
- const body = await res.json();
113
- expect(body.detail).toContain("worker gone");
114
- });
115
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,115 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- describe("log", () => {
3
- let writes;
4
- let origWrite;
5
- let origArgv;
6
- beforeEach(() => {
7
- writes = [];
8
- origWrite = process.stderr.write;
9
- origArgv = process.argv;
10
- process.stderr.write = ((chunk) => {
11
- writes.push(chunk);
12
- return true;
13
- });
14
- delete process.env.HARMONY_AGENT_PRETTY;
15
- delete process.env.HARMONY_AGENT_JSON;
16
- delete process.env.DEBUG;
17
- vi.resetModules();
18
- });
19
- afterEach(() => {
20
- process.stderr.write = origWrite;
21
- process.argv = origArgv;
22
- });
23
- it("emits JSON lines when stderr is not a TTY", async () => {
24
- // Vitest stderr is not a TTY by default; no need to force.
25
- const { log } = await import("../log.js");
26
- log.info("worker", "hello", { cardId: "c1", runId: "r1" });
27
- const parsed = JSON.parse(writes[0]);
28
- expect(parsed).toMatchObject({
29
- level: "info",
30
- tag: "worker",
31
- msg: "hello",
32
- cardId: "c1",
33
- runId: "r1",
34
- });
35
- expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
36
- });
37
- it("emits pretty output when stderr is a TTY", async () => {
38
- const originalIsTTY = process.stderr.isTTY;
39
- Object.defineProperty(process.stderr, "isTTY", {
40
- value: true,
41
- configurable: true,
42
- });
43
- try {
44
- const { log } = await import("../log.js");
45
- log.info("daemon", "up");
46
- expect(() => JSON.parse(writes[0])).toThrow();
47
- expect(writes[0]).toContain("INFO");
48
- expect(writes[0]).toContain("[daemon]");
49
- }
50
- finally {
51
- Object.defineProperty(process.stderr, "isTTY", {
52
- value: originalIsTTY,
53
- configurable: true,
54
- });
55
- }
56
- });
57
- it("--json forces JSON even on a TTY", async () => {
58
- const originalIsTTY = process.stderr.isTTY;
59
- Object.defineProperty(process.stderr, "isTTY", {
60
- value: true,
61
- configurable: true,
62
- });
63
- process.argv = [...process.argv, "--json"];
64
- try {
65
- const { log } = await import("../log.js");
66
- log.info("daemon", "up");
67
- const parsed = JSON.parse(writes[0]);
68
- expect(parsed.msg).toBe("up");
69
- }
70
- finally {
71
- Object.defineProperty(process.stderr, "isTTY", {
72
- value: originalIsTTY,
73
- configurable: true,
74
- });
75
- }
76
- });
77
- it("HARMONY_AGENT_JSON=1 beats HARMONY_AGENT_PRETTY=1", async () => {
78
- process.env.HARMONY_AGENT_PRETTY = "1";
79
- process.env.HARMONY_AGENT_JSON = "1";
80
- const { log } = await import("../log.js");
81
- log.info("daemon", "hi");
82
- const parsed = JSON.parse(writes[0]);
83
- expect(parsed.msg).toBe("hi");
84
- });
85
- it("suppresses debug when DEBUG is unset", async () => {
86
- const { log } = await import("../log.js");
87
- log.debug("x", "quiet");
88
- expect(writes).toEqual([]);
89
- });
90
- it("emits debug when DEBUG is set", async () => {
91
- process.env.DEBUG = "1";
92
- const { log } = await import("../log.js");
93
- log.debug("x", "loud");
94
- expect(writes).toHaveLength(1);
95
- const parsed = JSON.parse(writes[0]);
96
- expect(parsed.level).toBe("debug");
97
- });
98
- it("log.event attaches the event field", async () => {
99
- const { log } = await import("../log.js");
100
- log.event("pool", "card_enqueued", { cardId: "c2" });
101
- const parsed = JSON.parse(writes[0]);
102
- expect(parsed.event).toBe("card_enqueued");
103
- expect(parsed.msg).toBe("card_enqueued");
104
- expect(parsed.cardId).toBe("c2");
105
- });
106
- it("switches to ANSI pretty output under HARMONY_AGENT_PRETTY=1", async () => {
107
- process.env.HARMONY_AGENT_PRETTY = "1";
108
- const { log } = await import("../log.js");
109
- log.warn("daemon", "heads up");
110
- // Pretty output is not JSON — should fail parse and contain WARN label
111
- expect(() => JSON.parse(writes[0])).toThrow();
112
- expect(writes[0]).toContain("WARN");
113
- expect(writes[0]).toContain("[daemon]");
114
- });
115
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,107 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { MergeMonitor } from "../merge-monitor.js";
3
- vi.mock("../git-pr.js", () => ({
4
- detectGitProvider: () => "github",
5
- extractPrUrl: () => "https://example/pr/1",
6
- checkPrMergeStatus: vi.fn().mockResolvedValue("merged"),
7
- }));
8
- vi.mock("node:child_process", () => ({
9
- execFile: (_a, _b, _c, cb) => cb(null),
10
- }));
11
- function makeCard(overrides = {}) {
12
- return {
13
- id: "card-1",
14
- short_id: 42,
15
- project_id: "proj",
16
- title: "Card",
17
- description: "branch: agent/foo\n\nhttps://example/pr/1",
18
- column_id: "col-review",
19
- labelIds: ["lbl-approved"],
20
- done: false,
21
- archived_at: null,
22
- ...overrides,
23
- };
24
- }
25
- function makeConfig() {
26
- return {
27
- review: {
28
- enabled: true,
29
- pickupColumns: ["Review"],
30
- moveToColumn: "Done",
31
- failColumn: "To Do",
32
- devServerPort: 4300,
33
- maxTimeout: 600_000,
34
- postFindings: true,
35
- maxReviewCycles: 3,
36
- createPR: true,
37
- approvedLabel: "Ready to Merge",
38
- approvedLabelColor: "#22c55e",
39
- mergeMonitor: true,
40
- mergedLabel: "Merged",
41
- mergedLabelColor: "#6366f1",
42
- },
43
- };
44
- }
45
- function makeBoard(markCardsDone) {
46
- return {
47
- columns: [
48
- { id: "col-review", name: "Review", mark_cards_done: false },
49
- { id: "col-done", name: "Done", mark_cards_done: markCardsDone },
50
- ],
51
- labels: [
52
- { id: "lbl-approved", name: "Ready to Merge" },
53
- { id: "lbl-merged", name: "Merged" },
54
- ],
55
- cards: [makeCard()],
56
- };
57
- }
58
- function makeClient(markCardsDone) {
59
- return {
60
- getBoard: vi.fn().mockResolvedValue(makeBoard(markCardsDone)),
61
- moveCard: vi.fn().mockResolvedValue({}),
62
- addLabelToCard: vi.fn().mockResolvedValue({}),
63
- removeLabelFromCard: vi.fn().mockResolvedValue({}),
64
- createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-new" } }),
65
- updateCard: vi.fn().mockResolvedValue({}),
66
- };
67
- }
68
- function approvedLabel() {
69
- return { id: "lbl-approved", name: "Ready to Merge" };
70
- }
71
- function asPrivate(monitor) {
72
- return monitor;
73
- }
74
- describe("MergeMonitor.completeMergedCard", () => {
75
- beforeEach(() => {
76
- vi.clearAllMocks();
77
- });
78
- it("does NOT force done:true when Done column has mark_cards_done=false", async () => {
79
- const client = makeClient(false);
80
- const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
81
- await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
82
- const updateCalls = client.updateCard.mock.calls;
83
- for (const [, payload] of updateCalls) {
84
- expect(payload).not.toHaveProperty("done");
85
- }
86
- });
87
- it("moves to the configured Done column and updates description with merge timestamp", async () => {
88
- const client = makeClient(true);
89
- const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
90
- await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
91
- expect(client.moveCard).toHaveBeenCalledWith("card-1", "col-done");
92
- const descUpdate = client.updateCard.mock.calls.find(([, p]) => typeof p.description === "string" &&
93
- p.description.includes("Merged at"));
94
- expect(descUpdate).toBeDefined();
95
- });
96
- it("does not re-append merge timestamp when description already has one", async () => {
97
- const client = makeClient(true);
98
- const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
99
- const card = makeCard({
100
- description: "branch: agent/foo\n\nMerged at 2026-01-01T00:00:00Z",
101
- });
102
- await monitor.completeMergedCard(card, [approvedLabel()]);
103
- for (const [, payload] of client.updateCard.mock.calls) {
104
- expect(payload).not.toHaveProperty("description");
105
- }
106
- });
107
- });
@@ -1 +0,0 @@
1
- export {};