feat: Implement GitLabProvider for issue management using glab CLI
- Add GitLabProvider class for handling issue operations, label management, and MR checks. - Implement methods for ensuring labels, creating issues, listing issues by label, and transitioning labels. - Introduce a provider factory to auto-detect GitLab or GitHub based on the repository URL. - Create project registration tool to validate repositories, create state labels, and log project entries. - Enhance queue status and session health tools to support new session management features. - Update task completion and task creation tools to support auto-chaining and improved session handling. - Refactor task pickup tool to streamline model selection and session management.
This commit is contained in:
230
lib/tools/project-register.ts
Normal file
230
lib/tools/project-register.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* project_register — Register a new project with DevClaw.
|
||||
*
|
||||
* Atomically: validates repo, detects GitHub/GitLab provider, creates all 8 state labels (idempotent),
|
||||
* adds project entry to projects.json, and logs the event.
|
||||
*
|
||||
* Replaces the manual steps of running glab/gh label create + editing projects.json.
|
||||
*/
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
||||
import { resolveRepoPath } from "../gitlab.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
/**
|
||||
* Ensure default role files exist, then copy them into the project's role directory.
|
||||
* Returns true if files were created, false if they already existed.
|
||||
*/
|
||||
async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||
const defaultDir = path.join(workspaceDir, "roles", "default");
|
||||
const projectDir = path.join(workspaceDir, "roles", projectName);
|
||||
|
||||
// Ensure default role files exist
|
||||
await fs.mkdir(defaultDir, { recursive: true });
|
||||
|
||||
const defaultDev = path.join(defaultDir, "dev.md");
|
||||
const defaultQa = path.join(defaultDir, "qa.md");
|
||||
|
||||
try {
|
||||
await fs.access(defaultDev);
|
||||
} catch {
|
||||
await fs.writeFile(defaultDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(defaultQa);
|
||||
} catch {
|
||||
await fs.writeFile(defaultQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||
}
|
||||
|
||||
// Create project-specific role files (copy from default if not exist)
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const projectDev = path.join(projectDir, "dev.md");
|
||||
const projectQa = path.join(projectDir, "qa.md");
|
||||
let created = false;
|
||||
|
||||
try {
|
||||
await fs.access(projectDev);
|
||||
} catch {
|
||||
await fs.copyFile(defaultDev, projectDev);
|
||||
created = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(projectQa);
|
||||
} catch {
|
||||
await fs.copyFile(defaultQa, projectQa);
|
||||
created = true;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
|
||||
|
||||
- Work in a git worktree (never switch branches in the main repo)
|
||||
- Run tests before completing
|
||||
- Create an MR/PR to the base branch and merge it
|
||||
- Clean up the worktree after merging
|
||||
- When done, call task_complete with role "dev", result "done", and a brief summary
|
||||
- If you discover unrelated bugs, call task_create to file them
|
||||
- Do NOT call task_pickup, queue_status, session_health, or project_register
|
||||
`;
|
||||
|
||||
const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
||||
|
||||
- Pull latest from the base branch
|
||||
- Run tests and linting
|
||||
- Verify the changes address the issue requirements
|
||||
- Check for regressions in related functionality
|
||||
- When done, call task_complete with role "qa" and one of:
|
||||
- result "pass" if everything looks good
|
||||
- result "fail" with specific issues if problems found
|
||||
- result "refine" if you need human input to decide
|
||||
- If you discover unrelated bugs, call task_create to file them
|
||||
- Do NOT call task_pickup, queue_status, session_health, or project_register
|
||||
`;
|
||||
|
||||
export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
return (ctx: OpenClawPluginToolContext) => ({
|
||||
name: "project_register",
|
||||
description: `Register a new project with DevClaw. Creates all required state labels (idempotent) and adds the project to projects.json. One-time setup per project. Auto-detects GitHub/GitLab from git remote.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "name", "repo", "groupName", "baseBranch"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID (will be the key in projects.json)",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Short project name (e.g. 'my-webapp')",
|
||||
},
|
||||
repo: {
|
||||
type: "string",
|
||||
description: "Path to git repo (e.g. '~/git/my-project')",
|
||||
},
|
||||
groupName: {
|
||||
type: "string",
|
||||
description: "Telegram group display name (e.g. 'Dev - My Project')",
|
||||
},
|
||||
baseBranch: {
|
||||
type: "string",
|
||||
description: "Base branch for development (e.g. 'development', 'main')",
|
||||
},
|
||||
deployBranch: {
|
||||
type: "string",
|
||||
description: "Branch that triggers deployment. Defaults to baseBranch.",
|
||||
},
|
||||
deployUrl: {
|
||||
type: "string",
|
||||
description: "Deployment URL for the project",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const name = params.name as string;
|
||||
const repo = params.repo as string;
|
||||
const groupName = params.groupName as string;
|
||||
const baseBranch = params.baseBranch as string;
|
||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||
const deployUrl = (params.deployUrl as string) ?? "";
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// 1. Check project not already registered (allow re-register if incomplete)
|
||||
const data = await readProjects(workspaceDir);
|
||||
const existing = data.projects[groupId];
|
||||
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
|
||||
throw new Error(
|
||||
`Project already registered for groupId ${groupId}: "${existing.name}". Use a different group ID or remove the existing entry first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Resolve repo path
|
||||
const repoPath = resolveRepoPath(repo);
|
||||
|
||||
// 3. Create provider and verify it works
|
||||
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
|
||||
const ghPath = (api.pluginConfig as Record<string, unknown>)?.ghPath as string | undefined;
|
||||
const { provider, type: providerType } = createProvider({ glabPath, ghPath, repoPath });
|
||||
|
||||
const healthy = await provider.healthCheck();
|
||||
if (!healthy) {
|
||||
const cliName = providerType === "github" ? "gh" : "glab";
|
||||
const cliInstallUrl = providerType === "github"
|
||||
? "https://cli.github.com"
|
||||
: "https://gitlab.com/gitlab-org/cli";
|
||||
throw new Error(
|
||||
`${providerType.toUpperCase()} health check failed for ${repoPath}. ` +
|
||||
`Detected provider: ${providerType}. ` +
|
||||
`Ensure '${cliName}' CLI is installed, authenticated (${cliName} auth status), ` +
|
||||
`and the repo has a ${providerType.toUpperCase()} remote. ` +
|
||||
`Install ${cliName} from: ${cliInstallUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Create all 8 state labels (idempotent)
|
||||
await provider.ensureAllStateLabels();
|
||||
|
||||
// 5. Add project to projects.json
|
||||
data.projects[groupId] = {
|
||||
name,
|
||||
repo,
|
||||
groupName,
|
||||
deployUrl,
|
||||
baseBranch,
|
||||
deployBranch,
|
||||
autoChain: false,
|
||||
dev: emptyWorkerState(["haiku", "sonnet", "opus"]),
|
||||
qa: emptyWorkerState(["grok"]),
|
||||
};
|
||||
|
||||
await writeProjects(workspaceDir, data);
|
||||
|
||||
// 6. Scaffold role files
|
||||
const rolesCreated = await scaffoldRoleFiles(workspaceDir, name);
|
||||
|
||||
// 7. Audit log
|
||||
await auditLog(workspaceDir, "project_register", {
|
||||
project: name,
|
||||
groupId,
|
||||
repo,
|
||||
baseBranch,
|
||||
deployBranch,
|
||||
deployUrl: deployUrl || null,
|
||||
});
|
||||
|
||||
// 8. Return announcement
|
||||
const rolesNote = rolesCreated ? " Role files scaffolded." : "";
|
||||
const announcement = `📋 Project "${name}" registered for group ${groupName}. Labels created.${rolesNote} Ready for tasks.`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
project: name,
|
||||
groupId,
|
||||
repo,
|
||||
baseBranch,
|
||||
deployBranch,
|
||||
labelsCreated: 8,
|
||||
rolesScaffolded: rolesCreated,
|
||||
announcement,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -63,15 +63,15 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
groupId: pid,
|
||||
dev: {
|
||||
active: project.dev.active,
|
||||
sessionId: project.dev.sessionId,
|
||||
issueId: project.dev.issueId,
|
||||
model: project.dev.model,
|
||||
sessions: project.dev.sessions,
|
||||
},
|
||||
qa: {
|
||||
active: project.qa.active,
|
||||
sessionId: project.qa.sessionId,
|
||||
issueId: project.qa.issueId,
|
||||
model: project.qa.model,
|
||||
sessions: project.qa.sessions,
|
||||
},
|
||||
queue: {
|
||||
toImprove: queue["To Improve"],
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
* session_health — Check and fix session state consistency.
|
||||
*
|
||||
* Detects zombie sessions (active=true but session dead) and stale workers.
|
||||
* Replaces manual HEARTBEAT.md step 1.
|
||||
*
|
||||
* NOTE: This tool checks projects.json state only. The agent should verify
|
||||
* session liveness via sessions_list and pass the results. The tool cannot
|
||||
* call sessions_list directly (it's an agent-level tool).
|
||||
* Checks the sessions map for each worker's current model.
|
||||
*/
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
||||
import { readProjects, updateWorker } from "../projects.js";
|
||||
import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
|
||||
import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
return (ctx: OpenClawPluginToolContext) => ({
|
||||
name: "session_health",
|
||||
description: `Check session state consistency across all projects. Detects: active workers with dead sessions, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`,
|
||||
description: `Check session state consistency across all projects. Detects: active workers with no session in their sessions map, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -53,16 +49,20 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const worker = project[role];
|
||||
const currentSessionKey = worker.model
|
||||
? getSessionForModel(worker, worker.model)
|
||||
: null;
|
||||
|
||||
// Check 1: Active but no sessionId
|
||||
if (worker.active && !worker.sessionId) {
|
||||
// Check 1: Active but no session key for current model
|
||||
if (worker.active && !currentSessionKey) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "active_no_session",
|
||||
severity: "critical",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
message: `${role.toUpperCase()} marked active but has no sessionId`,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
@@ -76,12 +76,12 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// Check 2: Active with sessionId but session is dead (zombie)
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (
|
||||
worker.active &&
|
||||
worker.sessionId &&
|
||||
currentSessionKey &&
|
||||
activeSessions.length > 0 &&
|
||||
!activeSessions.includes(worker.sessionId)
|
||||
!activeSessions.includes(currentSessionKey)
|
||||
) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "zombie_session",
|
||||
@@ -89,8 +89,9 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
sessionId: worker.sessionId,
|
||||
message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`,
|
||||
sessionKey: currentSessionKey,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
@@ -107,9 +108,16 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
issue.labelRevertFailed = true;
|
||||
}
|
||||
|
||||
// Clear the dead session from the sessions map
|
||||
const updatedSessions = { ...worker.sessions };
|
||||
if (worker.model) {
|
||||
updatedSessions[worker.model] = null;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
sessions: updatedSessions,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
@@ -131,7 +139,7 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
groupId,
|
||||
role,
|
||||
hoursActive: Math.round(hoursActive * 10) / 10,
|
||||
sessionId: worker.sessionId,
|
||||
sessionKey: currentSessionKey,
|
||||
issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
|
||||
*
|
||||
* Handles: validation, GitLab label transition, projects.json state update,
|
||||
* issue close/reopen, and audit logging.
|
||||
* Handles: validation, label transition, projects.json state update,
|
||||
* issue close/reopen, audit logging, and optional auto-chaining.
|
||||
*
|
||||
* When project.autoChain is true:
|
||||
* - DEV "done" → automatically dispatches QA (default model: grok)
|
||||
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV model)
|
||||
*/
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
readProjects,
|
||||
getProject,
|
||||
getWorker,
|
||||
getSessionForModel,
|
||||
deactivateWorker,
|
||||
activateWorker,
|
||||
} from "../projects.js";
|
||||
import {
|
||||
getIssue,
|
||||
@@ -20,8 +24,8 @@ import {
|
||||
resolveRepoPath,
|
||||
type StateLabel,
|
||||
} from "../gitlab.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@@ -30,7 +34,7 @@ const execFileAsync = promisify(execFile);
|
||||
export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
return (ctx: OpenClawPluginToolContext) => ({
|
||||
name: "task_complete",
|
||||
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. For QA fail, also prepares DEV session instructions for the fix cycle.`,
|
||||
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix).`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
@@ -101,7 +105,6 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
|
||||
// === DEV DONE ===
|
||||
if (role === "dev" && result === "done") {
|
||||
// Pull latest on the project repo
|
||||
try {
|
||||
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
|
||||
output.gitPull = "success";
|
||||
@@ -109,22 +112,49 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
output.gitPull = `warning: ${(err as Error).message}`;
|
||||
}
|
||||
|
||||
// Deactivate DEV (preserves sessionId, model, startTime)
|
||||
await deactivateWorker(workspaceDir, groupId, "dev");
|
||||
|
||||
// Transition label: Doing → To Test
|
||||
await transitionLabel(issueId, "Doing", "To Test", glabOpts);
|
||||
|
||||
output.labelTransition = "Doing → To Test";
|
||||
output.announcement = `✅ DEV done #${issueId}${summary ? ` — ${summary}` : ""}. Moved to QA queue.`;
|
||||
|
||||
if (project.autoChain) {
|
||||
try {
|
||||
const issue = await getIssue(issueId, glabOpts);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role: "qa",
|
||||
modelAlias: "grok",
|
||||
fromLabel: "To Test",
|
||||
toLabel: "Testing",
|
||||
transitionLabel: (id, from, to) =>
|
||||
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
|
||||
});
|
||||
output.autoChain = {
|
||||
dispatched: true,
|
||||
role: "qa",
|
||||
model: chainResult.modelAlias,
|
||||
sessionAction: chainResult.sessionAction,
|
||||
announcement: chainResult.announcement,
|
||||
};
|
||||
} catch (err) {
|
||||
output.autoChain = { dispatched: false, error: (err as Error).message };
|
||||
}
|
||||
} else {
|
||||
output.nextAction = "qa_pickup";
|
||||
}
|
||||
}
|
||||
|
||||
// === QA PASS ===
|
||||
if (role === "qa" && result === "pass") {
|
||||
// Deactivate QA
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
|
||||
// Transition label: Testing → Done, close issue
|
||||
await transitionLabel(issueId, "Testing", "Done", glabOpts);
|
||||
await closeIssue(issueId, glabOpts);
|
||||
|
||||
@@ -135,44 +165,57 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
|
||||
// === QA FAIL ===
|
||||
if (role === "qa" && result === "fail") {
|
||||
// Deactivate QA
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
|
||||
// Transition label: Testing → To Improve, reopen issue
|
||||
await transitionLabel(issueId, "Testing", "To Improve", glabOpts);
|
||||
await reopenIssue(issueId, glabOpts);
|
||||
|
||||
// Prepare DEV fix cycle
|
||||
const issue = await getIssue(issueId, glabOpts);
|
||||
const devModel = selectModel(issue.title, issue.description ?? "", "dev");
|
||||
const devWorker = getWorker(project, "dev");
|
||||
const devModel = devWorker.model;
|
||||
const devSessionKey = devModel ? getSessionForModel(devWorker, devModel) : null;
|
||||
|
||||
output.labelTransition = "Testing → To Improve";
|
||||
output.issueReopened = true;
|
||||
output.announcement = `❌ QA FAIL #${issueId}${summary ? ` — ${summary}` : ""}. Sent back to DEV.`;
|
||||
output.devSessionAvailable = !!devSessionKey;
|
||||
if (devModel) output.devModel = devModel;
|
||||
|
||||
// If DEV session exists, prepare reuse instructions
|
||||
if (devWorker.sessionId) {
|
||||
output.devFixInstructions =
|
||||
`Send QA feedback to existing DEV session ${devWorker.sessionId}. ` +
|
||||
`If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` +
|
||||
`Then sessions_send with QA failure details. ` +
|
||||
`DEV will pick up from To Improve → Doing automatically.`;
|
||||
output.devSessionId = devWorker.sessionId;
|
||||
output.devModel = devModel.alias;
|
||||
if (project.autoChain && devModel) {
|
||||
try {
|
||||
const issue = await getIssue(issueId, glabOpts);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role: "dev",
|
||||
modelAlias: devModel,
|
||||
fromLabel: "To Improve",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) =>
|
||||
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
|
||||
});
|
||||
output.autoChain = {
|
||||
dispatched: true,
|
||||
role: "dev",
|
||||
model: chainResult.modelAlias,
|
||||
sessionAction: chainResult.sessionAction,
|
||||
announcement: chainResult.announcement,
|
||||
};
|
||||
} catch (err) {
|
||||
output.autoChain = { dispatched: false, error: (err as Error).message };
|
||||
}
|
||||
} else {
|
||||
output.devFixInstructions =
|
||||
`No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`;
|
||||
output.devModel = devModel.alias;
|
||||
output.nextAction = "dev_fix";
|
||||
}
|
||||
}
|
||||
|
||||
// === QA REFINE ===
|
||||
if (role === "qa" && result === "refine") {
|
||||
// Deactivate QA
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
|
||||
// Transition label: Testing → Refining
|
||||
await transitionLabel(issueId, "Testing", "Refining", glabOpts);
|
||||
|
||||
output.labelTransition = "Testing → Refining";
|
||||
@@ -188,6 +231,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
result,
|
||||
summary: summary ?? null,
|
||||
labelTransition: output.labelTransition,
|
||||
autoChain: output.autoChain ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
143
lib/tools/task-create.ts
Normal file
143
lib/tools/task-create.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* task_create — Create a new task (issue) in the project's issue tracker.
|
||||
*
|
||||
* Atomically: creates an issue with the specified title, description, and label.
|
||||
* Returns the created issue for immediate pickup if desired.
|
||||
*
|
||||
* Use this when:
|
||||
* - You want to create work items from chat
|
||||
* - A sub-agent finds a bug and needs to file a follow-up issue
|
||||
* - Breaking down an epic into smaller tasks
|
||||
*/
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
||||
import { readProjects, resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../issue-provider.js";
|
||||
|
||||
const STATE_LABELS: StateLabel[] = [
|
||||
"Planning",
|
||||
"To Do",
|
||||
"Doing",
|
||||
"To Test",
|
||||
"Testing",
|
||||
"Done",
|
||||
"To Improve",
|
||||
"Refining",
|
||||
];
|
||||
|
||||
export function createTaskCreateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: OpenClawPluginToolContext) => ({
|
||||
name: "task_create",
|
||||
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
|
||||
|
||||
Examples:
|
||||
- Simple: { title: "Fix login bug" }
|
||||
- With body: { title: "Add dark mode", description: "## Why\nUsers want dark mode...\n\n## Acceptance Criteria\n- [ ] Toggle in settings" }
|
||||
- Ready for dev: { title: "Implement auth", description: "...", label: "To Do", pickup: true }
|
||||
|
||||
The issue is created with a state label (defaults to "Planning"). Returns the created issue for immediate pickup.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "title"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID for the project",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short, descriptive issue title (e.g., 'Fix login timeout bug')",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Full issue body in markdown. Use for detailed context, acceptance criteria, reproduction steps, links. Supports GitHub-flavored markdown.",
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
description: `State label for the issue. One of: ${STATE_LABELS.join(", ")}. Defaults to "Planning".`,
|
||||
enum: STATE_LABELS,
|
||||
},
|
||||
assignees: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "GitHub/GitLab usernames to assign (optional)",
|
||||
},
|
||||
pickup: {
|
||||
type: "boolean",
|
||||
description: "If true, immediately pick up this issue for DEV after creation. Defaults to false.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const title = params.title as string;
|
||||
const description = (params.description as string) ?? "";
|
||||
const label = (params.label as StateLabel) ?? "Planning";
|
||||
const assignees = (params.assignees as string[] | undefined) ?? [];
|
||||
const pickup = (params.pickup as boolean) ?? false;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// 1. Resolve project
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[groupId];
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for groupId ${groupId}. Run project_register first.`);
|
||||
}
|
||||
|
||||
// 2. Create provider
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
const config = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const { provider, type: providerType } = createProvider({
|
||||
glabPath: config?.glabPath as string | undefined,
|
||||
ghPath: config?.ghPath as string | undefined,
|
||||
repoPath,
|
||||
});
|
||||
|
||||
// 3. Create the issue
|
||||
const issue = await provider.createIssue(title, description, label, assignees);
|
||||
|
||||
// 4. Audit log
|
||||
await auditLog(workspaceDir, "task_create", {
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
title,
|
||||
label,
|
||||
provider: providerType,
|
||||
pickup,
|
||||
});
|
||||
|
||||
// 5. Build response
|
||||
const hasBody = description && description.trim().length > 0;
|
||||
const result = {
|
||||
success: true,
|
||||
issue: {
|
||||
id: issue.iid,
|
||||
title: issue.title,
|
||||
body: hasBody ? description : null,
|
||||
url: issue.web_url,
|
||||
label,
|
||||
},
|
||||
project: project.name,
|
||||
provider: providerType,
|
||||
pickup,
|
||||
announcement: pickup
|
||||
? `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Picking up for DEV...`
|
||||
: `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Ready for pickup when needed.`,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
/**
|
||||
* task_pickup — Atomically pick up a task from the GitLab queue.
|
||||
* task_pickup — Atomically pick up a task from the issue queue.
|
||||
*
|
||||
* Handles: validation, model selection, GitLab label transition,
|
||||
* projects.json state update, and audit logging.
|
||||
* Handles: validation, model selection, then delegates to dispatchTask()
|
||||
* for label transition, session creation/reuse, task dispatch, state update,
|
||||
* and audit logging.
|
||||
*
|
||||
* Returns structured instructions for the agent to spawn/send a session.
|
||||
* Model selection is LLM-based: the orchestrator passes a `model` param.
|
||||
* A keyword heuristic is used as fallback if no model is specified.
|
||||
*/
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
readProjects,
|
||||
getProject,
|
||||
getWorker,
|
||||
activateWorker,
|
||||
} from "../projects.js";
|
||||
import { readProjects, getProject, getWorker } from "../projects.js";
|
||||
import {
|
||||
getIssue,
|
||||
getCurrentStateLabel,
|
||||
@@ -21,25 +18,25 @@ import {
|
||||
type StateLabel,
|
||||
} from "../gitlab.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
|
||||
export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: OpenClawPluginToolContext) => ({
|
||||
name: "task_pickup",
|
||||
description: `Pick up a task from the GitLab queue for a DEV or QA worker. Atomically handles: label transition, model selection, projects.json update, and audit logging. Returns session action instructions (spawn or send) for the agent to execute.`,
|
||||
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, model selection, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate model. Returns an announcement for the agent to post — no further session actions needed.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["issueId", "role", "projectGroupId"],
|
||||
properties: {
|
||||
issueId: { type: "number", description: "GitLab issue ID to pick up" },
|
||||
issueId: { type: "number", description: "Issue ID to pick up" },
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" },
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
|
||||
},
|
||||
modelOverride: {
|
||||
model: {
|
||||
type: "string",
|
||||
description: "Force a specific model alias (e.g. haiku, sonnet, opus, grok). Overrides automatic selection.",
|
||||
description: "Model alias to use (e.g. haiku, sonnet, opus, grok). The orchestrator should analyze the issue complexity and choose. Falls back to keyword heuristic if omitted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -48,7 +45,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
const issueId = params.issueId as number;
|
||||
const role = params.role as "dev" | "qa";
|
||||
const groupId = params.projectGroupId as string;
|
||||
const modelOverride = params.modelOverride as string | undefined;
|
||||
const modelParam = params.model as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
@@ -68,11 +65,11 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
throw new Error(
|
||||
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}, session: ${worker.sessionId}). Complete current task first.`,
|
||||
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}). Complete current task first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Fetch issue from GitLab and verify state
|
||||
// 3. Fetch issue and verify state
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
const glabOpts = {
|
||||
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
|
||||
@@ -82,7 +79,6 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
const issue = await getIssue(issueId, glabOpts);
|
||||
const currentLabel = getCurrentStateLabel(issue);
|
||||
|
||||
// Validate label matches expected state for the role
|
||||
const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"];
|
||||
const validLabelsForQa: StateLabel[] = ["To Test"];
|
||||
const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa;
|
||||
@@ -95,70 +91,40 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
|
||||
// 4. Select model
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let selectedModel = selectModel(issue.title, issue.description ?? "", role);
|
||||
if (modelOverride) {
|
||||
selectedModel = {
|
||||
model: modelOverride,
|
||||
alias: modelOverride,
|
||||
reason: `User override: ${modelOverride}`,
|
||||
};
|
||||
}
|
||||
let modelAlias: string;
|
||||
let modelReason: string;
|
||||
let modelSource: string;
|
||||
|
||||
// 5. Determine session action (spawn vs reuse)
|
||||
const existingSessionId = worker.sessionId;
|
||||
const sessionAction = existingSessionId ? "send" : "spawn";
|
||||
|
||||
// 6. Transition GitLab label
|
||||
await transitionLabel(issueId, currentLabel, targetLabel, glabOpts);
|
||||
|
||||
// 7. Update projects.json
|
||||
const now = new Date().toISOString();
|
||||
if (sessionAction === "spawn") {
|
||||
// New spawn — agent will provide sessionId after spawning
|
||||
await activateWorker(workspaceDir, groupId, role, {
|
||||
issueId: String(issueId),
|
||||
model: selectedModel.alias,
|
||||
startTime: now,
|
||||
});
|
||||
if (modelParam) {
|
||||
modelAlias = modelParam;
|
||||
modelReason = "LLM-selected by orchestrator";
|
||||
modelSource = "llm";
|
||||
} else {
|
||||
// Reuse existing session — preserve sessionId and startTime
|
||||
await activateWorker(workspaceDir, groupId, role, {
|
||||
issueId: String(issueId),
|
||||
model: selectedModel.alias,
|
||||
});
|
||||
const selected = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = selected.alias;
|
||||
modelReason = selected.reason;
|
||||
modelSource = "heuristic";
|
||||
}
|
||||
|
||||
// 8. Audit log
|
||||
await auditLog(workspaceDir, "task_pickup", {
|
||||
project: project.name,
|
||||
// 5. Dispatch via shared logic
|
||||
const dispatchResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
issue: issueId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
model: selectedModel.alias,
|
||||
modelReason: selectedModel.reason,
|
||||
sessionAction,
|
||||
sessionId: existingSessionId,
|
||||
labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelAlias,
|
||||
fromLabel: currentLabel,
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) =>
|
||||
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
|
||||
});
|
||||
|
||||
await auditLog(workspaceDir, "model_selection", {
|
||||
issue: issueId,
|
||||
role,
|
||||
selected: selectedModel.alias,
|
||||
fullModel: selectedModel.model,
|
||||
reason: selectedModel.reason,
|
||||
override: modelOverride ?? null,
|
||||
});
|
||||
|
||||
// 9. Build announcement and session instructions
|
||||
const emoji = role === "dev"
|
||||
? (selectedModel.alias === "haiku" ? "⚡" : selectedModel.alias === "opus" ? "🧠" : "🔧")
|
||||
: "🔍";
|
||||
|
||||
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${selectedModel.alias}) for #${issueId}: ${issue.title}`;
|
||||
|
||||
// 6. Build result
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
@@ -166,26 +132,17 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: selectedModel.alias,
|
||||
fullModel: selectedModel.model,
|
||||
modelReason: selectedModel.reason,
|
||||
sessionAction,
|
||||
announcement,
|
||||
model: dispatchResult.modelAlias,
|
||||
fullModel: dispatchResult.fullModel,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
announcement: dispatchResult.announcement,
|
||||
labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelReason,
|
||||
modelSource,
|
||||
};
|
||||
|
||||
if (sessionAction === "send") {
|
||||
result.sessionId = existingSessionId;
|
||||
result.instructions =
|
||||
`Session reuse: send new task to existing session ${existingSessionId}. ` +
|
||||
`If model "${selectedModel.alias}" differs from current session model, call sessions.patch first to update the model. ` +
|
||||
`Then call sessions_send with the task description. ` +
|
||||
`After spawning/sending, update projects.json sessionId if it changed.`;
|
||||
if (dispatchResult.sessionAction === "send") {
|
||||
result.tokensSavedEstimate = "~50K (session reuse)";
|
||||
} else {
|
||||
result.instructions =
|
||||
`New session: call sessions_spawn with model "${selectedModel.model}" for this ${role.toUpperCase()} task. ` +
|
||||
`After spawn completes, call task_pickup_confirm with the returned sessionId to update projects.json.`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user