diff --git a/action.yaml b/action.yaml index ce3b9dc..646d48c 100644 --- a/action.yaml +++ b/action.yaml @@ -33,8 +33,12 @@ inputs: required: true github-user-id: - description: "GitHub user ID to create task for" - required: true + description: "GitHub user ID to create task for. If provided, `coder-username` must not be set." + required: false + + coder-username: + description: "Coder username to create task for. If provided, github-user-id must not be set. Useful for automated workflows without a triggering user." + required: false # Optional inputs coder-organization: diff --git a/dist/index.js b/dist/index.js index 1ec215a..f32a1fb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26918,13 +26918,20 @@ class CoderTaskAction { } } async run() { - core.info(`GitHub user ID: ${this.inputs.githubUserID}`); - const coderUser = await this.coder.getCoderUserByGitHubId(this.inputs.githubUserID); + let coderUsername; + if (this.inputs.coderUsername) { + core.info(`Using provided Coder username: ${this.inputs.coderUsername}`); + coderUsername = this.inputs.coderUsername; + } else { + core.info(`Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`); + const coderUser = await this.coder.getCoderUserByGitHubId(this.inputs.githubUserID); + coderUsername = coderUser.username; + } const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubIssueURL(); core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); core.info(`GitHub issue number: ${githubIssueNumber}`); - core.info(`Coder username: ${coderUser.username}`); + core.info(`Coder username: ${coderUsername}`); if (!this.inputs.coderTaskNamePrefix || !this.inputs.githubIssueURL) { throw new Error("either taskName or both taskNamePrefix and issueURL must be provided"); } @@ -26956,20 +26963,20 @@ class CoderTaskAction { throw new Error(`Preset ${this.inputs.coderTemplatePreset} not found`); } core.info(`Coder Template: Preset ID: ${presetID}`); - const existingTask = await this.coder.getTask(coderUser.username, taskName); + const existingTask = await this.coder.getTask(coderUsername, taskName); if (existingTask) { core.info(`Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`); if (existingTask.status !== "active") { core.info(`Coder Task: waiting for task ${existingTask.name} to become active...`); - await this.coder.waitForTaskActive(coderUser.username, existingTask.id, core.debug, 1200000); + await this.coder.waitForTaskActive(coderUsername, existingTask.id, core.debug, 1200000); } core.info("Coder Task: Sending prompt to existing task..."); - await this.coder.sendTaskInput(coderUser.username, existingTask.id, this.inputs.coderTaskPrompt); + await this.coder.sendTaskInput(coderUsername, existingTask.id, this.inputs.coderTaskPrompt); core.info("Coder Task: Prompt sent successfully"); return { - coderUsername: coderUser.username, + coderUsername, taskName: existingTask.name, - taskUrl: this.generateTaskUrl(coderUser.username, existingTask.id), + taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), taskCreated: false }; } @@ -26980,9 +26987,9 @@ class CoderTaskAction { template_version_preset_id: presetID, input: this.inputs.coderTaskPrompt }; - const createdTask = await this.coder.createTask(coderUser.username, req); + const createdTask = await this.coder.createTask(coderUsername, req); core.info(`Coder Task: created successfully (status: ${createdTask.status})`); - const taskUrl = this.generateTaskUrl(coderUser.username, createdTask.id); + const taskUrl = this.generateTaskUrl(coderUsername, createdTask.id); core.info(`Coder Task: URL: ${taskUrl}`); if (this.inputs.commentOnIssue) { core.info(`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`); @@ -26992,7 +26999,7 @@ class CoderTaskAction { core.info(`Skipping comment on issue (commentOnIssue is false)`); } return { - coderUsername: coderUser.username, + coderUsername, taskName, taskUrl, taskCreated: true @@ -27001,19 +27008,30 @@ class CoderTaskAction { } // src/schemas.ts -var ActionInputsSchema = exports_external.object({ +var BaseInputsSchema = exports_external.object({ coderTaskPrompt: exports_external.string().min(1), coderToken: exports_external.string().min(1), coderURL: exports_external.string().url(), coderTemplateName: exports_external.string().min(1), githubIssueURL: exports_external.string().url(), githubToken: exports_external.string(), - githubUserID: exports_external.number().min(1), coderOrganization: exports_external.string().min(1).optional().default("default"), coderTaskNamePrefix: exports_external.string().min(1).optional().default("gh"), coderTemplatePreset: exports_external.string().optional(), commentOnIssue: exports_external.boolean().default(true) }); +var WithGithubUserIDSchema = BaseInputsSchema.extend({ + githubUserID: exports_external.number().min(1), + coderUsername: exports_external.undefined() +}); +var WithCoderUsernameSchema = BaseInputsSchema.extend({ + githubUserID: exports_external.undefined(), + coderUsername: exports_external.string().min(1) +}); +var ActionInputsSchema = exports_external.union([ + WithGithubUserIDSchema, + WithCoderUsernameSchema +]); var ActionOutputsSchema = exports_external.object({ coderUsername: exports_external.string(), taskName: exports_external.string(), @@ -27024,6 +27042,8 @@ var ActionOutputsSchema = exports_external.object({ // src/index.ts async function main() { try { + const githubUserIdInput = core2.getInput("github-user-id"); + const githubUserID = githubUserIdInput ? Number.parseInt(githubUserIdInput, 10) : undefined; const inputs = ActionInputsSchema.parse({ coderURL: core2.getInput("coder-url", { required: true }), coderToken: core2.getInput("coder-token", { required: true }), @@ -27039,7 +27059,8 @@ async function main() { }), githubIssueURL: core2.getInput("github-issue-url", { required: true }), githubToken: core2.getInput("github-token", { required: true }), - githubUserID: Number.parseInt(core2.getInput("github-user-id", { required: true }), 10), + githubUserID, + coderUsername: core2.getInput("coder-username") || undefined, coderTemplatePreset: core2.getInput("coder-template-preset") || undefined, commentOnIssue: core2.getBooleanInput("comment-on-issue") }); diff --git a/src/action.test.ts b/src/action.test.ts index 5c830d6..27b1960 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -378,6 +378,46 @@ describe("CoderTaskAction", () => { assertActionOutputs(parsedResult, true); }); + test("creates new task using direct coder-username (without github-user-id)", async () => { + // Setup - no user lookup needed when coder-username is provided directly + coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue( + mockTemplate, + ); + coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]); + coderClient.mockGetTask.mockResolvedValue(null); + coderClient.mockCreateTask.mockResolvedValue(mockTask); + coderClient.mockWaitForTaskActive.mockResolvedValue(undefined); + + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: mockUser.username, + }); + const action = new CoderTaskAction( + coderClient, + octokit as unknown as Octokit, + inputs, + ); + + // Execute + const result = await action.run(); + + // Verify - should NOT call any user lookup API when username is provided directly + expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); + expect(coderClient.mockGetTask).toHaveBeenCalledWith( + mockUser.username, + mockTask.name, + ); + expect(coderClient.mockCreateTask).toHaveBeenCalledWith(mockUser.username, { + name: mockTask.name, + template_version_id: mockTemplate.active_version_id, + template_version_preset_id: undefined, + input: inputs.coderTaskPrompt, + }); + + const parsedResult = ActionOutputsSchema.parse(result); + assertActionOutputs(parsedResult, true); + }); + test("sends prompt to existing task", async () => { // Setup coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); diff --git a/src/action.ts b/src/action.ts index 2eb0bfe..248d065 100644 --- a/src/action.ts +++ b/src/action.ts @@ -104,16 +104,25 @@ export class CoderTaskAction { * Main action execution */ async run(): Promise { - core.info(`GitHub user ID: ${this.inputs.githubUserID}`); - const coderUser = await this.coder.getCoderUserByGitHubId( - this.inputs.githubUserID, - ); + let coderUsername: string; + if (this.inputs.coderUsername) { + core.info(`Using provided Coder username: ${this.inputs.coderUsername}`); + coderUsername = this.inputs.coderUsername; + } else { + core.info( + `Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`, + ); + const coderUser = await this.coder.getCoderUserByGitHubId( + this.inputs.githubUserID, + ); + coderUsername = coderUser.username; + } const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubIssueURL(); core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); core.info(`GitHub issue number: ${githubIssueNumber}`); - core.info(`Coder username: ${coderUser.username}`); + core.info(`Coder username: ${coderUsername}`); if (!this.inputs.coderTaskNamePrefix || !this.inputs.githubIssueURL) { throw new Error( "either taskName or both taskNamePrefix and issueURL must be provided", @@ -158,7 +167,7 @@ export class CoderTaskAction { } core.info(`Coder Template: Preset ID: ${presetID}`); - const existingTask = await this.coder.getTask(coderUser.username, taskName); + const existingTask = await this.coder.getTask(coderUsername, taskName); if (existingTask) { core.info( `Coder Task: already exists: ${existingTask.name} (id: ${existingTask.id} status: ${existingTask.status})`, @@ -170,7 +179,7 @@ export class CoderTaskAction { `Coder Task: waiting for task ${existingTask.name} to become active...`, ); await this.coder.waitForTaskActive( - coderUser.username, + coderUsername, existingTask.id, core.debug, 1_200_000, @@ -180,15 +189,15 @@ export class CoderTaskAction { core.info("Coder Task: Sending prompt to existing task..."); // Send prompt to existing task using the task ID (UUID) await this.coder.sendTaskInput( - coderUser.username, + coderUsername, existingTask.id, this.inputs.coderTaskPrompt, ); core.info("Coder Task: Prompt sent successfully"); return { - coderUsername: coderUser.username, + coderUsername, taskName: existingTask.name, - taskUrl: this.generateTaskUrl(coderUser.username, existingTask.id), + taskUrl: this.generateTaskUrl(coderUsername, existingTask.id), taskCreated: false, }; } @@ -201,13 +210,13 @@ export class CoderTaskAction { input: this.inputs.coderTaskPrompt, }; // Create new task - const createdTask = await this.coder.createTask(coderUser.username, req); + const createdTask = await this.coder.createTask(coderUsername, req); core.info( `Coder Task: created successfully (status: ${createdTask.status})`, ); // 5. Generate task URL - const taskUrl = this.generateTaskUrl(coderUser.username, createdTask.id); + const taskUrl = this.generateTaskUrl(coderUsername, createdTask.id); core.info(`Coder Task: URL: ${taskUrl}`); // 6. Comment on issue if requested @@ -226,8 +235,8 @@ export class CoderTaskAction { core.info(`Skipping comment on issue (commentOnIssue is false)`); } return { - coderUsername: coderUser.username, - taskName: taskName, + coderUsername, + taskName, taskUrl, taskCreated: true, }; diff --git a/src/index.ts b/src/index.ts index 956071d..6910ac0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,11 @@ import { ActionInputsSchema } from "./schemas"; async function main() { try { // Parse and validate inputs + const githubUserIdInput = core.getInput("github-user-id"); + const githubUserID = githubUserIdInput + ? Number.parseInt(githubUserIdInput, 10) + : undefined; + const inputs = ActionInputsSchema.parse({ coderURL: core.getInput("coder-url", { required: true }), coderToken: core.getInput("coder-token", { required: true }), @@ -22,10 +27,8 @@ async function main() { }), githubIssueURL: core.getInput("github-issue-url", { required: true }), githubToken: core.getInput("github-token", { required: true }), - githubUserID: Number.parseInt( - core.getInput("github-user-id", { required: true }), - 10, - ), + githubUserID, + coderUsername: core.getInput("coder-username") || undefined, coderTemplatePreset: core.getInput("coder-template-preset") || undefined, commentOnIssue: core.getBooleanInput("comment-on-issue"), }); diff --git a/src/schemas.test.ts b/src/schemas.test.ts index ccd2ad8..06bb92f 100644 --- a/src/schemas.test.ts +++ b/src/schemas.test.ts @@ -34,7 +34,7 @@ describe("ActionInputsSchema", () => { }); test("accepts all optional inputs", () => { - const input: ActionInputs = { + const input = { ...actionInputValid, coderTemplatePreset: "custom", }; @@ -54,7 +54,7 @@ describe("ActionInputsSchema", () => { ]; for (const url of validUrls) { - const input: ActionInputs = { + const input = { ...actionInputValid, coderURL: url, }; @@ -66,12 +66,12 @@ describe("ActionInputsSchema", () => { describe("Invalid Input Cases", () => { test("rejects missing required fields", () => { - const input = {} as ActionInputs; + const input = {}; expect(() => ActionInputsSchema.parse(input)).toThrow(); }); test("rejects invalid URL format for coderUrl", () => { - const input: ActionInputs = { + const input = { ...actionInputValid, coderURL: "not-a-url", }; @@ -79,7 +79,7 @@ describe("ActionInputsSchema", () => { }); test("rejects invalid URL format for issueUrl", () => { - const input: ActionInputs = { + const input = { ...actionInputValid, githubIssueURL: "not-a-url", }; @@ -87,11 +87,62 @@ describe("ActionInputsSchema", () => { }); test("rejects empty strings for required fields", () => { - const input: ActionInputs = { + const input = { ...actionInputValid, coderToken: "", }; expect(() => ActionInputsSchema.parse(input)).toThrow(); }); }); + + describe("User Identification (Union Validation)", () => { + test("accepts input with only githubUserID", () => { + const result = ActionInputsSchema.parse(actionInputValid); + expect(result.githubUserID).toBe(12345); + expect(result.coderUsername).toBeUndefined(); + }); + + test("accepts input with only coderUsername", () => { + const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; + const input = { ...withoutGithubUserID, coderUsername: "testuser" }; + const result = ActionInputsSchema.parse(input); + expect(result.coderUsername).toBe("testuser"); + expect(result.githubUserID).toBeUndefined(); + }); + + test("rejects input with both githubUserID and coderUsername", () => { + const input = { + ...actionInputValid, + coderUsername: "testuser", + }; + expect(() => ActionInputsSchema.parse(input)).toThrow(); + }); + + test("rejects input with neither githubUserID nor coderUsername", () => { + const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; + expect(() => ActionInputsSchema.parse(withoutGithubUserID)).toThrow(); + }); + + test("rejects githubUserID of 0", () => { + const input = { + ...actionInputValid, + githubUserID: 0, + }; + expect(() => ActionInputsSchema.parse(input)).toThrow(); + }); + + test("rejects negative githubUserID", () => { + const input = { + ...actionInputValid, + githubUserID: -1, + }; + expect(() => ActionInputsSchema.parse(input)).toThrow(); + }); + + test("rejects empty coderUsername", () => { + const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; + const input = { ...withoutGithubUserID, coderUsername: "" }; + expect(() => ActionInputsSchema.parse(input)).toThrow(); + }); + }); }); diff --git a/src/schemas.ts b/src/schemas.ts index 295f854..437e836 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,23 +1,35 @@ import { z } from "zod"; -export type ActionInputs = z.infer; - -export const ActionInputsSchema = z.object({ - // Required +const BaseInputsSchema = z.object({ coderTaskPrompt: z.string().min(1), coderToken: z.string().min(1), coderURL: z.string().url(), coderTemplateName: z.string().min(1), githubIssueURL: z.string().url(), githubToken: z.string(), - githubUserID: z.number().min(1), - // Optional coderOrganization: z.string().min(1).optional().default("default"), coderTaskNamePrefix: z.string().min(1).optional().default("gh"), coderTemplatePreset: z.string().optional(), commentOnIssue: z.boolean().default(true), }); +const WithGithubUserIDSchema = BaseInputsSchema.extend({ + githubUserID: z.number().min(1), + coderUsername: z.undefined(), +}); + +const WithCoderUsernameSchema = BaseInputsSchema.extend({ + githubUserID: z.undefined(), + coderUsername: z.string().min(1), +}); + +export const ActionInputsSchema = z.union([ + WithGithubUserIDSchema, + WithCoderUsernameSchema, +]); + +export type ActionInputs = z.infer; + export const ActionOutputsSchema = z.object({ coderUsername: z.string(), taskName: z.string(), diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 65e0e56..6c3cb1a 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -119,7 +119,7 @@ export function createMockInputs( githubUserID: 12345, commentOnIssue: true, // default value from schema ...overrides, - }; + } as ActionInputs; } /**