Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/dispatch/attachment-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "./attachments.js";
import { readProjects, type Project } from "../projects/index.js";
import { createProvider } from "../providers/index.js";
import { normalizeRepoTarget } from "../tools/helpers.js";
import { log as auditLog } from "../audit.js";

/**
Expand Down Expand Up @@ -93,7 +94,12 @@ export function registerAttachmentHook(api: OpenClawPluginApi, ctx: PluginContex
// Process each referenced issue
for (const issueId of issueIds) {
try {
const { provider } = await createProvider({ repo: project.repo, provider: project.provider, runCommand: ctx.runCommand });
const { provider } = await createProvider({
repo: project.repo,
provider: project.provider,
target: project.repoRemote ? { repo: normalizeRepoTarget(project.repoRemote) } : undefined,
runCommand: ctx.runCommand,
});

await processAttachmentMessage({
workspaceDir,
Expand Down
50 changes: 48 additions & 2 deletions lib/providers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PrReviewComment,
PrState,
} from "./provider.js";
import type { ProviderTarget } from "./provider.js";
import type { RunCommand } from "../context.js";
import { withResilience } from "./resilience.js";
import {
Expand Down Expand Up @@ -39,23 +40,63 @@ export class GitHubProvider implements IssueProvider {
private repoPath: string;
private workflow: WorkflowConfig;
private runCommand: RunCommand;
private targetRepo?: string;

constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig }) {
constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig; target?: ProviderTarget }) {
this.repoPath = opts.repoPath;
this.runCommand = opts.runCommand;
this.workflow = opts.workflow ?? DEFAULT_WORKFLOW;
this.targetRepo = opts.target?.repo;
}

private async gh(args: string[]): Promise<string> {
return withResilience(async () => {
const result = await this.runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
const fullArgs = ["gh", ...this.withRepo(args)];
const result = await this.runCommand(fullArgs, { timeoutMs: 30_000, cwd: this.repoPath });
if (result.code != null && result.code !== 0) {
throw new Error(result.stderr?.trim() || `gh command failed with exit code ${result.code}`);
}
return result.stdout.trim();
});
}

private withRepo(args: string[]): string[] {
if (!this.targetRepo || args.length === 0) return args;
if (args[0] === "api") return this.withApiTarget(args);
if (!this.commandSupportsRepo(args)) return args;
if (args.includes("--repo") || args.includes("-R")) return args;
return [...args, "--repo", this.targetRepo];
}

private withApiTarget(args: string[]): string[] {
if (args.includes("graphql")) return args;
const repo = this.getTargetRepoParts();
if (!repo) return args;
if (args.length < 2) return args;

const route = args[1]
.replace(/(^|\/)repos\/:owner\/:repo(?=\/|$)/, `$1repos/${repo.owner}/${repo.name}`)
.replace(/(^|\/)projects\/:id(?=\/|$)/, `$1repos/${repo.owner}/${repo.name}`);

if (route === args[1]) return args;
return [args[0], route, ...args.slice(2)];
}

private commandSupportsRepo(args: string[]): boolean {
if (args.length === 0) return false;
if (args[0] === "repo") return true;
if (args[0] === "issue") return true;
if (args[0] === "pr") return true;
if (args[0] === "label") return true;
return false;
}

private getTargetRepoParts(): { owner: string; name: string } | null {
if (!this.targetRepo) return null;
const [owner, name] = this.targetRepo.split("/");
return owner && name ? { owner, name } : null;
}

/** Cached repo owner/name for GraphQL queries. */
private repoInfo: { owner: string; name: string } | null | undefined = undefined;

