From e0b8d4d312242b589bc0b69aee6c75c1479c25ea Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Sat, 18 Jul 2026 14:14:29 -0700 Subject: [PATCH] Add `user list`, `team members --json`, and member role markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related gaps in member listing: `team members` had no --json output. It now emits the connection shape ({ nodes, pageInfo }) with GraphQL field names preserved, matching label list and project list. There was no way to list everyone in the workspace, only per-team. Adds `linear user list` (alias `u`) querying viewer.organization.users. This is a sibling command rather than a `team members --organization` flag: workspace members are definitionally not team members, and a flag that has to error when combined with the positional team key is the design saying it's two commands. Members now show admin, owner, and you markers alongside the existing inactive/guest/not-assignable ones, via a shared renderer. Along the way this fixes `team members --all`, which was a no-op. getTeamMembers never passed includeDisabled, so Linear defaulted it to false and disabled users were never fetched — the client-side `active` filter was narrowing a set that could not contain them. The regression test pins includeDisabled in the mock variables, so it fails loudly if the flag stops reaching the API. Note: the --all fix could not be exercised against real data; the workspace used for QA has no disabled users. --- README.md | 9 + skills/linear-cli/SKILL.md | 4 + skills/linear-cli/references/commands.md | 1 + skills/linear-cli/references/team.md | 3 +- skills/linear-cli/references/user.md | 43 +++ src/cli.ts | 3 + src/commands/team/team-members.ts | 78 +++--- src/commands/user/user-list.ts | 54 ++++ src/commands/user/user.ts | 10 + src/utils/linear.ts | 144 +++++++++- src/utils/member-display.ts | 69 +++++ .../__snapshots__/team-members.test.ts.snap | 239 ++++++++++++++++ test/commands/team/team-members.test.ts | 260 ++++++++++++++++++ .../user/__snapshots__/user-list.test.ts.snap | 239 ++++++++++++++++ test/commands/user/user-list.test.ts | 242 ++++++++++++++++ 15 files changed, 1344 insertions(+), 54 deletions(-) create mode 100644 skills/linear-cli/references/user.md create mode 100644 src/commands/user/user-list.ts create mode 100644 src/commands/user/user.ts create mode 100644 src/utils/member-display.ts create mode 100644 test/commands/team/__snapshots__/team-members.test.ts.snap create mode 100644 test/commands/team/team-members.test.ts create mode 100644 test/commands/user/__snapshots__/user-list.test.ts.snap create mode 100644 test/commands/user/user-list.test.ts diff --git a/README.md b/README.md index d5faa859..b179a26a 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,19 @@ linear issue comment add ENG-123 -a ./screenshot.png --public # public image U linear team list # list teams linear team id # print out the team id (e.g. for scripts) linear team members # list team members +linear team members --all --json # include inactive members, as JSON linear team create # create a new team linear team autolinks # configure GitHub repository autolinks for Linear issues ``` +### user commands + +```bash +linear user list # list everyone in the workspace +linear user list --all # include deactivated members +linear user list --json # machine-readable output +``` + ### project commands ```bash diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index ab33907e..ff2a7045 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -169,6 +169,9 @@ linear team id linear team list linear team members linear team states + +linear user +linear user list ``` ## Reference Documentation @@ -187,6 +190,7 @@ linear team states - [project-update](references/project-update.md) - Manage project status updates - [schema](references/schema.md) - Print the GraphQL schema to stdout - [team](references/team.md) - Manage Linear teams +- [user](references/user.md) - Manage Linear users For curated examples of organization features (initiatives, labels, projects, bulk operations), see [organization-features](references/organization-features.md). diff --git a/skills/linear-cli/references/commands.md b/skills/linear-cli/references/commands.md index 531fc95e..275bfeb4 100644 --- a/skills/linear-cli/references/commands.md +++ b/skills/linear-cli/references/commands.md @@ -16,6 +16,7 @@ - [project-update](./project-update.md) - Manage project status updates - [schema](./schema.md) - Print the GraphQL schema to stdout - [team](./team.md) - Manage Linear teams +- [user](./user.md) - Manage Linear users ## Quick Reference diff --git a/skills/linear-cli/references/team.md b/skills/linear-cli/references/team.md index d78eea54..a8df27ee 100644 --- a/skills/linear-cli/references/team.md +++ b/skills/linear-cli/references/team.md @@ -138,7 +138,8 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) - -a, --all - Include inactive members + -a, --all - Include inactive members + -j, --json - Output as JSON ``` ### states diff --git a/skills/linear-cli/references/user.md b/skills/linear-cli/references/user.md new file mode 100644 index 00000000..64737839 --- /dev/null +++ b/skills/linear-cli/references/user.md @@ -0,0 +1,43 @@ +# user + +> Manage Linear users + +## Usage + +``` +Usage: linear user + +Description: + + Manage Linear users + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + +Commands: + + list - List members of the workspace +``` + +## Subcommands + +### list + +> List members of the workspace + +``` +Usage: linear user list + +Description: + + List members of the workspace + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -a, --all - Include inactive members + -j, --json - Output as JSON +``` diff --git a/src/cli.ts b/src/cli.ts index ed56fe26..d0417221 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import denoConfig from "../deno.json" with { type: "json" } import { authCommand } from "./commands/auth/auth.ts" import { issueCommand } from "./commands/issue/issue.ts" import { teamCommand } from "./commands/team/team.ts" +import { userCommand } from "./commands/user/user.ts" import { projectCommand } from "./commands/project/project.ts" import { projectUpdateCommand } from "./commands/project-update/project-update.ts" import { cycleCommand } from "./commands/cycle/cycle.ts" @@ -48,6 +49,8 @@ Environment Variables: .alias("i") .command("team", teamCommand) .alias("t") + .command("user", userCommand) + .alias("u") .command("project", projectCommand) .alias("p") .command("project-update", projectUpdateCommand) diff --git a/src/commands/team/team-members.ts b/src/commands/team/team-members.ts index bfbfc93d..6611c793 100644 --- a/src/commands/team/team-members.ts +++ b/src/commands/team/team-members.ts @@ -1,5 +1,7 @@ import { Command } from "@cliffy/command" import { getTeamKey, getTeamMembers } from "../../utils/linear.ts" +import { printMembers } from "../../utils/member-display.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, ValidationError } from "../../utils/errors.ts" export const membersCommand = new Command() @@ -7,7 +9,11 @@ export const membersCommand = new Command() .description("List team members") .arguments("[teamKey:string]") .option("-a, --all", "Include inactive members") - .action(async (options, teamKey?: string) => { + .option("-j, --json", "Output as JSON") + .action(async ({ all, json }, teamKey?: string) => { + const showSpinner = !json && shouldShowSpinner() + let spinner: { start: () => void; stop: () => void } | null = null + try { const resolvedTeamKey = teamKey || getTeamKey() if (!resolvedTeamKey) { @@ -17,60 +23,46 @@ export const membersCommand = new Command() ) } - const members = await getTeamMembers(resolvedTeamKey) + if (showSpinner) { + const { Spinner } = await import("@std/cli/unstable-spinner") + spinner = new Spinner() + spinner.start() + } - if (members.length === 0) { - console.log("No members found for this team.") + const includeDisabled = all === true + const { nodes, pageInfo } = await getTeamMembers( + resolvedTeamKey, + includeDisabled, + ) + + spinner?.stop() + + // --json is an output format, not a raw dump: it must respect --all just + // as the human output does. + const members = includeDisabled + ? nodes + : nodes.filter((member) => member.active) + + if (json) { + console.log(JSON.stringify({ nodes: members, pageInfo }, null, 2)) return } - const filteredMembers = options.all - ? members - : members.filter((member) => member.active) + if (nodes.length === 0) { + console.log("No members found for this team.") + return + } - if (filteredMembers.length === 0) { + if (members.length === 0) { console.log( "No active members found for this team. Use --all to include inactive members.", ) return } - console.log(`Team Members (${filteredMembers.length}):`) - console.log("") - - for (const member of filteredMembers) { - const status = member.active ? "" : " (inactive)" - const guestStatus = member.guest ? " (guest)" : "" - const assignableStatus = !member.isAssignable ? " (not assignable)" : "" - const displayName = member.displayName || member.name - const fullName = member.name !== member.displayName - ? ` (${member.name})` - : "" - - console.log( - `${displayName}${fullName} [${member.initials}]${status}${guestStatus}${assignableStatus}`, - ) - if (member.email) { - console.log(` Email: ${member.email}`) - } - if (member.description) { - console.log(` Role: ${member.description}`) - } - if (member.timezone) { - console.log(` Timezone: ${member.timezone}`) - } - if (member.statusEmoji && member.statusLabel) { - console.log( - ` Status: ${member.statusEmoji} ${member.statusLabel}`, - ) - } - if (member.lastSeen) { - const lastSeenDate = new Date(member.lastSeen) - console.log(` Last seen: ${lastSeenDate.toLocaleString()}`) - } - console.log("") - } + printMembers(members, "Team Members") } catch (error) { + spinner?.stop() handleError(error, "Failed to fetch team members") } }) diff --git a/src/commands/user/user-list.ts b/src/commands/user/user-list.ts new file mode 100644 index 00000000..67eb653d --- /dev/null +++ b/src/commands/user/user-list.ts @@ -0,0 +1,54 @@ +import { Command } from "@cliffy/command" +import { getOrganizationMembers } from "../../utils/linear.ts" +import { printMembers } from "../../utils/member-display.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { handleError } from "../../utils/errors.ts" + +export const listCommand = new Command() + .name("list") + .description("List members of the workspace") + .option("-a, --all", "Include inactive members") + .option("-j, --json", "Output as JSON") + .action(async ({ all, json }) => { + const showSpinner = !json && shouldShowSpinner() + let spinner: { start: () => void; stop: () => void } | null = null + + try { + if (showSpinner) { + const { Spinner } = await import("@std/cli/unstable-spinner") + spinner = new Spinner() + spinner.start() + } + + const includeDisabled = all === true + const { nodes, pageInfo } = await getOrganizationMembers(includeDisabled) + + spinner?.stop() + + const members = includeDisabled + ? nodes + : nodes.filter((member) => member.active) + + if (json) { + console.log(JSON.stringify({ nodes: members, pageInfo }, null, 2)) + return + } + + if (nodes.length === 0) { + console.log("No members found in this workspace.") + return + } + + if (members.length === 0) { + console.log( + "No active members found in this workspace. Use --all to include inactive members.", + ) + return + } + + printMembers(members, "Workspace Members") + } catch (error) { + spinner?.stop() + handleError(error, "Failed to fetch workspace members") + } + }) diff --git a/src/commands/user/user.ts b/src/commands/user/user.ts new file mode 100644 index 00000000..78524876 --- /dev/null +++ b/src/commands/user/user.ts @@ -0,0 +1,10 @@ +import { Command } from "@cliffy/command" + +import { listCommand } from "./user-list.ts" + +export const userCommand = new Command() + .description("Manage Linear users") + .action(function () { + this.showHelp() + }) + .command("list", listCommand) diff --git a/src/utils/linear.ts b/src/utils/linear.ts index f089d70f..35da850d 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -6,6 +6,7 @@ import type { GetIssueDetailsWithCommentsQuery, GetIssuesForQueryQuery, GetIssuesForStateQuery, + GetOrganizationMembersQuery, GetProjectsForTeamQuery, GetTeamMembersQuery, IssueFilter, @@ -15,7 +16,7 @@ import type { } from "../__codegen__/graphql.ts" import { Select } from "@cliffy/prompt" import { getOption } from "../config.ts" -import { NotFoundError, ValidationError } from "./errors.ts" +import { CliError, NotFoundError, ValidationError } from "./errors.ts" import { getGraphQLClient } from "./graphql.ts" import { normalizeIssueIdentifier } from "./issue-identifier.ts" import { getCurrentIssueFromVcs } from "./vcs.ts" @@ -1603,12 +1604,29 @@ export async function getLabelsForTeam( ) } -export async function getTeamMembers(teamKey: string) { +type TeamMembersConnection = GetTeamMembersQuery["team"]["members"] + +// `includeDisabled` is explicit so callers can't silently inherit Linear's +// default of false, which is what made `team members --all` a no-op: disabled +// users were never fetched, so filtering on `active` could not reveal them. +export async function getTeamMembers( + teamKey: string, + includeDisabled: boolean, +): Promise { const client = getGraphQLClient() const query = gql(/* GraphQL */ ` - query GetTeamMembers($teamKey: String!, $first: Int, $after: String) { + query GetTeamMembers( + $teamKey: String! + $includeDisabled: Boolean! + $first: Int + $after: String + ) { team(id: $teamKey) { - members(first: $first, after: $after) { + members( + includeDisabled: $includeDisabled + first: $first + after: $after + ) { nodes { id name @@ -1623,6 +1641,9 @@ export async function getTeamMembers(teamKey: string) { statusLabel guest isAssignable + admin + owner + isMe } pageInfo { hasNextPage @@ -1633,27 +1654,130 @@ export async function getTeamMembers(teamKey: string) { } `) - const allMembers = [] + const nodes: TeamMembersConnection["nodes"] = [] + // Describes the exhausted source connection, so hasNextPage is always false + // once pagination completes. Matches label list and project list. + let pageInfo: TeamMembersConnection["pageInfo"] = { + hasNextPage: false, + endCursor: null, + } let hasNextPage = true let after: string | null | undefined = undefined while (hasNextPage) { + // Annotated to break the circular inference between `after` and the + // request's own result type. const result: GetTeamMembersQuery = await client.request(query, { teamKey, + includeDisabled, first: 100, // Fetch 100 members per page after, }) - const members = result.team.members.nodes - allMembers.push(...members) + const members = result.team.members + nodes.push(...members.nodes) + pageInfo = members.pageInfo - hasNextPage = result.team.members.pageInfo.hasNextPage - after = result.team.members.pageInfo.endCursor + hasNextPage = members.pageInfo.hasNextPage + const nextCursor = members.pageInfo.endCursor + if (hasNextPage && (nextCursor == null || nextCursor === after)) { + throw new CliError( + "Linear reported more team members but did not advance the page cursor", + ) + } + after = nextCursor } - return allMembers.sort((a, b) => + // Sort after all pages are fetched so ordering is global, not per-page. + nodes.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) ) + + return { nodes, pageInfo } +} + +type OrganizationMembersConnection = + GetOrganizationMembersQuery["viewer"]["organization"]["users"] + +export async function getOrganizationMembers( + includeDisabled: boolean, +): Promise { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetOrganizationMembers( + $includeDisabled: Boolean! + $first: Int + $after: String + ) { + viewer { + organization { + users( + includeDisabled: $includeDisabled + first: $first + after: $after + ) { + nodes { + id + name + displayName + email + active + initials + description + timezone + lastSeen + statusEmoji + statusLabel + guest + isAssignable + admin + owner + isMe + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `) + + const nodes: OrganizationMembersConnection["nodes"] = [] + let pageInfo: OrganizationMembersConnection["pageInfo"] = { + hasNextPage: false, + endCursor: null, + } + let hasNextPage = true + let after: string | null | undefined = undefined + + while (hasNextPage) { + const result: GetOrganizationMembersQuery = await client.request(query, { + includeDisabled, + first: 100, + after, + }) + + const users = result.viewer.organization.users + nodes.push(...users.nodes) + pageInfo = users.pageInfo + + hasNextPage = users.pageInfo.hasNextPage + const nextCursor = users.pageInfo.endCursor + if (hasNextPage && (nextCursor == null || nextCursor === after)) { + throw new CliError( + "Linear reported more workspace members but did not advance the page cursor", + ) + } + after = nextCursor + } + + nodes.sort((a, b) => + a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) + ) + + return { nodes, pageInfo } } export async function getIssueProjectId( diff --git a/src/utils/member-display.ts b/src/utils/member-display.ts new file mode 100644 index 00000000..dcc1b883 --- /dev/null +++ b/src/utils/member-display.ts @@ -0,0 +1,69 @@ +// Structural shape shared by team members and workspace members. Both generated +// node types satisfy this, so the renderer stays decoupled from codegen symbols. +export interface MemberDisplayFields { + name: string + displayName: string + email: string + active: boolean + initials: string + description?: string | null + timezone?: string | null + lastSeen?: string | null + statusEmoji?: string | null + statusLabel?: string | null + guest: boolean + isAssignable: boolean + admin: boolean + owner: boolean + isMe: boolean +} + +// admin and owner are independent in Linear's schema — an owner who is also an +// admin shows both markers. +function markersFor(member: MemberDisplayFields): string { + const markers = [] + if (!member.active) markers.push("inactive") + if (member.guest) markers.push("guest") + if (!member.isAssignable) markers.push("not assignable") + if (member.admin) markers.push("admin") + if (member.owner) markers.push("owner") + if (member.isMe) markers.push("you") + + return markers.map((marker) => ` (${marker})`).join("") +} + +export function printMembers( + members: readonly MemberDisplayFields[], + heading: string, +): void { + console.log(`${heading} (${members.length}):`) + console.log("") + + for (const member of members) { + const displayName = member.displayName || member.name + const fullName = member.name !== member.displayName + ? ` (${member.name})` + : "" + + console.log( + `${displayName}${fullName} [${member.initials}]${markersFor(member)}`, + ) + if (member.email) { + console.log(` Email: ${member.email}`) + } + if (member.description) { + console.log(` Role: ${member.description}`) + } + if (member.timezone) { + console.log(` Timezone: ${member.timezone}`) + } + if (member.statusEmoji && member.statusLabel) { + console.log(` Status: ${member.statusEmoji} ${member.statusLabel}`) + } + if (member.lastSeen) { + const lastSeenDate = new Date(member.lastSeen) + console.log(` Last seen: ${lastSeenDate.toLocaleString()}`) + } + console.log("") + } +} diff --git a/test/commands/team/__snapshots__/team-members.test.ts.snap b/test/commands/team/__snapshots__/team-members.test.ts.snap new file mode 100644 index 00000000..458ccec6 --- /dev/null +++ b/test/commands/team/__snapshots__/team-members.test.ts.snap @@ -0,0 +1,239 @@ +export const snapshot = {}; + +snapshot[`Team Members Command - Help Text 1`] = ` +stdout: +" +Usage: members [teamKey] + +Description: + + List team members + +Options: + + -h, --help - Show this help. + -a, --all - Include inactive members + -j, --json - Output as JSON + +" +stderr: +"" +`; + +snapshot[`Team Members Command - Human Output 1`] = ` +stdout: +"Team Members (2): + +pat [XX] (you) + Email: pat@example.com + +zoe [XX] (admin) (owner) + Email: zoe@example.com + +" +stderr: +"" +`; + +snapshot[`Team Members Command - All Includes Inactive 1`] = ` +stdout: +"Team Members (3): + +olduser [XX] (inactive) (guest) (not assignable) + Email: olduser@example.com + +pat [XX] (you) + Email: pat@example.com + +zoe [XX] (admin) (owner) + Email: zoe@example.com + +" +stderr: +"" +`; + +snapshot[`Team Members Command - JSON 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "pat", + "email": "pat@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": true, + "id": "u-pat", + "displayName": "pat" + }, + { + "name": "zoe", + "email": "zoe@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": true, + "owner": true, + "isMe": false, + "id": "u-zoe", + "displayName": "zoe" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Team Members Command - All JSON Retains Inactive 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "olduser", + "email": "olduser@example.com", + "active": false, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": true, + "isAssignable": false, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-old", + "displayName": "olduser" + }, + { + "name": "pat", + "email": "pat@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": true, + "id": "u-pat", + "displayName": "pat" + }, + { + "name": "zoe", + "email": "zoe@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": true, + "owner": true, + "isMe": false, + "id": "u-zoe", + "displayName": "zoe" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Team Members Command - Empty JSON 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Team Members Command - Paginates And Sorts Globally 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "aaron", + "email": "aaron@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-first", + "displayName": "aaron" + }, + { + "name": "mona", + "email": "mona@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-mid", + "displayName": "mona" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/team/team-members.test.ts b/test/commands/team/team-members.test.ts new file mode 100644 index 00000000..0d2cc9ef --- /dev/null +++ b/test/commands/team/team-members.test.ts @@ -0,0 +1,260 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { membersCommand } from "../../../src/commands/team/team-members.ts" +import { teamCommand } from "../../../src/commands/team/team.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +const denoArgs = ["--allow-all", "--quiet"] + +// lastSeen is null throughout: the renderer formats it with toLocaleString(), +// which varies by the runner's TZ and locale and would make snapshots flaky. +function member( + overrides: Record & { id: string; displayName: string }, +) { + return { + name: overrides.displayName, + email: `${overrides.displayName}@example.com`, + active: true, + initials: "XX", + description: null, + timezone: null, + lastSeen: null, + statusEmoji: null, + statusLabel: null, + guest: false, + isAssignable: true, + admin: false, + owner: false, + isMe: false, + ...overrides, + } +} + +const ACTIVE_AND_INACTIVE = [ + member({ id: "u-zoe", displayName: "zoe", admin: true, owner: true }), + member({ id: "u-pat", displayName: "pat", isMe: true }), + member({ + id: "u-old", + displayName: "olduser", + active: false, + guest: true, + isAssignable: false, + }), +] + +function membersResponse( + nodes: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null } = { + hasNextPage: false, + endCursor: null, + }, +) { + return { data: { team: { members: { nodes, pageInfo } } } } +} + +// Wiring guard: the snapshot tests drive membersCommand directly, so they +// cannot catch a missing registration on the parent command. +Deno.test("team members - is registered on the team command", () => { + assertEquals(teamCommand.getCommand("members"), membersCommand) +}) + +await cliffySnapshotTest({ + name: "Team Members Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await membersCommand.parse() + }, +}) + +// Default run: inactive members are excluded, and the new admin/owner/you +// markers render. The mock pins includeDisabled to false, so this also proves +// the default does not ask Linear for disabled users. +await cliffySnapshotTest({ + name: "Team Members Command - Human Output", + meta: import.meta, + colors: false, + args: ["ENG"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: false }, + response: membersResponse(ACTIVE_AND_INACTIVE), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Regression test for `--all` having been a no-op: getTeamMembers never sent +// includeDisabled, so Linear defaulted it to false and disabled users were +// never fetched. The mock is keyed on includeDisabled: true, so this only +// passes if the flag actually reaches the API. +await cliffySnapshotTest({ + name: "Team Members Command - All Includes Inactive", + meta: import.meta, + colors: false, + args: ["ENG", "--all"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: true }, + response: membersResponse(ACTIVE_AND_INACTIVE), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// JSON preserves GraphQL field names and the connection shape. The fixture +// contains an inactive member that must be absent here, proving --json is a +// format and does not bypass --all semantics. +await cliffySnapshotTest({ + name: "Team Members Command - JSON", + meta: import.meta, + colors: false, + args: ["ENG", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: false }, + response: membersResponse(ACTIVE_AND_INACTIVE), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "Team Members Command - All JSON Retains Inactive", + meta: import.meta, + colors: false, + args: ["ENG", "--all", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: true }, + response: membersResponse(ACTIVE_AND_INACTIVE), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// An empty result must still emit valid connection-shaped JSON, not prose. +await cliffySnapshotTest({ + name: "Team Members Command - Empty JSON", + meta: import.meta, + colors: false, + args: ["ENG", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: false }, + response: membersResponse([]), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Pagination: page 2 is seeded with a name that must sort ahead of page 1's, so +// this fails if sorting is per-page rather than global. Pages are distinguished +// by pinning `after`, since the mock matcher does not consume entries. +await cliffySnapshotTest({ + name: "Team Members Command - Paginates And Sorts Globally", + meta: import.meta, + colors: false, + args: ["ENG", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamMembers", + variables: { teamKey: "ENG", includeDisabled: false, after: undefined }, + response: membersResponse( + [member({ id: "u-mid", displayName: "mona" })], + { hasNextPage: true, endCursor: "cursor-1" }, + ), + }, + { + queryName: "GetTeamMembers", + variables: { + teamKey: "ENG", + includeDisabled: false, + after: "cursor-1", + }, + response: membersResponse( + [member({ id: "u-first", displayName: "aaron" })], + ), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await membersCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/user/__snapshots__/user-list.test.ts.snap b/test/commands/user/__snapshots__/user-list.test.ts.snap new file mode 100644 index 00000000..85995c2c --- /dev/null +++ b/test/commands/user/__snapshots__/user-list.test.ts.snap @@ -0,0 +1,239 @@ +export const snapshot = {}; + +snapshot[`User List Command - Help Text 1`] = ` +stdout: +" +Usage: list + +Description: + + List members of the workspace + +Options: + + -h, --help - Show this help. + -a, --all - Include inactive members + -j, --json - Output as JSON + +" +stderr: +"" +`; + +snapshot[`User List Command - Human Output 1`] = ` +stdout: +"Workspace Members (2): + +pat [XX] (admin) (you) + Email: pat@example.com + +zoe [XX] (owner) + Email: zoe@example.com + +" +stderr: +"" +`; + +snapshot[`User List Command - All Includes Inactive 1`] = ` +stdout: +"Workspace Members (3): + +olduser [XX] (inactive) + Email: olduser@example.com + +pat [XX] (admin) (you) + Email: pat@example.com + +zoe [XX] (owner) + Email: zoe@example.com + +" +stderr: +"" +`; + +snapshot[`User List Command - JSON 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "pat", + "email": "pat@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": true, + "owner": false, + "isMe": true, + "id": "u-pat", + "displayName": "pat" + }, + { + "name": "zoe", + "email": "zoe@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": true, + "isMe": false, + "id": "u-zoe", + "displayName": "zoe" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`User List Command - All JSON Retains Inactive 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "olduser", + "email": "olduser@example.com", + "active": false, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-old", + "displayName": "olduser" + }, + { + "name": "pat", + "email": "pat@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": true, + "owner": false, + "isMe": true, + "id": "u-pat", + "displayName": "pat" + }, + { + "name": "zoe", + "email": "zoe@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": true, + "isMe": false, + "id": "u-zoe", + "displayName": "zoe" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`User List Command - Empty JSON 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`User List Command - Paginates And Sorts Globally 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": "aaron", + "email": "aaron@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-first", + "displayName": "aaron" + }, + { + "name": "mona", + "email": "mona@example.com", + "active": true, + "initials": "XX", + "description": null, + "timezone": null, + "lastSeen": null, + "statusEmoji": null, + "statusLabel": null, + "guest": false, + "isAssignable": true, + "admin": false, + "owner": false, + "isMe": false, + "id": "u-mid", + "displayName": "mona" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/user/user-list.test.ts b/test/commands/user/user-list.test.ts new file mode 100644 index 00000000..769e0d63 --- /dev/null +++ b/test/commands/user/user-list.test.ts @@ -0,0 +1,242 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { listCommand } from "../../../src/commands/user/user-list.ts" +import { userCommand } from "../../../src/commands/user/user.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +const denoArgs = ["--allow-all", "--quiet"] + +function member( + overrides: Record & { id: string; displayName: string }, +) { + return { + name: overrides.displayName, + email: `${overrides.displayName}@example.com`, + active: true, + initials: "XX", + description: null, + timezone: null, + lastSeen: null, + statusEmoji: null, + statusLabel: null, + guest: false, + isAssignable: true, + admin: false, + owner: false, + isMe: false, + ...overrides, + } +} + +const WORKSPACE_MEMBERS = [ + member({ id: "u-zoe", displayName: "zoe", owner: true }), + member({ id: "u-pat", displayName: "pat", isMe: true, admin: true }), + member({ id: "u-old", displayName: "olduser", active: false }), +] + +function usersResponse( + nodes: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null } = { + hasNextPage: false, + endCursor: null, + }, +) { + return { + data: { viewer: { organization: { users: { nodes, pageInfo } } } }, + } +} + +Deno.test("user list - is registered on the user command", () => { + assertEquals(userCommand.getCommand("list"), listCommand) +}) + +await cliffySnapshotTest({ + name: "User List Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await listCommand.parse() + }, +}) + +// LINEAR_TEAM_ID is deliberately empty: workspace listing must not resolve a +// team key, and only a GetOrganizationMembers mock is configured, so any team +// lookup would fail with NO_MOCK_CONFIGURED. +await cliffySnapshotTest({ + name: "User List Command - Human Output", + meta: import.meta, + colors: false, + args: [], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: false }, + response: usersResponse(WORKSPACE_MEMBERS), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + Deno.env.set("LINEAR_TEAM_ID", "") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + Deno.env.delete("LINEAR_TEAM_ID") + } + }, +}) + +await cliffySnapshotTest({ + name: "User List Command - All Includes Inactive", + meta: import.meta, + colors: false, + args: ["--all"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: true }, + response: usersResponse(WORKSPACE_MEMBERS), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "User List Command - JSON", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: false }, + response: usersResponse(WORKSPACE_MEMBERS), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "User List Command - All JSON Retains Inactive", + meta: import.meta, + colors: false, + args: ["--all", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: true }, + response: usersResponse(WORKSPACE_MEMBERS), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Separate empty-state path from team members: must emit JSON, not prose. +await cliffySnapshotTest({ + name: "User List Command - Empty JSON", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: false }, + response: usersResponse([]), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Exercises the separate viewer.organization.users pagination loop; page 2 +// sorts ahead of page 1 to prove the sort is global. +await cliffySnapshotTest({ + name: "User List Command - Paginates And Sorts Globally", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: false, after: undefined }, + response: usersResponse( + [member({ id: "u-mid", displayName: "mona" })], + { hasNextPage: true, endCursor: "cursor-1" }, + ), + }, + { + queryName: "GetOrganizationMembers", + variables: { includeDisabled: false, after: "cursor-1" }, + response: usersResponse( + [member({ id: "u-first", displayName: "aaron" })], + ), + }, + ]) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})