mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:46:17 +08:00
fix lgtm review gating and evidence display
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDisplayStatus, getReviewBadges } from "../src/review-badges.js";
|
||||
import { getDisplayStatus, getGateStatus, getReviewBadges } from "../src/review-badges.js";
|
||||
import type { Task } from "../src/types.js";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
@@ -60,6 +60,45 @@ describe("getReviewBadges", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGateStatus", () => {
|
||||
it("reports ready when human sign-off is open", () => {
|
||||
expect(getGateStatus(makeTask({
|
||||
pending_approval: true,
|
||||
metadata: { lgtm_evidence: "ok" },
|
||||
}))).toBe("ready for human sign-off via /lgtm 1");
|
||||
});
|
||||
|
||||
it("reports reviewer failure separately from rejected evidence", () => {
|
||||
expect(getGateStatus(makeTask({
|
||||
metadata: {
|
||||
lgtm_evidence: "ok",
|
||||
robot_review_last_error: "Unexpected token 'a'",
|
||||
},
|
||||
}))).toContain("automatic robot review failed");
|
||||
});
|
||||
|
||||
it("reports rejected robot review when latest review does not accept", () => {
|
||||
expect(getGateStatus(makeTask({
|
||||
metadata: {
|
||||
lgtm_evidence: "ok",
|
||||
robot_reviews: [{
|
||||
iteration: 1,
|
||||
reviewer: "opencode",
|
||||
scope: "task evidence",
|
||||
observations: ["Observed missing output"],
|
||||
blind_spots: "none",
|
||||
accepted: false,
|
||||
evidence_complete: false,
|
||||
evidence_convincing: false,
|
||||
missing_evidence: ["literal output"],
|
||||
submitted_at: "2026-04-17T00:00:00.000Z",
|
||||
mode: "manual",
|
||||
}],
|
||||
},
|
||||
}))).toBe("blocked: latest robot review rejected the evidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplayStatus", () => {
|
||||
it("returns pending for fresh tasks", () => {
|
||||
expect(getDisplayStatus(makeTask())).toBe("pending");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_ROBOT_REVIEW_TIMEOUT_MS,
|
||||
extractFinalAssistantTextFromPiJsonl,
|
||||
extractRobotReviewJson,
|
||||
getPiInvocation,
|
||||
getRobotReviewTimeoutMs,
|
||||
runRobotReviewCommand,
|
||||
@@ -27,6 +28,22 @@ describe("robot review runner helpers", () => {
|
||||
expect(extractFinalAssistantTextFromPiJsonl(output)).toContain("ROBOT_REVIEW_JSON_START");
|
||||
});
|
||||
|
||||
it("parses noisy JSON wrapped in review markers", () => {
|
||||
const output = [
|
||||
"ROBOT_REVIEW_JSON_START",
|
||||
"and here is the JSON you asked for:",
|
||||
"```json",
|
||||
'{"accepted":true,"observations":["ok"]}',
|
||||
"```",
|
||||
"ROBOT_REVIEW_JSON_END",
|
||||
].join("\n");
|
||||
expect(extractRobotReviewJson(output)).toEqual({ accepted: true, observations: ["ok"] });
|
||||
});
|
||||
|
||||
it("includes raw output context on parse failure", () => {
|
||||
expect(() => extractRobotReviewJson("ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END")).toThrow(/Raw output:/);
|
||||
});
|
||||
|
||||
it("uses configured timeout or falls back to default", () => {
|
||||
expect(getRobotReviewTimeoutMs({ PI_LGTM_ROBOT_REVIEW_TIMEOUT_MS: "2500" } as NodeJS.ProcessEnv)).toBe(2500);
|
||||
expect(getRobotReviewTimeoutMs({ PI_LGTM_ROBOT_REVIEW_TIMEOUT_MS: "bad" } as NodeJS.ProcessEnv)).toBe(DEFAULT_ROBOT_REVIEW_TIMEOUT_MS);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendRobotReviewMetadata, getLatestRobotReview, getRobotReviews } from "../src/robot-review.js";
|
||||
import { appendRobotReviewMetadata, getLatestRobotReview, getRobotReviews, shouldOpenHumanSignoffGate } from "../src/robot-review.js";
|
||||
import type { Task } from "../src/types.js";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
@@ -21,6 +21,12 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
}
|
||||
|
||||
describe("robot review helpers", () => {
|
||||
it("reopens the human gate when accepted review exists for stored evidence", () => {
|
||||
expect(shouldOpenHumanSignoffGate(makeTask({ metadata: { lgtm_evidence: "literal output" } }), true)).toBe(true);
|
||||
expect(shouldOpenHumanSignoffGate(makeTask({ metadata: { lgtm_evidence: "literal output" } }), false)).toBe(false);
|
||||
expect(shouldOpenHumanSignoffGate(makeTask(), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads legacy single-review metadata", () => {
|
||||
const task = makeTask({
|
||||
metadata: {
|
||||
|
||||
+25
-26
@@ -34,7 +34,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("creates tasks with optional fields", () => {
|
||||
const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" });
|
||||
|
||||
expect(t.activeForm).toBe("Running task");
|
||||
expect(t.progress_label).toBe("Running task");
|
||||
expect(t.metadata).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
@@ -72,16 +72,16 @@ describe("TaskStore (in-memory)", () => {
|
||||
const { changedFields } = store.update("1", {
|
||||
subject: "Updated subject",
|
||||
description: "Updated desc",
|
||||
owner: "agent-1",
|
||||
metadata: { owner: "agent-1" },
|
||||
});
|
||||
|
||||
expect(changedFields).toContain("subject");
|
||||
expect(changedFields).toContain("description");
|
||||
expect(changedFields).toContain("owner");
|
||||
expect(changedFields).toContain("metadata");
|
||||
|
||||
const task = store.get("1")!;
|
||||
expect(task.subject).toBe("Updated subject");
|
||||
expect(task.owner).toBe("agent-1");
|
||||
expect(task.metadata.owner).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("deletes a task with status: deleted", () => {
|
||||
@@ -114,7 +114,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
const t2 = store.get("2")!;
|
||||
@@ -126,7 +126,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
store.update("2", { add_blocked_by: ["1"] });
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
const t2 = store.get("2")!;
|
||||
@@ -138,8 +138,8 @@ describe("TaskStore (in-memory)", () => {
|
||||
store.create("A", "Desc", "done");
|
||||
store.create("B", "Desc", "done");
|
||||
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
store.update("1", { addBlocks: ["2"] }); // duplicate
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
store.update("1", { add_blocks: ["2"] }); // duplicate
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
expect(t1.blocks.filter(id => id === "2")).toHaveLength(1);
|
||||
@@ -148,7 +148,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("cleans up dependency edges on deletion", () => {
|
||||
store.create("A", "Desc", "done");
|
||||
store.create("B", "Desc", "done");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
|
||||
store.update("1", { status: "deleted" });
|
||||
|
||||
@@ -230,8 +230,8 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("allows circular dependencies with warning", () => {
|
||||
store.create("A", "Desc", "done");
|
||||
store.create("B", "Desc", "done");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
const { warnings } = store.update("2", { addBlocks: ["1"] });
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
const { warnings } = store.update("2", { add_blocks: ["1"] });
|
||||
|
||||
expect(store.get("1")!.blocks).toContain("2");
|
||||
expect(store.get("2")!.blocks).toContain("1");
|
||||
@@ -240,14 +240,14 @@ describe("TaskStore (in-memory)", () => {
|
||||
|
||||
it("allows self-dependency with warning", () => {
|
||||
store.create("Self", "Desc", "done");
|
||||
const { warnings } = store.update("1", { addBlocks: ["1"] });
|
||||
const { warnings } = store.update("1", { add_blocks: ["1"] });
|
||||
expect(store.get("1")!.blocks).toContain("1");
|
||||
expect(warnings).toContain("#1 blocks itself");
|
||||
});
|
||||
|
||||
it("stores dangling edge IDs with warning", () => {
|
||||
store.create("Real", "Desc", "done");
|
||||
const { warnings } = store.update("1", { addBlocks: ["9999"] });
|
||||
const { warnings } = store.update("1", { add_blocks: ["9999"] });
|
||||
expect(store.get("1")!.blocks).toContain("9999");
|
||||
expect(warnings).toContain("#9999 does not exist");
|
||||
});
|
||||
@@ -255,7 +255,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("returns no warnings for valid dependencies", () => {
|
||||
store.create("A", "Desc", "done");
|
||||
store.create("B", "Desc", "done");
|
||||
const { warnings } = store.update("1", { addBlocks: ["2"] });
|
||||
const { warnings } = store.update("1", { add_blocks: ["2"] });
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -264,11 +264,11 @@ describe("TaskStore (in-memory)", () => {
|
||||
expect(t.subject).toBe(" ");
|
||||
});
|
||||
|
||||
it("updates activeForm field", () => {
|
||||
it("updates progress_label field", () => {
|
||||
store.create("Test", "Desc", "done");
|
||||
const { changedFields } = store.update("1", { activeForm: "Running tests" });
|
||||
expect(changedFields).toContain("activeForm");
|
||||
expect(store.get("1")!.activeForm).toBe("Running tests");
|
||||
const { changedFields } = store.update("1", { progress_label: "Running tests" });
|
||||
expect(changedFields).toContain("progress_label");
|
||||
expect(store.get("1")!.progress_label).toBe("Running tests");
|
||||
});
|
||||
|
||||
it("updates description field", () => {
|
||||
@@ -295,9 +295,8 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("clearCompleted cleans up dependency edges", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
createAndApprove(store, "dummy"); // need task 1 to have pending_approval
|
||||
// Actually set pending_approval on task 1
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
// Set pending_approval on task 1 so complete() works via /lgtm path
|
||||
store.update("1", { pending_approval: true });
|
||||
store.complete("1");
|
||||
|
||||
@@ -312,7 +311,7 @@ describe("TaskStore (in-memory)", () => {
|
||||
store.create("B1", "Desc", "done");
|
||||
store.create("B2", "Desc", "done");
|
||||
|
||||
store.update("1", { addBlocks: ["2", "3"] });
|
||||
store.update("1", { add_blocks: ["2", "3"] });
|
||||
|
||||
expect(store.get("1")!.blocks).toEqual(["2", "3"]);
|
||||
expect(store.get("2")!.blockedBy).toContain("1");
|
||||
@@ -321,14 +320,14 @@ describe("TaskStore (in-memory)", () => {
|
||||
|
||||
it("addBlockedBy warns on self-dependency", () => {
|
||||
store.create("Self", "Desc", "done");
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["1"] });
|
||||
const { warnings } = store.update("1", { add_blocked_by: ["1"] });
|
||||
expect(store.get("1")!.blockedBy).toContain("1");
|
||||
expect(warnings).toContain("#1 blocks itself");
|
||||
});
|
||||
|
||||
it("addBlockedBy warns on dangling ref", () => {
|
||||
store.create("Real", "Desc", "done");
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["9999"] });
|
||||
const { warnings } = store.update("1", { add_blocked_by: ["9999"] });
|
||||
expect(store.get("1")!.blockedBy).toContain("9999");
|
||||
expect(warnings).toContain("#9999 does not exist");
|
||||
});
|
||||
@@ -336,8 +335,8 @@ describe("TaskStore (in-memory)", () => {
|
||||
it("addBlockedBy warns on cycle", () => {
|
||||
store.create("A", "Desc", "done");
|
||||
store.create("B", "Desc", "done");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["2"] });
|
||||
store.update("1", { add_blocks: ["2"] });
|
||||
const { warnings } = store.update("1", { add_blocked_by: ["2"] });
|
||||
expect(warnings).toContain("cycle: #1 and #2 block each other");
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("TaskWidget", () => {
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines).toHaveLength(2); // header + 1 task
|
||||
expect(lines).toHaveLength(3); // header + 1 task + done_criterion
|
||||
expect(lines[0]).toContain("1 tasks");
|
||||
expect(lines[0]).toContain("1 open");
|
||||
expect(lines[1]).toContain("◻");
|
||||
@@ -129,18 +129,20 @@ describe("TaskWidget", () => {
|
||||
it("shows blocked-by info for pending tasks", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
store.update("2", { add_blocked_by: ["1"] });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const blockedLine = lines.find(l => l.includes("Blocked"));
|
||||
// blocked-by suffix is only added via dim theme helper, which in mock is identity
|
||||
// So we should see the raw text. Check for the relevant subject line having blocked-by info
|
||||
expect(blockedLine).toContain("blocked by #1");
|
||||
});
|
||||
|
||||
it("hides completed blockers in blocked-by suffix", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
store.update("2", { add_blocked_by: ["1"] });
|
||||
store.update("1", { pending_approval: true });
|
||||
store.complete("1");
|
||||
widget.update();
|
||||
@@ -178,14 +180,14 @@ describe("TaskWidget", () => {
|
||||
|
||||
it("limits visible tasks to MAX_VISIBLE_TASKS", () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
store.create(`Task ${i + 1}`, "Desc");
|
||||
store.create(`Task ${i + 1}`, "Desc", "done");
|
||||
}
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// header + 10 tasks + "… and 5 more"
|
||||
// header + 5 visible tasks (each has 2 lines: task + done_criterion) + "...and 10 more"
|
||||
expect(lines).toHaveLength(12);
|
||||
expect(lines[11]).toContain("5 more");
|
||||
expect(lines[11]).toContain("10 more");
|
||||
});
|
||||
|
||||
it("tracks token usage for active tasks", () => {
|
||||
@@ -244,7 +246,7 @@ describe("TaskWidget", () => {
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Processing A…");
|
||||
expect(lines[2]).toContain("Processing B…");
|
||||
expect(lines[3]).toContain("Processing B…");
|
||||
});
|
||||
|
||||
it("distributes token usage across all active tasks", () => {
|
||||
@@ -260,7 +262,7 @@ describe("TaskWidget", () => {
|
||||
const lines = renderWidget(ui.state);
|
||||
// Both tasks should have the same token counts
|
||||
expect(lines[1]).toContain("↑ 100");
|
||||
expect(lines[2]).toContain("↑ 100");
|
||||
expect(lines[3]).toContain("↑ 100");
|
||||
});
|
||||
|
||||
it("dispose clears widget and timer", () => {
|
||||
|
||||
Reference in New Issue
Block a user