Expand All @@ -66,6 +107,11 @@ export class GitHubProvider implements IssueProvider {
private async getRepoInfo(): Promise<{ owner: string; name: string } | null> {
if (this.repoInfo !== undefined) return this.repoInfo;
try {
const targetRepo = this.getTargetRepoParts();
if (targetRepo) {
this.repoInfo = targetRepo;
return this.repoInfo;
}
const raw = await this.gh(["repo", "view", "--json", "owner,name"]);
const data = JSON.parse(raw);
this.repoInfo = { owner: data.owner.login, name: data.name };
Expand Down
8 changes: 6 additions & 2 deletions lib/providers/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PrReviewComment,
PrState,
} from "./provider.js";
import type { ProviderTarget } from "./provider.js";
import type { RunCommand } from "../context.js";
import { withResilience } from "./resilience.js";
import {
Expand All @@ -35,16 +36,19 @@ export class GitLabProvider implements IssueProvider {
private repoPath: string;
private workflow: WorkflowConfig;
private runCommand: RunCommand;
private targetRepo?: string;

constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig }) {
constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig; target?: ProviderTarget }) {
this.repoPath = opts.repoPath;
this.runCommand = opts.runCommand;
this.workflow = opts.workflow ?? DEFAULT_WORKFLOW;
this.targetRepo = opts.target?.repo;
}

private async glab(args: string[]): Promise<string> {
return withResilience(async () => {
const result = await this.runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
const fullArgs = this.targetRepo && !args.includes("--repo") ? ["glab", ...args, "--repo", this.targetRepo] : ["glab", ...args];
const result = await this.runCommand(fullArgs, { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
});
}
Expand Down
6 changes: 4 additions & 2 deletions lib/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Provider factory — auto-detects GitHub vs GitLab from git remote.
*/
import type { IssueProvider } from "./provider.js";
import type { ProviderTarget } from "./provider.js";
import type { RunCommand } from "../context.js";
import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js";
Expand All @@ -11,6 +12,7 @@ export type ProviderOptions = {
provider?: "gitlab" | "github";
repo?: string;
repoPath?: string;
target?: ProviderTarget;
runCommand: RunCommand;
};

Expand All @@ -34,7 +36,7 @@ export async function createProvider(opts: ProviderOptions): Promise<ProviderWit
const rc = opts.runCommand;
const type = opts.provider ?? await detectProvider(repoPath, rc);
const provider = type === "github"
? new GitHubProvider({ repoPath, runCommand: rc })
: new GitLabProvider({ repoPath, runCommand: rc });
? new GitHubProvider({ repoPath, runCommand: rc, target: opts.target })
: new GitLabProvider({ repoPath, runCommand: rc, target: opts.target });
return { provider, type };
}
152 changes: 152 additions & 0 deletions lib/providers/provider-targeting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Regression tests for explicit tracker targeting from project config.
*
* Run with: npx tsx --test lib/providers/provider-targeting.test.ts
*/
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { GitHubProvider } from "./github.js";

describe("GitHubProvider explicit repo targeting", () => {
it("passes --repo for issue creation when target repo is configured", async () => {
const calls: string[][] = [];
const runCommand = mock.fn(async (args: string[]) => {
calls.push(args);
if (args[1] === "issue" && args[2] === "create") {
return { stdout: "https://github.com/yaqub0r/devclaw/issues/999\n", stderr: "", code: 0 };
}
if (args[1] === "issue" && args[2] === "view") {
return {
stdout: JSON.stringify({ number: 999, title: "t", body: "d", labels: [{ name: "Planning" }], state: "OPEN", url: "https://github.com/yaqub0r/devclaw/issues/999" }),
stderr: "",
code: 0,
};
}
throw new Error(`Unexpected command: ${args.join(" ")}`);
});

const provider = new GitHubProvider({ repoPath: "/fake", runCommand: runCommand as any, target: { repo: "yaqub0r/devclaw" } });
const issue = await provider.createIssue("t", "d", "Planning");

assert.equal(issue.iid, 999);
const createCall = calls.find((c) => c[1] === "issue" && c[2] === "create");
assert.ok(createCall, "expected issue create call");
assert.deepEqual(createCall.slice(-2), ["--repo", "yaqub0r/devclaw"]);
});

it("passes --repo for issue read, edit, and label paths when target repo is configured", async () => {
const calls: string[][] = [];
let issue95State = "Planning";
const runCommand = mock.fn(async (args: string[]) => {
calls.push(args);

if (args[1] === "issue" && args[2] === "view") {
const issueId = args[3];
const labels = issueId === "95"
? [{ name: issue95State }, { name: "telegram:DevClaw" }]
: [{ name: "To Do" }, { name: "telegram:DevClaw" }];
return {
stdout: JSON.stringify({ number: Number(issueId), title: "Issue", body: "Body", labels, state: "OPEN", url: `https://github.com/yaqub0r/devclaw/issues/${issueId}` }),
stderr: "",
code: 0,
};
}

if (args[1] === "issue" && args[2] === "edit") {
if (args.includes("--add-label") && args.includes("To Do")) issue95State = "To Do";
return { stdout: "", stderr: "", code: 0 };
}

if (args[1] === "label" && args[2] === "create") {
return { stdout: "", stderr: "", code: 0 };
}

if (args[1] === "api" && args[2] === "repos/yaqub0r/devclaw/issues/95/comments") {
return { stdout: JSON.stringify({ id: 12345 }), stderr: "", code: 0 };
}

throw new Error(`Unexpected command: ${args.join(" ")}`);
});

const provider = new GitHubProvider({ repoPath: "/fake", runCommand: runCommand as any, target: { repo: "yaqub0r/devclaw" } });

const issue = await provider.getIssue(95);
assert.equal(issue.iid, 95);

await provider.transitionLabel(95, "Planning", "To Do");
await provider.ensureLabel("developer:medior", "#123456");
const commentId = await provider.addComment(95, "routing proof");
assert.equal(commentId, 12345);

const issueViewCalls = calls.filter((c) => c[1] === "issue" && c[2] === "view");
assert.ok(issueViewCalls.length >= 2, "expected issue view calls for read + transition validation");
for (const call of issueViewCalls) {
assert.deepEqual(call.slice(-2), ["--repo", "yaqub0r/devclaw"]);
}

const issueEditCalls = calls.filter((c) => c[1] === "issue" && c[2] === "edit");
assert.ok(issueEditCalls.length >= 1, "expected issue edit calls during transition");
for (const call of issueEditCalls) {
assert.deepEqual(call.slice(-2), ["--repo", "yaqub0r/devclaw"]);
}

const labelCreateCall = calls.find((c) => c[1] === "label" && c[2] === "create");
assert.ok(labelCreateCall, "expected label create call");
assert.deepEqual(labelCreateCall.slice(-2), ["--repo", "yaqub0r/devclaw"]);

const commentCall = calls.find((c) => c[1] === "api" && c[2] === "repos/yaqub0r/devclaw/issues/95/comments");
assert.ok(commentCall, "expected issue comment api call");
assert.ok(!commentCall.includes("--repo"), "gh api must not receive --repo");
});

it("rewrites only the gh api route placeholder to the configured repo without adding --repo", async () => {
const calls: string[][] = [];
const runCommand = mock.fn(async (args: string[]) => {
calls.push(args);

if (args[1] === "api" && args[2] === "repos/yaqub0r/devclaw/issues/95/comments") {
return { stdout: JSON.stringify([]), stderr: "", code: 0 };
}

if (args[1] === "api" && args[2] === "repos/yaqub0r/devclaw/issues/comments/42/reactions") {
return { stdout: "", stderr: "", code: 0 };
}

if (args[1] === "api" && args[2] === "repos/yaqub0r/devclaw/pulls/7/reviews") {
return { stdout: JSON.stringify([]), stderr: "", code: 0 };
}

throw new Error(`Unexpected command: ${args.join(" ")}`);
});

const provider = new GitHubProvider({ repoPath: "/fake", runCommand: runCommand as any, target: { repo: "yaqub0r/devclaw" } });

await provider.listComments(95);
await provider.reactToIssueComment(95, 42, "repos/:owner/:repo");
await (provider as any).hasChangesRequestedReview(7);

const apiCalls = calls.filter((c) => c[1] === "api");
assert.equal(apiCalls.length, 3);
for (const call of apiCalls) {
assert.ok(!call.includes("--repo"), "gh api must not receive --repo");
assert.ok(call[2]?.startsWith("repos/yaqub0r/devclaw/"), `expected concrete repo path, got ${call[2]}`);
}

const reactionCall = apiCalls.find((c) => c[2] === "repos/yaqub0r/devclaw/issues/comments/42/reactions");
assert.ok(reactionCall, "expected reactions api call");
const fieldIndex = reactionCall.indexOf("--field");
assert.equal(reactionCall[fieldIndex + 1], "content=repos/:owner/:repo", "non-route args must remain untouched");
});

it("uses configured target repo for repo info without gh repo view", async () => {
const runCommand = mock.fn(async (_args: string[]) => {
throw new Error("gh repo view should not be called when target is configured");
});

const provider = new GitHubProvider({ repoPath: "/fake", runCommand: runCommand as any, target: { repo: "yaqub0r/devclaw" } });
const info = await (provider as any).getRepoInfo();

assert.deepEqual(info, { owner: "yaqub0r", name: "devclaw" });
assert.equal(runCommand.mock.calls.length, 0);
});
});
10 changes: 10 additions & 0 deletions lib/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,13 @@ export interface IssueProvider {
}): Promise<string | null>;
healthCheck(): Promise<boolean>;
}

