Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions packages/cli/src/formatters/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,42 @@ function pickFields(
return Object.fromEntries(fields.map((field) => [field, item[field]]));
}

function isSensitiveJsonKey(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
return (
normalized.includes("apikey") ||
normalized.includes("password") ||
normalized.includes("secret") ||
normalized.includes("token") ||
normalized.includes("authorizationurl") ||
normalized.includes("clientid") ||
normalized.includes("redirecturi") ||
normalized.includes("scope") ||
normalized.includes("codeverifier")
Comment on lines +133 to +136
);
Comment on lines +125 to +137
}

function redactJsonData(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => redactJsonData(item));
}

if (!isRecord(value)) {
return value;
}

return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
isSensitiveJsonKey(key) ? "[REDACTED]" : redactJsonData(entry),
]),
);
}

function stringifyJsonOutput(value: unknown): string {
return JSON.stringify(redactJsonData(value), null, 2);
}

function toHumanRowsWithOptions(
items: readonly unknown[],
options: Pick<GlobalOptions, "fields" | "view">,
Expand Down Expand Up @@ -179,9 +215,32 @@ function printHumanData(data: unknown, options: GlobalOptions): void {
console.log(String(data));
}

function projectJsonData(data: unknown, fields: readonly string[]): unknown {
if (Array.isArray(data)) {
return data.map((item) => (isRecord(item) ? pickFields(item, fields) : item));
}

if (isPageResult(data)) {
return {
...data,
items: data.items.map((item) => (isRecord(item) ? pickFields(item, fields) : item)),
};
}

if (isRecord(data)) {
return pickFields(data, fields);
Comment on lines +218 to +231
}

return data;
}

export function renderEnvelope<Data>(envelope: OutputEnvelope<Data>, options: GlobalOptions): void {
if (options.json) {
console.log(JSON.stringify(envelope, null, 2));
const projected =
envelope.ok && options.fields && options.fields.length > 0
? { ...envelope, data: projectJsonData(envelope.data, options.fields) }
: envelope;
console.log(stringifyJsonOutput(projected));
return;
}

Expand All @@ -196,7 +255,7 @@ export function renderEnvelope<Data>(envelope: OutputEnvelope<Data>, options: Gl
if (isErrorEnvelope(envelope)) {
console.error(`${envelope.entity}.${envelope.action} failed: ${envelope.error.message}`);
if (envelope.error.details) {
console.error(JSON.stringify(envelope.error.details, null, 2));
console.error(stringifyJsonOutput(envelope.error.details));
}
}
}
5 changes: 4 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,10 @@ export function createProgram(authManager = new AuthManager()): Command {
.option("--sort <field>", "Sort by a field, prefix with - for descending")
.option("--view <preset>", "Human output preset: table | detail | dense")
.option("--all", "Drain all pages before filtering")
.option("--fields <list>", "Comma-separated field selection for human output");
.option(
"--fields <list>",
"Comma-separated field selection (applies to JSON and human output)",
);

const authCommand = program.command("auth").description("Authentication commands");

Expand Down
70 changes: 70 additions & 0 deletions packages/cli/tests/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,76 @@ describe("renderEnvelope", () => {
expect(logSpy).toHaveBeenCalledWith("issues.get");
});

test("applies --fields to issues JSON output and preserves nextCursor", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});

renderEnvelope(
successEnvelope("issues", "list", {
items: [
{
id: "issue-1",
identifier: "ENG-1",
title: "Fix output formatting",
priority: 2,
stateName: "Todo",
stateType: "unstarted",
updatedAt: "2026-03-16T17:00:00.000Z",
},
],
nextCursor: "cursor-2",
}),
{
json: true,
quiet: false,
fields: ["identifier", "stateType"],
},
);

const output = logSpy.mock.calls[0]?.[0];
expect(typeof output).toBe("string");
const parsed = JSON.parse(String(output));
expect(parsed.ok).toBe(true);
expect(parsed.data.items).toEqual([{ identifier: "ENG-1", stateType: "unstarted" }]);
expect(parsed.data.nextCursor).toBe("cursor-2");
});

test("redacts sensitive fields from JSON output", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});

