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
2 changes: 2 additions & 0 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Options:
--milestone <milestone> - Filter by project milestone name (requires --project)
-l, --label <label> - Filter by label name (can be repeated for multiple labels)
--limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50)
--created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD)
--updated-after <date> - 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
Expand Down
12 changes: 12 additions & 0 deletions src/commands/issue/issue-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ export const listCommand = new Command()
default: 50,
},
)
.option(
"--created-after <date:string>",
"Filter issues created after this date (ISO 8601 or YYYY-MM-DD)",
)
.option(
"--updated-after <date:string>",
"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")
Expand All @@ -128,6 +136,8 @@ export const listCommand = new Command()
label: labels,
limit,
pager,
createdAfter,
updatedAfter,
},
) => {
const usePager = pager !== false
Expand Down Expand Up @@ -257,6 +267,8 @@ export const listCommand = new Command()
milestoneId,
projectLabel,
labelNames,
createdAfter,
updatedAfter,
)
spinner?.stop()
const issues = result.issues?.nodes || []
Expand Down
38 changes: 38 additions & 0 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -436,6 +464,8 @@ export async function fetchIssuesForState(
milestoneId?: string,
projectLabel?: string,
labelNames?: string[],
createdAfter?: string,
updatedAfter?: string,
) {
const sort = sortParam ??
getOption("issue_sort") as "manual" | "priority" | undefined
Expand Down Expand Up @@ -497,6 +527,14 @@ export async function fetchIssuesForState(
}
}

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) {
Expand Down
2 changes: 2 additions & 0 deletions test/commands/issue/__snapshots__/issue-list.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Options:
--milestone <milestone> - Filter by project milestone name (requires --project)
-l, --label <label> - Filter by label name (can be repeated for multiple labels)
--limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m)
--created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD)
--updated-after <date> - 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
Expand Down
55 changes: 55 additions & 0 deletions test/commands/issue/issue-list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { snapshotTest } from "@cliffy/testing"
import { assertThrows } from "@std/assert"
import { listCommand } from "../../../src/commands/issue/issue-list.ts"
import { parseDateFilter } from "../../../src/utils/linear.ts"
import { ValidationError } from "../../../src/utils/errors.ts"
import {
commonDenoArgs,
setupMockLinearServer,
Expand Down Expand Up @@ -78,3 +81,55 @@ await snapshotTest({
}
},
})

// parseDateFilter unit tests

Deno.test("parseDateFilter - accepts YYYY-MM-DD format", () => {
const result = parseDateFilter("2024-01-15", "--created-after")
const expected = new Date("2024-01-15").toISOString()
if (result !== expected) {
throw new Error(`Expected ${expected}, got ${result}`)
}
})

Deno.test("parseDateFilter - accepts full ISO 8601 with time and Z", () => {
const result = parseDateFilter("2024-01-15T09:00:00Z", "--created-after")
if (result !== "2024-01-15T09:00:00.000Z") {
throw new Error(`Expected 2024-01-15T09:00:00.000Z, got ${result}`)
}
})

Deno.test("parseDateFilter - accepts ISO 8601 with timezone offset", () => {
const result = parseDateFilter(
"2024-01-15T09:00:00+05:30",
"--created-after",
)
const expected = new Date("2024-01-15T09:00:00+05:30").toISOString()
if (result !== expected) {
throw new Error(`Expected ${expected}, got ${result}`)
}
})

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