From 695e8cfde26604242a7e990c4fb0703fecc57f8c Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Mon, 24 Aug 2026 19:56:47 +0300 Subject: [PATCH] Use pipeline System.AccessToken for Azure DevOps auth, document usage PR #3 already added the shared comment.mjs module and the Azure DevOps adapter, but it authenticated only with a manually configured PAT (Basic auth) and left Azure DevOps usage undocumented. The issue asks for the pipeline-provided System.AccessToken to be used so users don't need to set up a PAT. - Default to Bearer auth using SYSTEM_ACCESSTOKEN (falls back to a manually configured PAT via AZURE_DEVOPS_TOKEN with Basic auth). - Derive the organization from the standard SYSTEM_COLLECTIONURI pipeline variable instead of requiring a custom one. - Add a README "Azure DevOps" section documenting the pipeline step, the required "Allow scripts to access the OAuth token" setting, and the env vars the adapter reads. Closes #1 --- README.md | 29 +++++++++++++++++++++++++++ src/azure/index.mjs | 42 +++++++++++++++++++++++++++++----------- src/azure/index.test.mjs | 35 +++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9f30432..3cbd34e 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,32 @@ The workflow needs `issues: write` to post comments. No other permissions are re ## Requirements Clicking **Open workspace** requires the [Worktree](https://worktree.io) app to be installed locally. See [worktree.io#install](https://worktree.io#install) for setup instructions. + +## Azure DevOps + +Azure Pipelines doesn't have a GitHub-Actions-style marketplace step here, so run the adapter as a pipeline script instead. Clone this repo (or install it as a dependency) and add a step like: + +```yaml +# azure-pipelines.yml +steps: + - checkout: self + - script: node path/to/comment-action/src/azure/index.mjs + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Post workspace link +``` + +You must explicitly map `System.AccessToken` into the environment as shown above — Azure Pipelines does not expose it to scripts otherwise. Also make sure the pipeline setting **"Allow scripts to access the OAuth token"** is enabled (Pipeline → Edit → triggers/options), and that the pipeline's build service account has permission to comment on work items in the project. + +The adapter reads: + +| Env var | Source | +| ----------------------- | ---------------------------------------------------------------- | +| `SYSTEM_ACCESSTOKEN` | `$(System.AccessToken)` — the pipeline's OAuth token (preferred). | +| `AZURE_DEVOPS_TOKEN` | A manually configured PAT, used only if `SYSTEM_ACCESSTOKEN` is not set. | +| `SYSTEM_COLLECTIONURI` | Standard pipeline variable; the organization name is parsed from it. Override with `AZURE_DEVOPS_ORG` if needed. | +| `SYSTEM_TEAMPROJECT` | Standard pipeline variable for the project name. | +| `BUILD_REPOSITORY_NAME`| Standard pipeline variable for the repo name. | +| `WORKITEM_ID` | The work item to comment on — set this from whatever trigger (service hook or pipeline trigger) fires on work item creation. | + +If any of these are missing, the adapter logs and exits without posting, so it's safe to wire into pipelines that also run for other events. diff --git a/src/azure/index.mjs b/src/azure/index.mjs index 8a51b62..e5083b7 100644 --- a/src/azure/index.mjs +++ b/src/azure/index.mjs @@ -5,26 +5,31 @@ 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 +// See https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add const API_VERSION = "7.1-preview.3"; +// ponytail: only Bearer (System.AccessToken / OAuth) and Basic (PAT) are supported — +// those are the two auth shapes ADO's REST API actually accepts. Add another if a +// workflow needs it. +function buildAuthHeader({ token, authScheme }) { + if (authScheme === "bearer") return `Bearer ${token}`; + return `Basic ${Buffer.from(`:${token}`).toString("base64")}`; +} + /** * 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 + * @param {{ organization: string, project: string, workItemId: string|number, token: string, authScheme?: "bearer"|"basic", text: string, fetchImpl?: typeof fetch }} params */ -async function postWorkItemComment({ organization, project, workItemId, token, text, fetchImpl }) { +async function postWorkItemComment({ organization, project, workItemId, token, authScheme, 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}`, + Authorization: buildAuthHeader({ token, authScheme: authScheme || "bearer" }), }, body: JSON.stringify({ text }), }); @@ -37,16 +42,31 @@ async function postWorkItemComment({ organization, project, workItemId, token, t return response.json(); } +// Pulls the organization name out of SYSTEM_COLLECTIONURI (e.g. +// "https://dev.azure.com/my-org/"), the standard pipeline-provided variable — +// avoids requiring users to configure it separately. +function organizationFromCollectionUri(collectionUri) { + if (!collectionUri) return undefined; + const match = collectionUri.match(/^https?:\/\/[^/]+\/([^/]+)\/?/); + return match?.[1]; +} + // 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 organization = process.env.AZURE_DEVOPS_ORG || organizationFromCollectionUri(process.env.SYSTEM_COLLECTIONURI); 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; + + // Prefer the pipeline-provided OAuth token (System.AccessToken) over a manually + // configured PAT — see README's Azure DevOps section for the pipeline setting + // required to expose it. + const systemAccessToken = process.env.SYSTEM_ACCESSTOKEN; + const token = systemAccessToken || process.env.AZURE_DEVOPS_TOKEN; + const authScheme = systemAccessToken ? "bearer" : "basic"; if (!organization || !project || !workItemId || !token) { console.log("Skipping: missing required Azure DevOps environment variables."); @@ -55,9 +75,9 @@ async function run() { const body = buildCommentBody({ owner: organization, repo, issue: workItemId }); - await postWorkItemComment({ organization, project, workItemId, token, text: body }); + await postWorkItemComment({ organization, project, workItemId, token, authScheme, text: body }); console.log(`Posted workspace link for work item #${workItemId}`); } -export { AzureDevOpsCommentError, postWorkItemComment, run }; +export { AzureDevOpsCommentError, organizationFromCollectionUri, postWorkItemComment, run }; diff --git a/src/azure/index.test.mjs b/src/azure/index.test.mjs index d6a5f57..61bc768 100644 --- a/src/azure/index.test.mjs +++ b/src/azure/index.test.mjs @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; -import { postWorkItemComment } from "./index.mjs"; +import { organizationFromCollectionUri, postWorkItemComment } from "./index.mjs"; -test("postWorkItemComment posts to the Work Item Comments REST API with Basic auth", async () => { +test("postWorkItemComment defaults to Bearer auth (System.AccessToken)", async () => { let capturedUrl; let capturedInit; @@ -17,7 +17,7 @@ test("postWorkItemComment posts to the Work Item Comments REST API with Basic au organization: "my-org", project: "My Project", workItemId: 42, - token: "secret-pat", + token: "oauth-token", text: "hello", fetchImpl, }); @@ -27,10 +27,37 @@ test("postWorkItemComment posts to the Work Item Comments REST API with Basic au "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(capturedInit.headers.Authorization, "Bearer oauth-token"); assert.equal(JSON.parse(capturedInit.body).text, "hello"); }); +test("postWorkItemComment uses Basic auth for a PAT when authScheme is 'basic'", async () => { + let capturedInit; + + const fetchImpl = async (url, init) => { + capturedInit = init; + return { ok: true, status: 200, json: async () => ({ id: 1 }) }; + }; + + await postWorkItemComment({ + organization: "my-org", + project: "proj", + workItemId: 1, + token: "secret-pat", + authScheme: "basic", + text: "hello", + fetchImpl, + }); + + assert.equal(capturedInit.headers.Authorization, `Basic ${Buffer.from(":secret-pat").toString("base64")}`); +}); + +test("organizationFromCollectionUri extracts the org name from SYSTEM_COLLECTIONURI", () => { + assert.equal(organizationFromCollectionUri("https://dev.azure.com/my-org/"), "my-org"); // eslint-disable-line default/no-hardcoded-urls + assert.equal(organizationFromCollectionUri("https://dev.azure.com/my-org"), "my-org"); // eslint-disable-line default/no-hardcoded-urls + assert.equal(organizationFromCollectionUri(undefined), undefined); +}); + test("postWorkItemComment throws on a non-ok response", async () => { const fetchImpl = async () => ({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "nope" });