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") + } + }, +})