feat: enhance review process and role management
- Refactor reviewPass function to identify states with review checks instead of specific review types. - Introduce review policies (HUMAN, AGENT, AUTO) to control PR review processes based on developer levels. - Update projectTick to handle review policies and step routing labels for reviewers and testers. - Add detailed reviewer instructions to templates for clarity on review responsibilities. - Implement role:level label management, allowing dynamic creation of labels based on project configuration. - Enhance task_update tool to support state and level updates, ensuring at least one parameter is provided. - Update work_finish tool to include reviewer actions (approve, reject) in task completion. - Modify work_start tool to utilize role-level detection for better level assignment. - Add tests for new functionalities, including review routing and level detection from labels.
This commit is contained in:
@@ -15,7 +15,8 @@ import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { ExecutionMode } from "../workflow.js";
|
||||
import { ExecutionMode, getRoleLabels } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
|
||||
@@ -141,6 +142,13 @@ export function createProjectRegisterTool() {
|
||||
// 4. Create all state labels (idempotent)
|
||||
await provider.ensureAllStateLabels();
|
||||
|
||||
// 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip)
|
||||
const resolvedConfig = await loadConfig(workspaceDir, name);
|
||||
const roleLabels = getRoleLabels(resolvedConfig.roles);
|
||||
for (const { name: labelName, color } of roleLabels) {
|
||||
await provider.ensureLabel(labelName, color);
|
||||
}
|
||||
|
||||
// 5. Add project to projects.json
|
||||
// Build workers map from all registered roles
|
||||
const workers: Record<string, import("../projects.js").WorkerState> = {};
|
||||
|
||||
@@ -1,64 +1,133 @@
|
||||
/**
|
||||
* Integration test for task_update tool.
|
||||
* Tests for task_update tool — state transitions and level overrides.
|
||||
*
|
||||
* Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts
|
||||
* Run: npx tsx --test lib/tools/task-update.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js";
|
||||
import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js";
|
||||
|
||||
describe("task_update tool", () => {
|
||||
it("has correct schema", () => {
|
||||
// Verify the tool signature matches requirements
|
||||
const requiredParams = ["projectGroupId", "issueId", "state"];
|
||||
const optionalParams = ["reason"];
|
||||
|
||||
// Schema validation would go here in a real test
|
||||
assert.ok(true, "Schema structure is valid");
|
||||
// state is now optional — at least one of state or level required
|
||||
const requiredParams = ["projectGroupId", "issueId"];
|
||||
assert.strictEqual(requiredParams.length, 2);
|
||||
});
|
||||
|
||||
it("supports all state labels", () => {
|
||||
const validStates = [
|
||||
"Planning",
|
||||
"To Do",
|
||||
"Doing",
|
||||
"To Test",
|
||||
"Testing",
|
||||
"Done",
|
||||
"To Improve",
|
||||
"Refining",
|
||||
"In Review",
|
||||
];
|
||||
|
||||
// In a real test, we'd verify these against the tool's enum
|
||||
assert.strictEqual(validStates.length, 9);
|
||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||
assert.strictEqual(labels.length, 12);
|
||||
assert.ok(labels.includes("Planning"));
|
||||
assert.ok(labels.includes("Done"));
|
||||
assert.ok(labels.includes("To Review"));
|
||||
});
|
||||
|
||||
it("validates required parameters", () => {
|
||||
// Test cases:
|
||||
// - Missing projectGroupId → Error
|
||||
// - Missing issueId → Error
|
||||
// - Missing state → Error
|
||||
// - Invalid state → Error
|
||||
// - Valid params → Success
|
||||
// At least one of state or level required
|
||||
assert.ok(true, "Parameter validation works");
|
||||
});
|
||||
|
||||
it("handles same-state transitions gracefully", () => {
|
||||
// When current state === new state, should return success without changes
|
||||
assert.ok(true, "No-op transitions handled correctly");
|
||||
});
|
||||
|
||||
it("logs to audit trail", () => {
|
||||
// Verify auditLog is called with correct parameters
|
||||
assert.ok(true, "Audit logging works");
|
||||
});
|
||||
});
|
||||
|
||||
// Test scenarios for manual verification:
|
||||
// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" })
|
||||
// → Should transition from "To Do" to "Planning"
|
||||
// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" })
|
||||
// → Should log reason in audit trail
|
||||
// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" })
|
||||
// → Should transition back from "Planning" to "To Do"
|
||||
describe("detectLevelFromLabels — colon format", () => {
|
||||
it("should detect level from colon-format labels", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior");
|
||||
assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior");
|
||||
assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior");
|
||||
});
|
||||
|
||||
it("should prioritize colon format over dot format", () => {
|
||||
// Colon format should win since it's checked first
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to dot format", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to plain level name", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should return null when no level found", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRoleLevelFromLabels", () => {
|
||||
it("should detect role and level from colon-format labels", () => {
|
||||
const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]);
|
||||
assert.deepStrictEqual(result, { role: "developer", level: "senior" });
|
||||
});
|
||||
|
||||
it("should detect tester role", () => {
|
||||
const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]);
|
||||
assert.deepStrictEqual(result, { role: "tester", level: "medior" });
|
||||
});
|
||||
|
||||
it("should return null for step routing labels", () => {
|
||||
// review:human is a step routing label, not a role:level label
|
||||
const result = detectRoleLevelFromLabels(["review:human", "Doing"]);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it("should return null when no colon labels present", () => {
|
||||
assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStepRouting", () => {
|
||||
it("should detect review:human", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human");
|
||||
});
|
||||
|
||||
it("should detect review:agent", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent");
|
||||
});
|
||||
|
||||
it("should detect review:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip");
|
||||
});
|
||||
|
||||
it("should detect test:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip");
|
||||
});
|
||||
|
||||
it("should return null when no matching step label", () => {
|
||||
assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReviewRouting", () => {
|
||||
it("should return review:human for HUMAN policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AGENT policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent");
|
||||
});
|
||||
|
||||
it("should return review:human for AUTO + senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AUTO + non-senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,27 +11,30 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
|
||||
export function createTaskUpdateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_update",
|
||||
label: "Task Update",
|
||||
description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow.
|
||||
description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level.
|
||||
|
||||
Use cases:
|
||||
- Orchestrator or worker needs to change state manually
|
||||
- Manual status adjustments (e.g., Planning → To Do after approval)
|
||||
- Override the assigned level (e.g., escalate to senior for human review)
|
||||
- Force human review via level change
|
||||
- Failed auto-transitions that need correction
|
||||
- Bulk state changes
|
||||
|
||||
Examples:
|
||||
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`,
|
||||
- State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" }
|
||||
- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "issueId", "state"],
|
||||
required: ["projectGroupId", "issueId"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
@@ -46,9 +49,13 @@ Examples:
|
||||
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
|
||||
},
|
||||
level: {
|
||||
type: "string",
|
||||
description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Optional audit log reason for the state change",
|
||||
description: "Optional audit log reason for the change",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -56,41 +63,86 @@ Examples:
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const issueId = params.issueId as number;
|
||||
const newState = params.state as StateLabel;
|
||||
const newState = (params.state as StateLabel) ?? undefined;
|
||||
const newLevel = (params.level as string) ?? undefined;
|
||||
const reason = (params.reason as string) ?? undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
if (!newState && !newLevel) {
|
||||
throw new Error("At least one of 'state' or 'level' must be provided.");
|
||||
}
|
||||
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider, type: providerType } = await resolveProvider(project);
|
||||
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const currentState = provider.getCurrentStateLabel(issue);
|
||||
if (!currentState) {
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`);
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`);
|
||||
}
|
||||
|
||||
if (currentState === newState) {
|
||||
return jsonResult({
|
||||
success: true, issueId, state: newState, changed: false,
|
||||
message: `Issue #${issueId} is already in state "${newState}".`,
|
||||
project: project.name, provider: providerType,
|
||||
});
|
||||
let stateChanged = false;
|
||||
let levelChanged = false;
|
||||
let fromLevel: string | undefined;
|
||||
|
||||
// Handle state transition
|
||||
if (newState && currentState !== newState) {
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
// Handle level override
|
||||
if (newLevel) {
|
||||
// Detect role from current (or new) state label
|
||||
const effectiveState = newState ?? currentState;
|
||||
const workflow = (await loadConfig(workspaceDir, project.name)).workflow;
|
||||
const stateConfig = findStateByLabel(workflow, effectiveState);
|
||||
const role = stateConfig?.role;
|
||||
if (!role) {
|
||||
throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`);
|
||||
}
|
||||
|
||||
// Validate level exists for role
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const roleConfig = resolvedConfig.roles[role];
|
||||
if (!roleConfig || !roleConfig.levels.includes(newLevel)) {
|
||||
throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`);
|
||||
}
|
||||
|
||||
// Remove old role:* labels, add new role:level
|
||||
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||
fromLevel = oldRoleLabels[0]?.split(":")[1];
|
||||
if (oldRoleLabels.length > 0) {
|
||||
await provider.removeLabels(issueId, oldRoleLabels);
|
||||
}
|
||||
await provider.addLabel(issueId, `${role}:${newLevel}`);
|
||||
levelChanged = fromLevel !== newLevel;
|
||||
}
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "task_update", {
|
||||
project: project.name, groupId, issueId,
|
||||
fromState: currentState, toState: newState,
|
||||
...(stateChanged ? { fromState: currentState, toState: newState } : {}),
|
||||
...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}),
|
||||
reason: reason ?? null, provider: providerType,
|
||||
});
|
||||
|
||||
// Build announcement
|
||||
const parts: string[] = [];
|
||||
if (stateChanged) parts.push(`"${currentState}" → "${newState}"`);
|
||||
if (levelChanged) parts.push(`level: ${fromLevel ?? "none"} → ${newLevel}`);
|
||||
const changeDesc = parts.join(", ");
|
||||
|
||||
return jsonResult({
|
||||
success: true, issueId, issueTitle: issue.title,
|
||||
state: newState, changed: true,
|
||||
labelTransition: `${currentState} → ${newState}`,
|
||||
...(newState ? { state: newState } : {}),
|
||||
...(newLevel ? { level: newLevel } : {}),
|
||||
changed: stateChanged || levelChanged,
|
||||
...(stateChanged ? { labelTransition: `${currentState} → ${newState}` } : {}),
|
||||
project: project.name, provider: providerType,
|
||||
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
|
||||
announcement: stateChanged || levelChanged
|
||||
? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}`
|
||||
: `Issue #${issueId} is already in the requested state.`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,13 +18,13 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_finish",
|
||||
label: "Work Finish",
|
||||
description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
||||
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
properties: {
|
||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
|
||||
projectGroupId: { type: "string", description: "Project group ID" },
|
||||
summary: { type: "string", description: "Brief summary" },
|
||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { StateLabel } from "../providers/provider.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js";
|
||||
import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
|
||||
|
||||
@@ -81,20 +81,16 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
// Get target label from workflow
|
||||
const targetLabel = getActiveLabel(workflow, role);
|
||||
|
||||
// Select level
|
||||
// Select level: LLM param → own role label → inherit other role label → heuristic
|
||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||
if (levelParam) {
|
||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||
} else {
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
if (!isLevelForRole(labelLevel, role)) {
|
||||
// Label level belongs to a different role — use heuristic for this role
|
||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||
selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override";
|
||||
} else {
|
||||
selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label";
|
||||
}
|
||||
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||
if (roleLevel?.role === role) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label";
|
||||
} else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited";
|
||||
} else {
|
||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
||||
|
||||
Reference in New Issue
Block a user