From e7bfcd4e77cd88a929e98edecbd6d62595a28f35 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 21 Apr 2026 13:20:21 +0000 Subject: [PATCH 1/3] fix: route task_create to configured tracker repo (#95) --- lib/providers/github.ts | 32 +++++++++++++++- lib/providers/gitlab.ts | 8 +++- lib/providers/index.ts | 6 ++- lib/providers/provider-targeting.test.ts | 48 ++++++++++++++++++++++++ lib/providers/provider.ts | 10 +++++ lib/tools/helpers.ts | 20 +++++++++- 6 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 lib/providers/provider-targeting.test.ts diff --git a/lib/providers/github.ts b/lib/providers/github.ts index d72c7c02..8ca5b743 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -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 { @@ -39,16 +40,19 @@ 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 { 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}`); } @@ -56,6 +60,23 @@ export class GitHubProvider implements IssueProvider { }); } + private withRepo(args: string[]): string[] { + if (!this.targetRepo) return args; + const needsExplicitRepo = this.commandSupportsRepo(args); + if (!needsExplicitRepo) return args; + if (args.includes("--repo") || args.includes("-R")) return args; + return [...args, "--repo", this.targetRepo]; + } + + 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] !== "api") return false; + return !args.includes("graphql"); + } + /** Cached repo owner/name for GraphQL queries. */ private repoInfo: { owner: string; name: string } | null | undefined = undefined; @@ -66,6 +87,13 @@ export class GitHubProvider implements IssueProvider { private async getRepoInfo(): Promise<{ owner: string; name: string } | null> { if (this.repoInfo !== undefined) return this.repoInfo; try { + if (this.targetRepo) { + const [owner, name] = this.targetRepo.split("/"); + if (owner && name) { + this.repoInfo = { owner, name }; + 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 }; diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index 4f489153..16ff17b6 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -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 { @@ -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 { 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(); }); } diff --git a/lib/providers/index.ts b/lib/providers/index.ts index 58bd1c96..e5705ddc 100644 --- a/lib/providers/index.ts +++ b/lib/providers/index.ts @@ -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"; @@ -11,6 +12,7 @@ export type ProviderOptions = { provider?: "gitlab" | "github"; repo?: string; repoPath?: string; + target?: ProviderTarget; runCommand: RunCommand; }; @@ -34,7 +36,7 @@ export async function createProvider(opts: ProviderOptions): Promise { + 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("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); + }); +}); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 32a7a946..bd0593bb 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -170,3 +170,13 @@ export interface IssueProvider { }): Promise; healthCheck(): Promise; } + +/** + * Optional tracker target override derived from project configuration. + * + * Example GitHub target: "yaqub0r/devclaw" + * Example GitLab target: "group/project" + */ +export type ProviderTarget = { + repo?: string; +}; diff --git a/lib/tools/helpers.ts b/lib/tools/helpers.ts index 3e0ab2dc..32a0a307 100644 --- a/lib/tools/helpers.ts +++ b/lib/tools/helpers.ts @@ -58,7 +58,25 @@ export async function resolveProject( * Uses stored provider type from project config if available, otherwise auto-detects. */ export async function resolveProvider(project: Project, runCommand: RunCommand): Promise { - return createProvider({ repo: project.repo, provider: project.provider, runCommand }); + const target = project.repoRemote ? { repo: normalizeRepoTarget(project.repoRemote) } : undefined; + return createProvider({ repo: project.repo, provider: project.provider, target, runCommand }); +} + +function normalizeRepoTarget(repoRemote: string): string | undefined { + const trimmed = repoRemote.trim(); + if (!trimmed) return undefined; + + const sshMatch = trimmed.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/i) + ?? trimmed.match(/gitlab\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/i); + if (sshMatch) return sshMatch[1]; + + try { + const url = new URL(trimmed); + const path = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, ""); + return path || undefined; + } catch { + return trimmed.replace(/\.git$/i, ""); + } } /** From 6352a321728312fbf2df54c1dfdb24d1f221c0a6 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Mon, 20 Apr 2026 20:41:21 +0000 Subject: [PATCH 2/3] fix: route label operations to configured repo (#95) --- lib/providers/github.ts | 1 + lib/providers/provider-targeting.test.ts | 65 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 8ca5b743..44b8fb9b 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -73,6 +73,7 @@ export class GitHubProvider implements IssueProvider { if (args[0] === "repo") return true; if (args[0] === "issue") return true; if (args[0] === "pr") return true; + if (args[0] === "label") return true; if (args[0] !== "api") return false; return !args.includes("graphql"); } diff --git a/lib/providers/provider-targeting.test.ts b/lib/providers/provider-targeting.test.ts index e676a5a8..fd488712 100644 --- a/lib/providers/provider-targeting.test.ts +++ b/lib/providers/provider-targeting.test.ts @@ -34,6 +34,71 @@ describe("GitHubProvider explicit repo targeting", () => { assert.deepEqual(createCall.slice(-2), ["--repo", "yaqub0r/devclaw"]); }); + it("passes --repo for issue read, edit, label, and comment 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/:owner/:repo/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/:owner/:repo/issues/95/comments"); + assert.ok(commentCall, "expected issue comment api call"); + assert.deepEqual(commentCall.slice(-2), ["--repo", "yaqub0r/devclaw"]); + }); + 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"); From 5957979782abfe86d35b19ec43e49a051bea6c33 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 21 Apr 2026 01:48:01 +0000 Subject: [PATCH 3/3] fix: complete provider targeting across github paths (#95) --- lib/dispatch/attachment-hook.ts | 8 ++- lib/providers/github.ts | 39 ++++++++++----- lib/providers/provider-targeting.test.ts | 47 ++++++++++++++++-- lib/services/heartbeat/tick-runner.ts | 2 + lib/services/tick-provider-targeting.test.ts | 51 ++++++++++++++++++++ lib/services/tick.ts | 8 ++- lib/setup/templates.ts | 18 +++++-- lib/tools/admin/sync-labels.ts | 2 + lib/tools/helpers.test.ts | 18 +++++++ lib/tools/helpers.ts | 2 +- 10 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 lib/services/tick-provider-targeting.test.ts create mode 100644 lib/tools/helpers.test.ts diff --git a/lib/dispatch/attachment-hook.ts b/lib/dispatch/attachment-hook.ts index aff9865d..469274d8 100644 --- a/lib/dispatch/attachment-hook.ts +++ b/lib/dispatch/attachment-hook.ts @@ -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"; /** @@ -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, diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 44b8fb9b..cc7d4b67 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -61,21 +61,40 @@ export class GitHubProvider implements IssueProvider { } private withRepo(args: string[]): string[] { - if (!this.targetRepo) return args; - const needsExplicitRepo = this.commandSupportsRepo(args); - if (!needsExplicitRepo) return args; + 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; - if (args[0] !== "api") return false; - return !args.includes("graphql"); + 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. */ @@ -88,12 +107,10 @@ export class GitHubProvider implements IssueProvider { private async getRepoInfo(): Promise<{ owner: string; name: string } | null> { if (this.repoInfo !== undefined) return this.repoInfo; try { - if (this.targetRepo) { - const [owner, name] = this.targetRepo.split("/"); - if (owner && name) { - this.repoInfo = { owner, name }; - return this.repoInfo; - } + 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); diff --git a/lib/providers/provider-targeting.test.ts b/lib/providers/provider-targeting.test.ts index fd488712..e69263ff 100644 --- a/lib/providers/provider-targeting.test.ts +++ b/lib/providers/provider-targeting.test.ts @@ -34,7 +34,7 @@ describe("GitHubProvider explicit repo targeting", () => { assert.deepEqual(createCall.slice(-2), ["--repo", "yaqub0r/devclaw"]); }); - it("passes --repo for issue read, edit, label, and comment paths when target repo is configured", async () => { + 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[]) => { @@ -61,7 +61,7 @@ describe("GitHubProvider explicit repo targeting", () => { return { stdout: "", stderr: "", code: 0 }; } - if (args[1] === "api" && args[2] === "repos/:owner/:repo/issues/95/comments") { + if (args[1] === "api" && args[2] === "repos/yaqub0r/devclaw/issues/95/comments") { return { stdout: JSON.stringify({ id: 12345 }), stderr: "", code: 0 }; } @@ -94,9 +94,48 @@ describe("GitHubProvider explicit repo targeting", () => { 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/:owner/:repo/issues/95/comments"); + 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.deepEqual(commentCall.slice(-2), ["--repo", "yaqub0r/devclaw"]); + 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 () => { diff --git a/lib/services/heartbeat/tick-runner.ts b/lib/services/heartbeat/tick-runner.ts index 26e3e30a..dafa891d 100644 --- a/lib/services/heartbeat/tick-runner.ts +++ b/lib/services/heartbeat/tick-runner.ts @@ -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"; @@ -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); diff --git a/lib/services/tick-provider-targeting.test.ts b/lib/services/tick-provider-targeting.test.ts new file mode 100644 index 00000000..ba63371b --- /dev/null +++ b/lib/services/tick-provider-targeting.test.ts @@ -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(); + } + }); +}); diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 29a44b7d..a80ab728 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -8,6 +8,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { RunCommand } from "../context.js"; import type { Issue, IssueProvider } from "../providers/provider.js"; import { createProvider } from "../providers/index.js"; +import { normalizeRepoTarget } from "../tools/helpers.js"; import { selectLevel } from "../roles/model-selector.js"; import { getRoleWorker, getProject, readProjects, findFreeSlot, countActiveSlots, reconcileSlots } from "../projects/index.js"; import { dispatchTask } from "../dispatch/index.js"; @@ -82,7 +83,12 @@ export async function projectTick(opts: { const resolvedConfig = await loadConfig(workspaceDir, project.name); const workflow = opts.workflow ?? resolvedConfig.workflow; - const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider, runCommand: runCommand! })).provider; + const provider = opts.provider ?? (await createProvider({ + repo: project.repo, + provider: project.provider, + target: project.repoRemote ? { repo: normalizeRepoTarget(project.repoRemote) } : undefined, + runCommand: runCommand!, + })).provider; const roleExecution = workflow.roleExecution ?? ExecutionMode.PARALLEL; const enabledRoles = Object.entries(resolvedConfig.roles) .filter(([, r]) => r.enabled) diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 0bfef758..3fa7e588 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -12,9 +12,21 @@ import path from "node:path"; // File loader — reads from defaults/ (single source of truth) // --------------------------------------------------------------------------- -// esbuild bundles everything into dist/index.js, so import.meta.url points to -// dist/index.js → one level up reaches the repo root where defaults/ lives. -const DEFAULTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "defaults"); +function resolveDefaultsDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, "..", "..", "defaults"), + path.join(here, "..", "defaults"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + throw new Error(`Failed to locate defaults directory from ${here}`); +} + +const DEFAULTS_DIR = resolveDefaultsDir(); function loadDefault(filename: string): string { const filePath = path.join(DEFAULTS_DIR, filename); diff --git a/lib/tools/admin/sync-labels.ts b/lib/tools/admin/sync-labels.ts index 27e5786c..b036e4f3 100644 --- a/lib/tools/admin/sync-labels.ts +++ b/lib/tools/admin/sync-labels.ts @@ -14,6 +14,7 @@ import type { PluginContext } from "../../context.js"; import { requireWorkspaceDir } from "../helpers.js"; import { readProjects, getProject } from "../../projects/index.js"; import { createProvider } from "../../providers/index.js"; +import { normalizeRepoTarget } from "../helpers.js"; import { loadConfig } from "../../config/index.js"; import { getStateLabels, @@ -81,6 +82,7 @@ export function createSyncLabelsTool(ctx: PluginContext) { const { provider } = await createProvider({ repo: project.repo, provider: project.provider, + target: project.repoRemote ? { repo: normalizeRepoTarget(project.repoRemote) } : undefined, runCommand: ctx.runCommand, }); diff --git a/lib/tools/helpers.test.ts b/lib/tools/helpers.test.ts new file mode 100644 index 00000000..26450382 --- /dev/null +++ b/lib/tools/helpers.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { normalizeRepoTarget } from "./helpers.js"; + +describe("normalizeRepoTarget", () => { + it("normalizes github https and ssh remotes", () => { + assert.equal(normalizeRepoTarget("https://github.com/yaqub0r/devclaw.git"), "yaqub0r/devclaw"); + assert.equal(normalizeRepoTarget("git@github.com:yaqub0r/devclaw.git"), "yaqub0r/devclaw"); + }); + + it("normalizes gitlab remotes and trims whitespace", () => { + assert.equal(normalizeRepoTarget(" https://gitlab.com/group/project.git "), "group/project"); + }); + + it("preserves already-normalized owner repo targets", () => { + assert.equal(normalizeRepoTarget("yaqub0r/devclaw"), "yaqub0r/devclaw"); + }); +}); diff --git a/lib/tools/helpers.ts b/lib/tools/helpers.ts index 32a0a307..eccf0bff 100644 --- a/lib/tools/helpers.ts +++ b/lib/tools/helpers.ts @@ -62,7 +62,7 @@ export async function resolveProvider(project: Project, runCommand: RunCommand): return createProvider({ repo: project.repo, provider: project.provider, target, runCommand }); } -function normalizeRepoTarget(repoRemote: string): string | undefined { +export function normalizeRepoTarget(repoRemote: string): string | undefined { const trimmed = repoRemote.trim(); if (!trimmed) return undefined;