diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 19cbb00..0b04152 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,13 +2,13 @@ "name": "task-action", "image": "mcr.microsoft.com/devcontainers/typescript-node:24", "features": { - "ghcr.io/devcontainers/features/github-cli:1": {} + "ghcr.io/devcontainers/features/github-cli:1": {}, }, "postCreateCommand": "npm install", "customizations": { "vscode": { "extensions": [ - "biomejs.biome" + "biomejs.biome", ], "settings": { "editor.defaultFormatter": "biomejs.biome", @@ -16,7 +16,7 @@ "editor.tabSize": 2, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" - } + }, } } } diff --git a/.zed/settings.json b/.zed/settings.json index 292cc49..9eb83d1 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -26,6 +26,6 @@ }, "JSONC": { "formatter": { "language_server": { "name": "biome" } } - } + }, } } diff --git a/src/config/app-config.ts b/src/config/app-config.ts index cf6287b..c03a00a 100644 --- a/src/config/app-config.ts +++ b/src/config/app-config.ts @@ -12,6 +12,10 @@ const AppConfigSchema = z.object({ coderTaskNamePrefix: z.string().min(1).default("gh"), coderTemplateName: z.string().min(1).default("task-template"), coderTemplateNameCodex: z.string().min(1).default("task-template-codex"), + // Hardcoded template name used when a repo-level config is present. Not + // env-overridable: the field is intentionally omitted from `loadConfig`'s + // env→raw mapping so it always falls through to this default. + codeFactoryTemplate: z.string().min(1).default("code-factory"), coderTemplatePreset: z.string().min(1).optional(), coderOrganization: z.string().min(1).default("default"), logFormat: z.string().optional(), diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index 38bdbb5..c5c1998 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -22,7 +22,7 @@ path = "/data" size = "20gb" [harness] -provider = "claude" +provider = "claude_code" [[scheduled_jobs]] name = "nightly" @@ -35,9 +35,9 @@ prompt = "Do the thing" expect(parsed.sandbox?.docker).toBe(true); expect(parsed.sandbox?.volumes?.[0]).toEqual({ path: "/data", - size: "20gb", + size: "20Gi", }); - expect(parsed.harness?.provider).toBe("claude"); + expect(parsed.harness?.provider).toBe("claude_code"); expect(parsed.scheduled_jobs?.[0]?.name).toBe("nightly"); }); test("unknown keys are dropped (write-side loose-parse)", () => { @@ -94,21 +94,21 @@ describe("resolveRepoConfigSettings — defaults applied on read", () => { expect(r.sandbox.size).toBe("medium"); expect(r.sandbox.docker).toBe(false); expect(r.sandbox.volumes).toEqual([]); - expect(r.harness.provider).toBe("claude"); + expect(r.harness.provider).toBe("claude_code"); expect(r.scheduled_jobs).toEqual([]); }); test("empty object → full defaults", () => { expect(resolveRepoConfigSettings({})).toEqual({ sandbox: { size: "medium", docker: false, volumes: [] }, - harness: { provider: "claude" }, + harness: { provider: "claude_code" }, scheduled_jobs: [], }); }); - test("volume with path-only → size defaulted to '10gb'", () => { + test("volume with path-only → size defaulted to '10Gi'", () => { const r = resolveRepoConfigSettings({ sandbox: { volumes: [{ path: "/data" }] }, }); - expect(r.sandbox.volumes[0]).toEqual({ path: "/data", size: "10gb" }); + expect(r.sandbox.volumes[0]).toEqual({ path: "/data", size: "10Gi" }); }); test("partial override: explicit size beats default", () => { const r = resolveRepoConfigSettings({ @@ -118,3 +118,50 @@ describe("resolveRepoConfigSettings — defaults applied on read", () => { expect(r.sandbox.docker).toBe(false); }); }); + +describe("volume size normalization → canonical Kubernetes binary-SI form", () => { + test.each([ + ["10gb", "10Gi"], + ["10GB", "10Gi"], + ["10Gb", "10Gi"], + ["10G", "10Gi"], + ["10g", "10Gi"], + ["10gi", "10Gi"], + ["10Gi", "10Gi"], + ["500mb", "500Mi"], + ["500M", "500Mi"], + ["500Mi", "500Mi"], + ["2tb", "2Ti"], + ["64k", "64Ki"], + [" 20 GB ", "20Gi"], + ])("parseRepoConfigToml normalizes %s → %s on write", (input, expected) => { + const parsed = parseRepoConfigToml( + `[[sandbox.volumes]]\npath = "/data"\nsize = "${input}"`, + ); + expect(parsed.sandbox?.volumes?.[0]?.size).toBe(expected); + }); + + test("resolveRepoConfigSettings normalizes legacy stored values on read", () => { + // Simulate a stored record written before the normalization transform + // existed — the resolved schema must re-normalize on read. + const r = resolveRepoConfigSettings({ + sandbox: { volumes: [{ path: "/data", size: "20gb" }] }, + }); + expect(r.sandbox.volumes[0]).toEqual({ path: "/data", size: "20Gi" }); + }); + + test.each([ + "10", + "gb", + "10bb", + "10.5gb", + "10eb", + "abc", + ])("invalid volume size %s → parse rejects", (input) => { + expect(() => + parseRepoConfigToml( + `[[sandbox.volumes]]\npath = "/data"\nsize = "${input}"`, + ), + ).toThrow(/Invalid RepoConfig/); + }); +}); diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 2df680f..9a86ea9 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -8,7 +8,35 @@ import { z } from "zod"; export const SandboxSizeSchema = z.enum(["small", "medium", "large"]); /** Allowed values for `harness.provider`. */ -export const HarnessProviderSchema = z.enum(["claude", "codex"]); +export const HarnessProviderSchema = z.enum(["claude_code", "codex"]); + +// ── Volume size normalization ──────────────────────────────────────────────── +// Kubernetes PVCs require binary-SI suffixes like `10Gi`. Users routinely +// write `10gb` / `10GB` / `10G` / `10gi`; we accept those shapes and normalize +// everything to `i` before the value leaves the write path. + +const VOLUME_SIZE_REGEX = /^\s*(\d+)\s*(k|kb|ki|m|mb|mi|g|gb|gi|t|tb|ti)\s*$/i; + +function normalizeVolumeSize(input: string): string { + const match = VOLUME_SIZE_REGEX.exec(input); + if (!match) return input; // unreachable: regex validated before transform + const digits = match[1] ?? ""; + const prefix = (match[2] ?? "").charAt(0).toUpperCase(); + return `${digits}${prefix}i`; +} + +/** + * A Kubernetes-compatible volume size. Accepts common variants (`10gb`, + * `10GB`, `10G`, `10gi`, `10Gi`, etc.) and always emits the canonical + * binary-SI form (`10Gi`). Supports `K/M/G/T` prefixes. + */ +export const VolumeSizeSchema = z + .string() + .regex( + VOLUME_SIZE_REGEX, + 'expected a size like "10Gi" (K/M/G/T with optional b/i suffix)', + ) + .transform(normalizeVolumeSize); // ── Sparse (stored) schemas ────────────────────────────────────────────────── // Sparse schemas mirror what users actually wrote in TOML. No `.default()`: @@ -18,7 +46,7 @@ export const HarnessProviderSchema = z.enum(["claude", "codex"]); /** Sparse shape for a single sandbox volume entry. `path` is required. */ export const StoredSandboxVolumeSchema = z.object({ path: z.string(), - size: z.string().optional(), + size: VolumeSizeSchema.optional(), }); /** Sparse shape for the `[sandbox]` section. */ @@ -55,10 +83,10 @@ export const StoredRepoConfigSettingsSchema = z.object({ // Resolved schemas apply defaults on read so every consumer sees a fully // populated object without worrying about whether a field was written. -/** Resolved volume: `size` defaults to `"10gb"` when absent. */ +/** Resolved volume: `size` defaults to `"10Gi"` when absent. */ export const ResolvedSandboxVolumeSchema = z.object({ path: z.string(), - size: z.string().default("10gb"), + size: VolumeSizeSchema.default("10Gi"), }); /** Resolved sandbox: size/docker/volumes all have defaults. */ @@ -68,9 +96,9 @@ export const ResolvedSandboxSchema = z.object({ volumes: z.array(ResolvedSandboxVolumeSchema).default([]), }); -/** Resolved harness: provider defaults to `"claude"`. */ +/** Resolved harness: provider defaults to `"claude_code"`. */ export const ResolvedHarnessSchema = z.object({ - provider: HarnessProviderSchema.default("claude"), + provider: HarnessProviderSchema.default("claude_code"), }); /** diff --git a/src/durable-objects/repo-config-do.test.ts b/src/durable-objects/repo-config-do.test.ts index 8ed96c0..94cfc14 100644 --- a/src/durable-objects/repo-config-do.test.ts +++ b/src/durable-objects/repo-config-do.test.ts @@ -48,7 +48,7 @@ describe("RepoConfigDO — get/set round-trip", () => { expect(read?.settings.sandbox.size).toBe("medium"); expect(read?.settings.sandbox.docker).toBe(false); expect(read?.settings.sandbox.volumes).toEqual([]); - expect(read?.settings.harness.provider).toBe("claude"); + expect(read?.settings.harness.provider).toBe("claude_code"); expect(read?.settings.scheduled_jobs).toEqual([]); }); @@ -86,7 +86,7 @@ describe("RepoConfigDO — get/set round-trip", () => { }); const read = await stub.getRepoConfig(); expect(read?.settings.sandbox.volumes).toEqual([ - { path: "/data", size: "10gb" }, + { path: "/data", size: "10Gi" }, ]); }); diff --git a/src/events/types.ts b/src/events/types.ts index 643033d..b04a54b 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -14,7 +14,7 @@ export type TaskRequestedEvent = { type: "task_requested"; source: EventSource; repository: { owner: string; name: string }; - issue: { number: number; url: string }; + issue: { id: number; number: number; url: string }; requester: { login: string; externalId: number }; }; diff --git a/src/services/coder/service.ts b/src/services/coder/service.ts index 924c26e..ddb1b89 100644 --- a/src/services/coder/service.ts +++ b/src/services/coder/service.ts @@ -358,8 +358,10 @@ export class CoderService implements TaskRunner { taskName: TaskName; owner: string; input: string; + templateName?: string; }): Promise { - const { taskName, owner, input } = params; + const { taskName, owner, input, templateName } = params; + const resolvedTemplateName = templateName ?? this.config.templateName; // 1. Check for an existing task const existing = await this.findTask(taskName, owner); @@ -373,7 +375,7 @@ export class CoderService implements TaskRunner { } // 2. Resolve template - const templateEndpoint = `/api/v2/organizations/${encodeURIComponent(this.config.organization)}/templates/${encodeURIComponent(this.config.templateName)}`; + const templateEndpoint = `/api/v2/organizations/${encodeURIComponent(this.config.organization)}/templates/${encodeURIComponent(resolvedTemplateName)}`; const rawTemplate = await this.request(templateEndpoint); const template = CoderSDKTemplateSchema.parse(rawTemplate); const templateVersionId = template.active_version_id; diff --git a/src/services/task-runner.ts b/src/services/task-runner.ts index dc4be40..325e74d 100644 --- a/src/services/task-runner.ts +++ b/src/services/task-runner.ts @@ -47,11 +47,13 @@ export interface TaskRunner { /** * Create a task. Returns the existing one if `(taskName, owner)` collides. + * `templateName` overrides the runner-configured default when provided. */ create(params: { taskName: TaskName; owner: string; input: string; + templateName?: string; }): Promise; /** diff --git a/src/webhooks/github/router.test.ts b/src/webhooks/github/router.test.ts index 9ae3bce..e2c90ce 100644 --- a/src/webhooks/github/router.test.ts +++ b/src/webhooks/github/router.test.ts @@ -78,6 +78,7 @@ describe("WebhookRouter", () => { expect(event.repository.owner).toBe("xmtplabs"); expect(event.repository.name).toBe("coder-action"); expect(event.issue.number).toBe(65); + expect(event.issue.id).toBe(4132709157); expect(event.issue.url).toBe( "https://github.com/xmtplabs/coder-action/issues/65", ); diff --git a/src/webhooks/github/router.ts b/src/webhooks/github/router.ts index eb377e5..a049659 100644 --- a/src/webhooks/github/router.ts +++ b/src/webhooks/github/router.ts @@ -164,6 +164,7 @@ export class WebhookRouter { name: payload.repository.name, }, issue: { + id: payload.issue.id, number: payload.issue.number, url: payload.issue.html_url, }, diff --git a/src/workflows/instance-id.test.ts b/src/workflows/instance-id.test.ts index 302187f..7c37946 100644 --- a/src/workflows/instance-id.test.ts +++ b/src/workflows/instance-id.test.ts @@ -8,7 +8,7 @@ describe("buildInstanceId", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "acme", name: "repo" }, - issue: { number: 42, url: "u" }, + issue: { id: 1, number: 42, url: "u" }, requester: { login: "u", externalId: 1 }, }; const id = buildInstanceId(event, "abc-123"); @@ -88,7 +88,7 @@ describe("buildInstanceId", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "ACME", name: "Repo.With.Dots" }, - issue: { number: 1, url: "u" }, + issue: { id: 1, number: 1, url: "u" }, requester: { login: "u", externalId: 1 }, }; const id = buildInstanceId(event, "d/e/l"); @@ -100,7 +100,7 @@ describe("buildInstanceId", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "o", name: "a".repeat(100) }, - issue: { number: 1, url: "u" }, + issue: { id: 1, number: 1, url: "u" }, requester: { login: "u", externalId: 1 }, }; const id = buildInstanceId(event, "d"); diff --git a/src/workflows/steps/create-task.test.ts b/src/workflows/steps/create-task.test.ts index 9f6257d..28dddee 100644 --- a/src/workflows/steps/create-task.test.ts +++ b/src/workflows/steps/create-task.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import type { AppConfig } from "../../config/app-config"; +import type { RepoConfig } from "../../config/repo-config-schema"; import type { TaskRequestedEvent } from "../../events/types"; import { TASK_STATUS_COMMENT_MARKER, @@ -25,14 +26,31 @@ const event: TaskRequestedEvent = { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "acme", name: "repo" }, - issue: { number: 42, url: "https://github.com/acme/repo/issues/42" }, + issue: { + id: 987654, + number: 42, + url: "https://github.com/acme/repo/issues/42", + }, requester: { login: "alice", externalId: 123 }, }; const config = { coderTaskNamePrefix: "gh", + codeFactoryTemplate: "code-factory", } as unknown as AppConfig; +function makeEnv(repoConfig: RepoConfig | null = null) { + const getRepoConfig = vi.fn(async () => repoConfig); + const stub = { getRepoConfig, setRepoConfig: vi.fn(async () => {}) }; + const env = { + REPO_CONFIG_DO: { + idFromName: vi.fn(() => "stub-id"), + get: vi.fn(() => stub), + }, + } as never; + return { env, getRepoConfig }; +} + function makeCoder(overrides: Record = {}) { return { lookupUser: vi.fn(async () => "coder-user"), @@ -62,7 +80,7 @@ function makeGithub(overrides: Record = {}) { } describe("runCreateTask", () => { - test("emits steps in order: check-github-permission (first), lookup-coder-user, create-coder-task, comment-on-issue, wait-*, update-status-comment", async () => { + test("emits steps in order: check-github-permission (first), lookup-coder-user, lookup-repo-config, create-coder-task, comment-on-issue, wait-*, update-status-comment", async () => { const step = makeStep(); const coder = makeCoder(); const github = makeGithub(); @@ -73,12 +91,14 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); // With a fast-path `active` observation at pre-poll, waitForTaskActive // emits exactly one step (`wait-lookup-task`). expect(step.calls).toEqual([ "check-github-permission", "lookup-coder-user", + "lookup-repo-config", "create-coder-task", "comment-on-issue", "wait-lookup-task", @@ -98,6 +118,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); // Only the permission check ran. expect(step.calls).toEqual(["check-github-permission"]); @@ -117,6 +138,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); const createIdx = step.do.mock.calls.findIndex( (c: unknown[]) => c[0] === "create-coder-task", @@ -143,6 +165,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); // First commentOnIssue call — the initial "Task created" comment. const firstCall = github.commentOnIssue.mock.calls[0] as unknown as [ @@ -183,6 +206,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); expect(step.calls).toContain("update-status-comment"); @@ -223,6 +247,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); expect(step.calls).toContain("update-status-comment"); @@ -256,6 +281,7 @@ describe("runCreateTask", () => { github: github as never, config, event, + env: makeEnv().env, }); expect(github.commentOnIssue).toHaveBeenCalledTimes(2); for (const call of github.commentOnIssue.mock.calls) { @@ -270,4 +296,81 @@ describe("runCreateTask", () => { expect(body.startsWith(TASK_STATUS_COMMENT_MARKER)).toBe(true); } }); + + test("no repo config → legacy prompt (issue URL) and no templateName override", async () => { + const step = makeStep(); + const coder = makeCoder(); + const github = makeGithub(); + await runCreateTask({ + step: step as never, + coder: coder as never, + github: github as never, + config, + event, + env: makeEnv(null).env, + }); + expect(coder.create).toHaveBeenCalledTimes(1); + const call = coder.create.mock.calls[0] as unknown as [ + { taskName: string; owner: string; input: string; templateName?: string }, + ]; + expect(call[0]).toEqual({ + taskName: "gh-repo-42", + owner: "coder-user", + input: "https://github.com/acme/repo/issues/42", + }); + }); + + test("repo config present → codeFactoryTemplate and JSON TemplateInputs prompt", async () => { + const step = makeStep(); + const coder = makeCoder(); + const github = makeGithub(); + const repoConfig: RepoConfig = { + repositoryId: 1, + repositoryFullName: "acme/repo", + installationId: 99, + settings: { + sandbox: { + size: "large", + docker: true, + // Resolved RepoConfigSettings always carries the canonical + // Kubernetes binary-SI size after schema normalization. + volumes: [{ path: "/data", size: "20Gi" }], + }, + harness: { provider: "codex" }, + scheduled_jobs: [], + }, + }; + await runCreateTask({ + step: step as never, + coder: coder as never, + github: github as never, + config, + event, + env: makeEnv(repoConfig).env, + }); + expect(coder.create).toHaveBeenCalledTimes(1); + const call = coder.create.mock.calls[0] as unknown as [ + { taskName: string; owner: string; input: string; templateName: string }, + ]; + const args = call[0]; + expect(args.templateName).toBe("code-factory"); + const parsed = JSON.parse(args.input); + expect(parsed).toEqual({ + repo_url: "https://github.com/acme/repo", + repo_name: "repo", + ai_prompt: [ + "ISSUE_URL: https://github.com/acme/repo/issues/42", + "REPO_OWNER: acme", + "REPO_NAME: repo", + "ISSUE_ID: 987654", + "", + "Use the /coder-task skill to resolve the issue", + "", + ].join("\n"), + ai_provider: "codex", + extra_volumes: [{ path: "/data", size: "20Gi" }], + size: "large", + docker: true, + }); + }); }); diff --git a/src/workflows/steps/create-task.ts b/src/workflows/steps/create-task.ts index 8a9c33e..7b51829 100644 --- a/src/workflows/steps/create-task.ts +++ b/src/workflows/steps/create-task.ts @@ -1,6 +1,8 @@ import type { WorkflowStep } from "cloudflare:workers"; import { generateTaskName } from "../../actions/task-naming"; import type { AppConfig } from "../../config/app-config"; +import type { RepoConfig } from "../../config/repo-config-schema"; +import type { RepoConfigDO } from "../../durable-objects/repo-config-do"; import type { TaskRequestedEvent } from "../../events/types"; import type { CoderService } from "../../services/coder/service"; import type { GitHubClient } from "../../services/github/client"; @@ -10,6 +12,7 @@ import { buildTaskStatusCommentBody, } from "../task-status-comment"; import { waitForTaskActive } from "../wait-for-task-active"; +import { buildTemplateInputs } from "./template-inputs"; export interface RunCreateTaskContext { step: WorkflowStep; @@ -17,6 +20,7 @@ export interface RunCreateTaskContext { github: GitHubClient; config: AppConfig; event: TaskRequestedEvent; + env: { REPO_CONFIG_DO: DurableObjectNamespace }; } /** @@ -29,7 +33,7 @@ export interface RunCreateTaskContext { * instances or raw SDK responses. See src/workflows/AGENTS.md. */ export async function runCreateTask(ctx: RunCreateTaskContext): Promise { - const { step, coder, github, config, event } = ctx; + const { step, coder, github, config, event, env } = ctx; const hasPermission = await step.do("check-github-permission", async () => github.checkActorPermission( @@ -59,9 +63,39 @@ export async function runCreateTask(ctx: RunCreateTaskContext): Promise { }), ); - const prompt = event.issue.url; // Default prompt = issue URL + const repoConfig = await step.do( + "lookup-repo-config", + async () => { + const fullName = `${event.repository.owner}/${event.repository.name}`; + const id = env.REPO_CONFIG_DO.idFromName(fullName); + const stub = env.REPO_CONFIG_DO.get(id); + return await stub.getRepoConfig(); + }, + ); + + // When a repo config is present we target the new template (`task-beta`) + // with a JSON `TemplateInputs` payload. Otherwise we fall back to the + // legacy template with the issue URL as a bare prompt. + const { prompt, templateName } = repoConfig + ? { + prompt: JSON.stringify( + buildTemplateInputs({ + repository: event.repository, + issue: { id: event.issue.id, url: event.issue.url }, + settings: repoConfig.settings, + }), + ), + templateName: config.codeFactoryTemplate, + } + : { prompt: event.issue.url, templateName: undefined }; + const created = await step.do("create-coder-task", async () => { - const task = await coder.create({ taskName, owner, input: prompt }); + const task = await coder.create({ + taskName, + owner, + input: prompt, + ...(templateName ? { templateName } : {}), + }); // Scalar projection per spec §4 serialization table. `taskId` keeps the // cached step output self-sufficient for any follow-up step that needs // to operate on the task by id without re-querying Coder. diff --git a/src/workflows/steps/template-inputs.ts b/src/workflows/steps/template-inputs.ts new file mode 100644 index 0000000..9f03bbc --- /dev/null +++ b/src/workflows/steps/template-inputs.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import type { RepoConfigSettings } from "../../config/repo-config-schema"; + +/** + * Zod schema for the JSON payload sent as `input` to the new Coder template + * (`code-factory`). When a repo has a `.code-factory/config.toml`, the + * workflow serializes an instance of this shape and passes it verbatim as + * the task input; the template parses it on the Terraform side. + */ +export const TemplateInputsSchema = z.object({ + repo_url: z.string(), + base_branch: z.string().optional(), + repo_name: z.string(), + ai_prompt: z.string(), + ai_provider: z.enum(["claude_code", "codex"]), + extra_volumes: z + .array(z.object({ path: z.string(), size: z.string() })) + .optional(), + size: z.enum(["small", "medium", "large"]), + docker: z.boolean(), +}); + +export type TemplateInputs = z.infer; + +export interface BuildTemplateInputsParams { + repository: { owner: string; name: string }; + issue: { id: number; url: string }; + settings: RepoConfigSettings; +} + +/** + * Compose the `ai_prompt` block consumed by the `/coder-task` skill. The + * fields are fixed key/value lines followed by the instruction to invoke the + * skill, separated by a blank line. Trailing newline is intentional. + */ +function buildAiPrompt(params: { + issueUrl: string; + repoOwner: string; + repoName: string; + issueId: number; +}): string { + return `ISSUE_URL: ${params.issueUrl} +REPO_OWNER: ${params.repoOwner} +REPO_NAME: ${params.repoName} +ISSUE_ID: ${params.issueId} + +Use the /coder-task skill to resolve the issue +`; +} + +/** + * Map a (repository, issue, resolved repo config) triple to the JSON payload + * that the new Coder template consumes. Pure — no I/O, safe inside `step.do`. + */ +export function buildTemplateInputs( + params: BuildTemplateInputsParams, +): TemplateInputs { + const { repository, issue, settings } = params; + const volumes = settings.sandbox.volumes; + return { + repo_url: `https://github.com/${repository.owner}/${repository.name}`, + repo_name: repository.name, + ai_prompt: buildAiPrompt({ + issueUrl: issue.url, + repoOwner: repository.owner, + repoName: repository.name, + issueId: issue.id, + }), + ai_provider: settings.harness.provider, + ...(volumes.length > 0 ? { extra_volumes: volumes } : {}), + size: settings.sandbox.size, + docker: settings.sandbox.docker, + }; +} diff --git a/src/workflows/task-runner-workflow.test.ts b/src/workflows/task-runner-workflow.test.ts index 1c1770b..3e56a10 100644 --- a/src/workflows/task-runner-workflow.test.ts +++ b/src/workflows/task-runner-workflow.test.ts @@ -67,7 +67,11 @@ describe("TaskRunnerWorkflow dispatch — task_requested", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "acme", name: "repo" }, - issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + issue: { + id: 1001, + number: 1, + url: "https://github.com/acme/repo/issues/1", + }, requester: { login: "alice", externalId: 42 }, }; await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); @@ -116,7 +120,11 @@ describe("TaskRunnerWorkflow dispatch — task_requested", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "acme", name: "repo" }, - issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + issue: { + id: 1001, + number: 1, + url: "https://github.com/acme/repo/issues/1", + }, requester: { login: "alice", externalId: 42 }, }; await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); @@ -183,7 +191,11 @@ describe("TaskRunnerWorkflow dispatch — task_requested", () => { }, }, repository: { owner: "acme", name: "repo" }, - issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + issue: { + id: 1001, + number: 1, + url: "https://github.com/acme/repo/issues/1", + }, requester: { login: "alice", externalId: 42 }, }; await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); @@ -247,7 +259,11 @@ describe("TaskRunnerWorkflow dispatch — task_requested", () => { type: "task_requested", source: { type: "github", installationId: 1 }, repository: { owner: "acme", name: "repo" }, - issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + issue: { + id: 1001, + number: 1, + url: "https://github.com/acme/repo/issues/1", + }, requester: { login: "alice", externalId: 42 }, }; await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); diff --git a/src/workflows/task-runner-workflow.ts b/src/workflows/task-runner-workflow.ts index 1f70d7f..5ca8485 100644 --- a/src/workflows/task-runner-workflow.ts +++ b/src/workflows/task-runner-workflow.ts @@ -96,7 +96,14 @@ export class TaskRunnerWorkflow extends WorkflowEntrypoint< switch (payload.type) { case "task_requested": - await runCreateTask({ step, coder, github, config, event: payload }); + await runCreateTask({ + step, + coder, + github, + config, + event: payload, + env: { REPO_CONFIG_DO: this.env.REPO_CONFIG_DO }, + }); break; case "task_closed": await runCloseTask({ step, coder, github, config, event: payload });