From 43dff09da11f40ba1c3fa3bb66a2e66ac54a8940 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 7 Jul 2026 12:06:49 -0700 Subject: [PATCH 1/5] feat(cli): resolve acting org from local git remotes Add the client-side org identity layer for the 0.10.x whoami contract: the CLI now maps the repo's local git remotes to a Taskless org UUID instead of relying on the token's numeric orgId claim. - canonicalOwnerUrl reduces any GitHub remote (SSH/HTTPS, .git, www., userinfo, mixed case, trailing slash) to the canonical owner url (https://github.com/{owner}), matching the server's orgs[].url normalization exactly. - listRemoteOwnerUrls enumerates git remotes, orders them origin -> upstream -> rest, canonicalizes + dedupes, and skips non-GitHub remotes. Returns [] when git is unavailable or the repo has no remotes, degrading cleanly to no current-org context. - selectOrgForOwners / resolveCurrentOrg pick the acting org: the first owner url that matches a github-sourced whoami org wins. This is the resolution layer only. Sending the org id UUID as the subject on write calls is deferred until the API team finalizes the wire field. The token keeps carrying the numeric orgId claim, so the change is additive and older clients are unaffected. Refs TSKL-245 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/auth/org.ts | 56 +++++++++++++++++ packages/cli/src/util/git-remote.ts | 90 ++++++++++++++++++++++++++++ packages/cli/test/git-remote.test.ts | 84 +++++++++++++++++++++++++- packages/cli/test/org.test.ts | 50 ++++++++++++++++ 4 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/auth/org.ts create mode 100644 packages/cli/test/org.test.ts diff --git a/packages/cli/src/auth/org.ts b/packages/cli/src/auth/org.ts new file mode 100644 index 00000000..c916117f --- /dev/null +++ b/packages/cli/src/auth/org.ts @@ -0,0 +1,56 @@ +import { listRemoteOwnerUrls } from "../util/git-remote"; + +/** + * One organization from `GET /cli/api/whoami` (0.10.x contract). Hand-typed + * here because the generated schema lags the server (the fields ship in + * lockstep). `orgId` stays the numeric GitHub org id; `id` is the Taskless org + * UUID — the value to send as the org subject on write calls. + */ +export interface WhoamiOrg { + /** GitHub numeric org id (unchanged from 0.9.x). */ + orgId: number; + /** Taskless org UUID — the org subject the CLI acts as. */ + id: string; + /** Org name (GitHub owner login). */ + name: string; + /** GitHub App installation id. */ + installationId: number; + /** Provider discriminator, e.g. `"github"`. */ + source: string; + /** Canonical OWNER url, e.g. `https://github.com/acme` (not per-repo). */ + url: string; +} + +/** + * 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); +} diff --git a/packages/cli/src/util/git-remote.ts b/packages/cli/src/util/git-remote.ts index 77aea75b..299f8b2e 100644 --- a/packages/cli/src/util/git-remote.ts +++ b/packages/cli/src/util/git-remote.ts @@ -52,3 +52,93 @@ export function canonicalizeGitHubUrl(rawUrl: string): string { `Unsupported git remote URL: "${rawUrl}". Only GitHub repositories (github.com) are supported.` ); } + +// git@github.com:owner/repo(.git) — owner only +const OWNER_SSH_PATTERN = + /^git@github\.com:(?[^/]+)\/[^/]+?(?:\.git)?$/i; +// http(s)://[user@][www.]github.com/owner/repo(.git)(/) — owner only +const OWNER_HTTPS_PATTERN = + /^https?:\/\/(?:[^@/]+@)?(?:www\.)?github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?\/?$/i; + +/** + * Reduce a GitHub remote URL to its canonical OWNER url — + * `https://github.com/{owner}` — matching the server's `orgs[].url` + * normalization exactly: scheme `https`, host `github.com` (no `www.`), owner + * lowercased, repo dropped, no `.git`, no trailing slash, no userinfo. + * Throws for anything that isn't a GitHub owner/repo remote. + */ +export function canonicalOwnerUrl(rawUrl: string): string { + const url = rawUrl.trim(); + const match = OWNER_SSH_PATTERN.exec(url) ?? OWNER_HTTPS_PATTERN.exec(url); + if (!match?.groups?.owner) { + throw new Error( + `Unsupported git remote URL: "${rawUrl}". Only GitHub repositories (github.com) are supported.` + ); + } + return `https://github.com/${match.groups.owner.toLowerCase()}`; +} + +/** 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 = [ + ...REMOTE_PRECEDENCE.map((name) => + remotes.find((remote) => remote.name === name) + ).filter((remote) => remote !== undefined), + ...remotes.filter((remote) => !REMOTE_PRECEDENCE.includes(remote.name)), + ]; + + const owners: string[] = []; + const seen = new Set(); + for (const remote of ordered) { + let owner: string; + try { + owner = canonicalOwnerUrl(remote.url); + } catch { + continue; // non-GitHub remote — can't map to an org + } + 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..73be4a2d 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,72 @@ describe("canonicalizeGitHubUrl", () => { ); }); }); + +describe("canonicalOwnerUrl", () => { + // The server normalizes orgs[].url identically; these are the doc's vectors. + it.each([ + "git@github.com:Acme/Widgets.git", + "https://github.com/acme/widgets", + "https://github.com/ACME/widgets.git", + "https://www.github.com/acme/widgets/", + "git@github.com:acme/widgets", + ])("reduces %s to the canonical owner url", (input) => { + expect(canonicalOwnerUrl(input)).toBe("https://github.com/acme"); + }); + + it("strips userinfo and lowercases the host", () => { + expect( + canonicalOwnerUrl("https://x-access-token@GitHub.com/Acme/Widgets") + ).toBe("https://github.com/acme"); + }); + + it("throws for non-GitHub remotes", () => { + expect(() => canonicalOwnerUrl("git@gitlab.com:acme/widgets.git")).toThrow( + /Only GitHub/ + ); + }); +}); + +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([]); + }); +}); diff --git a/packages/cli/test/org.test.ts b/packages/cli/test/org.test.ts new file mode 100644 index 00000000..6ed5cfd1 --- /dev/null +++ b/packages/cli/test/org.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { selectOrgForOwners, type WhoamiOrg } from "../src/auth/org"; + +const org = (id: string, url: string, source = "github"): WhoamiOrg => ({ + orgId: 1, + id, + name: url.split("/").pop() ?? "", + installationId: 1, + source, + url, +}); + +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(); + }); +}); From bb52b3162d76f705d7ba1e36042ac93fac8c0477 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 8 Jul 2026 10:56:31 -0700 Subject: [PATCH 2/5] refactor(cli): align canonicalOwnerUrl with the shared server algorithm The client canonicalizer was two hardcoded github.com regexes; the server builds whoami's per-org url from a URL-parse-based, host-agnostic canonicalOwnerUrl in @taskless/shared/github. The two are matched with string equality, so any divergence silently drops an org match. The regex version threw on ssh://, git://, and port-bearing github remotes the server accepts and reduces normally. That's a graceful miss (no subject sent, server falls back to the deprecated token claim) rather than a misroute, but such a repo loses the new multi-org routing. Port the shared algorithm verbatim so the two sides are byte-for-byte identical by construction: accept bare login / repo URL / scp- or URL-form SSH, default the host to github.com, strip www./userinfo/port/ .git/trailing slash, lowercase host + owner. It no longer throws; the github.com-only filter now lives in listRemoteOwnerUrls, which drops non-github owners (they can't match a github-sourced org anyway). Adds parity fixtures for the previously-throwing forms and a listRemoteOwnerUrls regression test for an ssh:// remote. Refs TSKL-245 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/util/git-remote.ts | 72 ++++++++++++++++++---------- packages/cli/test/git-remote.test.ts | 47 ++++++++++++------ 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/util/git-remote.ts b/packages/cli/src/util/git-remote.ts index 299f8b2e..0c1cc763 100644 --- a/packages/cli/src/util/git-remote.ts +++ b/packages/cli/src/util/git-remote.ts @@ -53,31 +53,53 @@ export function canonicalizeGitHubUrl(rawUrl: string): string { ); } -// git@github.com:owner/repo(.git) — owner only -const OWNER_SSH_PATTERN = - /^git@github\.com:(?[^/]+)\/[^/]+?(?:\.git)?$/i; -// http(s)://[user@][www.]github.com/owner/repo(.git)(/) — owner only -const OWNER_HTTPS_PATTERN = - /^https?:\/\/(?:[^@/]+@)?(?:www\.)?github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?\/?$/i; - /** - * Reduce a GitHub remote URL to its canonical OWNER url — - * `https://github.com/{owner}` — matching the server's `orgs[].url` - * normalization exactly: scheme `https`, host `github.com` (no `www.`), owner - * lowercased, repo dropped, no `.git`, no trailing slash, no userinfo. - * Throws for anything that isn't a GitHub owner/repo remote. + * 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(rawUrl: string): string { - const url = rawUrl.trim(); - const match = OWNER_SSH_PATTERN.exec(url) ?? OWNER_HTTPS_PATTERN.exec(url); - if (!match?.groups?.owner) { - throw new Error( - `Unsupported git remote URL: "${rawUrl}". Only GitHub repositories (github.com) are supported.` - ); +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. + } } - return `https://github.com/${match.groups.owner.toLowerCase()}`; + + 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 @@ -129,11 +151,11 @@ export async function listRemoteOwnerUrls(cwd: string): Promise { const owners: string[] = []; const seen = new Set(); for (const remote of ordered) { - let owner: string; - try { - owner = canonicalOwnerUrl(remote.url); - } catch { - continue; // non-GitHub remote — can't map to an org + 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); diff --git a/packages/cli/test/git-remote.test.ts b/packages/cli/test/git-remote.test.ts index 73be4a2d..d9c7fb4c 100644 --- a/packages/cli/test/git-remote.test.ts +++ b/packages/cli/test/git-remote.test.ts @@ -57,26 +57,30 @@ describe("canonicalizeGitHubUrl", () => { }); describe("canonicalOwnerUrl", () => { - // The server normalizes orgs[].url identically; these are the doc's vectors. + // 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", - "https://github.com/acme/widgets", - "https://github.com/ACME/widgets.git", - "https://www.github.com/acme/widgets/", - "git@github.com:acme/widgets", + "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("strips userinfo and lowercases the host", () => { - expect( - canonicalOwnerUrl("https://x-access-token@GitHub.com/Acme/Widgets") - ).toBe("https://github.com/acme"); - }); - - it("throws for non-GitHub remotes", () => { - expect(() => canonicalOwnerUrl("git@gitlab.com:acme/widgets.git")).toThrow( - /Only GitHub/ + 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" ); }); }); @@ -123,4 +127,17 @@ describe("listRemoteOwnerUrls", () => { ); expect(await listRemoteOwnerUrls(directory)).toEqual([]); }); + + 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", + ]); + }); }); From f6eaf560fce8c6f59ba4a67fd15971a554d918e6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 8 Jul 2026 13:59:51 -0700 Subject: [PATCH 3/5] chore(cli): regenerate API types and derive WhoamiOrg from the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live OpenAPI schema now carries the 0.10.x org-identity contract, so regenerate src/generated/api.d.ts from it (openapi-typescript, prettier formatted) and drop the hand-typed WhoamiOrg in favour of the generated type — per the styleguide, generated types over custom definitions. Schema changes now reflected: - whoami orgs gain `id` (Taskless UUID), `source` ("github"), and the canonical owner `url`; `email` is now optional. - /cli/api/reconcile is documented and its body carries the org subject: `orgId?: string | number` — the Taskless UUID (preferred) or numeric GitHub org id, falling back to the deprecated token claim. This is the wire field the send-side will populate. - /cli/api/rule-hash-vectors is documented; rule status gains `classifying` and `unsupported`. WhoamiOrg is now `paths[...whoami...]["orgs"][number]`. `email` becoming optional flows through to info.ts (local auth type) and the org.ts test helper casts `source` since the schema pins it to the literal "github". Refs TSKL-245 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/auth/org.ts | 27 ++---- packages/cli/src/commands/info.ts | 2 +- packages/cli/src/generated/api.d.ts | 145 +++++++++++++++++++++++++++- packages/cli/test/org.test.ts | 19 ++-- 4 files changed, 164 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/auth/org.ts b/packages/cli/src/auth/org.ts index c916117f..b814c8df 100644 --- a/packages/cli/src/auth/org.ts +++ b/packages/cli/src/auth/org.ts @@ -1,25 +1,16 @@ +import type { paths } from "../generated/api"; 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` (0.10.x contract). Hand-typed - * here because the generated schema lags the server (the fields ship in - * lockstep). `orgId` stays the numeric GitHub org id; `id` is the Taskless org - * UUID — the value to send as the org subject on write calls. + * 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 interface WhoamiOrg { - /** GitHub numeric org id (unchanged from 0.9.x). */ - orgId: number; - /** Taskless org UUID — the org subject the CLI acts as. */ - id: string; - /** Org name (GitHub owner login). */ - name: string; - /** GitHub App installation id. */ - installationId: number; - /** Provider discriminator, e.g. `"github"`. */ - source: string; - /** Canonical OWNER url, e.g. `https://github.com/acme` (not per-repo). */ - url: string; -} +export type WhoamiOrg = WhoamiData["orgs"][number]; /** * Pick the acting org from a whoami org list given the repo's owner urls (in 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/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/test/org.test.ts b/packages/cli/test/org.test.ts index 6ed5cfd1..5547908e 100644 --- a/packages/cli/test/org.test.ts +++ b/packages/cli/test/org.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from "vitest"; import { selectOrgForOwners, type WhoamiOrg } from "../src/auth/org"; -const org = (id: string, url: string, source = "github"): WhoamiOrg => ({ - orgId: 1, - id, - name: url.split("/").pop() ?? "", - installationId: 1, - source, - url, -}); +// `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 = [ From 79ba8c8865db6bbe399c814b81ea692c4615f5a0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 8 Jul 2026 20:31:20 -0700 Subject: [PATCH 4/5] feat(cli): send the resolved org subject on write calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the org-identity resolution built earlier into the write paths. The CLI now resolves the acting org's Taskless UUID (via whoami + the repo's git remotes) and sends it as the `orgId` subject on rule create, rule iterate, and reconcile. When no remote matches a known org it falls back to the token's deprecated numeric claim, so single-org behaviour is unchanged; multi-org users are no longer misrouted to a pinned org. - resolveOrgSubject(cwd, token): whoami-matched UUID, else the numeric claim, else undefined. resolveIdentity now returns `orgSubject` (string | number) instead of a numeric `orgId`. - submitRule / iterateRule / reconcile request types widen `orgId` to `string | number`; check.ts resolves and sends the subject too. - The server returns the same 404 `organization_not_found` for both "not your org" and "install doesn't cover this repo", so the message now names the coverage cause first, not just re-auth. - Handle two new generation states: `classifying` (transient, keep polling) and `unsupported` (terminal — the org's plan lacks the requested capability, e.g. runtime rules), surfaced with the new RULE_UNSUPPORTED code and documented in the create/improve recipes. Refs TSKL-245 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/org-subject-send.md | 9 +++ packages/cli/src/api/reconcile.ts | 14 +++-- packages/cli/src/api/rules.ts | 32 ++++++++--- packages/cli/src/auth/identity.ts | 26 +++++---- packages/cli/src/auth/org.ts | 24 ++++++++ packages/cli/src/commands/check.ts | 3 + packages/cli/src/commands/rules.ts | 34 +++++++++++- packages/cli/src/help/rule-create.txt | 1 + packages/cli/src/help/rule-improve.txt | 1 + packages/cli/src/types/errors.ts | 1 + packages/cli/test/org.test.ts | 76 +++++++++++++++++++++++++- 11 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 .changeset/org-subject-send.md 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 index b814c8df..789e5f11 100644 --- a/packages/cli/src/auth/org.ts +++ b/packages/cli/src/auth/org.ts @@ -1,4 +1,6 @@ import type { paths } from "../generated/api"; +import { decodeOrgId } from "./jwt"; +import { fetchWhoami } from "./whoami"; import { listRemoteOwnerUrls } from "../util/git-remote"; type WhoamiData = @@ -45,3 +47,25 @@ export async function resolveCurrentOrg( 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/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/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/test/org.test.ts b/packages/cli/test/org.test.ts index 5547908e..2efda1aa 100644 --- a/packages/cli/test/org.test.ts +++ b/packages/cli/test/org.test.ts @@ -1,5 +1,32 @@ -import { describe, expect, it } from "vitest"; -import { selectOrgForOwners, type WhoamiOrg } from "../src/auth/org"; +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. @@ -51,3 +78,48 @@ describe("selectOrgForOwners", () => { ).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(); + }); +}); From 453efcf18cc43ff8d050598d961954e56d64e8e8 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 8 Jul 2026 21:29:43 -0700 Subject: [PATCH 5/5] fix(cli): keep every url of a precedence remote in owner resolution listRemoteOwnerUrls used find() to pull origin/upstream, taking only the first remote..url. Git allows several urls per remote (e.g. `git remote set-url --add`), so a second origin/upstream url was dropped and could miss a matching org owner. Collect all urls per precedence remote with flatMap; dedup already handles repeats. Reported by Copilot review on #55. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/util/git-remote.ts | 9 ++++++--- packages/cli/test/git-remote.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/util/git-remote.ts b/packages/cli/src/util/git-remote.ts index 0c1cc763..cde5fbbc 100644 --- a/packages/cli/src/util/git-remote.ts +++ b/packages/cli/src/util/git-remote.ts @@ -142,9 +142,12 @@ const REMOTE_PRECEDENCE = ["origin", "upstream"]; export async function listRemoteOwnerUrls(cwd: string): Promise { const remotes = await listRemoteConfig(cwd); const ordered = [ - ...REMOTE_PRECEDENCE.map((name) => - remotes.find((remote) => remote.name === name) - ).filter((remote) => remote !== undefined), + // 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)), ]; diff --git a/packages/cli/test/git-remote.test.ts b/packages/cli/test/git-remote.test.ts index d9c7fb4c..dc7c2abb 100644 --- a/packages/cli/test/git-remote.test.ts +++ b/packages/cli/test/git-remote.test.ts @@ -128,6 +128,31 @@ describe("listRemoteOwnerUrls", () => { 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.