diff --git a/packages/cli/src/help/root-help.ts b/packages/cli/src/help/root-help.ts index 6ca8fd6..ee54617 100644 --- a/packages/cli/src/help/root-help.ts +++ b/packages/cli/src/help/root-help.ts @@ -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":""}' --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 diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3b7fa7e..3a75814 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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, @@ -313,6 +313,16 @@ export function createProgram(authManager = new AuthManager()): Command { .option("--label ", "Label filter") .option("--priority ", "Priority filter") .option("--status ", "Status or health filter") + .option("--query ", "Free-text search across identifier, title, and description") + .option( + "--updated-after ", + "Only items updated on/after the date (ISO 8601 date or relative duration like -P7D)", + ) + .option( + "--created-after ", + "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 ", "Lightweight filter expression, e.g. estimate>2") .option("--sort ", "Sort by a field, prefix with - for descending") .option("--view ", "Human output preset: table | detail | dense") @@ -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) => { @@ -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) { @@ -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) { diff --git a/packages/cli/src/runtime/options.ts b/packages/cli/src/runtime/options.ts index d3e9ccb..0de53af 100644 --- a/packages/cli/src/runtime/options.ts +++ b/packages/cli/src/runtime/options.ts @@ -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"; @@ -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" } : {}), diff --git a/packages/cli/src/runtime/query.ts b/packages/cli/src/runtime/query.ts index 381073d..f5e5891 100644 --- a/packages/cli/src/runtime/query.ts +++ b/packages/cli/src/runtime/query.ts @@ -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( loader: (options: ListOptions) => Promise>, globals: GlobalOptions, @@ -162,23 +236,39 @@ export async function collectPageResult( }; } +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 { diff --git a/packages/cli/tests/query.test.ts b/packages/cli/tests/query.test.ts index b78f9dc..9f8372d 100644 --- a/packages/cli/tests/query.test.ts +++ b/packages/cli/tests/query.test.ts @@ -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 () => { @@ -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();