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/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ linear issue # Manage Linear issues
linear team # Manage Linear teams
linear project # Manage Linear projects
linear project-update # Manage project status updates
linear cycle # Manage Linear team cycles
linear milestone # Manage Linear project milestones
linear initiative # Manage Linear initiatives
linear initiative-update # Manage initiative status updates (timeline posts)
Expand All @@ -84,6 +85,7 @@ linear api # Make a raw GraphQL API request
- [team](references/team.md) - Manage Linear teams
- [project](references/project.md) - Manage Linear projects
- [project-update](references/project-update.md) - Manage project status updates
- [cycle](references/cycle.md) - Manage Linear team cycles
- [milestone](references/milestone.md) - Manage Linear project milestones
- [initiative](references/initiative.md) - Manage Linear initiatives
- [initiative-update](references/initiative-update.md) - Manage initiative status updates (timeline posts)
Expand Down
1 change: 1 addition & 0 deletions skills/linear-cli/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Generated from linear CLI v1.10.0
- [team](./team.md) - Manage Linear teams
- [project](./project.md) - Manage Linear projects
- [project-update](./project-update.md) - Manage project status updates
- [cycle](./cycle.md) - Manage Linear team cycles
- [milestone](./milestone.md) - Manage Linear project milestones
- [initiative](./initiative.md) - Manage Linear initiatives
- [initiative-update](./initiative-update.md) - Manage initiative status updates (timeline posts)
Expand Down
64 changes: 64 additions & 0 deletions skills/linear-cli/references/cycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# cycle

> Manage Linear team cycles

## Usage

```
Usage: linear cycle
Version: 1.10.0

Description:

Manage Linear team cycles

Options:

-h, --help - Show this help.
-w, --workspace <slug> - Target workspace (uses credentials)

Commands:

list - List cycles for a team
view, v <cycleRef> - View cycle details
```

## Subcommands

### list

> List cycles for a team

```
Usage: linear cycle list
Version: 1.10.0

Description:

List cycles for a team

Options:

-h, --help - Show this help.
-w, --workspace <slug> - Target workspace (uses credentials)
--team <team> - Team key (defaults to current team)
```

### view

> View cycle details

```
Usage: linear cycle view <cycleRef>
Version: 1.10.0

Description:

View cycle details

Options:

-h, --help - Show this help.
-w, --workspace <slug> - Target workspace (uses credentials)
--team <team> - Team key (defaults to current team)
```
1 change: 1 addition & 0 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Options:
--sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: "manual", "priority")
--team <team> - Team to list issues for (if not your default team)
--project <project> - Filter by project name
--cycle <cycle> - Filter by cycle name, number, or 'active'
--limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50)
-w, --web - Open in web browser
-a, --app - Open in Linear.app
Expand Down
153 changes: 153 additions & 0 deletions src/commands/cycle/cycle-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Command } from "@cliffy/command"
import { unicodeWidth } from "@std/cli"
import { green } from "@std/fmt/colors"
import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { padDisplay } from "../../utils/display.ts"
import { getTeamIdByKey, getTeamKey } from "../../utils/linear.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { header, muted } from "../../utils/styling.ts"
import {
handleError,
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"

const GetTeamCycles = gql(`
query GetTeamCycles($teamId: String!) {
team(id: $teamId) {
id
name
cycles {
nodes {
id
number
name
startsAt
endsAt
completedAt
isActive
isFuture
isPast
}
}
}
}
`)

function getCycleStatus(cycle: {
isActive: boolean
isFuture: boolean
isPast: boolean
completedAt?: string | null
}): string {
if (cycle.isActive) return "Active"
if (cycle.isFuture) return "Upcoming"
if (cycle.completedAt != null) return "Completed"
if (cycle.isPast) return "Past"
return "Unknown"
}

function formatDate(dateString: string): string {
return dateString.slice(0, 10)
}

export const listCommand = new Command()
.name("list")
.description("List cycles for a team")
.option("--team <team:string>", "Team key (defaults to current team)")
.action(async ({ team }) => {
try {
const teamKey = team || getTeamKey()
if (!teamKey) {
throw new ValidationError(
"Could not determine team key from directory name or team flag",
)
}

const teamId = await getTeamIdByKey(teamKey)
if (!teamId) {
throw new NotFoundError("Team", teamKey)
}

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

const client = getGraphQLClient()
const result = await client.request(GetTeamCycles, { teamId })
spinner?.stop()

const cycles = result.team?.cycles?.nodes || []

if (cycles.length === 0) {
console.log("No cycles found for this team.")
return
}

const sortedCycles = [...cycles].sort((a, b) =>
b.startsAt.localeCompare(a.startsAt)
)

const { columns } = Deno.stdout.isTerminal()
? Deno.consoleSize()
: { columns: 120 }

const NUMBER_WIDTH = Math.max(
1,
...sortedCycles.map((c) => String(c.number).length),
)
const START_WIDTH = 10
const END_WIDTH = 10
const STATUS_WIDTH = 9
const SPACE_WIDTH = 4

const fixed = NUMBER_WIDTH + START_WIDTH + END_WIDTH + STATUS_WIDTH +
SPACE_WIDTH
const PADDING = 1
const maxNameWidth = Math.max(
4,
...sortedCycles.map((c) => unicodeWidth(c.name || `Cycle ${c.number}`)),
)
const availableWidth = Math.max(columns - PADDING - fixed, 0)
const nameWidth = Math.min(maxNameWidth, availableWidth)

const headerCells = [
padDisplay("#", NUMBER_WIDTH),
padDisplay("NAME", nameWidth),
padDisplay("START", START_WIDTH),
padDisplay("END", END_WIDTH),
padDisplay("STATUS", STATUS_WIDTH),
]

console.log(header(headerCells.join(" ")))

for (const cycle of sortedCycles) {
const name = cycle.name || `Cycle ${cycle.number}`
const truncName = name.length > nameWidth
? name.slice(0, nameWidth - 3) + "..."
: padDisplay(name, nameWidth)

const status = getCycleStatus(cycle)
const statusStr = padDisplay(status, STATUS_WIDTH)
let statusDisplay: string
if (cycle.isActive) {
statusDisplay = green(statusStr)
} else if (cycle.isPast || cycle.completedAt != null) {
statusDisplay = muted(statusStr)
} else {
statusDisplay = statusStr
}

const line = `${
padDisplay(String(cycle.number), NUMBER_WIDTH)
} ${truncName} ${padDisplay(formatDate(cycle.startsAt), START_WIDTH)} ${
padDisplay(formatDate(cycle.endsAt), END_WIDTH)
} ${statusDisplay}`
console.log(line)
}
} catch (error) {
handleError(error, "Failed to list cycles")
}
})
Loading