diff --git a/dist/index.mjs b/dist/index.mjs index 68b49c8..73dc468 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -31689,34 +31689,53 @@ module.exports = parseParams /******/ /************************************************************************/ var __webpack_exports__ = {}; -/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_0__ = __nccwpck_require__(6966); -/* harmony import */ var _actions_github__WEBPACK_IMPORTED_MODULE_1__ = __nccwpck_require__(4903); + +// EXTERNAL MODULE: ./node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core/lib/core.js +var core = __nccwpck_require__(6966); +// EXTERNAL MODULE: ./node_modules/.pnpm/@actions+github@6.0.1/node_modules/@actions/github/lib/github.js +var github = __nccwpck_require__(4903); +;// CONCATENATED MODULE: ./src/comment.mjs +const BASE_URL = "https://worktree.io"; // eslint-disable-line default/no-hardcoded-urls + +/** + * Builds the platform-agnostic "Open workspace" comment body. + * + * @param {{ owner: string, repo: string, issue: string|number }} params + * @returns {string} markdown comment body + */ +function buildCommentBody({ owner, repo, issue }) { + const url = `${BASE_URL}/open?owner=${owner}&repo=${repo}&issue=${issue}`; + + return [ + "A workspace is ready for this issue.", + "", + `Open workspace →`, + "", + `Powered by Worktree · Install`, + ].join("\n"); +} + + + +;// CONCATENATED MODULE: ./src/github/index.mjs -const BASE_URL = "https://worktree.io"; // eslint-disable-line default/no-hardcoded-urls async function run() { - const token = _actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput("token"); - const octokit = _actions_github__WEBPACK_IMPORTED_MODULE_1__.getOctokit(token); - const { context } = _actions_github__WEBPACK_IMPORTED_MODULE_1__; + const token = core.getInput("token"); + const octokit = github.getOctokit(token); + const { context } = github; if (context.eventName !== "issues" || context.payload.action !== "opened") { - _actions_core__WEBPACK_IMPORTED_MODULE_0__.info("Skipping: not an issue opened event."); + core.info("Skipping: not an issue opened event."); return; } const { owner, repo } = context.repo; const issue_number = context.payload.issue.number; - const url = `${BASE_URL}/open?owner=${owner}&repo=${repo}&issue=${issue_number}`; - const body = [ - "A workspace is ready for this issue.", - "", - `Open workspace →`, - "", - `Powered by Worktree · Install`, - ].join("\n"); + const body = buildCommentBody({ owner, repo, issue: issue_number }); await octokit.rest.issues.createComment({ owner, @@ -31725,8 +31744,8 @@ async function run() { body, }); - _actions_core__WEBPACK_IMPORTED_MODULE_0__.info(`Posted workspace link for issue #${issue_number}`); + core.info(`Posted workspace link for issue #${issue_number}`); } -run().catch(_actions_core__WEBPACK_IMPORTED_MODULE_0__.setFailed); +run().catch(core.setFailed); diff --git a/package.json b/package.json index 89864c5..5f341cc 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "type": "module", "main": "dist/index.mjs", "scripts": { - "build": "ncc build src/index.mjs -o dist --license licenses.txt", + "build": "ncc build src/github/index.mjs -o dist --license licenses.txt", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test": "node --test 'src/**/*.test.mjs'", "prepare": "husky" }, "dependencies": { diff --git a/src/azure/index.mjs b/src/azure/index.mjs new file mode 100644 index 0000000..8a51b62 --- /dev/null +++ b/src/azure/index.mjs @@ -0,0 +1,63 @@ +import { Buffer } from "node:buffer"; +import console from "node:console"; +import process from "node:process"; +import { buildCommentBody } from "../comment.mjs"; + +class AzureDevOpsCommentError extends Error {} + +// ponytail: PAT auth only (Basic auth, empty username). Service-connection auth +// is a bigger surface (OAuth/token exchange) with no clear consumer yet — add if +// a workflow actually needs it. See https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add +const API_VERSION = "7.1-preview.3"; + +/** + * Posts a comment on an Azure DevOps work item via the Work Item Comments REST API. + * + * @param {{ organization: string, project: string, workItemId: string|number, token: string, text: string, fetchImpl?: typeof fetch }} params + */ +async function postWorkItemComment({ organization, project, workItemId, token, text, fetchImpl }) { + const request = fetchImpl || globalThis.fetch; + const url = `https://dev.azure.com/${organization}/${encodeURIComponent(project)}/_apis/wit/workItems/${workItemId}/comments?api-version=${API_VERSION}`; // eslint-disable-line default/no-hardcoded-urls + const auth = Buffer.from(`:${token}`).toString("base64"); + + const response = await request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new AzureDevOpsCommentError(`Azure DevOps API error: ${response.status} ${response.statusText} ${detail}`.trim()); + } + + return response.json(); +} + +// ponytail: trigger wiring (service hook vs pipeline step) is an open design +// question from the issue, left for follow-up. This adapter assumes it is run +// with the env vars below already populated by whatever triggers it and only +// guards on their presence. +async function run() { + const organization = process.env.AZURE_DEVOPS_ORG; + const project = process.env.SYSTEM_TEAMPROJECT; + const repo = process.env.BUILD_REPOSITORY_NAME || project; + const workItemId = process.env.WORKITEM_ID; + const token = process.env.AZURE_DEVOPS_TOKEN; + + if (!organization || !project || !workItemId || !token) { + console.log("Skipping: missing required Azure DevOps environment variables."); + return; + } + + const body = buildCommentBody({ owner: organization, repo, issue: workItemId }); + + await postWorkItemComment({ organization, project, workItemId, token, text: body }); + + console.log(`Posted workspace link for work item #${workItemId}`); +} + +export { AzureDevOpsCommentError, postWorkItemComment, run }; diff --git a/src/azure/index.test.mjs b/src/azure/index.test.mjs new file mode 100644 index 0000000..d6a5f57 --- /dev/null +++ b/src/azure/index.test.mjs @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { postWorkItemComment } from "./index.mjs"; + +test("postWorkItemComment posts to the Work Item Comments REST API with Basic auth", async () => { + let capturedUrl; + let capturedInit; + + const fetchImpl = async (url, init) => { + capturedUrl = url; + capturedInit = init; + return { ok: true, status: 200, json: async () => ({ id: 1 }) }; + }; + + await postWorkItemComment({ + organization: "my-org", + project: "My Project", + workItemId: 42, + token: "secret-pat", + text: "hello", + fetchImpl, + }); + + assert.equal( + capturedUrl, + "https://dev.azure.com/my-org/My%20Project/_apis/wit/workItems/42/comments?api-version=7.1-preview.3", // eslint-disable-line default/no-hardcoded-urls + ); + assert.equal(capturedInit.method, "POST"); + assert.equal(capturedInit.headers.Authorization, `Basic ${Buffer.from(":secret-pat").toString("base64")}`); + assert.equal(JSON.parse(capturedInit.body).text, "hello"); +}); + +test("postWorkItemComment throws on a non-ok response", async () => { + const fetchImpl = async () => ({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "nope" }); + + await assert.rejects( + postWorkItemComment({ + organization: "my-org", + project: "proj", + workItemId: 1, + token: "bad", + text: "hello", + fetchImpl, + }), + /Azure DevOps API error: 401/, + ); +}); diff --git a/src/comment.mjs b/src/comment.mjs new file mode 100644 index 0000000..61addad --- /dev/null +++ b/src/comment.mjs @@ -0,0 +1,21 @@ +const BASE_URL = "https://worktree.io"; // eslint-disable-line default/no-hardcoded-urls + +/** + * Builds the platform-agnostic "Open workspace" comment body. + * + * @param {{ owner: string, repo: string, issue: string|number }} params + * @returns {string} markdown comment body + */ +function buildCommentBody({ owner, repo, issue }) { + const url = `${BASE_URL}/open?owner=${owner}&repo=${repo}&issue=${issue}`; + + return [ + "A workspace is ready for this issue.", + "", + `Open workspace →`, + "", + `Powered by Worktree · Install`, + ].join("\n"); +} + +export { BASE_URL, buildCommentBody }; diff --git a/src/comment.test.mjs b/src/comment.test.mjs new file mode 100644 index 0000000..e72b1ff --- /dev/null +++ b/src/comment.test.mjs @@ -0,0 +1,11 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildCommentBody } from "./comment.mjs"; + +test("buildCommentBody includes the workspace link with owner/repo/issue", () => { + const body = buildCommentBody({ owner: "worktree-io", repo: "comment-action", issue: 1 }); + + assert.match(body, /A workspace is ready for this issue\./); + assert.match(body, /owner=worktree-io&repo=comment-action&issue=1/); + assert.match(body, /Open workspace/); +}); diff --git a/src/github/index.mjs b/src/github/index.mjs new file mode 100644 index 0000000..5046e7d --- /dev/null +++ b/src/github/index.mjs @@ -0,0 +1,30 @@ +import core from "@actions/core"; +import github from "@actions/github"; +import { buildCommentBody } from "../comment.mjs"; + +async function run() { + const token = core.getInput("token"); + const octokit = github.getOctokit(token); + const { context } = github; + + if (context.eventName !== "issues" || context.payload.action !== "opened") { + core.info("Skipping: not an issue opened event."); + return; + } + + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + + const body = buildCommentBody({ owner, repo, issue: issue_number }); + + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + + core.info(`Posted workspace link for issue #${issue_number}`); +} + +run().catch(core.setFailed); diff --git a/src/index.mjs b/src/index.mjs deleted file mode 100644 index ccf5498..0000000 --- a/src/index.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import core from "@actions/core"; -import github from "@actions/github"; - -const BASE_URL = "https://worktree.io"; // eslint-disable-line default/no-hardcoded-urls - -async function run() { - const token = core.getInput("token"); - const octokit = github.getOctokit(token); - const { context } = github; - - if (context.eventName !== "issues" || context.payload.action !== "opened") { - core.info("Skipping: not an issue opened event."); - return; - } - - const { owner, repo } = context.repo; - const issue_number = context.payload.issue.number; - const url = `${BASE_URL}/open?owner=${owner}&repo=${repo}&issue=${issue_number}`; - - const body = [ - "A workspace is ready for this issue.", - "", - `Open workspace →`, - "", - `Powered by Worktree · Install`, - ].join("\n"); - - await octokit.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - - core.info(`Posted workspace link for issue #${issue_number}`); -} - -run().catch(core.setFailed);