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
5 changes: 5 additions & 0 deletions packages/cli/src/help/root-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ Examples:
linear issues list --limit 10 --json
linear issues list --mine --state "Todo" --view detail
linear issues list --fields identifier,title,assigneeName,projectName
linear issues list --project "Evalite setup" --label eng --priority 2 --json
linear issues list --query "evalite" --json
linear issues list --updated-after 2026-05-01 --json
linear issues list --created-after -P7D --json
linear issues list --no-parent --json
linear issues create --template "Bug Report" --input '{"teamId":"<team-id>"}' --json
linear issues bulk-update --ids ANN-1,ANN-2 --input '{"priority":2}' --dry-run --json
linear issues bulk-update --input-file updates.json --json
Expand Down
32 changes: 19 additions & 13 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ import { renderEnvelope } from "./formatters/output.js";
import { issueBranchHelpText, issuesHelpText, rootHelpText } from "./help/root-help.js";
import { getGlobalOptions } from "./runtime/options.js";
import {
buildIssueMatcher,
collectPageResult,
matchesCustomer,
matchesCustomerNeed,
matchesIssue,
matchesMilestone,
matchesNotification,
matchesProject,
Expand Down Expand Up @@ -313,6 +313,16 @@ export function createProgram(authManager = new AuthManager()): Command {
.option("--label <name>", "Label filter")
.option("--priority <value>", "Priority filter")
.option("--status <name>", "Status or health filter")
.option("--query <text>", "Free-text search across identifier, title, and description")
.option(
"--updated-after <date>",
"Only items updated on/after the date (ISO 8601 date or relative duration like -P7D)",
)
.option(
"--created-after <date>",
"Only items created on/after the date (ISO 8601 date or relative duration like -P7D)",
)
.option("--no-parent", "Only list top-level issues with no parent")
.option("--filter <expr>", "Lightweight filter expression, e.g. estimate>2")
.option("--sort <field>", "Sort by a field, prefix with - for descending")
.option("--view <preset>", "Human output preset: table | detail | dense")
Expand Down Expand Up @@ -522,11 +532,8 @@ export function createProgram(authManager = new AuthManager()): Command {
const globals = getGlobalOptions(cmd);
const viewerName = await resolveViewerName(cmd);
const gateway = await sessionGateway(cmd);
return collectPageResult(
(options) => gateway.listIssues(options),
globals,
(issue) => matchesIssue(issue, globals, viewerName),
);
const matcher = buildIssueMatcher(globals, viewerName);
return collectPageResult((options) => gateway.listIssues(options), globals, matcher);
},
get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getIssue(id),
create: async (_manager, payload, cmd) => {
Expand Down Expand Up @@ -1246,13 +1253,12 @@ export function createProgram(authManager = new AuthManager()): Command {
try {
const viewerName = await resolveViewerName(cmd, { forceMine: true });
const gateway = await sessionGateway(cmd);
const mineGlobals = { ...globals, mine: true };
const matcher = buildIssueMatcher(mineGlobals, viewerName);
const data = await collectPageResult(
(options) => gateway.listIssues(options),
{
...globals,
mine: true,
},
(issue) => matchesIssue(issue, { ...globals, mine: true }, viewerName),
mineGlobals,
matcher,
);
renderEnvelope(successEnvelope("issues", "list", data), globals);
} catch (error) {
Expand All @@ -1277,12 +1283,12 @@ export function createProgram(authManager = new AuthManager()): Command {

try {
const gateway = await sessionGateway(cmd);
const matcher = buildIssueMatcher(globals);
const data = await collectPageResult(
(options) => gateway.listIssues(options),
globals,
(issue) =>
matchesIssue(issue, globals) &&
(!issue.assigneeName || /triage/i.test(issue.stateName ?? "")),
matcher(issue) && (!issue.assigneeName || /triage/i.test(issue.stateName ?? "")),
);
renderEnvelope(successEnvelope("issues", "list", data), globals);
} catch (error) {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/runtime/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface GlobalOptions {
readonly label?: string;
readonly priority?: string;
readonly status?: string;
readonly query?: string;
readonly updatedAfter?: string;
readonly createdAfter?: string;
readonly noParent?: boolean;
readonly filter?: string;
readonly sort?: string;
readonly view?: "table" | "detail" | "dense";
Expand Down Expand Up @@ -55,6 +59,10 @@ export function getGlobalOptions(command: Command): GlobalOptions {
...(readString(raw.label) ? { label: readString(raw.label) } : {}),
...(readString(raw.priority) ? { priority: readString(raw.priority) } : {}),
...(readString(raw.status) ? { status: readString(raw.status) } : {}),
...(readString(raw.query) ? { query: readString(raw.query) } : {}),
...(readString(raw.updatedAfter) ? { updatedAfter: readString(raw.updatedAfter) } : {}),
...(readString(raw.createdAfter) ? { createdAfter: readString(raw.createdAfter) } : {}),
...(raw.parent === false ? { noParent: true } : {}),
...(readString(raw.filter) ? { filter: readString(raw.filter) } : {}),
...(readString(raw.sort) ? { sort: readString(raw.sort) } : {}),
...(readString(raw.view) ? { view: readString(raw.view) as "table" | "detail" | "dense" } : {}),
Expand Down
114 changes: 102 additions & 12 deletions packages/cli/src/runtime/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,84 @@ function hasLocalFiltering(options: GlobalOptions): boolean {
options.label ||
options.priority ||
options.status ||
options.query ||
options.updatedAfter ||
options.createdAfter ||
options.noParent ||
options.filter,
);
}

const ISO_DURATION_PATTERN =
/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;

const MS_PER_DAY = 24 * 60 * 60 * 1000;

function isoDurationToMs(duration: string): number | undefined {
const match = ISO_DURATION_PATTERN.exec(duration);
if (!match || match[0] === "P" || match[0] === "PT") {
return undefined;
}

const [, y, mo, w, d, h, mi, s] = match;
const n = (value?: string) => (value ? Number(value) : 0);
return (
n(y) * 365 * MS_PER_DAY +
n(mo) * 30 * MS_PER_DAY +
n(w) * 7 * MS_PER_DAY +
n(d) * MS_PER_DAY +
n(h) * 60 * 60 * 1000 +
n(mi) * 60 * 1000 +
n(s) * 1000
);
}

export function parseDateBoundary(input: string, now: Date = new Date()): Date | undefined {
const trimmed = input.trim();
if (!trimmed) {
return undefined;
}

if (trimmed.startsWith("-P") || trimmed.startsWith("+P")) {
const ms = isoDurationToMs(trimmed.slice(1));
if (ms === undefined) {
return undefined;
}
const sign = trimmed.startsWith("-") ? -1 : 1;
return new Date(now.getTime() + sign * ms);
}

const date = new Date(trimmed);
return Number.isNaN(date.getTime()) ? undefined : date;
}

function isAfter(timestamp: string | undefined, cutoff: Date | undefined): boolean {
if (!cutoff) {
return true;
}
if (!timestamp) {
return false;
}
const value = new Date(timestamp);
if (Number.isNaN(value.getTime())) {
return false;
}
return value.getTime() >= cutoff.getTime();
}

function parseBoundaryOrThrow(input: string | undefined, flag: string): Date | undefined {
if (!input) {
return undefined;
}
const parsed = parseDateBoundary(input);
if (!parsed) {
throw new Error(
`Invalid value for ${flag}: "${input}". Use an ISO 8601 date (e.g. 2026-05-01) or a negative duration (e.g. -P7D).`,
);
}
return parsed;
}

export async function collectPageResult<T extends object>(
loader: (options: ListOptions) => Promise<PageResult<T>>,
globals: GlobalOptions,
Expand Down Expand Up @@ -162,23 +236,39 @@ export async function collectPageResult<T extends object>(
};
}

export function buildIssueMatcher(
globals: GlobalOptions,
viewerName?: string,
): (issue: IssueRecord) => boolean {
const updatedAfter = parseBoundaryOrThrow(globals.updatedAfter, "--updated-after");
const createdAfter = parseBoundaryOrThrow(globals.createdAfter, "--created-after");
const assigneeQuery = globals.mine ? (viewerName ?? globals.assignee) : globals.assignee;

return (issue) => {
const labels = issue.labelNames?.join(" ");
return (
matchAnyText(globals.team, issue.teamKey, issue.teamName) &&
matchAnyText(globals.project, issue.projectId, issue.projectName) &&
matchAnyText(globals.cycle, issue.cycleId, issue.cycleName) &&
matchText(issue.stateName, globals.state) &&
matchText(issue.assigneeName, assigneeQuery) &&
matchText(labels, globals.label) &&
(globals.priority ? String(issue.priority) === String(globals.priority) : true) &&
matchAnyText(globals.query, issue.identifier, issue.title, issue.description) &&
isAfter(issue.updatedAt, updatedAfter) &&
isAfter(issue.createdAt, createdAfter) &&
(globals.noParent ? !issue.parentId : true) &&
runFilterExpression(issue, globals.filter)
);
};
}

export function matchesIssue(
issue: IssueRecord,
globals: GlobalOptions,
viewerName?: string,
): boolean {
const labels = issue.labelNames?.join(" ");
const assigneeQuery = globals.mine ? (viewerName ?? globals.assignee) : globals.assignee;
return (
matchAnyText(globals.team, issue.teamKey, issue.teamName) &&
matchAnyText(globals.project, issue.projectId, issue.projectName) &&
matchAnyText(globals.cycle, issue.cycleId, issue.cycleName) &&
matchText(issue.stateName, globals.state) &&
matchText(issue.assigneeName, assigneeQuery) &&
matchText(labels, globals.label) &&
(globals.priority ? String(issue.priority) === String(globals.priority) : true) &&
runFilterExpression(issue, globals.filter)
);
return buildIssueMatcher(globals, viewerName)(issue);
}

export function matchesProject(project: ProjectRecord, globals: GlobalOptions): boolean {
Expand Down
87 changes: 86 additions & 1 deletion packages/cli/tests/query.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { IssueRecord, PageResult } from "@wiseiodev/linear-core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createProgram } from "../src/index.js";
import { collectPageResult, matchesCustomerNeed } from "../src/runtime/query.js";
import {
buildIssueMatcher,
collectPageResult,
matchesCustomerNeed,
matchesIssue,
parseDateBoundary,
} from "../src/runtime/query.js";

describe("collectPageResult", () => {
test("fetches fixed-size batches until filtered results satisfy the requested limit", async () => {
Expand Down Expand Up @@ -117,6 +123,85 @@ describe("matchesCustomerNeed", () => {
});
});

describe("parseDateBoundary", () => {
test("parses ISO date strings", () => {
expect(parseDateBoundary("2026-05-01")?.toISOString()).toBe("2026-05-01T00:00:00.000Z");
});

test("parses negative ISO 8601 durations as offsets from now", () => {
const now = new Date("2026-05-08T00:00:00.000Z");
expect(parseDateBoundary("-P7D", now)?.toISOString()).toBe("2026-05-01T00:00:00.000Z");
expect(parseDateBoundary("-PT2H", now)?.toISOString()).toBe("2026-05-07T22:00:00.000Z");
});

test("returns undefined for unparseable input", () => {
expect(parseDateBoundary("nonsense")).toBeUndefined();
expect(parseDateBoundary("-P")).toBeUndefined();
});
});

describe("matchesIssue", () => {
const baseIssue: IssueRecord = {
id: "issue-1",
number: 1,
identifier: "ENG-1",
title: "Set up Evalite",
description: "Configure evalite for the CLI",
priority: 2,
stateName: "Todo",
assigneeName: "Wise Dev",
teamKey: "ENG",
teamName: "Engineering",
parentId: undefined,
url: "https://linear.app/x/issue/ENG-1",
createdAt: "2026-05-02T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
};

test("query matches across identifier, title, and description", () => {
expect(matchesIssue(baseIssue, { json: false, quiet: false, query: "evalite" })).toBe(true);
expect(matchesIssue(baseIssue, { json: false, quiet: false, query: "ENG-1" })).toBe(true);
expect(matchesIssue(baseIssue, { json: false, quiet: false, query: "missing" })).toBe(false);
});

test("updated-after filters out older issues", () => {
expect(matchesIssue(baseIssue, { json: false, quiet: false, updatedAfter: "2026-05-01" })).toBe(
true,
);
expect(matchesIssue(baseIssue, { json: false, quiet: false, updatedAfter: "2026-05-06" })).toBe(
false,
);
});

test("created-after filters out older issues", () => {
expect(matchesIssue(baseIssue, { json: false, quiet: false, createdAfter: "2026-05-01" })).toBe(
true,
);
expect(matchesIssue(baseIssue, { json: false, quiet: false, createdAfter: "2026-05-03" })).toBe(
false,
);
});

test("buildIssueMatcher throws for invalid date boundaries instead of silently dropping all results", () => {
expect(() =>
buildIssueMatcher({ json: false, quiet: false, updatedAfter: "not-a-date" }),
).toThrow(/--updated-after/);
expect(() => buildIssueMatcher({ json: false, quiet: false, createdAfter: "garbage" })).toThrow(
/--created-after/,
);
});

test("no-parent only keeps issues without a parent", () => {
expect(matchesIssue(baseIssue, { json: false, quiet: false, noParent: true })).toBe(true);
expect(
matchesIssue(
{ ...baseIssue, parentId: "parent-1" },
{ json: false, quiet: false, noParent: true },
),
).toBe(false);
});
});

describe("my-work", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down
Loading