diff --git a/.changeset/org-subject-send.md b/.changeset/org-subject-send.md new file mode 100644 index 00000000..974fa25d --- /dev/null +++ b/.changeset/org-subject-send.md @@ -0,0 +1,9 @@ +--- +"@taskless/cli": minor +--- + +Send the acting organization's identity on every write request. The CLI now resolves which Taskless org owns the current repository by matching the repo's git remotes (`origin` → `upstream` → rest) against the canonical owner URLs returned by `whoami`, and sends that org's Taskless UUID as the subject on rule generation, iterate, and reconcile calls. This fixes multi-org users being routed to whichever org their token happened to pin; the server authorizes the chosen org per request. When no remote matches a known org, the CLI falls back to the token's numeric `orgId` claim, so single-org behavior is unchanged. + +The client-side owner-URL canonicalizer is a verbatim port of the server's shared implementation, so both sides compare by exact string equality across SSH, `ssh://`/`git://`, port, and `www.` remote forms. + +`rule create`/`rule improve` now handle two additional generation states: `classifying` (a transient pre-build phase) and `unsupported`, a terminal state emitted when the request needs a capability the organization's plan doesn't include (for example, runtime rules) — surfaced with a clear message and the new `RULE_UNSUPPORTED` error code. When the server can't act on a repository for the selected org (its GitHub App installation doesn't cover the repo, or membership changed), the CLI now explains the coverage cause rather than only suggesting re-authentication. diff --git a/packages/cli/src/api/reconcile.ts b/packages/cli/src/api/reconcile.ts index eee9c873..08309102 100644 --- a/packages/cli/src/api/reconcile.ts +++ b/packages/cli/src/api/reconcile.ts @@ -3,10 +3,11 @@ import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; /** * Server-owned rule reconciliation (TSKL-270). The CLI reports the rule files - * it holds and the server returns the exact subset that may run. This endpoint - * is not in the generated schema (it may not be deployed everywhere yet), so it - * is called with a hand-typed request over plain `fetch`; migrate it onto the - * typed client once it lands in `GET /cli/api/__schema`. + * it holds and the server returns the exact subset that may run. The endpoint + * is now in the generated schema, but this stays on hand-typed plain `fetch` + * for its degradation contract (`ReconcileOutcome` never throws for expected + * network/auth/deployment conditions, so `check` falls back to a local scan); + * migrating it onto the typed client would mean re-expressing that handling. */ /** A rule file reported for reconciliation. */ @@ -16,6 +17,11 @@ export interface ReportedFile { } export interface ReconcileRequest { + /** + * Org subject: Taskless UUID (preferred) or numeric GitHub org id. Optional — + * the server falls back to the deprecated token claim when it is absent. + */ + orgId?: string | number; repositoryUrl: string; files: ReportedFile[]; } diff --git a/packages/cli/src/api/rules.ts b/packages/cli/src/api/rules.ts index 0237b052..5ec2c840 100644 --- a/packages/cli/src/api/rules.ts +++ b/packages/cli/src/api/rules.ts @@ -23,13 +23,32 @@ function parseErrorBody(rawError: unknown): Record { return {}; } +/** + * The server returns the same 404 `organization_not_found` whether the org + * isn't yours or its GitHub App installation doesn't cover this repository (it + * deliberately doesn't distinguish, to avoid leaking org existence), so the + * message names both causes — coverage first, since a resolved org subject + * makes membership the less likely one. + */ +function orgNotFoundMessage(): string { + return [ + "Taskless could not act on this repository for your organization.", + "", + "Most often the organization's Taskless GitHub App installation does not cover this repository. It can also mean your login no longer has access to the organization.", + "", + "- Confirm the Taskless app is installed on this repository's owner and includes this repository.", + `- If access recently changed, re-authenticate with \`${getCliPrefix()} auth login\`.`, + ].join("\n"); +} + // --- API functions --- /** Submit a new rule generation request */ export async function submitRule( token: string, request: { - orgId: number; + /** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */ + orgId: string | number; repositoryUrl: string; prompt: string; successCases?: string[]; @@ -65,9 +84,7 @@ export async function submitRule( response.status === 404 && errorData.error === "organization_not_found" ) { - throw new Error( - `Organization not found. Try running \`${getCliPrefix()} auth login\` to re-authenticate.` - ); + throw new Error(orgNotFoundMessage()); } throw new Error( `Request submission failed (HTTP ${String(response.status)})` @@ -103,7 +120,8 @@ export async function iterateRule( token: string, ruleId: string, request: { - orgId: number; + /** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */ + orgId: string | number; guidance: string; references?: Array<{ filename: string; content: string }>; } @@ -133,9 +151,7 @@ export async function iterateRule( response.status === 404 && errorData.error === "organization_not_found" ) { - throw new Error( - `Organization not found. Try running \`${getCliPrefix()} auth login\` to re-authenticate.` - ); + throw new Error(orgNotFoundMessage()); } throw new Error(`Iterate request failed (HTTP ${String(response.status)})`); } diff --git a/packages/cli/src/auth/identity.ts b/packages/cli/src/auth/identity.ts index 39c3a0d6..95fdebd0 100644 --- a/packages/cli/src/auth/identity.ts +++ b/packages/cli/src/auth/identity.ts @@ -1,20 +1,26 @@ import { getToken } from "./token"; -import { decodeOrgId } from "./jwt"; +import { resolveOrgSubject } from "./org"; import { resolveRepositoryUrl } from "../util/git-remote"; import { getCliPrefix } from "../util/package-manager"; export interface Identity { token: string; - orgId: number; + /** + * Org subject to send on write calls: the current org's Taskless UUID + * (preferred) or the deprecated numeric `orgId` claim. See `resolveOrgSubject`. + */ + orgSubject: string | number; repositoryUrl: string; } /** - * Resolve the current user's identity from JWT claims and git remote. - * - orgId: extracted from the JWT's orgId claim + * Resolve the current user's identity for a write call. + * - orgSubject: the current org's Taskless UUID matched from `whoami` + the + * repo's remotes, falling back to the token's deprecated numeric `orgId` claim * - repositoryUrl: inferred from `git remote get-url origin` * - * Throws if auth is missing, the JWT lacks orgId, or the git remote is unavailable. + * Throws if auth is missing, no org subject can be determined, or the git + * remote is unavailable. */ export async function resolveIdentity(cwd: string): Promise { const token = await getToken(cwd); @@ -24,14 +30,14 @@ export async function resolveIdentity(cwd: string): Promise { ); } - const orgId = decodeOrgId(token); - if (orgId === undefined) { + const repositoryUrl = await resolveRepositoryUrl(cwd); + + const orgSubject = await resolveOrgSubject(cwd, token); + if (orgSubject === undefined) { throw new Error( `Your auth token is missing organization info. Run \`${getCliPrefix()} auth login\` to re-authenticate.` ); } - const repositoryUrl = await resolveRepositoryUrl(cwd); - - return { token, orgId, repositoryUrl }; + return { token, orgSubject, repositoryUrl }; } diff --git a/packages/cli/src/auth/org.ts b/packages/cli/src/auth/org.ts new file mode 100644 index 00000000..789e5f11 --- /dev/null +++ b/packages/cli/src/auth/org.ts @@ -0,0 +1,71 @@ +import type { paths } from "../generated/api"; +import { decodeOrgId } from "./jwt"; +import { fetchWhoami } from "./whoami"; +import { listRemoteOwnerUrls } from "../util/git-remote"; + +type WhoamiData = + paths["/cli/api/whoami"]["get"]["responses"]["200"]["content"]["application/json"]; + +/** + * One organization from `GET /cli/api/whoami`, derived from the generated + * OpenAPI schema. `orgId` is the numeric GitHub org id; `id` is the Taskless + * org UUID — the subject the CLI acts as on write calls; `url` is the canonical + * OWNER url (e.g. `https://github.com/acme`) matched against the repo's remotes. + */ +export type WhoamiOrg = WhoamiData["orgs"][number]; + +/** + * Pick the acting org from a whoami org list given the repo's owner urls (in + * `origin` → `upstream` → rest precedence). Exact `url` equality against + * GitHub-sourced orgs; the first remote that matches an org wins. Returns + * `undefined` when nothing matches (no current-org context). + */ +export function selectOrgForOwners( + ownerUrls: string[], + orgs: WhoamiOrg[] +): WhoamiOrg | undefined { + const byUrl = new Map( + orgs.filter((org) => org.source === "github").map((org) => [org.url, org]) + ); + for (const ownerUrl of ownerUrls) { + const org = byUrl.get(ownerUrl); + if (org) return org; + } + return undefined; +} + +/** + * Resolve which org the CLI acts as for the repo at `cwd`: match the repo's + * local git remotes against `orgs[].url`. Returns `undefined` when there is no + * GitHub remote or no org owns it — the caller then has no current-org context + * (and any write it attempts is authorized, and may be denied, server-side). + */ +export async function resolveCurrentOrg( + cwd: string, + orgs: WhoamiOrg[] +): Promise { + const ownerUrls = await listRemoteOwnerUrls(cwd); + return selectOrgForOwners(ownerUrls, orgs); +} + +/** + * The org subject to send on write calls. Prefers the current org's Taskless + * UUID (`id`), resolved by matching the repo's remotes against `whoami`; falls + * back to the deprecated numeric `orgId` claim in the token when whoami is + * unavailable or no org owns the repo. A new client thus routes multi-org users + * correctly, while older single-org behaviour is preserved via the claim. + * + * Returns `undefined` only when there is neither a matched org nor a claim + * (a broken or pre-org token) — the caller has no subject to send. + */ +export async function resolveOrgSubject( + cwd: string, + token: string +): Promise { + const whoami = await fetchWhoami(token); + if (whoami && whoami.orgs.length > 0) { + const org = await resolveCurrentOrg(cwd, whoami.orgs); + if (org) return org.id; + } + return decodeOrgId(token); +} diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index b2021c84..ac856e94 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -10,6 +10,7 @@ import { getTelemetry } from "../telemetry"; import { outputSchema as checkOutputSchema } from "../schemas/check"; import { makeErrorEnvelope } from "../types/errors"; import { getToken } from "../auth/token"; +import { resolveOrgSubject } from "../auth/org"; import { resolveRepositoryUrl } from "../util/git-remote"; import { getCliPrefix } from "../util/package-manager"; import { reconcile } from "../api/reconcile"; @@ -184,7 +185,9 @@ async function planRuntime( reason: "its check.ts is missing or unreadable", })); + const orgSubject = await resolveOrgSubject(cwd, token); const outcome = await reconcile(token, { + orgId: orgSubject, repositoryUrl, files: reportRuntimeChecks(cwd, signed), }); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 1da8a835..ec91e922 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -37,7 +37,7 @@ export const infoCommand = defineCommand({ args.anonymous ? Promise.resolve() : getToken(cwd), ]); - let auth: { user: string; email: string; orgs: string[] } | undefined; + let auth: { user: string; email?: string; orgs: string[] } | undefined; if (!args.anonymous && token) { const whoami = await fetchWhoami(token); if (whoami) { diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index 64e884fe..034c1ffd 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -39,6 +39,20 @@ function getTimestamp(): string { const POLL_INTERVAL_MS = 15_000; +/** + * `unsupported` is a terminal status: the request asked for a rule generation + * the account can't access — e.g. runtime rules that aren't enabled on the + * current plan. It is not a transient failure to retry; the plan or entitlement + * has to change first. + */ +function unsupportedMessage(): string { + return [ + "This rule generation isn't available on your current Taskless plan.", + "", + "It may need a capability that isn't enabled for your organization yet (for example, runtime rules). Ask your Taskless administrator or upgrade your plan to enable it.", + ].join("\n"); +} + const createCommand = defineCommand({ meta: { name: "create", @@ -163,7 +177,7 @@ const createCommand = defineCommand({ let ruleId: string; try { const response = await submitRule(identity.token, { - orgId: identity.orgId, + orgId: identity.orgSubject, repositoryUrl: identity.repositoryUrl, prompt: request.prompt, successCases: request.successCases, @@ -198,10 +212,18 @@ const createCommand = defineCommand({ console.error("Status: accepted — waiting for processing..."); break; } + case "classifying": { + console.error("Status: classifying — analyzing your request..."); + break; + } case "building": { console.error("Status: building — generating rules..."); break; } + case "unsupported": { + fail(unsupportedMessage(), "RULE_UNSUPPORTED"); + break; + } case "failed": { fail( `Rule generation failed: ${status.error}`, @@ -396,7 +418,7 @@ const improveCommand = defineCommand({ let requestId: string; try { const response = await iterateRule(identity.token, request.ruleId, { - orgId: identity.orgId, + orgId: identity.orgSubject, guidance: request.guidance, references: request.references, }); @@ -431,10 +453,18 @@ const improveCommand = defineCommand({ console.error("Status: accepted — waiting for processing..."); break; } + case "classifying": { + console.error("Status: classifying — analyzing your request..."); + break; + } case "building": { console.error("Status: building — generating rules..."); break; } + case "unsupported": { + fail(unsupportedMessage(), "RULE_UNSUPPORTED"); + break; + } case "failed": { fail( `Rule iteration failed: ${status.error}`, diff --git a/packages/cli/src/generated/api.d.ts b/packages/cli/src/generated/api.d.ts index faea19c2..40e558e0 100644 --- a/packages/cli/src/generated/api.d.ts +++ b/packages/cli/src/generated/api.d.ts @@ -31,14 +31,23 @@ export interface paths { /** @description Display name */ user: string; /** @description Email address */ - email: string; + email?: string; orgs: { /** @description GitHub org ID */ orgId: number; + /** @description Taskless organization UUID */ + id: string; /** @description Organization name */ name: string; /** @description GitHub App installation ID */ installationId: number; + /** + * @description Identity provider + * @constant + */ + source: "github"; + /** @description Canonical owner URL the client matches its repository against */ + url: string; }[]; }; }; @@ -141,12 +150,14 @@ export interface paths { /** @enum {string} */ status: | "accepted" + | "classifying" | "building" | "generated" | "failed" | "pr" | "merged" - | "closed"; + | "closed" + | "unsupported"; /** @description Generated rules (present when status is generated) */ rules?: { /** @description Rule identifier (matches content.id) */ @@ -296,6 +307,136 @@ export interface paths { patch?: never; trace?: never; }; + "/cli/api/reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reconcile reported rule files against the blessed corpus; returns what may run */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + /** @description Full repository URL */ + repositoryUrl: string; + /** @description The rule files the client holds and their local signatures */ + files: { + /** @description Delivered rule filename under .taskless/rules/ on the client */ + file: string; + /** @description Canonical signature the client computed for its local file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Files the client may execute — exactly these, nothing else */ + run: { + /** @description The model-assigned rule identifier */ + ruleId: string; + /** @description Delivered rule filename under .taskless/rules/ */ + file: string; + /** @description Blessed canonical signature (`1;h=sha-256;d=`) */ + signature: string; + }[]; + /** @description Reported files whose content differs from the blessed rule */ + unsafe: { + /** @description Delivered rule filename */ + file: string; + /** @description The blessed (authoritative) signature */ + expected: string; + /** @description The signature the client reported */ + got: string; + }[]; + /** @description Reported files the corpus never issued */ + unknown: { + /** @description Delivered rule filename the corpus never issued */ + file: string; + }[]; + /** @description Blessed rules the client did not report */ + missing: { + /** @description The model-assigned rule identifier */ + ruleId: string; + /** @description Delivered rule filename the client did not report */ + file: string; + }[]; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/rule-hash-vectors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public: canonical rule-hash conformance vectors for cross-repo validation */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Canonical conformance vectors every implementation must reproduce */ + vectors: { + /** @description Human-readable case name, e.g. "crlf-equals-lf" */ + name: string; + /** @description Raw rule text to hash */ + input: string; + /** @description Canonical signature envelope: ;h=;d= */ + signature: string; + }[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/packages/cli/src/help/rule-create.txt b/packages/cli/src/help/rule-create.txt index f36e67fd..3b65fec8 100644 --- a/packages/cli/src/help/rule-create.txt +++ b/packages/cli/src/help/rule-create.txt @@ -92,6 +92,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: | `INVALID_INPUT` | `--from` JSON failed validation | re-read the input schema, fix, retry | | `NETWORK_ERROR` | API submit/poll failed | report and suggest retry | | `RULE_GENERATION_FAILED` | API returned a generation failure | report the message; suggest enriching prompt | +| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | ## See Also diff --git a/packages/cli/src/help/rule-improve.txt b/packages/cli/src/help/rule-improve.txt index adfdbf21..bc40ff65 100644 --- a/packages/cli/src/help/rule-improve.txt +++ b/packages/cli/src/help/rule-improve.txt @@ -95,6 +95,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: | `RULE_NOT_FOUND` | metadata missing for the given rule | use anonymous variant or recreate via create | | `NETWORK_ERROR` | API submit/poll failed | report and suggest retry | | `RULE_GENERATION_FAILED` | API returned a generation failure | report; suggest enriching guidance/references | +| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | ## See Also diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index a459b78f..1d788087 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -10,6 +10,7 @@ export type CLIErrorCode = | "AUTH_REQUIRED" | "NO_GITHUB_REMOTE" | "RULE_GENERATION_FAILED" + | "RULE_UNSUPPORTED" | "RULE_NOT_FOUND" | "INVALID_INPUT" | "NETWORK_ERROR" diff --git a/packages/cli/src/util/git-remote.ts b/packages/cli/src/util/git-remote.ts index 77aea75b..cde5fbbc 100644 --- a/packages/cli/src/util/git-remote.ts +++ b/packages/cli/src/util/git-remote.ts @@ -52,3 +52,118 @@ export function canonicalizeGitHubUrl(rawUrl: string): string { `Unsupported git remote URL: "${rawUrl}". Only GitHub repositories (github.com) are supported.` ); } + +/** + * Reduce a git remote reference to a canonical OWNER url — + * `https://{host}/{owner}`. This is a verbatim port of the server's + * `@taskless/shared/github` `canonicalOwnerUrl`, which builds `whoami`'s + * per-org `url`. The two sides are compared with `===`, so this MUST stay + * byte-for-byte identical — any divergence silently drops an org match. + * + * Accepts a bare owner login, a full repo URL, or an SSH remote (scp-like or + * `ssh://`/`git://` URL form). Host and owner are lowercased (GitHub logins are + * case-insensitive), `www.` is stripped, a trailing `.git`/slash removed, and + * userinfo, port, query, and fragment discarded. Host defaults to `github.com` + * when the input carries none. Never throws: a non-GitHub host comes back as + * `https://{that-host}/{owner}`, which simply won't match a GitHub org url — + * `listRemoteOwnerUrls` is where non-GitHub owners are dropped. + */ +export function canonicalOwnerUrl(ownerOrUrl: string): string { + const raw = ownerOrUrl.trim(); + let host = "github.com"; + let path = raw; + + const sshRemote = /^[^@/]+@([^:/]+):(.+)$/.exec(raw); + if (sshRemote) { + // scp-like SSH remote: git@github.com:owner/repo(.git) + host = sshRemote[1] ?? host; + path = sshRemote[2] ?? path; + } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) { + // Absolute (https://, ssh://, git://) or scheme-relative (//host/...) URL + try { + const url = new URL(raw.startsWith("//") ? `https:${raw}` : raw); + host = url.hostname; + path = url.pathname; + } catch { + // Not parseable as a URL — fall through and treat the input as a path. + } + } + + host = host.toLowerCase().replace(/^www\./, ""); + const owner = (path.replace(/^\/+/, "").split("/")[0] ?? "") + .replace(/\.git$/i, "") + .toLowerCase(); + + return `https://${host}/${owner}`; +} + +/** A canonical owner url on github.com with a non-empty owner segment. */ +const GITHUB_OWNER_URL = /^https:\/\/github\.com\/[^/]+$/; + +/** Read every `remote..url` from git config; empty if not a repo / no remotes. */ +function listRemoteConfig( + cwd: string +): Promise<{ name: string; url: string }[]> { + return new Promise((resolve) => { + execFile( + "git", + ["config", "--get-regexp", String.raw`^remote\..*\.url$`], + { cwd }, + (error, stdout) => { + if (error) { + resolve([]); // not a repo, no remotes, or git unavailable → no context + return; + } + const remotes: { name: string; url: string }[] = []; + for (const line of stdout.split("\n")) { + // `remote..url ` — name may contain dots, so match greedily + // up to the final `.url` before the value. + const match = /^remote\.(?.+)\.url\s+(?.+)$/.exec( + line.trim() + ); + if (match?.groups?.name && match.groups.url) { + remotes.push({ name: match.groups.name, url: match.groups.url }); + } + } + resolve(remotes); + } + ); + }); +} + +/** Remotes we trust most, in order, before falling back to config order. */ +const REMOTE_PRECEDENCE = ["origin", "upstream"]; + +/** + * The repo's canonical OWNER urls, ordered `origin` → `upstream` → remaining + * remotes in config order, de-duplicated. Non-GitHub remotes are skipped. Used + * to pick the acting org by matching against `whoami` `orgs[].url`. + */ +export async function listRemoteOwnerUrls(cwd: string): Promise { + const remotes = await listRemoteConfig(cwd); + const ordered = [ + // A remote can carry several `remote..url` entries (e.g. `git remote + // set-url --add`), so collect ALL of a precedence remote's urls, not just + // the first — otherwise a second origin/upstream url is dropped entirely. + ...REMOTE_PRECEDENCE.flatMap((name) => + remotes.filter((remote) => remote.name === name) + ), + ...remotes.filter((remote) => !REMOTE_PRECEDENCE.includes(remote.name)), + ]; + + const owners: string[] = []; + const seen = new Set(); + for (const remote of ordered) { + const owner = canonicalOwnerUrl(remote.url); + // Only a github.com owner can match a `github`-sourced whoami org. Drop + // other hosts and any empty owner (a remote with no owner segment). + if (!GITHUB_OWNER_URL.test(owner)) { + continue; + } + if (!seen.has(owner)) { + seen.add(owner); + owners.push(owner); + } + } + return owners; +} diff --git a/packages/cli/test/git-remote.test.ts b/packages/cli/test/git-remote.test.ts index bb7add47..dc7c2abb 100644 --- a/packages/cli/test/git-remote.test.ts +++ b/packages/cli/test/git-remote.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { canonicalizeGitHubUrl } from "../src/util/git-remote"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + canonicalizeGitHubUrl, + canonicalOwnerUrl, + listRemoteOwnerUrls, +} from "../src/util/git-remote"; + +const execFileAsync = promisify(execFile); describe("canonicalizeGitHubUrl", () => { it("canonicalizes SSH URLs with .git suffix", () => { @@ -44,3 +55,114 @@ describe("canonicalizeGitHubUrl", () => { ); }); }); + +describe("canonicalOwnerUrl", () => { + // Parity fixtures with the server's @taskless/shared/github canonicalOwnerUrl. + // The dashboard builds whoami's per-org `url` with the same function and the + // two are compared with `===`, so every form here MUST reduce identically — + // including the ssh://, git://, and port forms the old regex version threw on. + it.each([ + "git@github.com:Acme/Widgets.git", // scp-like SSH + "https://github.com/acme/widgets", // https + "https://github.com/ACME/widgets.git", // .git suffix + "https://www.github.com/acme/widgets/", // www. + trailing slash + "git@github.com:acme/widgets", // scp-like, no .git + "ssh://git@github.com/acme/widgets.git", // ssh:// URL form + "git://github.com/acme/widgets.git", // git:// URL form + "https://github.com:443/acme/widgets", // explicit port + "https://x-access-token@GitHub.com/Acme/Widgets", // userinfo + mixed case + "acme", // bare owner login (the form the server canonicalizes) + ])("reduces %s to the canonical owner url", (input) => { + expect(canonicalOwnerUrl(input)).toBe("https://github.com/acme"); + }); + + it("keeps the host for non-GitHub remotes (dropped later by listRemoteOwnerUrls)", () => { + // Mirrors the server: host-agnostic and never throws. A gitlab owner url + // just won't match a github-sourced org. + expect(canonicalOwnerUrl("git@gitlab.com:acme/widgets.git")).toBe( + "https://gitlab.com/acme" + ); + }); +}); + +describe("listRemoteOwnerUrls", () => { + let directory: string; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "tskl-remotes-")); + await execFileAsync("git", ["init"], { cwd: directory }); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it("orders origin before upstream before other remotes, deduped", async () => { + await execFileAsync( + "git", + ["remote", "add", "fork", "git@github.com:me/widgets.git"], + { cwd: directory } + ); + await execFileAsync( + "git", + ["remote", "add", "upstream", "https://github.com/acme/widgets.git"], + { cwd: directory } + ); + await execFileAsync( + "git", + ["remote", "add", "origin", "git@github.com:Origin-Org/widgets.git"], + { cwd: directory } + ); + expect(await listRemoteOwnerUrls(directory)).toEqual([ + "https://github.com/origin-org", + "https://github.com/acme", + "https://github.com/me", + ]); + }); + + it("skips non-GitHub remotes and returns [] with no remotes", async () => { + expect(await listRemoteOwnerUrls(directory)).toEqual([]); + await execFileAsync( + "git", + ["remote", "add", "origin", "git@gitlab.com:acme/widgets.git"], + { cwd: directory } + ); + expect(await listRemoteOwnerUrls(directory)).toEqual([]); + }); + + it("keeps every url of a precedence remote (multi-url origin)", async () => { + // `git remote set-url --add` gives origin a second url; both owners must be + // considered, in order, rather than the first winning outright. + await execFileAsync( + "git", + ["remote", "add", "origin", "git@github.com:primary/widgets.git"], + { cwd: directory } + ); + await execFileAsync( + "git", + [ + "remote", + "set-url", + "--add", + "origin", + "https://github.com/secondary/widgets.git", + ], + { cwd: directory } + ); + expect(await listRemoteOwnerUrls(directory)).toEqual([ + "https://github.com/primary", + "https://github.com/secondary", + ]); + }); + + it("matches a github remote given in ssh:// url form", async () => { + // Regression guard: the old regex canonicalizer threw on ssh:// remotes, so + // this repo would have resolved to no current-org context. + await execFileAsync( + "git", + ["remote", "add", "origin", "ssh://git@github.com/Acme/Widgets.git"], + { cwd: directory } + ); + expect(await listRemoteOwnerUrls(directory)).toEqual([ + "https://github.com/acme", + ]); + }); +}); diff --git a/packages/cli/test/org.test.ts b/packages/cli/test/org.test.ts new file mode 100644 index 00000000..2efda1aa --- /dev/null +++ b/packages/cli/test/org.test.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchWhoami } from "../src/auth/whoami"; +import { + resolveOrgSubject, + selectOrgForOwners, + type WhoamiOrg, +} from "../src/auth/org"; + +vi.mock("../src/auth/whoami", () => ({ fetchWhoami: vi.fn() })); + +const execFileAsync = promisify(execFile); +const mockedFetchWhoami = vi.mocked(fetchWhoami); + +// Minimal unsigned JWT (header: {"alg":"none","typ":"JWT"}) for the claim fallback. +const JWT_HEADER = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0"; +const makeJwt = (payload: Record): string => + `${JWT_HEADER}.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.`; + +const whoamiWith = (orgs: WhoamiOrg[]) => + ({ user: "Ada", orgs }) as Awaited>; + +// fetchWhoami resolves to undefined when the call fails; name it so the +// unavailable case reads clearly and doesn't trip no-useless-undefined. +const WHOAMI_UNAVAILABLE = undefined as Awaited>; + +// `source` is cast because the generated schema pins it to the literal +// "github"; the source-filter test deliberately injects another provider. +const org = (id: string, url: string, source = "github"): WhoamiOrg => + ({ + orgId: 1, + id, + name: url.split("/").pop() ?? "", + installationId: 1, + source, + url, + }) as WhoamiOrg; + +describe("selectOrgForOwners", () => { + const orgs = [ + org("uuid-acme", "https://github.com/acme"), + org("uuid-me", "https://github.com/me"), + ]; + + it("returns the first owner (origin precedence) that matches an org", () => { + // origin=me, upstream=acme → me wins (it comes first in the owner list). + expect( + selectOrgForOwners( + ["https://github.com/me", "https://github.com/acme"], + orgs + )?.id + ).toBe("uuid-me"); + }); + + it("falls through to a later remote when the first has no matching org", () => { + expect( + selectOrgForOwners( + ["https://github.com/stranger", "https://github.com/acme"], + orgs + )?.id + ).toBe("uuid-acme"); + }); + + it("returns undefined when no owner matches", () => { + expect( + selectOrgForOwners(["https://github.com/stranger"], orgs) + ).toBeUndefined(); + }); + + it("ignores non-github-sourced orgs", () => { + const gitlab = [org("uuid-x", "https://github.com/acme", "gitlab")]; + expect( + selectOrgForOwners(["https://github.com/acme"], gitlab) + ).toBeUndefined(); + }); +}); + +describe("resolveOrgSubject", () => { + let directory: string; + const token = makeJwt({ orgId: 4242 }); + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "tskl-subject-")); + await execFileAsync("git", ["init"], { cwd: directory }); + await execFileAsync( + "git", + ["remote", "add", "origin", "git@github.com:acme/widgets.git"], + { cwd: directory } + ); + mockedFetchWhoami.mockReset(); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it("prefers the matched org's UUID when a remote matches a whoami org", async () => { + mockedFetchWhoami.mockResolvedValue( + whoamiWith([org("uuid-acme", "https://github.com/acme")]) + ); + expect(await resolveOrgSubject(directory, token)).toBe("uuid-acme"); + }); + + it("falls back to the numeric claim when no whoami org matches the repo", async () => { + mockedFetchWhoami.mockResolvedValue( + whoamiWith([org("uuid-other", "https://github.com/other")]) + ); + expect(await resolveOrgSubject(directory, token)).toBe(4242); + }); + + it("falls back to the numeric claim when whoami is unavailable", async () => { + mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE); + expect(await resolveOrgSubject(directory, token)).toBe(4242); + }); + + it("returns undefined when whoami is unavailable and the token has no claim", async () => { + mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE); + expect( + await resolveOrgSubject(directory, makeJwt({ sub: "u" })) + ).toBeUndefined(); + }); +});