feat: implement runCommand wrapper and refactor command executions across modules

This commit is contained in:
Lauren ten Hoor
2026-02-13 10:50:35 +08:00
parent e55b7fa555
commit 83f1f1adf0
24 changed files with 209 additions and 178 deletions

View File

@@ -1,11 +1,6 @@
/**
* GitHubProvider — IssueProvider implementation using gh CLI.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
type IssueProvider,
type Issue,
@@ -13,8 +8,7 @@ import {
STATE_LABELS,
LABEL_COLORS,
} from "./provider.js";
const execFileAsync = promisify(execFile);
import { runCommand } from "../run-command.js";
type GhIssue = {
number: number;
@@ -37,8 +31,8 @@ export class GitHubProvider implements IssueProvider {
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
private async gh(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("gh", args, { cwd: this.repoPath, timeout: 30_000 });
return stdout.trim();
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
}
async ensureLabel(name: string, color: string): Promise<void> {
@@ -51,16 +45,12 @@ export class GitHubProvider implements IssueProvider {
}
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
await writeFile(tempFile, description, "utf-8");
try {
const args = ["issue", "create", "--title", title, "--body-file", tempFile, "--label", label];
if (assignees?.length) args.push("--assignee", assignees.join(","));
const url = await this.gh(args);
const match = url.match(/\/issues\/(\d+)$/);
if (!match) throw new Error(`Failed to parse issue URL: ${url}`);
return this.getIssue(parseInt(match[1], 10));
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
const args = ["issue", "create", "--title", title, "--body", description, "--label", label];
if (assignees?.length) args.push("--assignee", assignees.join(","));
const url = await this.gh(args);
const match = url.match(/\/issues\/(\d+)$/);
if (!match) throw new Error(`Failed to parse issue URL: ${url}`);
return this.getIssue(parseInt(match[1], 10));
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
@@ -111,10 +101,7 @@ export class GitHubProvider implements IssueProvider {
}
async addComment(issueId: number, body: string): Promise<void> {
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
await writeFile(tempFile, body, "utf-8");
try { await this.gh(["issue", "comment", String(issueId), "--body-file", tempFile]); }
finally { try { await unlink(tempFile); } catch { /* ignore */ } }
await this.gh(["issue", "comment", String(issueId), "--body", body]);
}
async healthCheck(): Promise<boolean> {

View File

@@ -1,11 +1,6 @@
/**
* GitLabProvider — IssueProvider implementation using glab CLI.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
type IssueProvider,
type Issue,
@@ -13,16 +8,15 @@ import {
STATE_LABELS,
LABEL_COLORS,
} from "./provider.js";
const execFileAsync = promisify(execFile);
import { runCommand } from "../run-command.js";
export class GitLabProvider implements IssueProvider {
private repoPath: string;
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
private async glab(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("glab", args, { cwd: this.repoPath, timeout: 30_000 });
return stdout.trim();
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
}
async ensureLabel(name: string, color: string): Promise<void> {
@@ -35,19 +29,15 @@ export class GitLabProvider implements IssueProvider {
}
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
await writeFile(tempFile, description, "utf-8");
try {
const { exec } = await import("node:child_process");
const execAsync = promisify(exec);
let cmd = `glab issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}"`;
if (assignees?.length) cmd += ` --assignee "${assignees.join(",")}"`;
const { stdout } = await execAsync(cmd, { cwd: this.repoPath, timeout: 30_000 });
// glab issue create returns the issue URL
const match = stdout.trim().match(/\/issues\/(\d+)/);
if (!match) throw new Error(`Failed to parse issue URL: ${stdout.trim()}`);
return this.getIssue(parseInt(match[1], 10));
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
// Pass description directly as argv — runCommand uses spawn (no shell),
// so no escaping issues with special characters.
const args = ["issue", "create", "--title", title, "--description", description, "--label", label];
if (assignees?.length) args.push("--assignee", assignees.join(","));
const stdout = await this.glab(args);
// glab issue create returns the issue URL
const match = stdout.match(/\/issues\/(\d+)/);
if (!match) throw new Error(`Failed to parse issue URL: ${stdout}`);
return this.getIssue(parseInt(match[1], 10));
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
@@ -101,13 +91,8 @@ export class GitLabProvider implements IssueProvider {
}
async addComment(issueId: number, body: string): Promise<void> {
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
await writeFile(tempFile, body, "utf-8");
try {
const { exec } = await import("node:child_process");
const execAsync = promisify(exec);
await execAsync(`glab issue note ${issueId} --message "$(cat ${tempFile})"`, { cwd: this.repoPath, timeout: 30_000 });
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
// Pass message directly as argv — no shell escaping needed with spawn
await this.glab(["issue", "note", String(issueId), "--message", body]);
}
async healthCheck(): Promise<boolean> {

View File

@@ -1,11 +1,11 @@
/**
* Provider factory — auto-detects GitHub vs GitLab from git remote.
*/
import { execFileSync } from "node:child_process";
import type { IssueProvider } from "./provider.js";
import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js";
import { resolveRepoPath } from "../projects.js";
import { runCommand } from "../run-command.js";
export type ProviderOptions = {
provider?: "gitlab" | "github";
@@ -18,19 +18,19 @@ export type ProviderWithType = {
type: "github" | "gitlab";
};
function detectProvider(repoPath: string): "gitlab" | "github" {
async function detectProvider(repoPath: string): Promise<"gitlab" | "github"> {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd: repoPath, timeout: 5_000 }).toString().trim();
return url.includes("github.com") ? "github" : "gitlab";
const result = await runCommand(["git", "remote", "get-url", "origin"], { timeoutMs: 5_000, cwd: repoPath });
return result.stdout.trim().includes("github.com") ? "github" : "gitlab";
} catch {
return "gitlab";
}
}
export function createProvider(opts: ProviderOptions): ProviderWithType {
export async function createProvider(opts: ProviderOptions): Promise<ProviderWithType> {
const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
if (!repoPath) throw new Error("Either repoPath or repo must be provided");
const type = opts.provider ?? detectProvider(repoPath);
const type = opts.provider ?? await detectProvider(repoPath);
const provider = type === "github" ? new GitHubProvider({ repoPath }) : new GitLabProvider({ repoPath });
return { provider, type };
}