renderEnvelope(
successEnvelope("auth", "login", {
profile: "default",
method: "oauth",
apiKey: "lin_api_secret",
accessToken: "access_secret",
refreshToken: "refresh_secret",
authorizationUrl: "https://linear.app/oauth/authorize?state=secret_state",
redirectUri: "http://127.0.0.1:8787/oauth/callback",
oauth: {
clientId: "client_secretish",
tokenUrl: "https://api.linear.app/oauth/token",
scopes: ["read", "write"],
nested: {
password: "pw_secret",
},
},
}),
{
json: true,
quiet: false,
},
);

const output = String(logSpy.mock.calls[0]?.[0]);
expect(output).not.toContain("lin_api_secret");
expect(output).not.toContain("access_secret");
expect(output).not.toContain("refresh_secret");
expect(output).not.toContain("secret_state");
expect(output).not.toContain("client_secretish");
expect(output).not.toContain("pw_secret");
expect(output).toContain("[REDACTED]");
});

test("supports detail views with field selection for issue lists", () => {
const tableSpy = vi.spyOn(console, "table").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
Expand Down
65 changes: 46 additions & 19 deletions packages/linear-core/src/entities/linear-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,35 @@ function readDisplayName(
}

async function toIssue(record: SdkIssueLike): Promise<IssueRecord> {
const [state, assignee, project, cycle, team, milestone, parent, labels, children, relations] =
await Promise.all([
resolveFetch(record.state),
resolveFetch(record.assignee),
resolveFetch(record.project),
resolveFetch(record.cycle),
resolveFetch(record.team),
resolveFetch(record.projectMilestone),
resolveFetch(record.parent),
resolveConnectionNodes(
typeof record.labels === "function" ? () => record.labels() : undefined,
),
resolveConnectionNodes(
typeof record.children === "function" ? () => record.children() : undefined,
),
resolveConnectionNodes(
typeof record.relations === "function" ? () => record.relations() : undefined,
),
]);
const [
state,
assignee,
creator,
project,
cycle,
team,
milestone,
parent,
labels,
children,
relations,
] = await Promise.all([
resolveFetch(record.state),
resolveFetch(record.assignee),
resolveFetch(record.creator),
resolveFetch(record.project),
resolveFetch(record.cycle),
resolveFetch(record.team),
resolveFetch(record.projectMilestone),
resolveFetch(record.parent),
resolveConnectionNodes(typeof record.labels === "function" ? () => record.labels() : undefined),
resolveConnectionNodes(
typeof record.children === "function" ? () => record.children() : undefined,
),
resolveConnectionNodes(
typeof record.relations === "function" ? () => record.relations() : undefined,
),
]);

const childrenCount = children.length;

Expand All @@ -141,9 +151,13 @@ async function toIssue(record: SdkIssueLike): Promise<IssueRecord> {
priority: record.priority,
estimate: record.estimate ?? undefined,
dueDate: record.dueDate ?? undefined,
stateId: state?.id,
stateName: state?.name,
stateType: state?.type,
assigneeId: record.assigneeId ?? undefined,
assigneeName: readDisplayName(assignee),
creatorId: record.creatorId ?? undefined,
creatorName: readDisplayName(creator),
teamId: record.teamId,
teamKey: team?.key,
teamName: team?.displayName ?? team?.name,
Expand All @@ -159,13 +173,26 @@ async function toIssue(record: SdkIssueLike): Promise<IssueRecord> {
labelNames: labels
.map((label) => label.name)
.filter((value): value is string => typeof value === "string"),
labels: labels
.filter(
(label): label is typeof label & { id: string; name: string } =>
typeof label.id === "string" && typeof label.name === "string",
)
.map((label) => ({
id: label.id,
name: label.name,
...(label.color ? { color: label.color } : {}),
})),
childCount: childrenCount,
childrenCount,
hasChildren: childrenCount > 0,
relationCount: relations.length,
url: record.url,
createdAt: toDateString(record.createdAt),
updatedAt: toDateString(record.updatedAt),
completedAt: record.completedAt ? toDateString(record.completedAt) : undefined,
canceledAt: record.canceledAt ? toDateString(record.canceledAt) : undefined,
archivedAt: record.archivedAt ? toDateString(record.archivedAt) : undefined,
};
}

Expand Down
14 changes: 14 additions & 0 deletions packages/linear-core/src/entities/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export interface IssueLabelSummary {
readonly id: string;
readonly name: string;
readonly color?: string;
}

export interface IssueRecord {
readonly id: string;
readonly number: number;
Expand All @@ -8,9 +14,13 @@ export interface IssueRecord {
readonly priority: number;
readonly estimate?: number;
readonly dueDate?: string;
readonly stateId?: string;
readonly stateName?: string;
readonly stateType?: string;
readonly assigneeId?: string;
readonly assigneeName?: string;
readonly creatorId?: string;
readonly creatorName?: string;
readonly teamId?: string;
readonly teamKey?: string;
readonly teamName?: string;
Expand All @@ -24,13 +34,17 @@ export interface IssueRecord {
readonly parentIdentifier?: string;
readonly parentTitle?: string;
readonly labelNames?: readonly string[];
readonly labels?: readonly IssueLabelSummary[];
readonly childCount?: number;
readonly childrenCount?: number;
readonly hasChildren?: boolean;
readonly relationCount?: number;
readonly url: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly completedAt?: string;
readonly canceledAt?: string;
readonly archivedAt?: string;
}

export interface ProjectRecord {
Expand Down
32 changes: 32 additions & 0 deletions packages/linear-core/tests/linear-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ function createTestClient(): SdkLinearClient {
url: "https://linear.app/issue/ENG-1",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
completedAt: new Date("2024-01-03T00:00:00.000Z"),
canceledAt: new Date("2024-01-04T00:00:00.000Z"),
archivedAt: new Date("2024-01-05T00:00:00.000Z"),
creatorId: "user_2",
creator: Promise.resolve({
id: "user_2",
name: "Sam Creator",
displayName: "Sam",
}),
state: Promise.resolve({
id: "s_1",
name: "In Progress",
Expand All @@ -47,6 +56,15 @@ function createTestClient(): SdkLinearClient {
url: "https://linear.app/issue/ENG-1",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
completedAt: new Date("2024-01-03T00:00:00.000Z"),
canceledAt: new Date("2024-01-04T00:00:00.000Z"),
archivedAt: new Date("2024-01-05T00:00:00.000Z"),
creatorId: "user_2",
creator: Promise.resolve({
id: "user_2",
name: "Sam Creator",
displayName: "Sam",
}),
state: Promise.resolve({
id: "s_1",
name: "In Progress",
Expand Down Expand Up @@ -524,6 +542,13 @@ describe("LinearGateway", () => {
expect(result.items[0]?.identifier).toBe("ENG-1");
expect(result.items[0]?.branchName).toBe("eng-1-fix-parser");
expect(result.items[0]?.stateName).toBe("In Progress");
expect(result.items[0]?.stateId).toBe("s_1");
expect(result.items[0]?.stateType).toBe("started");
expect(result.items[0]?.creatorId).toBe("user_2");
expect(result.items[0]?.creatorName).toBe("Sam");
expect(result.items[0]?.completedAt).toBe("2024-01-03T00:00:00.000Z");
expect(result.items[0]?.canceledAt).toBe("2024-01-04T00:00:00.000Z");
expect(result.items[0]?.archivedAt).toBe("2024-01-05T00:00:00.000Z");
expect(result.nextCursor).toBe("cursor-2");
});

Expand All @@ -543,6 +568,13 @@ describe("LinearGateway", () => {
expect(result.identifier).toBe("ENG-1");
expect(result.description).toBe("Parser crashes when a trailing comma is present.");
expect(result.stateName).toBe("In Progress");
expect(result.stateId).toBe("s_1");
expect(result.stateType).toBe("started");
expect(result.creatorId).toBe("user_2");
expect(result.creatorName).toBe("Sam");
expect(result.completedAt).toBe("2024-01-03T00:00:00.000Z");
expect(result.canceledAt).toBe("2024-01-04T00:00:00.000Z");
expect(result.archivedAt).toBe("2024-01-05T00:00:00.000Z");
});

test("archives cycle via delete operation", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/linear-core/tests/v2-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ describe("LinearGateway v2", () => {
expect(issue.parentIdentifier).toBe("ENG-0");
expect(issue.parentTitle).toBe("Parent issue");
expect(issue.labelNames).toEqual(["Bug"]);
expect(issue.labels).toEqual([{ id: "label_1", name: "Bug", color: "#ff0000" }]);
expect(issue.childCount).toBe(1);
expect(issue.childrenCount).toBe(1);
expect(issue.hasChildren).toBe(true);
Expand Down
Loading