/**
* Optional tracker target override derived from project configuration.
*
* Example GitHub target: "yaqub0r/devclaw"
* Example GitLab target: "group/project"
*/
export type ProviderTarget = {
repo?: string;
};
2 changes: 2 additions & 0 deletions lib/services/heartbeat/tick-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "./health.js";
import { projectTick } from "../tick.js";
import { createProvider } from "../../providers/index.js";
import { normalizeRepoTarget } from "../../tools/helpers.js";
import { loadConfig } from "../../config/index.js";
import { ExecutionMode } from "../../workflow/index.js";
import type { HeartbeatConfig } from "./config.js";
Expand Down Expand Up @@ -91,6 +92,7 @@ export async function tick(opts: {
const { provider } = await createProvider({
repo: project.repo,
provider: project.provider,
target: project.repoRemote ? { repo: normalizeRepoTarget(project.repoRemote) } : undefined,
runCommand,
});
const resolvedConfig = await loadConfig(workspaceDir, project.name);
Expand Down
51 changes: 51 additions & 0 deletions lib/services/tick-provider-targeting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Regression tests for projectTick provider creation with repoRemote targeting.
*
* Run with: npx tsx --test lib/services/tick-provider-targeting.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestHarness } from "../testing/index.js";
import { projectTick } from "./tick.js";

describe("projectTick provider targeting", () => {
it("threads project repoRemote into provider creation from persisted project config", async () => {
const h = await createTestHarness();
try {
const projects = await h.readProjects();
projects.projects[h.project.slug] = {
...projects.projects[h.project.slug]!,
repoRemote: "https://github.com/yaqub0r/devclaw.git",
provider: "github",
};
await h.writeProjects(projects);

const ghCalls: string[][] = [];
const runCommand = async (argv: string[]) => {
if (argv[0] === "gh") {
ghCalls.push(argv);
if (argv[1] === "issue" && argv[2] === "list") {
return { stdout: "[]", stderr: "", code: 0, signal: null, killed: false as const };
}
}
return { stdout: "{}", stderr: "", code: 0, signal: null, killed: false as const };
};

const result = await projectTick({
workspaceDir: h.workspaceDir,
projectSlug: h.project.slug,
targetRole: "developer",
runCommand: runCommand as any,
});

assert.equal(result.pickups.length, 0);
const issueListCalls = ghCalls.filter((call) => call[1] === "issue" && call[2] === "list");
assert.ok(issueListCalls.length >= 1, "expected projectTick to hit gh through a created provider");
for (const call of issueListCalls) {
assert.deepEqual(call.slice(-2), ["--repo", "yaqub0r/devclaw"]);
}
} finally {
await h.cleanup();
}
});
});
Loading