diff --git a/README.md b/README.md index dbda35c1..05e5de39 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ linear issue url # prints the Linear.app URL for the issue linear issue pr # creates a GitHub PR with issue details via `gh pr create` linear issue list # list your issues in a table view (supports -s/--state and --sort) linear issue list --project "My Project" --milestone "Phase 1" # filter by milestone +linear issue list --created-after 2024-01-01 # issues created after a date +linear issue list --updated-after 2024-01-01 # issues updated after a date linear issue list -w # open issue list in web browser linear issue list -a # open issue list in Linear.app linear issue start # create/switch to issue branch and mark as started diff --git a/docs/cast-issue-create.svg b/docs/cast-issue-create.svg index 9ff98288..1ccc142e 100644 --- a/docs/cast-issue-create.svg +++ b/docs/cast-issue-create.svg @@ -4,15 +4,15 @@ width="1300" height="607.88" > - + - + + diff --git a/docs/cast-issue-start.svg b/docs/cast-issue-start.svg index 0c9d81a5..edc08372 100644 --- a/docs/cast-issue-start.svg +++ b/docs/cast-issue-start.svg @@ -4,15 +4,15 @@ width="1300" height="607.88" > - + - + + diff --git a/docs/usage.md b/docs/usage.md index d98fefc6..1fe1f734 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -53,6 +53,16 @@ linear issue list --unassigned linear issue list --all-assignees ``` +filter by date: + +```bash +# List issues created after a specific date +linear issue list --created-after 2024-01-01 + +# List issues updated after a specific date +linear issue list --updated-after 2024-01-01 +``` + other options: ```bash diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 16f8bc03..113a9727 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -82,6 +82,8 @@ Options: --cycle - Filter by cycle name, number, or 'active' --milestone - Filter by project milestone name (requires --project) --limit - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50) + --created-after - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) -w, --web - Open in web browser -a, --app - Open in Linear.app --no-pager - Disable automatic paging for long output diff --git a/src/commands/issue/issue-list.ts b/src/commands/issue/issue-list.ts index 841bfcc5..d7fe329c 100644 --- a/src/commands/issue/issue-list.ts +++ b/src/commands/issue/issue-list.ts @@ -97,6 +97,14 @@ export const listCommand = new Command() default: 50, }, ) + .option( + "--created-after ", + "Filter issues created after this date (ISO 8601 or YYYY-MM-DD)", + ) + .option( + "--updated-after ", + "Filter issues updated after this date (ISO 8601 or YYYY-MM-DD)", + ) .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") .option("--no-pager", "Disable automatic paging for long output") @@ -117,6 +125,8 @@ export const listCommand = new Command() milestone, limit, pager, + createdAfter, + updatedAfter, }, ) => { const usePager = pager !== false @@ -221,6 +231,8 @@ export const listCommand = new Command() sort, cycleId, milestoneId, + createdAfter, + updatedAfter, ) spinner?.stop() const issues = result.issues?.nodes || [] diff --git a/src/utils/linear.ts b/src/utils/linear.ts index cd4d2c0c..86e0070c 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -13,6 +13,34 @@ import { getGraphQLClient } from "./graphql.ts" import { getCurrentIssueFromVcs } from "./vcs.ts" import { NotFoundError, ValidationError } from "./errors.ts" +/** + * Validate and parse a date string in ISO 8601 format (YYYY-MM-DD or full ISO 8601). + * Rejects permissive date strings that `new Date()` would accept (e.g. "1", "March 2024"). + */ +export function parseDateFilter(value: string, flagName: string): string { + const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?([+-]\d{2}:?\d{2})?)?$/ + if (!ISO_DATE_RE.test(value)) { + throw new ValidationError( + `Invalid date format for ${flagName}: "${value}"`, + { + suggestion: + "Use YYYY-MM-DD or ISO 8601 format (e.g. 2024-01-15 or 2024-01-15T09:00:00Z).", + }, + ) + } + const parsed = new Date(value) + if (isNaN(parsed.getTime())) { + throw new ValidationError( + `Invalid date for ${flagName}: "${value}"`, + { + suggestion: + "Use YYYY-MM-DD or ISO 8601 format (e.g. 2024-01-15 or 2024-01-15T09:00:00Z).", + }, + ) + } + return parsed.toISOString() +} + function isValidLinearIdentifier(id: string): boolean { return /^[a-zA-Z0-9]+-[1-9][0-9]*$/i.test(id) } @@ -422,6 +450,8 @@ export async function fetchIssuesForState( sortParam?: "manual" | "priority", cycleId?: string, milestoneId?: string, + createdAfter?: string, + updatedAfter?: string, ) { const sort = sortParam ?? getOption("issue_sort") as "manual" | "priority" | undefined @@ -469,6 +499,14 @@ export async function fetchIssuesForState( filter.projectMilestone = { id: { eq: milestoneId } } } + if (createdAfter) { + filter.createdAt = { gte: parseDateFilter(createdAfter, "--created-after") } + } + + if (updatedAfter) { + filter.updatedAt = { gte: parseDateFilter(updatedAfter, "--updated-after") } + } + const query = gql(/* GraphQL */ ` query GetIssuesForState($sort: [IssueSortInput!], $filter: IssueFilter!, $first: Int, $after: String) { issues(filter: $filter, sort: $sort, first: $first, after: $after) { diff --git a/test/commands/issue/__snapshots__/issue-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-list.test.ts.snap index 68b208a7..de24447b 100644 --- a/test/commands/issue/__snapshots__/issue-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-list.test.ts.snap @@ -24,6 +24,8 @@ Options: --cycle - Filter by cycle name, number, or 'active' --milestone - Filter by project milestone name (requires --project) --limit - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) + --created-after - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) -w, --web - Open in web browser -a, --app - Open in Linear.app --no-pager - Disable automatic paging for long output diff --git a/test/commands/issue/issue-list.test.ts b/test/commands/issue/issue-list.test.ts index 277bae42..aeee7df2 100644 --- a/test/commands/issue/issue-list.test.ts +++ b/test/commands/issue/issue-list.test.ts @@ -1,5 +1,11 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals, assertRejects, assertThrows } from "@std/assert" import { listCommand } from "../../../src/commands/issue/issue-list.ts" +import { + fetchIssuesForState, + parseDateFilter, +} from "../../../src/utils/linear.ts" +import { ValidationError } from "../../../src/utils/errors.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" // Test help output @@ -13,3 +19,93 @@ await snapshotTest({ await listCommand.parse() }, }) + +// Test invalid --created-after date +Deno.test("fetchIssuesForState - rejects invalid createdAfter date", async () => { + await assertRejects( + () => + fetchIssuesForState( + "TEST", + ["unstarted"], + undefined, + false, + false, + 50, + undefined, + "manual", + undefined, + undefined, + "not-a-date", + undefined, + ), + ValidationError, + 'Invalid date format for --created-after: "not-a-date"', + ) +}) + +// Test invalid --updated-after date +Deno.test("fetchIssuesForState - rejects invalid updatedAfter date", async () => { + await assertRejects( + () => + fetchIssuesForState( + "TEST", + ["unstarted"], + undefined, + false, + false, + 50, + undefined, + "manual", + undefined, + undefined, + undefined, + "xyz-bad-date", + ), + ValidationError, + 'Invalid date format for --updated-after: "xyz-bad-date"', + ) +}) + +// parseDateFilter unit tests + +Deno.test("parseDateFilter - accepts YYYY-MM-DD format", () => { + const result = parseDateFilter("2024-01-15", "--created-after") + assertEquals(result, new Date("2024-01-15").toISOString()) +}) + +Deno.test("parseDateFilter - accepts full ISO 8601 with time and Z", () => { + const result = parseDateFilter("2024-01-15T09:00:00Z", "--created-after") + assertEquals(result, "2024-01-15T09:00:00.000Z") +}) + +Deno.test("parseDateFilter - accepts ISO 8601 with timezone offset", () => { + const result = parseDateFilter( + "2024-01-15T09:00:00+05:30", + "--created-after", + ) + assertEquals(result, new Date("2024-01-15T09:00:00+05:30").toISOString()) +}) + +Deno.test('parseDateFilter - rejects permissive date string "1"', () => { + assertThrows( + () => parseDateFilter("1", "--created-after"), + ValidationError, + 'Invalid date format for --created-after: "1"', + ) +}) + +Deno.test('parseDateFilter - rejects permissive date string "March 2024"', () => { + assertThrows( + () => parseDateFilter("March 2024", "--updated-after"), + ValidationError, + 'Invalid date format for --updated-after: "March 2024"', + ) +}) + +Deno.test('parseDateFilter - rejects permissive date string "Jan 1"', () => { + assertThrows( + () => parseDateFilter("Jan 1", "--created-after"), + ValidationError, + 'Invalid date format for --created-after: "Jan 1"', + ) +})