feat: abstract GitLab/GitHub CLI usage (#10)

- Move resolveRepoPath to lib/utils.ts
- Update all tools to use createProvider() from lib/providers/
- Remove direct imports from lib/gitlab.ts
- Mark lib/gitlab.ts as deprecated
- All tools now work with both GitHub (gh CLI) and GitLab (glab CLI)
- Provider auto-detected from git remote URL
This commit is contained in:
Lauren ten Hoor
2026-02-09 22:19:43 +08:00
parent bbef2970d1
commit 3197f442d2
8 changed files with 67 additions and 46 deletions

View File

@@ -1,4 +1,7 @@
/** /**
* @deprecated This module is deprecated and kept only for reference.
* Use lib/providers/index.ts with createProvider() for GitLab/GitHub abstraction.
*
* GitLab wrapper using glab CLI. * GitLab wrapper using glab CLI.
* Handles label transitions, issue fetching, and MR verification. * Handles label transitions, issue fetching, and MR verification.
*/ */

View File

@@ -12,7 +12,7 @@ import type { ToolContext } from "../types.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js"; import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js"; import { resolveRepoPath } from "../utils.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { DEV_TIERS, QA_TIERS } from "../tiers.js"; import { DEV_TIERS, QA_TIERS } from "../tiers.js";

View File

@@ -7,9 +7,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects, getProject } from "../projects.js"; import { readProjects, getProject } from "../projects.js";
import { listIssuesByLabel, resolveRepoPath, type StateLabel } from "../gitlab.js"; import { type StateLabel } from "../issue-provider.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { detectContext, generateGuardrails } from "../context-guard.js"; import { detectContext, generateGuardrails } from "../context-guard.js";
import { resolveRepoPath } from "../utils.js";
export function createQueueStatusTool(api: OpenClawPluginApi) { export function createQueueStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -61,7 +63,6 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
? [groupId] ? [groupId]
: Object.keys(data.projects); : Object.keys(data.projects);
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const projects: Array<Record<string, unknown>> = []; const projects: Array<Record<string, unknown>> = [];
for (const pid of projectIds) { for (const pid of projectIds) {
@@ -69,15 +70,19 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
if (!project) continue; if (!project) continue;
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const glabOpts = { glabPath, repoPath }; const { provider } = createProvider({
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
ghPath: (api.pluginConfig as Record<string, unknown>)?.ghPath as string | undefined,
repoPath,
});
// Fetch queue counts from GitLab // Fetch queue counts from issue tracker
const queueLabels: StateLabel[] = ["To Improve", "To Test", "To Do"]; const queueLabels: StateLabel[] = ["To Improve", "To Test", "To Do"];
const queue: Record<string, Array<{ id: number; title: string }>> = {}; const queue: Record<string, Array<{ id: number; title: string }>> = {};
for (const label of queueLabels) { for (const label of queueLabels) {
try { try {
const issues = await listIssuesByLabel(label, glabOpts); const issues = await provider.listIssuesByLabel(label);
queue[label] = issues.map((i) => ({ id: i.iid, title: i.title })); queue[label] = issues.map((i) => ({ id: i.iid, title: i.title }));
} catch { } catch {
queue[label] = []; queue[label] = [];

View File

@@ -8,8 +8,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects, updateWorker, getSessionForModel } from "../projects.js"; import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js"; import { type StateLabel } from "../issue-provider.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { resolveRepoPath } from "../utils.js";
export function createSessionHealthTool(api: OpenClawPluginApi) { export function createSessionHealthTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -41,14 +43,17 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
} }
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const issues: Array<Record<string, unknown>> = []; const issues: Array<Record<string, unknown>> = [];
let fixesApplied = 0; let fixesApplied = 0;
for (const [groupId, project] of Object.entries(data.projects)) { for (const [groupId, project] of Object.entries(data.projects)) {
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const glabOpts = { glabPath, repoPath }; const { provider } = createProvider({
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
ghPath: (api.pluginConfig as Record<string, unknown>)?.ghPath as string | undefined,
repoPath,
});
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {
const worker = project[role]; const worker = project[role];
@@ -98,13 +103,13 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
}; };
if (autoFix) { if (autoFix) {
// Revert GitLab label // Revert issue label
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
try { try {
if (worker.issueId) { if (worker.issueId) {
const primaryIssueId = Number(worker.issueId.split(",")[0]); const primaryIssueId = Number(worker.issueId.split(",")[0]);
await transitionLabel(primaryIssueId, currentLabel, revertLabel, glabOpts); await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
issue.labelReverted = `${currentLabel}${revertLabel}`; issue.labelReverted = `${currentLabel}${revertLabel}`;
} }
} catch { } catch {

View File

@@ -14,14 +14,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { import { type StateLabel } from "../issue-provider.js";
closeIssue, import { createProvider } from "../providers/index.js";
getIssue,
reopenIssue,
resolveRepoPath,
transitionLabel,
type StateLabel,
} from "../gitlab.js";
import { import {
deactivateWorker, deactivateWorker,
getProject, getProject,
@@ -30,6 +24,7 @@ import {
readProjects, readProjects,
} from "../projects.js"; } from "../projects.js";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { resolveRepoPath } from "../utils.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -111,12 +106,15 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
} }
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const glabOpts = { const { provider } = createProvider({
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as
| string | string
| undefined, | undefined,
ghPath: (api.pluginConfig as Record<string, unknown>)?.ghPath as
| string
| undefined,
repoPath, repoPath,
}; });
const output: Record<string, unknown> = { const output: Record<string, unknown> = {
success: true, success: true,
@@ -140,7 +138,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
} }
await deactivateWorker(workspaceDir, groupId, "dev"); await deactivateWorker(workspaceDir, groupId, "dev");
await transitionLabel(issueId, "Doing", "To Test", glabOpts); await provider.transitionLabel(issueId, "Doing", "To Test");
output.labelTransition = "Doing → To Test"; output.labelTransition = "Doing → To Test";
output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`; output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`;
@@ -150,7 +148,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
const pluginConfig = api.pluginConfig as const pluginConfig = api.pluginConfig as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
const issue = await getIssue(issueId, glabOpts); const issue = await provider.getIssue(issueId);
const chainResult = await dispatchTask({ const chainResult = await dispatchTask({
workspaceDir, workspaceDir,
agentId: ctx.agentId, agentId: ctx.agentId,
@@ -165,11 +163,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
fromLabel: "To Test", fromLabel: "To Test",
toLabel: "Testing", toLabel: "Testing",
transitionLabel: (id, from, to) => transitionLabel: (id, from, to) =>
transitionLabel( provider.transitionLabel(
id, id,
from as StateLabel, from as StateLabel,
to as StateLabel, to as StateLabel,
glabOpts,
), ),
pluginConfig, pluginConfig,
}); });
@@ -194,8 +191,8 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === QA PASS === // === QA PASS ===
if (role === "qa" && result === "pass") { if (role === "qa" && result === "pass") {
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
await transitionLabel(issueId, "Testing", "Done", glabOpts); await provider.transitionLabel(issueId, "Testing", "Done");
await closeIssue(issueId, glabOpts); await provider.closeIssue(issueId);
output.labelTransition = "Testing → Done"; output.labelTransition = "Testing → Done";
output.issueClosed = true; output.issueClosed = true;
@@ -205,8 +202,8 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === QA FAIL === // === QA FAIL ===
if (role === "qa" && result === "fail") { if (role === "qa" && result === "fail") {
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
await transitionLabel(issueId, "Testing", "To Improve", glabOpts); await provider.transitionLabel(issueId, "Testing", "To Improve");
await reopenIssue(issueId, glabOpts); await provider.reopenIssue(issueId);
const devWorker = getWorker(project, "dev"); const devWorker = getWorker(project, "dev");
const devModel = devWorker.model; const devModel = devWorker.model;
@@ -225,7 +222,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
const pluginConfig = api.pluginConfig as const pluginConfig = api.pluginConfig as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
const issue = await getIssue(issueId, glabOpts); const issue = await provider.getIssue(issueId);
const chainResult = await dispatchTask({ const chainResult = await dispatchTask({
workspaceDir, workspaceDir,
agentId: ctx.agentId, agentId: ctx.agentId,
@@ -240,11 +237,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
fromLabel: "To Improve", fromLabel: "To Improve",
toLabel: "Doing", toLabel: "Doing",
transitionLabel: (id, from, to) => transitionLabel: (id, from, to) =>
transitionLabel( provider.transitionLabel(
id, id,
from as StateLabel, from as StateLabel,
to as StateLabel, to as StateLabel,
glabOpts,
), ),
pluginConfig, pluginConfig,
}); });
@@ -269,7 +265,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === QA REFINE === // === QA REFINE ===
if (role === "qa" && result === "refine") { if (role === "qa" && result === "refine") {
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
await transitionLabel(issueId, "Testing", "Refining", glabOpts); await provider.transitionLabel(issueId, "Testing", "Refining");
output.labelTransition = "Testing → Refining"; output.labelTransition = "Testing → Refining";
output.announcement = `🤔 QA REFINE #${issueId}${summary ? `${summary}` : ""}. Awaiting human decision.`; output.announcement = `🤔 QA REFINE #${issueId}${summary ? `${summary}` : ""}. Awaiting human decision.`;

View File

@@ -13,7 +13,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js"; import { readProjects } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js"; import { resolveRepoPath } from "../utils.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../issue-provider.js"; import type { StateLabel } from "../issue-provider.js";

View File

@@ -11,17 +11,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { import { type StateLabel } from "../issue-provider.js";
getCurrentStateLabel, import { createProvider } from "../providers/index.js";
getIssue,
resolveRepoPath,
transitionLabel,
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js"; import { selectModel } from "../model-selector.js";
import { getProject, getWorker, readProjects } from "../projects.js"; import { getProject, getWorker, readProjects } from "../projects.js";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { detectContext, generateGuardrails } from "../context-guard.js"; import { detectContext, generateGuardrails } from "../context-guard.js";
import { resolveRepoPath } from "../utils.js";
export function createTaskPickupTool(api: OpenClawPluginApi) { export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -101,15 +97,18 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
// 3. Fetch issue and verify state // 3. Fetch issue and verify state
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const glabOpts = { const { provider } = createProvider({
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as
| string | string
| undefined, | undefined,
ghPath: (api.pluginConfig as Record<string, unknown>)?.ghPath as
| string
| undefined,
repoPath, repoPath,
}; });
const issue = await getIssue(issueId, glabOpts); const issue = await provider.getIssue(issueId);
const currentLabel = getCurrentStateLabel(issue); const currentLabel = provider.getCurrentStateLabel(issue);
const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"]; const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"];
const validLabelsForQa: StateLabel[] = ["To Test"]; const validLabelsForQa: StateLabel[] = ["To Test"];
@@ -160,7 +159,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
fromLabel: currentLabel, fromLabel: currentLabel,
toLabel: targetLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts), provider.transitionLabel(id, from as StateLabel, to as StateLabel),
pluginConfig, pluginConfig,
}); });

13
lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Shared utilities for DevClaw.
*/
/**
* Resolve the repo path from projects.json repo field (handles ~/).
*/
export function resolveRepoPath(repoField: string): string {
if (repoField.startsWith("~/")) {
return repoField.replace("~", process.env.HOME ?? "/home/lauren");
}
return repoField;
}