diff --git a/.craft.yml b/.craft.yml index bea8700ed4..24db28f8ae 100644 --- a/.craft.yml +++ b/.craft.yml @@ -28,6 +28,9 @@ targets: - name: npm id: "@sentry/junior-github" includeNames: /^sentry-junior-github-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-gocd" + includeNames: /^sentry-junior-gocd-\d.*\.tgz$/ - name: npm id: "@sentry/junior-hex" includeNames: /^sentry-junior-hex-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6f017d5f..5afd539288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: - packages/junior-cloudflare/** - packages/junior-datadog/** - packages/junior-github/** + - packages/junior-gocd/** - packages/junior-hex/** - packages/junior-linear/** - packages/junior-maintenance/** @@ -215,10 +216,12 @@ jobs: - uses: ./.github/actions/setup-node-pnpm - run: pnpm --filter @sentry/junior-memory build - run: pnpm --filter @sentry/junior-github build + - run: pnpm --filter @sentry/junior-gocd build - run: pnpm --filter @sentry/junior-sentry build - run: pnpm --filter @sentry/junior-vercel build - run: pnpm --filter @sentry/junior-memory test - run: pnpm --filter @sentry/junior-github test + - run: pnpm --filter @sentry/junior-gocd test - run: pnpm --filter @sentry/junior-sentry test - run: pnpm --filter @sentry/junior-vercel test @@ -354,6 +357,7 @@ jobs: pnpm --filter @sentry/junior-dashboard pack --pack-destination artifacts pnpm --filter @sentry/junior-datadog pack --pack-destination artifacts pnpm --filter @sentry/junior-github pack --pack-destination artifacts + pnpm --filter @sentry/junior-gocd pack --pack-destination artifacts pnpm --filter @sentry/junior-hex pack --pack-destination artifacts pnpm --filter @sentry/junior-linear pack --pack-destination artifacts pnpm --filter @sentry/junior-memory pack --pack-destination artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95fef036cc..be981fec52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,7 @@ This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior-dashboard` - `@sentry/junior-datadog` - `@sentry/junior-github` +- `@sentry/junior-gocd` - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-maintenance` diff --git a/README.md b/README.md index 595a6035bb..7d2716f5d2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Start here: | `@sentry/junior-dashboard` | Authenticated dashboard package for Junior runtime diagnostics | | `@sentry/junior-datadog` | Datadog plugin package for observability workflows through Datadog's Pup CLI | | `@sentry/junior-github` | GitHub plugin package for issue workflows | +| `@sentry/junior-gocd` | GoCD plugin package for read-only pipeline history through host egress auth | | `@sentry/junior-hex` | Hex plugin package for data warehouse query workflows | | `@sentry/junior-linear` | Linear plugin package for issue workflows | | `@sentry/junior-memory` | Memory plugin package for long-term Junior memory storage | diff --git a/package.json b/package.json index 0ef6116908..314b1a567e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-gocd lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "migration-metadata:check": "node --test scripts/check-drizzle-migrations.test.mjs && node scripts/check-drizzle-migrations.mjs", @@ -21,15 +21,15 @@ "dashboard-style:check": "node --test scripts/check-dashboard-styles.test.mjs && node scripts/check-dashboard-styles.mjs", "ast-grep:lint": "ast-grep scan", "lint-staged": "lint-staged", - "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build", + "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-gocd build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build", "build:example": "pnpm --filter @sentry/junior-example build", "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", - "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-octolens packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", + "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-gocd packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-octolens packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", - "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-sentry test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test", + "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-gocd build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-gocd test && pnpm --filter @sentry/junior-sentry test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test", "test:e2e:dashboard": "pnpm --filter @sentry/junior-dashboard build && playwright test -c packages/junior-dashboard/playwright.config.ts", "visual:dashboard": "pnpm --filter @sentry/junior-dashboard build && pnpm exec tsx -- packages/junior-dashboard/visual/capture.ts", "test:watch": "pnpm --filter @sentry/junior test:watch", @@ -40,7 +40,7 @@ "evals:record": "pnpm --filter @sentry/junior-evals evals:record", "typecheck": "pnpm -r run typecheck", "skills:check": "pnpm --filter @sentry/junior skills:check", - "test:ci": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test:coverage && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-sentry test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test:coverage" + "test:ci": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-gocd build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test:coverage && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-gocd test && pnpm --filter @sentry/junior-sentry test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test:coverage" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged", diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index ae6875f431..eafa8419d9 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -19,6 +19,7 @@ Junior uses lockstep package releases for: - `@sentry/junior-dashboard` - `@sentry/junior-datadog` - `@sentry/junior-github` +- `@sentry/junior-gocd` - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-maintenance` diff --git a/packages/junior-gocd/README.md b/packages/junior-gocd/README.md new file mode 100644 index 0000000000..ef45a0af63 --- /dev/null +++ b/packages/junior-gocd/README.md @@ -0,0 +1,99 @@ +# @sentry/junior-gocd + +`@sentry/junior-gocd` adds generic read-only GoCD tools to Junior. + +This package is host-agnostic: + +- tools call GoCD through `ctx.egress.fetch` +- Junior injects host-managed auth headers at egress +- deploy topology and private host defaults stay out of this package + +## Install + +```ts +import { defineJuniorPlugins } from "@sentry/junior"; +import { gocdPlugin } from "@sentry/junior-gocd"; + +export const plugins = defineJuniorPlugins([ + gocdPlugin({ + // Optional. You can also set GOCD_URL in the host environment. + baseUrl: "https://gocd.example.com", + }), +]); +``` + +## Environment + +| Variable | Required | Notes | +| ------------------- | --------------------------- | --------------------------------------------------------------------------- | +| `GOCD_URL` | when `baseUrl` is omitted | Absolute https GoCD origin, for example `https://gocd.example.com` | +| `GOCD_ACCESS_TOKEN` | for the default bearer path | Read-only GoCD API token. Injected by Junior as `Authorization: bearer ...` | + +### Static bearer headers + +When you pass only `baseUrl` / `GOCD_URL`, the plugin declares: + +- `domains: []` +- `apiHeaders.Authorization: bearer ${GOCD_ACCESS_TOKEN}` + +### Host credential hooks + +Hosts that need extra headers (for example Google IAP `Proxy-Authorization`) +should pass `hooks.grantForEgress` and `hooks.issueCredential`. In that mode the +plugin keeps `domains` and omits static `apiHeaders`, matching Junior's egress +credential contract. Keep private proxy defaults and service-account names in +the host app, not this package. + +```ts +gocdPlugin({ + baseUrl: "https://gocd.example.com", + hooks: { + grantForEgress() { + return { + access: "read", + name: "gocd-read", + reason: "read-only GoCD API access", + }; + }, + async issueCredential() { + // Mint short-lived header transforms for owned domains. + return { + type: "lease", + lease: { + expiresAt: new Date(Date.now() + 55 * 60_000).toISOString(), + headerTransforms: [ + { + domain: "gocd.example.com", + headers: { + Authorization: "bearer ...", + "Proxy-Authorization": "Bearer ...", + }, + }, + ], + }, + }; + }, + }, +}); +``` + +## Tools + +- `pipelineHistory`: recent runs for one exact pipeline name +- `stage`: one exact stage run — result, jobs, failed job names, and a stable link +- `jobLog`: console log for one exact job — tailed, de-duplicated, and secret-redacted + +API usage is checked against GoCD **25.2.0**: + +- bearer auth: `Authorization: bearer ` +- history: `GET /go/api/pipelines/:name/history` with `Accept: application/vnd.go.cd.v1+json` +- stage: `GET /go/api/stages/:pipeline/:pipeline_counter/:stage/:stage_counter` with `Accept: application/vnd.go.cd.v3+json` +- job log: `GET /go/files/:pipeline/:pipeline_counter/:stage/:stage_counter/:job/cruise-output/console.log` (text) +- `page_size` clamped to 10..100 per server rules +- `jobLog` returns `available: false` when a console log is missing or expired + +## Notes + +Register this package from the host app that owns your GoCD deployment. Keep +environment-specific pipeline names, regions, and deploy topology in host +skills or private plugins. diff --git a/packages/junior-gocd/package.json b/packages/junior-gocd/package.json new file mode 100644 index 0000000000..09a1a8b229 --- /dev/null +++ b/packages/junior-gocd/package.json @@ -0,0 +1,43 @@ +{ + "name": "@sentry/junior-gocd", + "version": "0.163.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-gocd" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts vitest.config.ts", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sentry/junior-plugin-api": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "oxlint": "^1.66.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/packages/junior-gocd/skills/gocd/SKILL.md b/packages/junior-gocd/skills/gocd/SKILL.md new file mode 100644 index 0000000000..932dbda6b2 --- /dev/null +++ b/packages/junior-gocd/skills/gocd/SKILL.md @@ -0,0 +1,33 @@ +--- +name: gocd +description: Query read-only GoCD pipeline history through the GoCD API. Use when users ask about GoCD pipeline runs, deployment failures, run history, or stage and job outcomes. +--- + +# GoCD + +Use the GoCD plugin tools for live pipeline data. + +- `pipelineHistory` returns recent runs for one exact pipeline name. +- `stage` returns one exact stage run: its result, jobs, and failed job names. +- `jobLog` returns the console log for one exact job. It is tailed, de-duplicated, and secret-redacted. + +Resolve the exact run first (`pipelineHistory` or a GoCD link), read the failed `stage`, then read the failed `jobLog`. Start with a small tail and expand only if the failure is not in it. + +## Auth model + +Junior injects host-managed credentials at egress. Tools must not read GoCD tokens directly. + +Host configuration: + +- `GOCD_URL` or `gocdPlugin({ baseUrl })` +- default path: `GOCD_ACCESS_TOKEN` for bearer auth +- advanced path: host `grantForEgress` / `issueCredential` hooks for extra headers + +## Guardrails + +- GoCD access is read-only. +- Use an exact pipeline name. +- Do not claim that old console logs are available from pipeline history. Use `jobLog` for console output. +- If `jobLog` reports `available: false`, the log is expired or missing. Say so; do not guess its contents. +- Keep private deploy topology out of generic answers unless the host skill provides it. +- If authentication fails, report whether the host base URL/token is missing or GoCD rejected the request. diff --git a/packages/junior-gocd/skills/gocd/SOURCES.md b/packages/junior-gocd/skills/gocd/SOURCES.md new file mode 100644 index 0000000000..6eecc9a587 --- /dev/null +++ b/packages/junior-gocd/skills/gocd/SOURCES.md @@ -0,0 +1,4 @@ +# Sources + +- GoCD user docs (25.2.0): https://docs.gocd.org/25.2.0/ +- GoCD API reference (25.2.0): https://api.gocd.org/25.2.0/ diff --git a/packages/junior-gocd/src/config.ts b/packages/junior-gocd/src/config.ts new file mode 100644 index 0000000000..947f50bafe --- /dev/null +++ b/packages/junior-gocd/src/config.ts @@ -0,0 +1,112 @@ +/** Host-configurable GoCD connection settings. */ + +import { z } from "zod"; + +export interface GocdPluginOptions { + /** + * Optional default GoCD base URL, for example `https://gocd.example.com`. + * When omitted, tools require `GOCD_URL` or a per-call `baseUrl`. + */ + baseUrl?: string; + /** + * Hostname used for egress domain ownership and header injection. + * Defaults from `baseUrl` / `GOCD_URL` when possible. + */ + host?: string; +} + +export interface ResolvedGocdTarget { + baseUrl: string; + host: string; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + +/** Resolve a GoCD host from an absolute base URL. */ +export function hostFromBaseUrl(baseUrl: string): string { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error(`Invalid GoCD base URL: ${baseUrl}`); + } + if (url.protocol !== "https:") { + throw new Error("GoCD base URL must use https"); + } + return url.host; +} + +/** Resolve the absolute request URL for one GoCD API path. */ +export function resolveGocdApiUrl(baseUrl: string, path: string): string { + if (!path.startsWith("/go/")) { + throw new Error("GoCD API paths must start with /go/"); + } + return `${trimTrailingSlash(baseUrl)}${path}`; +} + +/** + * Resolve the GoCD target for one tool call. + * Prefer explicit tool input, then plugin options, then `GOCD_URL`. + */ +export function resolveGocdTarget(input: { + baseUrl?: string; + options?: GocdPluginOptions; +}): ResolvedGocdTarget { + const baseUrl = trimTrailingSlash( + ( + input.baseUrl ?? + input.options?.baseUrl ?? + process.env.GOCD_URL ?? + "" + ).trim(), + ); + if (!baseUrl) { + throw new Error( + "GoCD base URL is required. Pass baseUrl, configure gocdPlugin({ baseUrl }), or set GOCD_URL.", + ); + } + const host = (input.options?.host ?? hostFromBaseUrl(baseUrl)).trim(); + if (!host) { + throw new Error("GoCD host is required"); + } + return { baseUrl, host }; +} + +/** Input fields that identify one exact GoCD stage run, shared across tools. */ +export const stageRunInputShape = { + baseUrl: z + .string() + .trim() + .url() + .optional() + .describe( + "Optional absolute GoCD base URL. Defaults to gocdPlugin({ baseUrl }) or GOCD_URL.", + ), + pipeline: z.string().trim().min(1).describe("Exact GoCD pipeline name."), + pipelineCounter: z + .number() + .int() + .min(1) + .describe("Pipeline run counter (integer)."), + stage: z.string().trim().min(1).describe("Exact stage name."), + stageCounter: z + .number() + .int() + .min(1) + .describe("Stage run counter (integer)."), +}; + +/** Encode the `pipeline/counter/stage/counter` path segment shared by GoCD URLs. */ +export function stageRunPath(ref: { + pipeline: string; + pipelineCounter: number; + stage: string; + stageCounter: number; +}): string { + return ( + `${encodeURIComponent(ref.pipeline)}/${ref.pipelineCounter}` + + `/${encodeURIComponent(ref.stage)}/${ref.stageCounter}` + ); +} diff --git a/packages/junior-gocd/src/index.ts b/packages/junior-gocd/src/index.ts new file mode 100644 index 0000000000..bef0b160c2 --- /dev/null +++ b/packages/junior-gocd/src/index.ts @@ -0,0 +1,107 @@ +/** + * GoCD plugin runtime boundary. + * + * This package owns generic read-only GoCD tools. Host deployments supply the + * GoCD base URL and inject authentication at Junior egress. Do not put + * environment-specific deploy topology or private host defaults here. + */ +import { + defineJuniorPlugin, + type PluginHooks, + type PluginRegistration, +} from "@sentry/junior-plugin-api"; +import { hostFromBaseUrl, type GocdPluginOptions } from "./config.js"; +import { createGocdJobLogTool } from "./tools/job-log.js"; +import { createGocdPipelineHistoryTool } from "./tools/pipeline-history.js"; +import { createGocdStageTool } from "./tools/stage.js"; + +export type GocdCredentialHooks = Pick< + PluginHooks, + "grantForEgress" | "issueCredential" | "onEgressResponse" +>; + +export interface GocdPluginRegistrationOptions extends GocdPluginOptions { + /** + * Optional host-owned egress credential hooks. + * When set, the plugin declares domains only and lets the host mint headers. + * When omitted and a host is known, static bearer `apiHeaders` are used. + */ + hooks?: GocdCredentialHooks; +} + +function resolveManifestHost( + options: GocdPluginRegistrationOptions, +): string | undefined { + const configured = options.host?.trim(); + if (configured) return configured; + const baseUrl = (options.baseUrl ?? process.env.GOCD_URL ?? "").trim(); + if (!baseUrl) return undefined; + try { + return hostFromBaseUrl(baseUrl); + } catch { + return undefined; + } +} + +/** Register read-only GoCD tools that authenticate through Junior egress. */ +export function gocdPlugin( + options: GocdPluginRegistrationOptions = {}, +): PluginRegistration { + const host = resolveManifestHost(options); + const credentialHooks = options.hooks; + const usesCredentialHooks = Boolean( + credentialHooks?.grantForEgress || credentialHooks?.issueCredential, + ); + + return defineJuniorPlugin({ + packageName: "@sentry/junior-gocd", + manifest: { + ...(host + ? { + domains: [host], + ...(usesCredentialHooks + ? {} + : { + apiHeaders: { + Authorization: "bearer ${GOCD_ACCESS_TOKEN}", + }, + }), + } + : {}), + description: + "Query GoCD pipeline history through host-managed egress credentials", + displayName: "GoCD", + envVars: { + GOCD_ACCESS_TOKEN: {}, + GOCD_URL: {}, + }, + name: "gocd", + }, + hooks: { + ...(credentialHooks?.grantForEgress + ? { grantForEgress: credentialHooks.grantForEgress } + : {}), + ...(credentialHooks?.issueCredential + ? { issueCredential: credentialHooks.issueCredential } + : {}), + ...(credentialHooks?.onEgressResponse + ? { onEgressResponse: credentialHooks.onEgressResponse } + : {}), + tools(ctx) { + return { + pipelineHistory: createGocdPipelineHistoryTool(ctx, options), + stage: createGocdStageTool(ctx, options), + jobLog: createGocdJobLogTool(ctx, options), + }; + }, + }, + }); +} + +export { + hostFromBaseUrl, + resolveGocdApiUrl, + resolveGocdTarget, + type GocdPluginOptions, + type ResolvedGocdTarget, +} from "./config.js"; diff --git a/packages/junior-gocd/src/log.ts b/packages/junior-gocd/src/log.ts new file mode 100644 index 0000000000..28ed04330a --- /dev/null +++ b/packages/junior-gocd/src/log.ts @@ -0,0 +1,53 @@ +/** Console-log processing helpers for the GoCD job_log tool. */ + +const SECRET_KEY_PATTERN = + /\b(authorization|proxy-authorization|token|access[_-]?token|refresh[_-]?token|api[_-]?key|password|secret|x-goog-signature|x-amz-signature|signature)("?\s*[:=]\s*"?)(?:[A-Za-z][A-Za-z0-9-]*\s+)?[^\s&"']+/gi; +const BEARER_PATTERN = /\bbearer\s+[A-Za-z0-9._~+/=-]+/gi; + +// Mask a sensitive key's value, any auth scheme word (Basic/Token/bearer), and +// quoted JSON values; the trailing-space requirement keeps bare `token=abc` safe. +export function redactSecrets(text: string): string { + return text + .replace(SECRET_KEY_PATTERN, (_m, key, sep) => `${key}${sep}[REDACTED]`) + .replace(BEARER_PATTERN, "bearer [REDACTED]"); +} + +function normalize(line: string): string { + return line.replace(/\d+/g, "#"); +} + +/** + * Collapse each run of `minRun`+ consecutive near-identical lines (equal after + * masking digits) into the first line plus a count marker. + */ +export function dedupeConsecutive( + lines: string[], + minRun = 3, +): { lines: string[]; deduped: boolean } { + const out: string[] = []; + let deduped = false; + let i = 0; + while (i < lines.length) { + const form = normalize(lines[i]!); + let j = i; + while (j + 1 < lines.length && normalize(lines[j + 1]!) === form) j++; + const runLength = j - i + 1; + if (runLength >= minRun) { + out.push(lines[i]!, `… ${runLength - 1} more similar lines`); + deduped = true; + } else { + for (let k = i; k <= j; k++) out.push(lines[k]!); + } + i = j + 1; + } + return { lines: out, deduped }; +} + +/** Keep only the last `n` lines, reporting whether earlier lines were dropped. */ +export function tailLines( + lines: string[], + n: number, +): { lines: string[]; truncated: boolean } { + if (lines.length <= n) return { lines, truncated: false }; + return { lines: lines.slice(lines.length - n), truncated: true }; +} diff --git a/packages/junior-gocd/src/tools/job-log.ts b/packages/junior-gocd/src/tools/job-log.ts new file mode 100644 index 0000000000..93e7290ce5 --- /dev/null +++ b/packages/junior-gocd/src/tools/job-log.ts @@ -0,0 +1,150 @@ +import { + definePluginTool, + pluginToolOutputSchema, + type PluginToolOutput, + type ToolRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { + resolveGocdApiUrl, + resolveGocdTarget, + stageRunInputShape, + stageRunPath, + type GocdPluginOptions, +} from "../config.js"; +import { dedupeConsecutive, redactSecrets, tailLines } from "../log.js"; + +const inputSchema = z + .object({ + ...stageRunInputShape, + job: z.string().trim().min(1).describe("Exact job name."), + tail: z + .number() + .int() + .min(1) + .max(2000) + .default(200) + .describe("Return only the last N lines of console output."), + }) + .strict(); + +const outputSchema = pluginToolOutputSchema.extend({ + target: z.literal("jobLog"), + baseUrl: z.string(), + link: z.string(), + pipeline: z.string(), + pipelineCounter: z.number(), + stage: z.string(), + stageCounter: z.number(), + job: z.string(), + available: z.boolean(), + log: z.string(), + totalLines: z.number(), + returnedLines: z.number(), + truncated: z.boolean(), + deduped: z.boolean(), + note: z.string(), +}); + +interface Result extends PluginToolOutput { + target: "jobLog"; + baseUrl: string; + link: string; + pipeline: string; + pipelineCounter: number; + stage: string; + stageCounter: number; + job: string; + available: boolean; + log: string; + totalLines: number; + returnedLines: number; + truncated: boolean; + deduped: boolean; + note: string; +} + +/** + * Return bounded, de-duplicated, secret-redacted console output for one exact + * GoCD job. Reads the text `.../cruise-output/console.log` endpoint. An expired + * or missing log returns `available: false` instead of throwing. + * Auth headers are injected by the runtime, not read in this tool. + */ +export function createGocdJobLogTool( + ctx: ToolRegistrationHookContext, + options: GocdPluginOptions = {}, +) { + return definePluginTool({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + }, + description: + "Fetch the console log for one exact GoCD job, tailed and de-duplicated, with secrets redacted and a stable link. Identify the job with pipeline name, pipeline counter, stage name, stage counter, and job name. Expired logs return available=false. Auth is injected at egress from host-managed credentials.", + inputSchema, + outputSchema, + async execute(input): Promise { + const target = resolveGocdTarget({ baseUrl: input.baseUrl, options }); + const runPath = `${stageRunPath(input)}/${encodeURIComponent(input.job)}`; + const link = resolveGocdApiUrl( + target.baseUrl, + `/go/tab/build/detail/${runPath}`, + ); + const base = { + target: "jobLog" as const, + baseUrl: target.baseUrl, + link, + pipeline: input.pipeline, + pipelineCounter: input.pipelineCounter, + stage: input.stage, + stageCounter: input.stageCounter, + job: input.job, + }; + + const response = await ctx.egress.fetch({ + operation: "gocd.job.log", + provider: "gocd", + request: new Request( + resolveGocdApiUrl( + target.baseUrl, + `/go/files/${runPath}/cruise-output/console.log`, + ), + { headers: { Accept: "text/plain" }, method: "GET" }, + ), + }); + + if (response.status === 404) { + return { + ...base, + available: false, + log: "", + totalLines: 0, + returnedLines: 0, + truncated: false, + deduped: false, + note: "Console log is unavailable or expired for this job.", + }; + } + if (!response.ok) { + throw new Error(`GoCD job log failed with HTTP ${response.status}`); + } + + const text = await response.text(); + const rawLines = text.length ? text.replace(/\n$/, "").split("\n") : []; + const { lines: dedupedLines, deduped } = dedupeConsecutive(rawLines); + const { lines: tailed, truncated } = tailLines(dedupedLines, input.tail); + return { + ...base, + available: true, + log: redactSecrets(tailed.join("\n")), + totalLines: dedupedLines.length, + returnedLines: tailed.length, + truncated, + deduped, + note: "", + }; + }, + }); +} diff --git a/packages/junior-gocd/src/tools/pipeline-history.ts b/packages/junior-gocd/src/tools/pipeline-history.ts new file mode 100644 index 0000000000..cbe5942f03 --- /dev/null +++ b/packages/junior-gocd/src/tools/pipeline-history.ts @@ -0,0 +1,105 @@ +import { + definePluginTool, + pluginToolOutputSchema, + type PluginToolOutput, + type ToolRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { + resolveGocdApiUrl, + resolveGocdTarget, + type GocdPluginOptions, +} from "../config.js"; + +const inputSchema = z + .object({ + baseUrl: z + .string() + .trim() + .url() + .optional() + .describe( + "Optional absolute GoCD base URL. Defaults to gocdPlugin({ baseUrl }) or GOCD_URL.", + ), + pipeline: z.string().trim().min(1).describe("Exact GoCD pipeline name."), + count: z.number().int().min(1).max(100).default(20), + }) + .strict(); + +const outputSchema = pluginToolOutputSchema.extend({ + target: z.literal("pipeline_history"), + baseUrl: z.string(), + pipeline: z.string(), + runs: z.array(z.unknown()), +}); + +interface Result extends PluginToolOutput { + target: "pipeline_history"; + baseUrl: string; + pipeline: string; + runs: unknown[]; +} + +/** + * Return recent runs for one GoCD pipeline through Junior egress. + * + * Uses GoCD 25.2.0 `GET /go/api/pipelines/:name/history` with Accept v1. + * `page_size` must stay in 10..100; smaller counts are clamped then sliced. + * Auth headers are injected by the runtime, not read in this tool. + */ +export function createGocdPipelineHistoryTool( + ctx: ToolRegistrationHookContext, + options: GocdPluginOptions = {}, +) { + return definePluginTool({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + }, + description: + "Fetch recent runs for an exact GoCD pipeline. Use this to inspect deployment history and stage or job outcomes. Auth is injected at egress from host-managed credentials.", + inputSchema, + outputSchema, + async execute(input): Promise { + const target = resolveGocdTarget({ + baseUrl: input.baseUrl, + options, + }); + // GoCD 25.2.0 rejects page_size outside 10..100 with HTTP 404. + const pageSize = Math.min(100, Math.max(10, input.count)); + const response = await ctx.egress.fetch({ + operation: "gocd.pipeline.history", + provider: "gocd", + request: new Request( + resolveGocdApiUrl( + target.baseUrl, + `/go/api/pipelines/${encodeURIComponent(input.pipeline)}/history?page_size=${pageSize}`, + ), + { + headers: { + Accept: "application/vnd.go.cd.v1+json", + }, + method: "GET", + }, + ), + }); + if (!response.ok) { + throw new Error( + `GoCD pipeline history failed with HTTP ${response.status}`, + ); + } + const body = z + .object({ pipelines: z.array(z.unknown()) }) + .passthrough() + .parse(await response.json()); + return { + baseUrl: target.baseUrl, + pipeline: input.pipeline, + runs: body.pipelines.slice(0, input.count), + target: "pipeline_history", + }; + }, + }); +} diff --git a/packages/junior-gocd/src/tools/stage.ts b/packages/junior-gocd/src/tools/stage.ts new file mode 100644 index 0000000000..8399db3bce --- /dev/null +++ b/packages/junior-gocd/src/tools/stage.ts @@ -0,0 +1,130 @@ +import { + definePluginTool, + pluginToolOutputSchema, + type PluginToolOutput, + type ToolRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { + resolveGocdApiUrl, + resolveGocdTarget, + stageRunInputShape, + stageRunPath, + type GocdPluginOptions, +} from "../config.js"; + +const inputSchema = z.object(stageRunInputShape).strict(); + +const jobSchema = z + .object({ + name: z.string(), + result: z.string().optional(), + state: z.string().optional(), + }) + .passthrough(); + +const stageBodySchema = z + .object({ + result: z.string().optional(), + jobs: z.array(jobSchema).default([]), + }) + .passthrough(); + +const outputSchema = pluginToolOutputSchema.extend({ + target: z.literal("stage"), + baseUrl: z.string(), + link: z.string(), + pipeline: z.string(), + pipelineCounter: z.number(), + stage: z.string(), + stageCounter: z.number(), + result: z.string(), + jobs: z.array( + z.object({ name: z.string(), result: z.string(), state: z.string() }), + ), + failedJobs: z.array(z.string()), +}); + +interface Job { + name: string; + result: string; + state: string; +} + +interface Result extends PluginToolOutput { + target: "stage"; + baseUrl: string; + link: string; + pipeline: string; + pipelineCounter: number; + stage: string; + stageCounter: number; + result: string; + jobs: Job[]; + failedJobs: string[]; +} + +/** + * Return one exact GoCD stage run: its result, jobs, failed job names, and a + * stable UI link. Uses GoCD 25.2.0 `GET /go/api/stages/...` with Accept v3. + * Auth headers are injected by the runtime, not read in this tool. + */ +export function createGocdStageTool( + ctx: ToolRegistrationHookContext, + options: GocdPluginOptions = {}, +) { + return definePluginTool({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + }, + description: + "Fetch one exact GoCD stage run (result, jobs, and failed job names) with a stable link. Identify the run with pipeline name, pipeline counter, stage name, and stage counter. Auth is injected at egress from host-managed credentials.", + inputSchema, + outputSchema, + async execute(input): Promise { + const target = resolveGocdTarget({ baseUrl: input.baseUrl, options }); + const runPath = stageRunPath(input); + const response = await ctx.egress.fetch({ + operation: "gocd.stage", + provider: "gocd", + request: new Request( + resolveGocdApiUrl(target.baseUrl, `/go/api/stages/${runPath}`), + { + headers: { Accept: "application/vnd.go.cd.v3+json" }, + method: "GET", + }, + ), + }); + if (!response.ok) { + throw new Error(`GoCD stage failed with HTTP ${response.status}`); + } + const body = stageBodySchema.parse(await response.json()); + const jobs: Job[] = body.jobs.map((job) => ({ + name: job.name, + result: job.result ?? "Unknown", + state: job.state ?? "Unknown", + })); + const link = resolveGocdApiUrl( + target.baseUrl, + `/go/pipelines/${runPath}`, + ); + return { + target: "stage", + baseUrl: target.baseUrl, + link, + pipeline: input.pipeline, + pipelineCounter: input.pipelineCounter, + stage: input.stage, + stageCounter: input.stageCounter, + result: body.result ?? "Unknown", + jobs, + failedJobs: jobs + .filter((job) => job.result === "Failed") + .map((job) => job.name), + }; + }, + }); +} diff --git a/packages/junior-gocd/tests/config.test.ts b/packages/junior-gocd/tests/config.test.ts new file mode 100644 index 0000000000..0f49ee9d0a --- /dev/null +++ b/packages/junior-gocd/tests/config.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + hostFromBaseUrl, + resolveGocdApiUrl, + resolveGocdTarget, +} from "../src/config"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("GoCD config", () => { + it("requires https base URLs", () => { + expect(hostFromBaseUrl("https://gocd.example.com")).toBe( + "gocd.example.com", + ); + expect(() => hostFromBaseUrl("http://gocd.example.com")).toThrow( + "GoCD base URL must use https", + ); + }); + + it("builds API urls under /go/", () => { + expect( + resolveGocdApiUrl( + "https://gocd.example.com/", + "/go/api/pipelines/demo/history", + ), + ).toBe("https://gocd.example.com/go/api/pipelines/demo/history"); + expect(() => + resolveGocdApiUrl("https://gocd.example.com", "/api/version"), + ).toThrow("GoCD API paths must start with /go/"); + }); + + it("prefers explicit baseUrl over plugin options and env", () => { + vi.stubEnv("GOCD_URL", "https://env.example.com"); + expect( + resolveGocdTarget({ + baseUrl: "https://call.example.com", + options: { baseUrl: "https://option.example.com" }, + }), + ).toEqual({ + baseUrl: "https://call.example.com", + host: "call.example.com", + }); + }); + + it("falls back to GOCD_URL", () => { + vi.stubEnv("GOCD_URL", " https://env.example.com/ "); + expect(resolveGocdTarget({})).toEqual({ + baseUrl: "https://env.example.com", + host: "env.example.com", + }); + }); + + it("names a missing base URL", () => { + vi.stubEnv("GOCD_URL", ""); + expect(() => resolveGocdTarget({})).toThrow("GoCD base URL is required"); + }); +}); diff --git a/packages/junior-gocd/tests/job-log.test.ts b/packages/junior-gocd/tests/job-log.test.ts new file mode 100644 index 0000000000..a0cea2b54e --- /dev/null +++ b/packages/junior-gocd/tests/job-log.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGocdJobLogTool } from "../src/tools/job-log"; + +function toolFixture(response: Response) { + const fetch = vi.fn().mockResolvedValue(response); + return { + fetch, + tool: createGocdJobLogTool({ egress: { fetch } } as never, { + baseUrl: "https://gocd.example.com", + }), + }; +} + +const input = { + pipeline: "deploy-getsentry-backend-de", + pipelineCounter: 7, + stage: "deploy-primary", + stageCounter: 1, + job: "create-sentry-release", + tail: 200, +}; + +describe("GoCD job log", () => { + it("returns deduped, redacted console output with a stable link", async () => { + const text = [ + "start", + "Authorization: bearer SECRETTOKENVALUE", + "waiting for pod 1", + "waiting for pod 2", + "waiting for pod 3", + "waiting for pod 4", + "end", + ].join("\n"); + const { fetch, tool } = toolFixture(new Response(text, { status: 200 })); + + const result = (await tool.execute?.(input, { + toolCallId: "job-log", + })) as any; + + expect(result.target).toBe("jobLog"); + expect(result.available).toBe(true); + expect(result.deduped).toBe(true); + expect(result.truncated).toBe(false); + expect(result.totalLines).toBe(5); + expect(result.returnedLines).toBe(5); + expect(result.log).not.toContain("SECRETTOKENVALUE"); + expect(result.link).toBe( + "https://gocd.example.com/go/tab/build/detail/deploy-getsentry-backend-de/7/deploy-primary/1/create-sentry-release", + ); + + expect(fetch).toHaveBeenCalledWith({ + operation: "gocd.job.log", + provider: "gocd", + request: expect.objectContaining({ + method: "GET", + url: "https://gocd.example.com/go/files/deploy-getsentry-backend-de/7/deploy-primary/1/create-sentry-release/cruise-output/console.log", + }), + }); + const request = fetch.mock.calls[0]?.[0]?.request as Request; + expect(request.headers.get("Authorization")).toBeNull(); + }); + + it("tails to the requested number of lines", async () => { + const text = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"].join( + "\n", + ); + const { tool } = toolFixture(new Response(text, { status: 200 })); + + const result = (await tool.execute?.( + { ...input, tail: 2 }, + { toolCallId: "job-log-tail" }, + )) as any; + + expect(result.returnedLines).toBe(2); + expect(result.totalLines).toBe(6); + expect(result.truncated).toBe(true); + expect(result.log).toBe("echo\nfoxtrot"); + }); + + it("reports expired or unavailable logs without throwing", async () => { + const { tool } = toolFixture(new Response("", { status: 404 })); + const result = (await tool.execute?.(input, { + toolCallId: "job-log-expired", + })) as any; + expect(result.available).toBe(false); + expect(result.log).toBe(""); + }); + + it("throws on non-404 upstream failures", async () => { + const { tool } = toolFixture(new Response("boom", { status: 500 })); + await expect( + tool.execute?.(input, { toolCallId: "job-log-error" }), + ).rejects.toThrow("GoCD job log failed with HTTP 500"); + }); +}); diff --git a/packages/junior-gocd/tests/log.test.ts b/packages/junior-gocd/tests/log.test.ts new file mode 100644 index 0000000000..74241af525 --- /dev/null +++ b/packages/junior-gocd/tests/log.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { dedupeConsecutive, redactSecrets, tailLines } from "../src/log"; + +describe("redactSecrets", () => { + it("masks a bearer token but keeps surrounding text", () => { + const out = redactSecrets("Authorization: bearer abc123XYZ.tok_-value"); + expect(out).not.toContain("abc123XYZ"); + expect(out).toContain("[REDACTED]"); + }); + + it("masks token and password query values but keeps other params", () => { + const out = redactSecrets( + "GET /go?token=supersecret&page=2&password=hunter2", + ); + expect(out).not.toContain("supersecret"); + expect(out).not.toContain("hunter2"); + expect(out).toContain("page=2"); + }); + + it("masks a signed-url signature", () => { + const out = redactSecrets("https://x/y?X-Goog-Signature=deadbeefcafe&z=1"); + expect(out).not.toContain("deadbeefcafe"); + expect(out).toContain("z=1"); + }); + + it("masks non-bearer Authorization schemes", () => { + const basic = redactSecrets("Authorization: Basic dXNlcjpwYXNzd29yZA=="); + expect(basic).not.toContain("dXNlcjpwYXNzd29yZA=="); + expect(basic).toContain("[REDACTED]"); + const token = redactSecrets("Authorization: Token ghp_ABCDEFG1234567890"); + expect(token).not.toContain("ghp_ABCDEFG1234567890"); + expect(token).toContain("[REDACTED]"); + }); + + it("masks secrets in JSON-quoted keys", () => { + const out = redactSecrets('{"access_token":"ya29.SECRETVALUE","x":1}'); + expect(out).not.toContain("ya29.SECRETVALUE"); + expect(out).toContain('"x":1'); + }); +}); + +describe("dedupeConsecutive", () => { + it("collapses a run of digit-varying lines into the first line plus a marker", () => { + const lines = [ + "waiting for pod 1", + "waiting for pod 2", + "waiting for pod 3", + "waiting for pod 4", + "done", + ]; + const result = dedupeConsecutive(lines); + expect(result.deduped).toBe(true); + expect(result.lines).toEqual([ + "waiting for pod 1", + "… 3 more similar lines", + "done", + ]); + }); + + it("leaves a short run untouched", () => { + const lines = ["a", "a", "b"]; + const result = dedupeConsecutive(lines); + expect(result.deduped).toBe(false); + expect(result.lines).toEqual(["a", "a", "b"]); + }); +}); + +describe("tailLines", () => { + it("returns the last n lines and reports truncation", () => { + const result = tailLines(["1", "2", "3", "4", "5"], 2); + expect(result.lines).toEqual(["4", "5"]); + expect(result.truncated).toBe(true); + }); + + it("returns every line when fewer than n exist", () => { + const result = tailLines(["1", "2"], 10); + expect(result.lines).toEqual(["1", "2"]); + expect(result.truncated).toBe(false); + }); +}); diff --git a/packages/junior-gocd/tests/pipeline-history.test.ts b/packages/junior-gocd/tests/pipeline-history.test.ts new file mode 100644 index 0000000000..4d952aea25 --- /dev/null +++ b/packages/junior-gocd/tests/pipeline-history.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGocdPipelineHistoryTool } from "../src/tools/pipeline-history"; + +function toolFixture( + response: Response = Response.json({ + pipelines: [{ name: "demo", counter: 1 }], + }), +) { + const fetch = vi.fn().mockResolvedValue(response); + return { + fetch, + tool: createGocdPipelineHistoryTool( + { + egress: { fetch }, + } as never, + { baseUrl: "https://gocd.example.com" }, + ), + }; +} + +describe("GoCD pipeline history", () => { + it("fetches history through egress without reading secrets", async () => { + const { fetch, tool } = toolFixture(); + + await expect( + tool.execute?.( + { pipeline: "demo", count: 5 }, + { toolCallId: "history" }, + ), + ).resolves.toMatchObject({ + baseUrl: "https://gocd.example.com", + pipeline: "demo", + runs: [{ name: "demo", counter: 1 }], + target: "pipeline_history", + }); + + expect(fetch).toHaveBeenCalledWith({ + operation: "gocd.pipeline.history", + provider: "gocd", + request: expect.objectContaining({ + method: "GET", + url: "https://gocd.example.com/go/api/pipelines/demo/history?page_size=10", + }), + }); + const request = fetch.mock.calls[0]?.[0]?.request as Request; + expect(request.headers.get("Accept")).toBe( + "application/vnd.go.cd.v1+json", + ); + expect(request.headers.get("Authorization")).toBeNull(); + }); + + it("clamps page_size to the GoCD 10..100 range", async () => { + const { fetch, tool } = toolFixture(); + await tool.execute?.( + { pipeline: "demo", count: 1 }, + { toolCallId: "history-small" }, + ); + expect(fetch.mock.calls[0]?.[0]?.request.url).toContain("page_size=10"); + }); + + it("reports upstream failures", async () => { + const { tool } = toolFixture(new Response("nope", { status: 403 })); + await expect( + tool.execute?.( + { pipeline: "missing" }, + { toolCallId: "history-missing" }, + ), + ).rejects.toThrow("GoCD pipeline history failed with HTTP 403"); + }); +}); diff --git a/packages/junior-gocd/tests/plugin.test.ts b/packages/junior-gocd/tests/plugin.test.ts new file mode 100644 index 0000000000..1f69372934 --- /dev/null +++ b/packages/junior-gocd/tests/plugin.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { gocdPlugin } from "../src"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("gocdPlugin", () => { + it("stays host-agnostic until a base URL is configured", () => { + vi.stubEnv("GOCD_URL", ""); + const plugin = gocdPlugin(); + expect(plugin.packageName).toBe("@sentry/junior-gocd"); + expect(plugin.manifest).toMatchObject({ + name: "gocd", + envVars: { + GOCD_ACCESS_TOKEN: {}, + GOCD_URL: {}, + }, + }); + expect(plugin.manifest.domains).toBeUndefined(); + expect(plugin.manifest.apiHeaders).toBeUndefined(); + }); + + it("declares egress domains and bearer injection from the host base URL", () => { + const plugin = gocdPlugin({ baseUrl: "https://gocd.example.com" }); + expect(plugin.manifest).toMatchObject({ + apiHeaders: { + Authorization: "bearer ${GOCD_ACCESS_TOKEN}", + }, + domains: ["gocd.example.com"], + }); + const tools = plugin.hooks?.tools?.({ + egress: { fetch: vi.fn() }, + } as never); + expect(tools).toHaveProperty("pipelineHistory"); + expect(tools).toHaveProperty("stage"); + expect(tools).toHaveProperty("jobLog"); + }); + + it("can derive the egress domain from GOCD_URL", () => { + vi.stubEnv("GOCD_URL", "https://ci.example.org"); + expect(gocdPlugin().manifest.domains).toEqual(["ci.example.org"]); + }); + + it("uses host credential hooks instead of static apiHeaders", () => { + const grantForEgress = vi.fn(() => ({ + access: "read" as const, + name: "iap-read", + reason: "test", + })); + const issueCredential = vi.fn(); + const plugin = gocdPlugin({ + baseUrl: "https://gocd.example.com", + hooks: { grantForEgress, issueCredential }, + }); + expect(plugin.manifest.domains).toEqual(["gocd.example.com"]); + expect(plugin.manifest.apiHeaders).toBeUndefined(); + expect(plugin.hooks?.grantForEgress).toBe(grantForEgress); + expect(plugin.hooks?.issueCredential).toBe(issueCredential); + }); +}); diff --git a/packages/junior-gocd/tests/stage.test.ts b/packages/junior-gocd/tests/stage.test.ts new file mode 100644 index 0000000000..f0594b81e4 --- /dev/null +++ b/packages/junior-gocd/tests/stage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGocdStageTool } from "../src/tools/stage"; + +function toolFixture( + response: Response = Response.json({ + result: "Failed", + jobs: [ + { name: "create-sentry-release", result: "Failed", state: "Completed" }, + { name: "warm-cache", result: "Passed", state: "Completed" }, + ], + }), +) { + const fetch = vi.fn().mockResolvedValue(response); + return { + fetch, + tool: createGocdStageTool({ egress: { fetch } } as never, { + baseUrl: "https://gocd.example.com", + }), + }; +} + +const input = { + pipeline: "deploy-getsentry-backend-de", + pipelineCounter: 7, + stage: "deploy-primary", + stageCounter: 1, +}; + +describe("GoCD stage", () => { + it("returns the stage result, jobs, failed job names, and a stable link", async () => { + const { fetch, tool } = toolFixture(); + + await expect( + tool.execute?.(input, { toolCallId: "stage" }), + ).resolves.toMatchObject({ + target: "stage", + baseUrl: "https://gocd.example.com", + pipeline: "deploy-getsentry-backend-de", + stage: "deploy-primary", + result: "Failed", + failedJobs: ["create-sentry-release"], + jobs: [ + { name: "create-sentry-release", result: "Failed", state: "Completed" }, + { name: "warm-cache", result: "Passed", state: "Completed" }, + ], + link: "https://gocd.example.com/go/pipelines/deploy-getsentry-backend-de/7/deploy-primary/1", + }); + + expect(fetch).toHaveBeenCalledWith({ + operation: "gocd.stage", + provider: "gocd", + request: expect.objectContaining({ + method: "GET", + url: "https://gocd.example.com/go/api/stages/deploy-getsentry-backend-de/7/deploy-primary/1", + }), + }); + const request = fetch.mock.calls[0]?.[0]?.request as Request; + expect(request.headers.get("Accept")).toBe("application/vnd.go.cd.v3+json"); + expect(request.headers.get("Authorization")).toBeNull(); + }); + + it("reports upstream failures", async () => { + const { tool } = toolFixture(new Response("nope", { status: 404 })); + await expect( + tool.execute?.(input, { toolCallId: "stage-missing" }), + ).rejects.toThrow("GoCD stage failed with HTTP 404"); + }); +}); diff --git a/packages/junior-gocd/tsconfig.build.json b/packages/junior-gocd/tsconfig.build.json new file mode 100644 index 0000000000..03b59964a0 --- /dev/null +++ b/packages/junior-gocd/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/junior-gocd/tsconfig.json b/packages/junior-gocd/tsconfig.json new file mode 100644 index 0000000000..4c12d9d7dc --- /dev/null +++ b/packages/junior-gocd/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-gocd/tsup.config.ts b/packages/junior-gocd/tsup.config.ts new file mode 100644 index 0000000000..ae8f3ea379 --- /dev/null +++ b/packages/junior-gocd/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + dts: false, + entry: ["src/index.ts"], + external: ["@sentry/junior-plugin-api", "zod"], + format: ["esm"], + target: "node24", +}); diff --git a/packages/junior-gocd/vitest.config.ts b/packages/junior-gocd/vitest.config.ts new file mode 100644 index 0000000000..25bf25596b --- /dev/null +++ b/packages/junior-gocd/vitest.config.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@sentry/junior-plugin-api": path.resolve( + __dirname, + "../junior-plugin-api/src/index.ts", + ), + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac7d48790..c5c530e7ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -505,6 +505,31 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(tsx@4.22.3) + packages/junior-gocd: + dependencies: + '@sentry/junior-plugin-api': + specifier: workspace:* + version: link:../junior-plugin-api + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(tsx@4.22.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) + packages/junior-hex: {} packages/junior-linear: @@ -5141,9 +5166,6 @@ packages: es-module-lexer@1.5.0: resolution: {integrity: sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -5164,6 +5186,7 @@ packages: esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} + hasBin: true esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} @@ -5176,6 +5199,7 @@ packages: esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} + hasBin: true esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -7287,6 +7311,7 @@ packages: rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -7602,6 +7627,7 @@ packages: sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} + hasBin: true supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -7720,6 +7746,7 @@ packages: tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -7819,6 +7846,7 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} + hasBin: true ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -8303,6 +8331,7 @@ packages: why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} + hasBin: true word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} @@ -13317,8 +13346,6 @@ snapshots: es-module-lexer@1.5.0: {} - es-module-lexer@2.1.0: {} - es-module-lexer@2.3.1: {} es-object-atoms@1.1.2: @@ -16847,7 +16874,7 @@ snapshots: source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: typescript: 6.0.3 @@ -16874,7 +16901,7 @@ snapshots: source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: typescript: 6.0.3 @@ -17200,16 +17227,16 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 @@ -17240,16 +17267,16 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.14(@types/node@25.9.1)(tsx@4.22.3) why-is-node-running: 2.3.0 @@ -17279,16 +17306,16 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.14(@types/node@25.9.1)(tsx@4.22.3) why-is-node-running: 2.3.0 @@ -17317,16 +17344,16 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.14(@types/node@25.9.1)(tsx@4.22.3) why-is-node-running: 2.3.0 @@ -17355,16 +17382,16 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.14(tsx@4.22.3) why-is-node-running: 2.3.0 diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 4c0bfcf5b0..197b8e1637 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -17,6 +17,7 @@ const files = [ "packages/junior-dashboard/package.json", "packages/junior-datadog/package.json", "packages/junior-github/package.json", + "packages/junior-gocd/package.json", "packages/junior-hex/package.json", "packages/junior-linear/package.json", "packages/junior-memory/package.json",