Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 31 additions & 11 deletions src/azure/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
Expand All @@ -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.");
Expand All @@ -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 };
35 changes: 31 additions & 4 deletions src/azure/index.test.mjs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
});
Expand All @@ -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" });

Expand Down