Compare commits

...

18 Commits

Author SHA1 Message Date
Cian Johnston e29335e7e1 getting there 2025-10-28 15:19:50 +00:00
Cian Johnston defb8d080a add test with and without preset id 2025-10-23 16:54:29 +01:00
Cian Johnston c6151931b9 fix 2025-10-23 16:50:14 +01:00
Cian Johnston ce5e368f40 more wip 2025-10-23 11:11:36 +01:00
Cian Johnston 884d4b5d0e fix schemas 2025-10-21 21:44:56 +01:00
Cian Johnston f54fee0f72 fix 2025-10-21 21:01:48 +01:00
Cian Johnston e8b436ef61 moving things around 2025-10-21 20:59:18 +01:00
Cian Johnston 0e99594b70 linter finally not angry 2025-10-21 17:36:53 +01:00
Cian Johnston bd669b352d linter fixes 2025-10-21 15:41:00 +01:00
Cian Johnston 7437fdf535 address more linter errors 2025-10-21 13:58:48 +01:00
Cian Johnston ee8e2213b0 linter fixes 2025-10-21 13:00:12 +01:00
Cian Johnston 15175a4780 dry 2025-10-21 12:40:15 +01:00
Cian Johnston ee64d91eac s/jest/mock 2025-10-21 12:32:26 +01:00
Cian Johnston 91bf3cfc28 fix tests 2025-10-21 12:22:09 +01:00
Cian Johnston 03559ade48 replace console.log with core.debug 2025-10-20 17:44:02 +01:00
Cian Johnston 15730d40fc add lint script 2025-10-20 17:43:34 +01:00
Cian Johnston 295869bef1 fixup! wip 2025-10-20 17:17:57 +01:00
Cian Johnston 2fe1353f7a wip 2025-10-20 17:06:21 +01:00
14 changed files with 2199 additions and 121 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+86
View File
@@ -0,0 +1,86 @@
name: "Create Coder Task"
description: "Create a Coder task for a GitHub user, with support for issue commenting"
inputs:
# Required: Coder configuration
coder-url:
description: "Coder deployment URL"
required: true
coder-token:
description: "Coder session token for authentication"
required: true
# Required: Task configuration
template-name:
description: "Coder template to use for workspace"
required: true
task-prompt:
description: "Prompt/instructions to send to the task"
required: true
# Optional: User identification
github-user-id:
description: "GitHub user ID (defaults to event sender)"
required: false
github-username:
description: "GitHub username (defaults to event sender)"
required: false
# Optional: Task configuration
template-preset:
description: "Template preset to use"
required: false
default: "Default"
task-name-prefix:
description: "Prefix for task name"
required: false
default: "task"
task-name:
description: "Full task name (overrides auto-generation)"
required: false
organization:
description: "Coder organization name"
required: false
default: "coder"
# Optional: Issue integration
issue-url:
description: "GitHub issue URL to comment on"
required: false
comment-on-issue:
description: "Whether to comment on the issue"
required: false
default: "true"
coder-web-url:
description: "Coder web UI URL for task links (defaults to coder-url)"
required: false
# GitHub token for API operations
github-token:
description: "GitHub token for commenting on issues"
required: true
outputs:
coder-username:
description: "The Coder username resolved from GitHub user"
task-name:
description: "The full task name (username/task-name)"
task-url:
description: "The URL to view the task in Coder"
task-exists:
description: "Whether the task already existed (true/false)"
runs:
using: "node20"
main: "dist/index.js"
+158
View File
@@ -0,0 +1,158 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "coder-task-action",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
"@octokit/rest": "^21.1.1",
"zod": "^3.24.2",
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/bun": "latest",
"typescript": "^5.0.0",
},
},
},
"packages": {
"@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
"@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="],
"@actions/github": ["@actions/github@6.0.1", "", { "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", "@octokit/plugin-paginate-rest": "^9.2.2", "@octokit/plugin-rest-endpoint-methods": "^10.4.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "undici": "^5.28.5" } }, "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw=="],
"@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
"@octokit/core": ["@octokit/core@5.2.2", "", { "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.0.0", "before-after-hook": "^2.2.0", "universal-user-agent": "^6.0.0" } }, "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg=="],
"@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="],
"@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="],
"@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@9.2.2", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@5.3.1", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@10.4.1", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg=="],
"@octokit/request": ["@octokit/request@8.4.1", "", { "dependencies": { "@octokit/endpoint": "^9.0.6", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw=="],
"@octokit/request-error": ["@octokit/request-error@5.1.1", "", { "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", "once": "^1.4.0" } }, "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g=="],
"@octokit/rest": ["@octokit/rest@21.1.1", "", { "dependencies": { "@octokit/core": "^6.1.4", "@octokit/plugin-paginate-rest": "^11.4.2", "@octokit/plugin-request-log": "^5.3.1", "@octokit/plugin-rest-endpoint-methods": "^13.3.0" } }, "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg=="],
"@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="],
"fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@octokit/plugin-request-log/@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@octokit/rest/@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="],
"@octokit/rest/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@11.6.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw=="],
"@octokit/rest/@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@13.5.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@octokit/plugin-request-log/@octokit/core/before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="],
"@octokit/plugin-request-log/@octokit/core/universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="],
"@octokit/rest/@octokit/core/@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="],
"@octokit/rest/@octokit/core/@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="],
"@octokit/rest/@octokit/core/@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="],
"@octokit/rest/@octokit/core/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="],
"@octokit/rest/@octokit/core/universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="],
"@octokit/plugin-request-log/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="],
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "coder-task-action",
"version": "1.0.0",
"description": "GitHub Action to create and manage Coder tasks",
"main": "dist/index.js",
"scripts": {
"build": "bun build src/index.ts --outfile dist/index.js --target node",
"dev": "bun run --watch src/index.ts",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint --error-on-warnings .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
"@octokit/rest": "^21.1.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/bun": "latest",
"typescript": "^5.0.0"
}
}
@@ -0,0 +1,682 @@
import { describe, expect, test, beforeEach } from "bun:test";
import { CoderTaskAction } from "./action";
import type { Octokit } from "./action";
import {
MockCoderClient,
createMockOctokit,
createMockInputs,
mockUser,
mockTask,
mockTemplate,
} from "./test-helpers";
describe("CoderTaskAction", () => {
let coderClient: MockCoderClient;
let octokit: ReturnType<typeof createMockOctokit>;
beforeEach(() => {
coderClient = new MockCoderClient();
octokit = createMockOctokit();
});
describe("parseGithubIssueUrl", () => {
test("parses valid GitHub issue URL", () => {
const inputs = createMockInputs({
githubIssueURL: "https://github.com/owner/repo/issues/123",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (
action as unknown as CoderTaskAction
).parseGithubIssueURL();
expect(result).toEqual({
githubOrg: "owner",
githubRepo: "repo",
githubIssueNumber: 123,
});
});
test("throws when no issue URL provided", () => {
const inputs = createMockInputs({ githubIssueURL: undefined });
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (
action as unknown as CoderTaskAction
).parseGithubIssueURL();
expect(result).toThrowError("Missing issue URL");
});
test("throws for invalid URL format", () => {
const inputs = createMockInputs({ githubIssueURL: "not-a-url" });
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (
action as unknown as CoderTaskAction
).parseGithubIssueURL();
expect(result).toThrowError("Invalid issue URL: not-a-url");
});
test("handled non-github.com URL", () => {
const inputs = createMockInputs({
githubIssueURL: "https://code.acme.com/owner/repo/issues/123",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (
action as unknown as CoderTaskAction
).parseGithubIssueURL();
expect(result).toEqual({
githubOrg: "owner",
githubRepo: "repo",
githubIssueNumber: 123,
});
});
test("handles URL with trailing junk", () => {
const inputs = createMockInputs({
githubIssueURL:
"https://github.com/owner/repo/issues/123/?param=value#anchor",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (
action as unknown as CoderTaskAction
).parseGithubIssueURL();
// Should still parse correctly
expect(result).toEqual({
githubOrg: "owner",
githubRepo: "repo",
githubIssueNumber: 123,
});
});
});
describe("generateTaskUrl", () => {
test("generates correct task URL", () => {
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (action as unknown as CoderTaskAction).generateTaskUrl(
"testuser",
"task-123",
);
expect(result).toBe("https://coder.test/tasks/testuser/task-123");
});
test("handles URL with trailing junk", () => {
const inputs = createMockInputs({
coderURL: "https://coder.test/?param=value#anchor",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
const result = (action as unknown as CoderTaskAction).generateTaskUrl(
"testuser",
"task-123",
);
// Should not have double slash
expect(result).toBe("https://coder.test//tasks/testuser/task-123");
});
});
describe("commentOnIssue", () => {
describe("Success Cases", () => {
test("creates new comment when none exists", async () => {
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.createComment>,
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await (action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"owner",
"repo",
123,
);
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
issue_number: 123,
body: "Task created: https://coder.test/tasks/testuser/task-123",
});
});
test("updates existing Task created comment", async () => {
octokit.rest.issues.listComments.mockResolvedValue({
data: [
{ id: 1, body: "Task created: old-url" },
{ id: 2, body: "Other comment" },
{ id: 3, body: "Task created: another-old-url" },
],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.updateComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.updateComment>,
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await (action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"owner",
"repo",
123,
);
// Should update the last "Task created:" comment
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
comment_id: 3,
body: "Task created: https://coder.test/tasks/testuser/task-123",
});
});
test("parses owner/repo/issue from URL correctly", async () => {
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.createComment>,
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await (action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"different-owner",
"different-repo",
456,
);
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: "different-owner",
repo: "different-repo",
issue_number: 456,
body: "Task created: https://coder.test/tasks/testuser/task-123",
});
});
});
describe("Error Cases", () => {
test("warns but doesn't fail on GitHub API error", async () => {
octokit.rest.issues.listComments.mockRejectedValue(
new Error("API Error"),
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Should not throw
expect(
(action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"owner",
"repo",
123,
),
).resolves.toBeUndefined();
});
test("warns but doesn't fail on permission error", async () => {
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockRejectedValue(
new Error("Permission denied"),
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Should not throw
expect(
(action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"owner",
"repo",
123,
),
).resolves.toBeUndefined();
});
});
describe("Edge Cases", () => {
test("updates last comment when multiple Task created comments exist", async () => {
octokit.rest.issues.listComments.mockResolvedValue({
data: [
{ id: 1, body: "Task created: url1" },
{ id: 2, body: "Other comment" },
{ id: 3, body: "Task created: url2" },
{ id: 4, body: "Another comment" },
{ id: 5, body: "Task created: url3" },
],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.updateComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.updateComment>,
);
const inputs = createMockInputs();
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await (action as unknown as CoderTaskAction).commentOnIssue(
"https://coder.test/tasks/testuser/task-123",
"owner",
"repo",
123,
);
// Should update comment 5 (last Task created comment)
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
expect.objectContaining({
comment_id: 5,
}),
);
});
});
});
test("creates new task successfully", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
const inputs = createMockInputs({
githubUserID: 12345,
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Execute
const result = await action.run();
// Verify
expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith(12345);
expect(coderClient.mockGetTask).toHaveBeenCalledWith(
mockUser.username,
mockTask.name,
);
expect(coderClient.mockCreateTask).toHaveBeenCalledWith({
username: mockUser.username,
name: mockTask.name,
template_id: mockTemplate.id,
input: "idk",
});
expect(result.coderUsername).toBe("testuser");
expect(result.taskCreated).toBe(false);
expect(result.taskUrl).toContain("/tasks/testuser/");
});
test("sends prompt to existing task", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockGetTask.mockResolvedValue(mockTask);
coderClient.mockSendTaskInput.mockResolvedValue(undefined);
const inputs = createMockInputs({
githubUserID: 12345,
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Execute
const result = await action.run();
// Verify
expect(coderClient.mockGetTask).toHaveBeenCalledWith(
mockUser.username,
mockTask.name,
);
expect(coderClient.mockSendTaskInput).toHaveBeenCalledWith(mockTask.id, {
prompt: "test prompt",
});
expect(coderClient.mockCreateTask).not.toHaveBeenCalled();
expect(result.taskCreated).toBe(false);
});
test("errors without issue URL", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
const inputs = createMockInputs({
githubUserID: 12345,
githubIssueURL: undefined,
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Execute
expect(action.run()).rejects.toThrowError("Missing issue URL");
});
test("comments on issue", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.updateComment>,
);
const inputs = createMockInputs({
githubUserID: 12345,
githubIssueURL: "https://github.com/owner/repo/issues/123",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Execute
await action.run();
// Verify
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
issue_number: 123,
body: "Task created: https://coder.test/tasks/testuser/task-123",
}),
);
});
test("updates existing comment on issue", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
octokit.rest.issues.listComments.mockResolvedValue({
data: [
{
id: 23455,
body: "An unrelated comment",
},
{
id: 23456,
body: "Task created:",
},
{
id: 23457,
body: "Another unrelated comment",
},
],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.updateComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.updateComment>,
);
const inputs = createMockInputs({
githubUserID: 12345,
githubIssueURL: "https://github.com/owner/repo/issues/123",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
// Execute
await action.run();
// Verify
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
comment_id: 23456,
body: "Task created: https://coder.test/tasks/testuser/task-123",
}),
);
});
test("handles error when comment on issue fails", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockRejectedValue(
new Error("Failed to comment on issue"),
);
const inputs = createMockInputs({
githubUserID: 12345,
githubIssueURL: "https://github.com/owner/repo/issues/123",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await action.run();
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
issue_number: 123,
}),
);
});
describe("run - Error Scenarios", () => {
test("throws error when Coder user not found", async () => {
coderClient.mockGetCoderUserByGithubID.mockRejectedValue(
new Error("No Coder user found with GitHub user ID 12345"),
);
const inputs = createMockInputs({ githubUserID: 12345 });
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
expect(action.run()).rejects.toThrow(
"No Coder user found with GitHub user ID 12345",
);
});
test("throws error when template not found", async () => {
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockRejectedValue(
new Error("Template not found"),
);
coderClient.mockCreateTask.mockRejectedValue(
new Error("Template not found: nonexistent"),
);
const inputs = createMockInputs({
githubUserID: 12345,
coderTemplateName: "nonexistent",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
expect(action.run()).rejects.toThrow("Template not found");
});
test("throws error when task creation fails", async () => {
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockRejectedValue(
new Error("Failed to create task"),
);
const inputs = createMockInputs({ githubUserID: 12345 });
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
expect(action.run()).rejects.toThrow("Failed to create task");
});
test("throws error on permission denied", async () => {
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockRejectedValue(
new Error("Permission denied"),
);
const inputs = createMockInputs({ githubUserID: 12345 });
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
expect(action.run()).rejects.toThrow("Permission denied");
});
});
// NOTE: this may or may not work in the real world depending on the permissions of the user
test("handles cross-repository issue", async () => {
// Setup
coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser);
coderClient.mockGetTask.mockResolvedValue(null);
coderClient.mockGetTemplateByOrganizationAndName.mockResolvedValue(
mockTemplate,
);
coderClient.mockGetTemplateVersionPresets.mockResolvedValue([]);
coderClient.mockCreateTask.mockResolvedValue(mockTask);
octokit.rest.issues.listComments.mockResolvedValue({
data: [],
} as ReturnType<typeof octokit.rest.issues.listComments>);
octokit.rest.issues.createComment.mockResolvedValue(
{} as ReturnType<typeof octokit.rest.issues.createComment>,
);
const inputs = createMockInputs({
githubIssueURL:
"https://github.com/different-owner/different-repo/issues/456",
});
const action = new CoderTaskAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);
await action.run();
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
owner: "different-owner",
repo: "different-repo",
issue_number: 456,
}),
);
});
});
+198
View File
@@ -0,0 +1,198 @@
import * as core from "@actions/core";
import {
ExperimentalCoderSDKCreateTaskRequest,
type CoderClient,
} from "./coder-client";
import type { ActionInputs, ActionOutputs } from "./schemas";
import type { getOctokit } from "@actions/github";
export type Octokit = ReturnType<typeof getOctokit>;
export class CoderTaskAction {
constructor(
private readonly coder: CoderClient,
private readonly octokit: Octokit,
private readonly inputs: ActionInputs,
) {}
/**
* Parse owner and repo from issue URL
*/
parseGithubIssueURL(): {
githubOrg: string;
githubRepo: string;
githubIssueNumber: number;
} {
if (!this.inputs.githubIssueURL) {
throw new Error(`Missing issue URL`);
}
// Parse: https://github.com/owner/repo/issues/123
const match = this.inputs.githubIssueURL.match(
/([^/]+)\/([^/]+)\/issues\/(\d+)/,
);
if (!match) {
throw new Error(`Invalid issue URL: ${this.inputs.githubIssueURL}`);
}
return {
githubOrg: match[1],
githubRepo: match[2],
githubIssueNumber: parseInt(match[3], 10),
};
}
/**
* Generate task URL
*/
generateTaskUrl(coderUsername: string, taskName: string): string {
return `${this.inputs.coderURL}/tasks/${coderUsername}/${taskName}`;
}
/**
* Comment on GitHub issue with task link
*/
async commentOnIssue(
taskUrl: string,
owner: string,
repo: string,
issueNumber: number,
): Promise<void> {
const body = `Task created: ${taskUrl}`;
try {
// Try to find existing comment from bot
const { data: comments } = await this.octokit.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
});
// Find the last comment that starts with "Task created:"
const existingComment = comments
.reverse()
.find((comment: { body?: string }) =>
comment.body?.startsWith("Task created:"),
);
if (existingComment) {
// Update existing comment
await this.octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
} else {
// Create new comment
await this.octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
}
} catch (error) {
core.error(`Failed to comment on issue: ${error}`);
}
}
/**
* Main action execution
*/
async run(): Promise<ActionOutputs> {
core.debug(`GitHub user ID: ${this.inputs.githubUserID}`);
const coderUser = await this.coder.getCoderUserByGitHubId(
this.inputs.githubUserID,
);
const { githubOrg, githubRepo, githubIssueNumber } =
this.parseGithubIssueURL();
core.debug(`GitHub owner: ${githubOrg}`);
core.debug(`GitHub repo: ${githubRepo}`);
core.debug(`GitHub issue number: ${githubIssueNumber}`);
core.debug(`Coder username: ${coderUser.username}`);
if (!this.inputs.coderTaskNamePrefix || !this.inputs.githubIssueURL) {
throw new Error(
"either taskName or both taskNamePrefix and issueURL must be provided",
);
}
const taskName = `${this.inputs.coderTaskNamePrefix}-${githubIssueNumber}`;
core.debug(`Coder Task name: ${taskName}`);
const template = await this.coder.getTemplateByOrganizationAndName(
this.inputs.coderOrganization,
this.inputs.coderTemplateName,
);
core.debug(
`Coder Template: ${template.name} (id:${template.id}, active_version_id:${template.active_version_id})`,
);
const templateVersionPresets = await this.coder.getTemplateVersionPresets(
template.active_version_id,
);
let presetID = undefined;
// If no preset specified, use default preset
if (!this.inputs.coderTemplatePreset) {
for (const preset of templateVersionPresets) {
if (preset.Name === this.inputs.coderTemplatePreset) {
presetID = preset.ID;
core.debug(`Coder Template Preset ID: ${presetID}`);
break;
}
}
// User requested a preset that does not exist
if (this.inputs.coderTemplatePreset && !presetID) {
throw new Error(`Preset ${this.inputs.coderTemplatePreset} not found`);
}
}
const existingTask = await this.coder.getTask(coderUser.username, taskName);
if (existingTask) {
core.debug(`Task already exists: ${existingTask.id}`);
core.debug("Sending prompt to existing task...");
// Send prompt to existing task
await this.coder.sendTaskInput(
coderUser.username,
taskName,
this.inputs.coderTaskPrompt,
);
core.debug("Prompt sent successfully");
return {
coderUsername: coderUser.username,
taskName: existingTask.name,
taskUrl: this.generateTaskUrl(coderUser.username, taskName),
taskCreated: false,
};
}
core.debug("Creating Coder task...");
const req: ExperimentalCoderSDKCreateTaskRequest = {
name: taskName,
template_version_id: this.inputs.coderTemplateName,
template_version_preset_id: presetID,
input: this.inputs.coderTaskPrompt,
};
// Create new task
const createdTask = await this.coder.createTask(coderUser.username, req);
core.debug("Task created successfully");
// 5. Generate task URL
const taskUrl = this.generateTaskUrl(coderUser.username, createdTask.name);
core.debug(`Task URL: ${taskUrl}`);
// 6. Comment on issue if requested
core.debug(
`Commenting on issue ${githubOrg}/${githubRepo}#${githubIssueNumber}`,
);
await this.commentOnIssue(
taskUrl,
githubOrg,
githubRepo,
githubIssueNumber,
);
core.debug(`Comment posted successfully`);
return {
coderUsername: coderUser.username,
taskName: taskName,
taskUrl,
taskCreated: true,
};
}
}
@@ -0,0 +1,326 @@
import { describe, expect, test, beforeEach, mock } from "bun:test";
import {
RealCoderClient,
CoderAPIError,
ExperimentalCoderSDKCreateTaskRequestSchema,
ExperimentalCoderSDKCreateTaskRequest,
} from "./coder-client";
import {
mockUser,
mockUserList,
mockUserListEmpty,
mockUserListDuplicate,
mockTemplate,
mockTemplateVersionPresets,
mockTask,
mockTaskList,
mockTaskListEmpty,
createMockInputs,
createMockResponse,
mockTemplateVersionPreset,
} from "./test-helpers";
describe("CoderClient", () => {
let client: RealCoderClient;
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
const mockInputs = createMockInputs();
client = new RealCoderClient(mockInputs.coderURL, mockInputs.coderToken);
mockFetch = mock(() => Promise.resolve(createMockResponse([])));
global.fetch = mockFetch as unknown as typeof fetch;
});
describe("getCoderUserByGitHubId", () => {
test("returns the user when found", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockUserList));
const result = await client.getCoderUserByGitHubId(
mockUser.github_com_user_id,
);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/users?q=github_com_user_id%3A${mockUser.github_com_user_id!.toString()}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
expect(result.id).toBe(mockUser.id);
expect(result.username).toBe(mockUser.username);
expect(result.github_com_user_id).toBe(mockUser.github_com_user_id);
});
test("throws an error if multiple Coder users are found with the same GitHub ID", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockUserListDuplicate));
expect(
client.getCoderUserByGitHubId(mockUser.github_com_user_id!),
).rejects.toThrow(CoderAPIError);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/users?q=github_com_user_id%3A${mockUser.github_com_user_id!.toString()}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
});
test("throws an error if no Coder user is found with the given GitHub ID", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockUserListEmpty));
expect(
client.getCoderUserByGitHubId(mockUser.github_com_user_id!),
).rejects.toThrow(CoderAPIError);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/users?q=github_com_user_id%3A${mockUser.github_com_user_id}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
});
test("throws error on 401 unauthorized", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
{ error: "Unauthorized" },
{ ok: false, status: 401, statusText: "Unauthorized" },
),
);
expect(
client.getCoderUserByGitHubId(mockUser.github_com_user_id!),
).rejects.toThrow(CoderAPIError);
});
test("throws error on 500 server error", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
{ error: "Internal Server Error" },
{ ok: false, status: 500, statusText: "Internal Server Error" },
),
);
expect(
client.getCoderUserByGitHubId(mockUser.github_com_user_id!),
).rejects.toThrow(CoderAPIError);
});
test("throws an error when GitHub user ID is 0", async () => {
mockFetch.mockResolvedValue(createMockResponse([mockUser]));
expect(client.getCoderUserByGitHubId(0)).rejects.toThrow(
"GitHub user ID cannot be 0",
);
});
});
describe("getTemplateByOrganizationAndName", () => {
test("the given template is returned successfully if it exists", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockTemplate));
const mockInputs = createMockInputs();
const result = await client.getTemplateByOrganizationAndName(
mockInputs.coderOrganization,
mockTemplate.name,
);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/organizations/${mockInputs.coderOrganization}/templates/${mockTemplate.name}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
expect(result.id).toBe(mockTemplate.id);
expect(result.name).toBe(mockTemplate.name);
expect(result.active_version_id).toBe(mockTemplate.active_version_id);
});
test("throws an error when the given template is not found", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
{ error: "Not found" },
{ ok: false, status: 404, statusText: "Not Found" },
),
);
const mockInputs = createMockInputs();
expect(
client.getTemplateByOrganizationAndName(
mockInputs.coderOrganization,
"nonexistent",
),
).rejects.toThrow(CoderAPIError);
});
});
describe("getTemplateVersionPresets", () => {
test("returns template version presets", async () => {
mockFetch.mockResolvedValue(
createMockResponse(mockTemplateVersionPresets),
);
const result = await client.getTemplateVersionPresets(
mockTemplate.active_version_id,
);
expect(result).not.toBeNull();
expect(result).toHaveLength(mockTemplateVersionPresets.length);
for (let idx = 0; idx < result.length; idx++) {
expect(result[idx].ID).toBe(mockTemplateVersionPresets[idx].ID);
expect(result[idx].Name).toBe(mockTemplateVersionPresets[idx].Name);
}
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/templateversions/${mockTemplate.active_version_id}/presets`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
});
});
describe("getTask", () => {
test("returns task when task exists", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockTaskList));
const result = await client.getTask(mockUser.username, mockTask.name);
expect(result).not.toBeNull();
expect(result?.id).toBe(mockTask.id);
expect(result?.name).toBe(mockTask.name);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/experimental/tasks?q=owner%3A${mockUser.username}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
});
test("returns null when task doesn't exist (404)", async () => {
mockFetch.mockResolvedValue(createMockResponse(mockTaskListEmpty));
const result = await client.getTask(mockUser.username, mockTask.name);
expect(result).toBeNull();
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/experimental/tasks?q=owner%3A${mockUser.username}`,
expect.objectContaining({
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
}),
);
});
});
describe("createTask", () => {
test("creates task successfully given valid input", async () => {
mockFetch.mockResolvedValueOnce(createMockResponse(mockTask));
const mockInputs = createMockInputs();
const result = await client.createTask(mockUser.username, {
name: mockTask.name,
template_version_id: mockTemplate.active_version_id,
input: mockInputs.coderTaskPrompt,
});
expect(result.id).toBe(mockTask.id);
expect(result.name).toBe(mockTask.name);
expect(mockFetch).toHaveBeenNthCalledWith(
1,
`https://coder.test/api/experimental/tasks/${mockUser.username}`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
body: JSON.stringify({
name: mockTask.name,
template_version_id: mockTemplate.active_version_id,
input: mockInputs.coderTaskPrompt,
}),
}),
);
});
test("creates task successfully with a given preset", async () => {
mockFetch.mockResolvedValueOnce(createMockResponse(mockTask));
const mockInputs = {
...createMockInputs(),
template_version_preset_id: mockTemplateVersionPreset.ID,
};
const result = await client.createTask(mockUser.username, {
name: mockTask.name,
template_version_id: mockTemplate.active_version_id,
template_version_preset_id: mockTemplateVersionPreset.ID,
input: mockInputs.coderTaskPrompt,
});
expect(result.id).toBe(mockTask.id);
expect(result.name).toBe(mockTask.name);
expect(mockFetch).toHaveBeenNthCalledWith(
1,
`https://coder.test/api/experimental/tasks/${mockUser.username}`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Coder-Session-Token": "test-token",
}),
body: JSON.stringify({
name: mockTask.name,
template_version_id: mockTemplate.active_version_id,
template_version_preset_id: mockTemplateVersionPreset.ID,
input: mockInputs.coderTaskPrompt,
}),
}),
);
});
});
describe("sendTaskInput", () => {
test("sends input successfully", async () => {
mockFetch.mockResolvedValue(createMockResponse({}));
const testInput = "Test input";
await client.sendTaskInput(mockUser.username, mockTask.name, testInput);
expect(mockFetch).toHaveBeenCalledWith(
`https://coder.test/api/v2/users/${mockUser.username}/tasks/${mockTask.name}/send`,
expect.objectContaining({
method: "POST",
body: expect.stringContaining(testInput),
}),
);
});
test("request body contains input field", async () => {
mockFetch.mockResolvedValue(createMockResponse({}));
const testInput = "Test input";
await client.sendTaskInput(mockUser.username, mockTask.name, testInput);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body);
expect(body.input).toBe(testInput);
});
test("throws error when task not found (404)", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
{ error: "Not Found" },
{ ok: false, status: 404, statusText: "Not Found" },
),
);
const testInput = "Test input";
expect(
client.sendTaskInput(mockUser.username, mockTask.name, testInput),
).rejects.toThrow(CoderAPIError);
});
test("throws error when task not running (400)", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
{ error: "Bad Request" },
{ ok: false, status: 400, statusText: "Bad Request" },
),
);
const testInput = "Test input";
expect(
client.sendTaskInput(mockUser.username, mockTask.name, testInput),
).rejects.toThrow(CoderAPIError);
});
});
});
@@ -0,0 +1,271 @@
import { z } from "zod";
export interface CoderClient {
getCoderUserByGitHubId(
githubUserId: number | undefined,
): Promise<CoderSDKUser>;
getTemplateByOrganizationAndName(
organizationName: string,
templateName: string,
): Promise<CoderSDKTemplate>;
getTemplateVersionPresets(
templateVersionId: string,
): Promise<CoderSDKTemplateVersionPreset[]>;
getTask(
owner: string,
taskName: string,
): Promise<ExperimentalCoderSDKTask | null>;
createTask(
owner: string,
params: ExperimentalCoderSDKCreateTaskRequest,
): Promise<ExperimentalCoderSDKTask>;
sendTaskInput(owner: string, taskName: string, input: string): Promise<void>;
}
// CoderClient provides a minimal set of methods for interacting with the Coder API.
export class RealCoderClient implements CoderClient {
private readonly headers: Record<string, string>;
constructor(
private readonly serverURL: string,
apiToken: string,
) {
this.headers = {
"Coder-Session-Token": apiToken,
"Content-Type": "application/json",
};
}
private async request<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const url = `${this.serverURL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: { ...this.headers, ...options?.headers },
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new CoderAPIError(
`Coder API error: ${response.statusText}`,
response.status,
body,
);
}
return response.json() as Promise<T>;
}
/**
* getCoderUserByGitHubId retrieves an existing Coder user with the given GitHub user ID using Coder's stable API.
* Throws an error if more than one user exists with the same GitHub user ID or if a GitHub user ID of 0 is provided.
*/
async getCoderUserByGitHubId(
githubUserId: number | undefined,
): Promise<CoderSDKUser> {
if (githubUserId === undefined) {
throw new CoderAPIError("GitHub user ID cannot be undefined", 400);
}
if (githubUserId === 0) {
throw "GitHub user ID cannot be 0";
}
const endpoint = `/api/v2/users?q=${encodeURIComponent(`github_com_user_id:${githubUserId}`)}`;
const response = await this.request<unknown[]>(endpoint);
const userList = CoderSDKGetUsersResponseSchema.parse(response);
if (userList.users.length === 0) {
throw new CoderAPIError(
`No Coder user found with GitHub user ID ${githubUserId}`,
404,
);
}
if (userList.users.length > 1) {
throw new CoderAPIError(
`Multiple Coder users found with GitHub user ID ${githubUserId}`,
409,
);
}
return CoderSDKUserSchema.parse(userList.users[0]);
}
/**
* getTemplateByOrganizationAndName retrieves a template via Coder's stable API.
*/
async getTemplateByOrganizationAndName(
organizationName: string,
templateName: string,
): Promise<CoderSDKTemplate> {
const endpoint = `/api/v2/organizations/${encodeURIComponent(organizationName)}/templates/${encodeURIComponent(templateName)}`;
const response =
await this.request<typeof CoderSDKTemplateSchema>(endpoint);
return CoderSDKTemplateSchema.parse(response);
}
/**
* getTemplateVersionPresets retrieves the presets for a given template version (UUID).
*/
async getTemplateVersionPresets(
templateVersionId: string,
): Promise<CoderSDKTemplateVersionPresetsResponse> {
const endpoint = `/api/v2/templateversions/${encodeURIComponent(templateVersionId)}/presets`;
const response =
await this.request<CoderSDKTemplateVersionPresetsResponse>(endpoint);
return CoderSDKTemplateVersionPresetsResponseSchema.parse(response);
}
/**
* getTask retrieves an existing task via Coder's experimental Tasks API.
* Returns null if the task does not exist.
*/
async getTask(
owner: string,
taskName: string,
): Promise<ExperimentalCoderSDKTask | null> {
// TODO: needs taskByOwnerAndName endpoint, fake it for now with the list endpoint.
try {
const allTasksResponse = await this.request<unknown>(
`/api/experimental/tasks?q=${encodeURIComponent(`owner:${owner}`)}`,
);
const allTasks =
ExperimentalCoderSDKTaskListResponseSchema.parse(allTasksResponse);
const task = allTasks.tasks.find((t) => t.name === taskName);
if (!task) {
return null;
}
return task;
} catch (error) {
if (error instanceof CoderAPIError && error.statusCode === 404) {
return null;
}
throw error;
}
}
/**
* createTask creates a new task with the given parameters using Coder's experimental Tasks API.
*/
async createTask(
owner: string,
params: ExperimentalCoderSDKCreateTaskRequest,
): Promise<ExperimentalCoderSDKTask> {
const endpoint = `/api/experimental/tasks/${encodeURIComponent(owner)}`;
const response = await this.request<unknown>(endpoint, {
method: "POST",
body: JSON.stringify(params),
});
return ExperimentalCoderSDKTaskSchema.parse(response);
}
/**
* sendTaskInput sends the given input to an existing task via Coder's experimental Tasks API.
*/
async sendTaskInput(
ownerUsername: string,
taskName: string,
input: string,
): Promise<void> {
const endpoint = `/api/v2/users/${ownerUsername}/tasks/${taskName}/send`;
await this.request<unknown>(endpoint, {
method: "POST",
body: JSON.stringify({ input }),
});
}
}
// CoderSDKUserSchema is the schema for codersdk.User.
export const CoderSDKUserSchema = z.object({
id: z.string().uuid(),
username: z.string(),
email: z.string().email(),
organization_ids: z.array(z.string().uuid()),
github_com_user_id: z.number().optional(),
});
export type CoderSDKUser = z.infer<typeof CoderSDKUserSchema>;
// CoderSDKUserListSchema is the schema for codersdk.GetUsersResponse.
export const CoderSDKGetUsersResponseSchema = z.object({
users: z.array(CoderSDKUserSchema),
});
export type CoderSDKGetUsersResponse = z.infer<
typeof CoderSDKGetUsersResponseSchema
>;
// CoderSDKTemplateSchema is the schema for codersdk.Template.
export const CoderSDKTemplateSchema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().optional(),
organization_id: z.string().uuid(),
active_version_id: z.string().uuid(),
});
export type CoderSDKTemplate = z.infer<typeof CoderSDKTemplateSchema>;
// CoderSDKTemplateVersionPresetSchema is the schema for codersdk.Preset.
export const CoderSDKTemplateVersionPresetSchema = z.object({
ID: z.string().uuid(),
Name: z.string(),
Default: z.boolean(),
});
export type CoderSDKTemplateVersionPreset = z.infer<
typeof CoderSDKTemplateVersionPresetSchema
>;
// CoderSDKTemplateVersionPresetsResponseSchema is the schema for []codersdk.Preset which is returned by the API.
export const CoderSDKTemplateVersionPresetsResponseSchema = z.array(
CoderSDKTemplateVersionPresetSchema,
);
export type CoderSDKTemplateVersionPresetsResponse = z.infer<
typeof CoderSDKTemplateVersionPresetsResponseSchema
>;
// ExperimentalCoderSDKCreateTaskRequestSchema is the schema for experimental codersdk.CreateTaskRequest.
export const ExperimentalCoderSDKCreateTaskRequestSchema = z.object({
name: z.string().min(1),
template_version_id: z.string().min(1),
template_version_preset_id: z.string().min(1).optional(),
input: z.string().min(1),
});
export type ExperimentalCoderSDKCreateTaskRequest = z.infer<
typeof ExperimentalCoderSDKCreateTaskRequestSchema
>;
// ExperimentalCoderSDKTaskSchema is the schema for experimental codersdk.Task.
export const ExperimentalCoderSDKTaskSchema = z.object({
id: z.string().uuid(),
name: z.string(),
owner_id: z.string().uuid(),
template_id: z.string().uuid(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
});
export type ExperimentalCoderSDKTask = z.infer<
typeof ExperimentalCoderSDKTaskSchema
>;
// ExperimentalCoderSDKTaskListResponseSchema is the schema for Coder's GET /api/experimental/tasks endpoint.
// At the time of writing, this type is not exported by github.com/coder/coder/v2/codersdk.
export const ExperimentalCoderSDKTaskListResponseSchema = z.object({
tasks: z.array(ExperimentalCoderSDKTaskSchema),
});
export type ExperimentalCoderSDKTaskListResponse = z.infer<
typeof ExperimentalCoderSDKTaskListResponseSchema
>;
// CoderAPIError is a custom error class for Coder API errors.
export class CoderAPIError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly response?: unknown,
) {
super(message);
this.name = "CoderAPIError";
}
}
+67
View File
@@ -0,0 +1,67 @@
import * as core from "@actions/core";
import * as github from "@actions/github";
import { CoderTaskAction } from "./action";
import { RealCoderClient } from "./coder-client";
import { ActionInputsSchema } from "./schemas";
async function main() {
try {
// Parse and validate inputs
const inputs = ActionInputsSchema.parse({
coderUrl: core.getInput("coder-url", { required: true }),
coderToken: core.getInput("coder-token", { required: true }),
templateName: core.getInput("template-name", { required: true }),
taskPrompt: core.getInput("task-prompt", { required: true }),
githubUserId: core.getInput("github-user-id")
? Number.parseInt(core.getInput("github-user-id"), 10)
: undefined,
githubUsername: core.getInput("github-username") || undefined,
templatePreset: core.getInput("template-preset") || "Default",
taskNamePrefix: core.getInput("task-name-prefix") || "task",
taskName: core.getInput("task-name") || undefined,
organization: core.getInput("organization") || "coder",
issueUrl: core.getInput("issue-url") || undefined,
commentOnIssue: core.getBooleanInput("comment-on-issue") !== false,
coderWebUrl: core.getInput("coder-web-url") || undefined,
githubToken: core.getInput("github-token", { required: true }),
});
core.debug("Inputs validated successfully");
core.debug(`Coder URL: ${inputs.coderURL}`);
core.debug(`Template: ${inputs.coderTemplateName}`);
core.debug(`Organization: ${inputs.coderOrganization}`);
// Initialize clients
const coder = new RealCoderClient(inputs.coderURL, inputs.coderToken);
const octokit = github.getOctokit(inputs.githubToken);
core.debug("Clients initialized");
// Execute action
const action = new CoderTaskAction(coder, octokit, inputs);
const outputs = await action.run();
// Set outputs
core.setOutput("coder-username", outputs.coderUsername);
core.setOutput("task-name", outputs.taskName);
core.setOutput("task-url", outputs.taskUrl);
core.setOutput("task-exists", outputs.taskCreated.toString());
core.debug("Action completed successfully");
core.debug(`Outputs: ${JSON.stringify(outputs, null, 2)}`);
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
console.error("Action failed:", error);
if (error.stack) {
console.error("Stack trace:", error.stack);
}
} else {
core.setFailed("Unknown error occurred");
console.error("Unknown error:", error);
}
process.exit(1);
}
}
main();
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { ActionInputs, ActionInputsSchema } from "./schemas";
const actionInputValid: ActionInputs = {
coderURL: "https://coder.test",
coderToken: "test-token",
coderOrganization: "my-org",
coderTaskNamePrefix: "gh",
coderTaskPrompt: "test prompt",
coderTemplateName: "test-template",
githubIssueURL: "https://github.com/owner/repo/issues/123",
githubToken: "github-token",
githubUserID: 12345,
coderTemplatePreset: "",
};
describe("ActionInputsSchema", () => {
describe("Valid Input Cases", () => {
test("accepts minimal required inputs and sets default values correctly", () => {
const result = ActionInputsSchema.parse(actionInputValid);
expect(result.coderURL).toBe(actionInputValid.coderURL);
expect(result.coderToken).toBe(actionInputValid.coderToken);
expect(result.coderOrganization).toBe(actionInputValid.coderOrganization);
expect(result.coderTaskNamePrefix).toBe(
actionInputValid.coderTaskNamePrefix,
);
expect(result.coderTaskPrompt).toBe(actionInputValid.coderTaskPrompt);
expect(result.coderTemplateName).toBe(actionInputValid.coderTemplateName);
expect(result.githubIssueURL).toBe(actionInputValid.githubIssueURL);
expect(result.githubToken).toBe(actionInputValid.githubToken);
expect(result.githubUserID).toBe(actionInputValid.githubUserID);
expect(result.coderTemplatePreset).toBeEmpty();
});
test("accepts all optional inputs", () => {
const input: ActionInputs = {
...actionInputValid,
coderTemplatePreset: "custom",
};
const result = ActionInputsSchema.parse(input);
expect(result.coderTemplatePreset).toBe(input.coderTemplatePreset);
});
test("accepts valid URL formats", () => {
const validUrls = [
"https://coder.test",
"https://coder.example.com:8080",
"http://12.34.56.78",
"https://12.34.56.78:9000",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://[::1]:3000",
];
for (const url of validUrls) {
const input: ActionInputs = {
...actionInputValid,
coderURL: url,
};
const result = ActionInputsSchema.parse(input);
expect(result.coderURL).toBe(url);
}
});
});
describe("Invalid Input Cases", () => {
test("rejects missing required fields", () => {
const input = {} as ActionInputs;
expect(() => ActionInputsSchema.parse(input)).toThrow();
});
test("rejects invalid URL format for coderUrl", () => {
const input: ActionInputs = {
...actionInputValid,
coderURL: "not-a-url",
};
expect(() => ActionInputsSchema.parse(input)).toThrow();
});
test("rejects invalid URL format for issueUrl", () => {
const input: ActionInputs = {
...actionInputValid,
githubIssueURL: "not-a-url",
};
expect(() => ActionInputsSchema.parse(input)).toThrow();
});
test("rejects empty strings for required fields", () => {
const input: ActionInputs = {
...actionInputValid,
coderToken: "",
};
expect(() => ActionInputsSchema.parse(input)).toThrow();
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
export type ActionInputs = z.infer<typeof ActionInputsSchema>;
export const ActionInputsSchema = z.object({
// Required
coderTaskPrompt: z.string().min(1),
coderToken: z.string().min(1),
coderURL: z.string().url(),
coderOrganization: z.string().min(1),
coderTaskNamePrefix: z.string().min(1),
coderTemplateName: z.string().min(1),
githubIssueURL: z.string().url(),
githubToken: z.string(),
githubUserID: z.number().min(1),
// Optional
coderTemplatePreset: z.string().optional(),
});
export const ActionOutputsSchema = z.object({
coderUsername: z.string(),
taskName: z.string(),
taskUrl: z.string().url(),
taskCreated: z.boolean(),
});
export type ActionOutputs = z.infer<typeof ActionOutputsSchema>;
@@ -0,0 +1,207 @@
import { mock } from "bun:test";
import { CoderClient } from "./coder-client";
import type {
CoderSDKUser,
CoderSDKGetUsersResponse,
CoderSDKTemplate,
CoderSDKTemplateVersionPreset,
ExperimentalCoderSDKTask,
ExperimentalCoderSDKTaskListResponse,
ExperimentalCoderSDKCreateTaskRequest,
} from "./coder-client";
import type { ActionInputs } from "./schemas";
/**
* Mock data for tests
*/
export const mockUser: CoderSDKUser = {
id: "550e8400-e29b-41d4-a716-446655440000",
username: "testuser",
email: "test@example.com",
organization_ids: ["660e8400-e29b-41d4-a716-446655440000"],
github_com_user_id: 12345,
};
export const mockUserList: CoderSDKGetUsersResponse = {
users: [mockUser],
};
export const mockUserListEmpty: CoderSDKGetUsersResponse = {
users: [],
};
export const mockUserListDuplicate: CoderSDKGetUsersResponse = {
users: [
mockUser,
{
...mockUser,
id: "660e8400-e29b-41d4-a716-446655440001",
username: "testuser2",
},
],
};
export const mockTemplate: CoderSDKTemplate = {
id: "770e8400-e29b-41d4-a716-446655440000",
name: "my-template",
description: "AI triage template",
organization_id: "660e8400-e29b-41d4-a716-446655440000",
active_version_id: "880e8400-e29b-41d4-a716-446655440000",
};
export const mockTemplateVersionPreset: CoderSDKTemplateVersionPreset = {
ID: "880e8400-e29b-41d4-a716-446655440000",
Name: "default-preset",
Default: true,
};
export const mockTemplateVersionPreset2: CoderSDKTemplateVersionPreset = {
ID: "990e8400-e29b-41d4-a716-446655440000",
Name: "another-preset",
Default: false,
};
export const mockTemplateVersionPresets = [
mockTemplateVersionPreset,
mockTemplateVersionPreset2,
];
export const mockTask: ExperimentalCoderSDKTask = {
id: "990e8400-e29b-41d4-a716-446655440000",
name: "task-123",
owner_id: "550e8400-e29b-41d4-a716-446655440000",
template_id: "770e8400-e29b-41d4-a716-446655440000",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
status: "running",
};
export const mockTaskList: ExperimentalCoderSDKTaskListResponse = {
tasks: [mockTask],
};
export const mockTaskListEmpty: ExperimentalCoderSDKTaskListResponse = {
tasks: [],
};
/**
* Create mock ActionInputs with defaults
*/
export function createMockInputs(
overrides?: Partial<ActionInputs>,
): ActionInputs {
return {
coderTaskPrompt: "Test prompt",
coderToken: "test-token",
coderURL: "https://coder.test",
coderOrganization: "coder",
coderTaskNamePrefix: "task",
coderTemplateName: "my-template",
githubToken: "github-token",
githubIssueURL: "https://github.com/test-org/test-repo/issues/12345",
githubUserID: 12345,
...overrides,
};
}
/**
* Mock CoderClient for testing
*/
export class MockCoderClient implements CoderClient {
private readonly headers: Record<string, string>;
public mockGetCoderUserByGithubID = mock();
public mockGetTemplateByOrganizationAndName = mock();
public mockGetTemplateVersionPresets = mock();
public mockGetTask = mock();
public mockCreateTask = mock();
public mockSendTaskInput = mock();
constructor() // private readonly serverURL: string,
// apiToken: string,
{
this.headers = {};
}
async getCoderUserByGitHubId(githubUserId: number): Promise<CoderSDKUser> {
return this.mockGetCoderUserByGithubID(githubUserId);
}
async getTemplateByOrganizationAndName(
organization: string,
templateName: string,
): Promise<CoderSDKTemplate> {
return this.mockGetTemplateByOrganizationAndName(
organization,
templateName,
);
}
async getTemplateVersionPresets(
templateVersionId: string,
): Promise<CoderSDKTemplateVersionPreset[]> {
return this.mockGetTemplateVersionPresets(templateVersionId);
}
async getTask(
username: string,
taskName: string,
): Promise<ExperimentalCoderSDKTask | null> {
return this.mockGetTask(username, taskName);
}
async createTask(
username: string,
params: ExperimentalCoderSDKCreateTaskRequest,
): Promise<ExperimentalCoderSDKTask> {
return this.mockCreateTask(username, params);
}
async sendTaskInput(
username: string,
taskName: string,
input: string,
): Promise<void> {
return this.mockSendTaskInput(username, taskName, input);
}
}
/**
* Mock Octokit for testing
*/
export function createMockOctokit() {
return {
rest: {
users: {
getByUsername: mock(),
},
issues: {
listComments: mock(),
createComment: mock(),
updateComment: mock(),
},
},
};
}
/**
* Mock fetch for testing
*/
export function createMockFetch() {
return mock();
}
/**
* Create mock fetch response
*/
export function createMockResponse(
body: unknown,
options: { ok?: boolean; status?: number; statusText?: string } = {},
) {
return {
ok: options.ok ?? true,
status: options.status ?? 200,
statusText: options.statusText ?? "OK",
json: async () => body,
text: async () => JSON.stringify(body),
};
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+36 -121
View File
@@ -41,70 +41,25 @@ jobs:
actions: write
steps:
# This is only required for testing locally using nektos/act, so leaving commented out.
# An alternative is to use a larger or custom image.
# - name: Install Github CLI
# id: install-gh
# run: |
# (type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
# && sudo mkdir -p -m 755 /etc/apt/keyrings \
# && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
# && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
# && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
# && sudo mkdir -p -m 755 /etc/apt/sources.list.d \
# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
# && sudo apt update \
# && sudo apt install gh -y
- name: Determine Inputs
id: determine-inputs
if: always()
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EVENT_ISSUE_HTML_URL: ${{ github.event.issue.html_url }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }}
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'traiage' }}
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'Default'}}
INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Using template name: ${INPUTS_TEMPLATE_NAME}"
echo "template_name=${INPUTS_TEMPLATE_NAME}" >> "${GITHUB_OUTPUT}"
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
echo "Using prefix: ${INPUTS_PREFIX}"
echo "prefix=${INPUTS_PREFIX}" >> "${GITHUB_OUTPUT}"
# For workflow_dispatch, use the actor who triggered it
# For issues events, use the issue author.
# Determine issue URL based on event type
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
exit 1
fi
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
echo "Using issue URL: ${INPUTS_ISSUE_URL}"
echo "issue_url=${INPUTS_ISSUE_URL}" >> "${GITHUB_OUTPUT}"
exit 0
elif [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
GITHUB_USER_ID=${GITHUB_EVENT_USER_ID}
echo "Using issue author: ${GITHUB_EVENT_USER_LOGIN} (ID: ${GITHUB_USER_ID})"
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
echo "github_username=${GITHUB_EVENT_USER_LOGIN}" >> "${GITHUB_OUTPUT}"
echo "Using issue URL: ${GITHUB_EVENT_ISSUE_HTML_URL}"
echo "issue_url=${GITHUB_EVENT_ISSUE_HTML_URL}" >> "${GITHUB_OUTPUT}"
exit 0
else
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
exit 1
@@ -113,80 +68,36 @@ jobs:
- name: Verify push access
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
GITHUB_USERNAME: ${{ steps.determine-inputs.outputs.github_username }}
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
run: |
# Query the actors permission on this repo
can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_USERNAME}/permission" --jq '.user.permissions.push')"
# Determine username based on event type
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
USERNAME="${GITHUB_ACTOR}"
else
USERNAME="${GITHUB_EVENT_USER_LOGIN}"
fi
# Query the user's permission on this repo
can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${USERNAME}/permission" --jq '.user.permissions.push')"
if [[ "${can_push}" != "true" ]]; then
echo "::error title=Access Denied::${GITHUB_USERNAME} does not have push access to ${GITHUB_REPOSITORY}"
echo "::error title=Access Denied::${USERNAME} does not have push access to ${GITHUB_REPOSITORY}"
exit 1
fi
- name: Extract context key from issue
id: extract-context
env:
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
GH_TOKEN: ${{ github.token }}
run: |
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
context_key="gh-${issue_number}"
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
echo "CONTEXT_KEY=${context_key}" >> "${GITHUB_ENV}"
- name: Download and install Coder binary
shell: bash
env:
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
run: |
if [ "${{ runner.arch }}" == "ARM64" ]; then
ARCH="arm64"
else
ARCH="amd64"
fi
mkdir -p "${HOME}/.local/bin"
curl -fsSL --compressed "$CODER_URL/bin/coder-linux-${ARCH}" -o "${HOME}/.local/bin/coder"
chmod +x "${HOME}/.local/bin/coder"
export PATH="$HOME/.local/bin:$PATH"
coder version
coder whoami
echo "$HOME/.local/bin" >> "${GITHUB_PATH}"
- name: Get Coder username from GitHub actor
id: get-coder-username
env:
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
GH_TOKEN: ${{ github.token }}
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
run: |
user_json=$(
coder users list --github-user-id="${GITHUB_USER_ID}" --output=json
)
coder_username=$(jq -r 'first | .username' <<< "$user_json")
[[ -z "${coder_username}" || "${coder_username}" == "null" ]] && echo "No Coder user with GitHub user ID ${GITHUB_USER_ID} found" && exit 1
echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}"
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# TODO(Cian): this is a good use-case for 'recipes'
- name: Create Coder task
id: create-task
- name: Fetch issue description
id: fetch-issue
env:
CODER_USERNAME: ${{ steps.get-coder-username.outputs.coder_username }}
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
PREFIX: ${{ steps.determine-inputs.outputs.prefix }}
RUN_ID: ${{ github.run_id }}
TEMPLATE_NAME: ${{ steps.determine-inputs.outputs.template_name }}
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
TEMPLATE_PRESET: ${{ steps.determine-inputs.outputs.template_preset }}
GH_TOKEN: ${{ github.token }}
run: |
# Fetch issue description using `gh` CLI
#shellcheck disable=SC2016 # The template string should not be subject to shell expansion
@@ -194,24 +105,28 @@ jobs:
--json 'title,body,comments' \
--template '{{printf "%s\n\n%s\n\nComments:\n" .title .body}}{{range $k, $v := .comments}} - {{index $v.author "login"}}: {{printf "%s\n" $v.body}}{{end}}')
# Write a prompt to PROMPT_FILE
PROMPT=$(cat <<EOF
# Create prompt for the task
{
echo "prompt<<EOF"
cat <<PROMPT
Fix ${ISSUE_URL}
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
---
${issue_description}
EOF
)
export PROMPT
PROMPT
echo "EOF"
} >> "${GITHUB_OUTPUT}"
export TASK_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
echo "Creating task: $TASK_NAME"
./scripts/traiage.sh create
if [[ "${ISSUE_URL}" == "https://github.com/${GITHUB_REPOSITORY}"* ]]; then
gh issue comment "${ISSUE_URL}" --body "Task created: https://dev.coder.com/tasks/${CODER_USERNAME}/${TASK_NAME}" --create-if-none --edit-last
else
echo "Skipping comment on other repo."
fi
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
- name: Create Coder Task
uses: ./.github/actions/coder-task
with:
coder-url: ${{ secrets.TRAIAGE_CODER_URL }}
coder-token: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
template-name: ${{ steps.determine-inputs.outputs.template_name }}
template-preset: ${{ steps.determine-inputs.outputs.template_preset }}
task-name-prefix: ${{ steps.determine-inputs.outputs.prefix }}
task-prompt: ${{ steps.fetch-issue.outputs.prompt }}
issue-url: ${{ steps.determine-inputs.outputs.issue_url }}
coder-web-url: "https://dev.coder.com"
github-token: ${{ github.token }}