Skip to content
Open
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
22 changes: 22 additions & 0 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Commands:
id - Print the issue based on the current git branch
list - List your issues
search <query> - Search issues by text in title and description
title [issueId] - Print the issue title
start [issueId] - Start working on an issue
view, v [issueId] - View issue details (default) or open in browser/app
Expand Down Expand Up @@ -87,6 +88,27 @@ Options:
--no-pager - Disable automatic paging for long output
```

### search

> Search issues by text in title and description
```
Usage: linear issue search <query>
Version: 1.9.1
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@schpetbot you need to update the Version to 1.10.0 in order for CI to pass 🤖

Description:
Search issues by text in title and description
Options:
-h, --help - Show this help.
-w, --workspace <slug> - Target workspace (uses credentials)
--team <team> - Team to search issues in (if not your default team)
--limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50)
--no-pager - Disable automatic paging for long output
```

### title

> Print the issue title
Expand Down
306 changes: 306 additions & 0 deletions src/commands/issue/issue-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { Command } from "@cliffy/command"
import { unicodeWidth } from "@std/cli"
import { rgb24 } from "@std/fmt/colors"
import {
getPriorityDisplay,
getTimeAgo,
padDisplay,
truncateText,
} from "../../utils/display.ts"
import { getTeamKey } from "../../utils/linear.ts"
import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts"
import { header, muted } from "../../utils/styling.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { handleError, ValidationError } from "../../utils/errors.ts"
import { gql } from "../../__codegen__/gql.ts"
import type { SearchIssuesQuery } from "../../__codegen__/graphql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"

export const searchCommand = new Command()
.name("search")
.description("Search issues by text in title and description")
.arguments("<query:string>")
.option(
"--team <team:string>",
"Team to search issues in (if not your default team)",
)
.option(
"--limit <limit:number>",
"Maximum number of issues to fetch (default: 50, use 0 for unlimited)",
{
default: 50,
},
)
.option("--no-pager", "Disable automatic paging for long output")
.action(
async (
{ team, limit, pager },
query,
) => {
const usePager = pager !== false
try {
const teamKey = team || getTeamKey()
if (!teamKey) {
throw new ValidationError(
"Could not determine team key from directory name or team flag",
)
}

const { Spinner } = await import("@std/cli/unstable-spinner")
const showSpinner = shouldShowSpinner()
const spinner = showSpinner ? new Spinner() : null
spinner?.start()

const searchQuery = gql(/* GraphQL */ `
query SearchIssues($teamKey: String!, $query: String!, $first: Int, $after: String) {
issueSearch(
filter: { team: { key: { eq: $teamKey } } }
query: $query
first: $first
after: $after
) {
nodes {
id
identifier
title
priority
estimate
assignee {
initials
}
state {
id
name
color
}
labels {
nodes {
id
name
color
}
}
updatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
`)

const client = getGraphQLClient()
const pageSize = limit !== undefined ? Math.min(limit, 100) : 50
const fetchAll = limit === undefined || limit === 0

const allIssues = []
let hasNextPage = true
let after: string | null | undefined = undefined

while (hasNextPage) {
const result: SearchIssuesQuery = await client.request(searchQuery, {
teamKey,
query,
first: pageSize,
after,
})

const issues = result.issueSearch?.nodes || []
allIssues.push(...issues)

if (!fetchAll && allIssues.length >= limit!) {
break
}

hasNextPage = result.issueSearch?.pageInfo?.hasNextPage || false
after = result.issueSearch?.pageInfo?.endCursor
}

spinner?.stop()
const issues = allIssues.slice(0, limit === 0 ? undefined : limit)

if (issues.length === 0) {
console.log("No issues found.")
return
}

const { columns } = Deno.stdout.isTerminal()
? Deno.consoleSize()
: { columns: 120 }
const PRIORITY_WIDTH = 3
const ID_WIDTH = Math.max(
2, // minimum width for "ID" header
...issues.map((issue) => issue.identifier.length),
)
const LABEL_WIDTH = Math.min(
25, // maximum width for labels column
Math.max(
6, // minimum width for "LABELS" header
...issues.map((issue) =>
unicodeWidth(
issue.labels.nodes.map((l: { name: string }) => l.name).join(
", ",
),
)
),
),
)
const ESTIMATE_WIDTH = 1 // fixed width for estimate
const STATE_WIDTH = Math.min(
20, // maximum width for state
Math.max(
5, // minimum width for "STATE" header
...issues.map((issue) => unicodeWidth(issue.state.name)),
),
)
const SPACE_WIDTH = 4
const updatedHeader = "UPDATED"
const UPDATED_WIDTH = Math.max(
unicodeWidth(updatedHeader),
...issues.map((issue) =>
unicodeWidth(getTimeAgo(new Date(issue.updatedAt)))
),
)

type TableRow = {
priorityStr: string
identifier: string
title: string
labels: string
state: string
timeAgo: string
estimate: number | null | undefined
}

const tableData: Array<TableRow> = issues.map((issue) => {
let labels: string
if (issue.labels.nodes.length === 0) {
labels = " ".repeat(LABEL_WIDTH)
} else {
const coloredLabels: string[] = []
let currentWidth = 0

for (let i = 0; i < issue.labels.nodes.length; i++) {
const label = issue.labels.nodes[i]
const coloredLabel = rgb24(
label.name,
parseInt(label.color.replace("#", ""), 16),
)
const separator = i > 0 ? ", " : ""
const testText = separator + label.name

if (currentWidth + unicodeWidth(testText) > LABEL_WIDTH) {
const remainingWidth = LABEL_WIDTH - currentWidth
if (remainingWidth >= 4) { // Need at least 4 chars for "..."
const truncatedName = truncateText(
label.name,
remainingWidth - (separator.length),
)
coloredLabels.push(
separator +
rgb24(
truncatedName,
parseInt(label.color.replace("#", ""), 16),
),
)
}
break
}

coloredLabels.push(separator + coloredLabel)
currentWidth += unicodeWidth(testText)
}

labels = coloredLabels.join("")
const ansiRegex = new RegExp("\u001B\\[[0-9;]*m", "g")
const actualLabelsWidth = unicodeWidth(
coloredLabels.join("").replace(ansiRegex, ""),
)
const remainingSpace = Math.max(0, LABEL_WIDTH - actualLabelsWidth)
labels += " ".repeat(remainingSpace)
}
const updatedAt = new Date(issue.updatedAt)
const timeAgo = getTimeAgo(updatedAt)

const priorityStr = getPriorityDisplay(issue.priority)

const stateName = truncateText(issue.state.name, STATE_WIDTH)
const stateColored = rgb24(
stateName,
parseInt(issue.state.color.replace("#", ""), 16),
)
const stateRemainingSpace = Math.max(
0,
STATE_WIDTH - unicodeWidth(stateName),
)
const statePadded = stateColored + " ".repeat(stateRemainingSpace)

return {
priorityStr,
identifier: issue.identifier,
title: issue.title,
labels,
state: statePadded,
timeAgo,
estimate: issue.estimate,
}
})

const fixed = PRIORITY_WIDTH + ID_WIDTH + UPDATED_WIDTH + SPACE_WIDTH +
LABEL_WIDTH + ESTIMATE_WIDTH + STATE_WIDTH + SPACE_WIDTH
const PADDING = 1
const maxTitleWidth = Math.max(
...tableData.map((row) => unicodeWidth(row.title)),
)
const availableWidth = Math.max(columns - PADDING - fixed, 0)
const titleWidth = Math.min(maxTitleWidth, availableWidth) // use smaller of max title width or available space
const headerCells = [
padDisplay("◌", PRIORITY_WIDTH),
padDisplay("ID", ID_WIDTH),
padDisplay("TITLE", titleWidth),
padDisplay("LABELS", LABEL_WIDTH),
padDisplay("E", ESTIMATE_WIDTH),
padDisplay("STATE", STATE_WIDTH),
padDisplay(updatedHeader, UPDATED_WIDTH),
]

const formattedHeaderLine = header(headerCells.join(" "))

const outputLines: string[] = []

outputLines.push(formattedHeaderLine)

for (const row of tableData) {
const {
priorityStr,
identifier,
title,
labels,
state,
timeAgo,
estimate,
} = row
const truncTitle = padDisplay(
truncateText(title, titleWidth),
titleWidth,
)

const issueLine = `${padDisplay(priorityStr, PRIORITY_WIDTH)} ${
padDisplay(identifier, ID_WIDTH)
} ${truncTitle} ${labels} ${
padDisplay(estimate?.toString() || "-", ESTIMATE_WIDTH)
} ${state} ${muted(padDisplay(timeAgo, UPDATED_WIDTH))}`
outputLines.push(issueLine)
}

if (shouldUsePager(outputLines, usePager)) {
await pipeToUserPager(outputLines.join("\n"))
} else {
outputLines.forEach((line) => console.log(line))
}
} catch (error) {
handleError(error, "Failed to search issues")
}
},
)
2 changes: 2 additions & 0 deletions src/commands/issue/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { idCommand } from "./issue-id.ts"
import { listCommand } from "./issue-list.ts"
import { pullRequestCommand } from "./issue-pull-request.ts"
import { relationCommand } from "./issue-relation.ts"
import { searchCommand } from "./issue-search.ts"
import { startCommand } from "./issue-start.ts"
import { titleCommand } from "./issue-title.ts"
import { updateCommand } from "./issue-update.ts"
Expand All @@ -22,6 +23,7 @@ export const issueCommand = new Command()
})
.command("id", idCommand)
.command("list", listCommand)
.command("search", searchCommand)
.command("title", titleCommand)
.command("start", startCommand)
.command("view", viewCommand)
Expand Down
Loading