Files
devclaw-gitea/lib/gitlab.ts
Lauren ten Hoor 9ace15dad5 Initial commit: DevClaw OpenClaw plugin
Multi-project dev/qa pipeline orchestration with 4 agent tools:
- task_pickup: atomic task pickup with model selection and session reuse
- task_complete: DEV done, QA pass/fail/refine with label transitions
- queue_status: task queue and worker status across projects
- session_health: zombie detection and state consistency checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:26:29 +08:00

183 lines
3.7 KiB
TypeScript

/**
* GitLab wrapper using glab CLI.
* Handles label transitions, issue fetching, and MR verification.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// State labels — each issue has exactly ONE at a time
const STATE_LABELS = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
] as const;
export type StateLabel = (typeof STATE_LABELS)[number];
type GlabOptions = {
glabPath?: string;
repoPath: string;
};
async function glab(
args: string[],
opts: GlabOptions,
): Promise<string> {
const bin = opts.glabPath ?? "glab";
const { stdout } = await execFileAsync(bin, args, {
cwd: opts.repoPath,
timeout: 30_000,
});
return stdout.trim();
}
export type GitLabIssue = {
iid: number;
title: string;
description: string;
labels: string[];
state: string;
web_url: string;
};
/**
* Fetch a single issue by ID.
*/
export async function getIssue(
issueId: number,
opts: GlabOptions,
): Promise<GitLabIssue> {
const raw = await glab(
["issue", "view", String(issueId), "--output", "json"],
opts,
);
return JSON.parse(raw) as GitLabIssue;
}
/**
* List issues with a specific label.
*/
export async function listIssuesByLabel(
label: StateLabel,
opts: GlabOptions,
): Promise<GitLabIssue[]> {
try {
const raw = await glab(
["issue", "list", "--label", label, "--output", "json"],
opts,
);
return JSON.parse(raw) as GitLabIssue[];
} catch {
// glab returns error when no issues found
return [];
}
}
/**
* Transition an issue from one state label to another.
* Uses --unlabel + --label to ensure only one state label at a time.
*/
export async function transitionLabel(
issueId: number,
from: StateLabel,
to: StateLabel,
opts: GlabOptions,
): Promise<void> {
await glab(
[
"issue",
"update",
String(issueId),
"--unlabel",
from,
"--label",
to,
],
opts,
);
}
/**
* Close an issue.
*/
export async function closeIssue(
issueId: number,
opts: GlabOptions,
): Promise<void> {
await glab(["issue", "close", String(issueId)], opts);
}
/**
* Reopen an issue.
*/
export async function reopenIssue(
issueId: number,
opts: GlabOptions,
): Promise<void> {
await glab(["issue", "reopen", String(issueId)], opts);
}
/**
* Check if the current state label on an issue matches expected.
*/
export function hasStateLabel(
issue: GitLabIssue,
expected: StateLabel,
): boolean {
return issue.labels.includes(expected);
}
/**
* Get the current state label of an issue (first match from STATE_LABELS).
*/
export function getCurrentStateLabel(
issue: GitLabIssue,
): StateLabel | null {
for (const label of STATE_LABELS) {
if (issue.labels.includes(label)) {
return label;
}
}
return null;
}
/**
* Check if any merged MR exists for a specific issue.
*/
export async function hasMergedMR(
issueId: number,
opts: GlabOptions,
): Promise<boolean> {
try {
const raw = await glab(
["mr", "list", "--output", "json", "--state", "merged"],
opts,
);
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
const pattern = `#${issueId}`;
return mrs.some(
(mr) =>
mr.title.includes(pattern) || (mr.description ?? "").includes(pattern),
);
} catch {
return false;
}
}
/**
* 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;
}