From 3a09a15c1d7c673f43d3b085a35c2138bd109e07 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Tue, 19 May 2026 15:31:06 -0700 Subject: [PATCH] feat(issue): show blocked indicator in mine and query output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'B' column (with ⊘) to 'linear issue mine' and 'linear issue query' that flags issues with at least one active blocker — a blocked-by relation whose blocker is not in a completed or canceled workflow state. The inverseRelations connection is also surfaced in --json output. --- src/commands/issue/issue-mine.ts | 15 +- src/commands/issue/issue-query.ts | 14 +- src/utils/linear.ts | 60 +++++++ .../__snapshots__/issue-query.test.ts.snap | 6 + test/commands/issue/issue-list.test.ts | 6 +- test/commands/issue/issue-mine.test.ts | 148 +++++++++++++++++- test/commands/issue/issue-query.test.ts | 87 ++++++++++ test/utils/linear.test.ts | 2 + 8 files changed, 330 insertions(+), 8 deletions(-) diff --git a/src/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index 57c65566..e8ee021f 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -16,11 +16,12 @@ import { getProjectOptionsByName, getTeamIdByKey, getTeamKey, + isIssueBlocked, selectOption, } from "../../utils/linear.ts" import { openTeamAssigneeView } from "../../utils/actions.ts" import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" -import { header, muted } from "../../utils/styling.ts" +import { header, muted, warning } from "../../utils/styling.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, @@ -291,6 +292,7 @@ export const mineCommand = new Command() ? Deno.consoleSize() : { columns: 120 } const PRIORITY_WIDTH = 3 + const BLOCKED_WIDTH = 1 const ID_WIDTH = Math.max( 2, // minimum width for "ID" header ...issues.map((issue) => issue.identifier.length), @@ -323,6 +325,7 @@ export const mineCommand = new Command() type TableRow = { priorityStr: string + blockedStr: string identifier: string title: string labels: string @@ -394,8 +397,11 @@ export const mineCommand = new Command() ) const statePadded = stateColored + " ".repeat(stateRemainingSpace) + const blockedStr = isIssueBlocked(issue) ? warning("⊘") : " " + return { priorityStr, + blockedStr, identifier: issue.identifier, title: issue.title, labels, @@ -405,7 +411,8 @@ export const mineCommand = new Command() } }) - const fixed = PRIORITY_WIDTH + ID_WIDTH + UPDATED_WIDTH + SPACE_WIDTH + + const fixed = PRIORITY_WIDTH + BLOCKED_WIDTH + ID_WIDTH + + UPDATED_WIDTH + SPACE_WIDTH + LABEL_WIDTH + ESTIMATE_WIDTH + STATE_WIDTH + SPACE_WIDTH const PADDING = 1 const maxTitleWidth = Math.max( @@ -418,6 +425,7 @@ export const mineCommand = new Command() padDisplay("ID", ID_WIDTH), padDisplay("TITLE", titleWidth), padDisplay("LABELS", LABEL_WIDTH), + padDisplay("B", BLOCKED_WIDTH), padDisplay("E", ESTIMATE_WIDTH), padDisplay("STATE", STATE_WIDTH), padDisplay(updatedHeader, UPDATED_WIDTH), @@ -432,6 +440,7 @@ export const mineCommand = new Command() for (const row of tableData) { const { priorityStr, + blockedStr, identifier, title, labels, @@ -446,7 +455,7 @@ export const mineCommand = new Command() const issueLine = `${padDisplay(priorityStr, PRIORITY_WIDTH)} ${ padDisplay(identifier, ID_WIDTH) - } ${truncTitle} ${labels} ${ + } ${truncTitle} ${labels} ${padDisplay(blockedStr, BLOCKED_WIDTH)} ${ padDisplay(estimate?.toString() || "-", ESTIMATE_WIDTH) } ${state} ${muted(padDisplay(timeAgo, UPDATED_WIDTH))}` outputLines.push(issueLine) diff --git a/src/commands/issue/issue-query.ts b/src/commands/issue/issue-query.ts index 6c85f880..5578d7c4 100644 --- a/src/commands/issue/issue-query.ts +++ b/src/commands/issue/issue-query.ts @@ -16,12 +16,13 @@ import { getProjectOptionsByName, getTeamIdByKey, getTeamKey, + isIssueBlocked, searchIssuesByTerm, selectOption, } from "../../utils/linear.ts" import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { header, muted } from "../../utils/styling.ts" +import { header, muted, warning } from "../../utils/styling.ts" import { handleError, NotFoundError, @@ -423,6 +424,12 @@ interface DisplayableIssue { assignee?: { initials: string } | null team?: { key: string } labels: { nodes: Array<{ name: string; color: string }> } + inverseRelations?: { + nodes: Array<{ + type: string + issue?: { state?: { type?: string | null } | null } | null + }> + } | null } function formatIssueTable( @@ -435,6 +442,7 @@ function formatIssueTable( : { columns: 120 } const priorityWidth = 3 + const blockedWidth = 1 const idWidth = Math.max(2, ...issues.map((i) => i.identifier.length)) const teamWidth = showTeamColumn ? Math.max( @@ -468,6 +476,7 @@ function formatIssueTable( idWidth, ...(showTeamColumn ? [teamWidth] : []), labelWidth, + blockedWidth, estimateWidth, ...(showAssigneeColumn ? [assigneeWidth] : []), stateWidth, @@ -485,6 +494,7 @@ function formatIssueTable( ...(showTeamColumn ? [padDisplay("TEAM", teamWidth)] : []), padDisplay("TITLE", titleWidth), padDisplay("LABELS", labelWidth), + padDisplay("B", blockedWidth), padDisplay("E", estimateWidth), ...(showAssigneeColumn ? [padDisplay("A", assigneeWidth)] : []), padDisplay("STATE", stateWidth), @@ -508,12 +518,14 @@ function formatIssueTable( const timeAgo = muted( padDisplay(getTimeAgo(new Date(issue.updatedAt)), updatedWidth), ) + const blockedCell = isIssueBlocked(issue) ? warning("⊘") : " " const cells = [ padDisplay(getPriorityDisplay(issue.priority), priorityWidth), padDisplay(issue.identifier, idWidth), ...(showTeamColumn ? [padDisplay(issue.team?.key ?? "", teamWidth)] : []), title, formatLabels(issue.labels.nodes, labelWidth), + padDisplay(blockedCell, blockedWidth), padDisplay(issue.estimate?.toString() || "-", estimateWidth), ...(showAssigneeColumn ? [ diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 4b373cd0..bfdd2a78 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -47,6 +47,26 @@ export function parseDateFilter(value: string, flagName: string): string { return parsed.toISOString() } +type InverseRelationNode = { + type: string + issue?: { state?: { type?: string | null } | null } | null +} + +export function isIssueBlocked(issue: { + inverseRelations?: { nodes: ReadonlyArray } | null +}): boolean { + const nodes = issue.inverseRelations?.nodes + if (!nodes) return false + for (const rel of nodes) { + if (rel.type !== "blocks") continue + const blockerStateType = rel.issue?.state?.type + if (blockerStateType !== "completed" && blockerStateType !== "canceled") { + return true + } + } + return false +} + export function formatIssueIdentifier(providedId: string): string { return normalizeIssueIdentifier(providedId) ?? providedId.toUpperCase() } @@ -596,6 +616,7 @@ export async function fetchIssuesForState( id name color + type } labels { nodes { @@ -604,6 +625,19 @@ export async function fetchIssuesForState( color } } + inverseRelations(first: 100) { + nodes { + id + type + issue { + id + identifier + state { + type + } + } + } + } updatedAt } pageInfo { @@ -732,6 +766,19 @@ const queryIssuesQuery = gql(/* GraphQL */ ` color } } + inverseRelations(first: 100) { + nodes { + id + type + issue { + id + identifier + state { + type + } + } + } + } } pageInfo { hasNextPage @@ -971,6 +1018,19 @@ const searchIssuesQuery = gql(/* GraphQL */ ` color } } + inverseRelations(first: 100) { + nodes { + id + type + issue { + id + identifier + state { + type + } + } + } + } metadata } pageInfo { diff --git a/test/commands/issue/__snapshots__/issue-query.test.ts.snap b/test/commands/issue/__snapshots__/issue-query.test.ts.snap index 61138378..8adef3b4 100644 --- a/test/commands/issue/__snapshots__/issue-query.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-query.test.ts.snap @@ -85,6 +85,9 @@ stdout: "color": "#eb5757" } ] + }, + "inverseRelations": { + "nodes": [] } } ], @@ -144,6 +147,9 @@ stdout: } ] }, + "inverseRelations": { + "nodes": [] + }, "metadata": { "context": {}, "score": 0.42 diff --git a/test/commands/issue/issue-list.test.ts b/test/commands/issue/issue-list.test.ts index 02df8216..8d42fcee 100644 --- a/test/commands/issue/issue-list.test.ts +++ b/test/commands/issue/issue-list.test.ts @@ -67,6 +67,7 @@ Deno.test("Issue List Command - Filter By Label", async () => { id: "state-1", name: "In Progress", color: "#f2c94c", + type: "started", }, labels: { nodes: [{ @@ -75,6 +76,7 @@ Deno.test("Issue List Command - Filter By Label", async () => { color: "#eb5757", }], }, + inverseRelations: { nodes: [] }, updatedAt: "2026-03-13T10:00:00.000Z", }, ], @@ -102,8 +104,8 @@ Deno.test("Issue List Command - Filter By Label", async () => { assertEquals( logs.join("\n") + "\n", - "◌ ID TITLE LABELS E STATE UPDATED \n" + - "⚠⚠⚠ ENG-101 Fix login bug Bug 3 In Progress 17 days ago\n", + "◌ ID TITLE LABELS B E STATE UPDATED \n" + + "⚠⚠⚠ ENG-101 Fix login bug Bug 3 In Progress 17 days ago\n", ) } finally { logStub.restore() diff --git a/test/commands/issue/issue-mine.test.ts b/test/commands/issue/issue-mine.test.ts index cc7f3693..574c9c4e 100644 --- a/test/commands/issue/issue-mine.test.ts +++ b/test/commands/issue/issue-mine.test.ts @@ -65,6 +65,7 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { id: "state-1", name: "In Progress", color: "#f2c94c", + type: "started", }, labels: { nodes: [{ @@ -73,6 +74,7 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { color: "#eb5757", }], }, + inverseRelations: { nodes: [] }, updatedAt: "2026-03-13T10:00:00.000Z", }, ], @@ -100,8 +102,8 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { assertEquals( logs.join("\n") + "\n", - "◌ ID TITLE LABELS E STATE UPDATED \n" + - "⚠⚠⚠ ENG-101 Fix login bug Bug 3 In Progress 17 days ago\n", + "◌ ID TITLE LABELS B E STATE UPDATED \n" + + "⚠⚠⚠ ENG-101 Fix login bug Bug 3 In Progress 17 days ago\n", ) } finally { logStub.restore() @@ -110,3 +112,145 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { await cleanup() } }) + +Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { + const fixedNow = new Date("2026-03-30T10:00:00.000Z") + const RealDate = Date + const originalColorEnabled = getColorEnabled() + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value == null ? fixedNow.toISOString() : value) + } + + static override now(): number { + return fixedNow.getTime() + } + } + globalThis.Date = MockDate as DateConstructor + setColorEnabled(false) + + const baseState = { + id: "state-1", + name: "Todo", + color: "#e2e2e2", + type: "unstarted", + } + + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetIssuesForState", + response: { + data: { + issues: { + nodes: [ + { + id: "issue-blocked-by-open", + identifier: "ENG-200", + title: "Blocked by open issue", + priority: 0, + estimate: null, + assignee: { initials: "MC" }, + state: baseState, + labels: { nodes: [] }, + inverseRelations: { + nodes: [ + { + id: "rel-1", + type: "blocks", + issue: { + id: "blocker-open", + identifier: "ENG-100", + state: { type: "started" }, + }, + }, + ], + }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-blocked-by-done", + identifier: "ENG-201", + title: "Blocker completed", + priority: 0, + estimate: null, + assignee: { initials: "MC" }, + state: baseState, + labels: { nodes: [] }, + inverseRelations: { + nodes: [ + { + id: "rel-2", + type: "blocks", + issue: { + id: "blocker-done", + identifier: "ENG-101", + state: { type: "completed" }, + }, + }, + ], + }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-unrelated-relation", + identifier: "ENG-202", + title: "Has only related relation", + priority: 0, + estimate: null, + assignee: { initials: "MC" }, + state: baseState, + labels: { nodes: [] }, + inverseRelations: { + nodes: [ + { + id: "rel-3", + type: "related", + issue: { + id: "rel-other", + identifier: "ENG-102", + state: { type: "started" }, + }, + }, + ], + }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG", LINEAR_ISSUE_SORT: "priority", NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await mineCommand.parse(["--team", "ENG", "--sort", "priority"]) + + const output = logs.join("\n") + // ENG-200 is blocked by an open issue → indicator present. + // ENG-201's blocker is completed → not shown. + // ENG-202 has only a "related" relation → not shown. + const eng200 = output.split("\n").find((l) => l.includes("ENG-200"))! + const eng201 = output.split("\n").find((l) => l.includes("ENG-201"))! + const eng202 = output.split("\n").find((l) => l.includes("ENG-202"))! + + assertEquals(eng200.includes("⊘"), true) + assertEquals(eng201.includes("⊘"), false) + assertEquals(eng202.includes("⊘"), false) + } finally { + logStub.restore() + globalThis.Date = RealDate + setColorEnabled(originalColorEnabled) + await cleanup() + } +}) diff --git a/test/commands/issue/issue-query.test.ts b/test/commands/issue/issue-query.test.ts index 5dc57bda..06ba61c0 100644 --- a/test/commands/issue/issue-query.test.ts +++ b/test/commands/issue/issue-query.test.ts @@ -59,6 +59,7 @@ const mockIssueNode = { { id: "label-1", name: "Bug", color: "#eb5757" }, ], }, + inverseRelations: { nodes: [] }, } // Test JSON output with filter mode (issues() backend) @@ -229,6 +230,92 @@ Deno.test("Issue Query Command - All Teams shows TEAM column", async () => { } }) +// Blocked indicator in table output +Deno.test("Issue Query Command - Shows Blocked Indicator", async () => { + const fixedNow = new Date("2026-04-03T10:00:00.000Z") + const RealDate = Date + const originalColorEnabled = getColorEnabled() + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value == null ? fixedNow.toISOString() : value) + } + static override now(): number { + return fixedNow.getTime() + } + } + globalThis.Date = MockDate as DateConstructor + setColorEnabled(false) + + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssuesForQuery", + response: { + data: { + issues: { + nodes: [ + { + ...mockIssueNode, + id: "blocked-1", + identifier: "ENG-300", + title: "Blocked by open", + inverseRelations: { + nodes: [{ + id: "rel-a", + type: "blocks", + issue: { + id: "blocker", + identifier: "ENG-200", + state: { type: "started" }, + }, + }], + }, + }, + { + ...mockIssueNode, + id: "unblocked-1", + identifier: "ENG-301", + title: "Blocker done", + inverseRelations: { + nodes: [{ + id: "rel-b", + type: "blocks", + issue: { + id: "blocker-done", + identifier: "ENG-201", + state: { type: "canceled" }, + }, + }], + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await queryCommand.parse(["--team", "ENG", "--all-states"]) + + const lines = logs.join("\n").split("\n") + const blocked = lines.find((l) => l.includes("ENG-300"))! + const unblocked = lines.find((l) => l.includes("ENG-301"))! + assertEquals(blocked.includes("⊘"), true) + assertEquals(unblocked.includes("⊘"), false) + } finally { + logStub.restore() + globalThis.Date = RealDate + setColorEnabled(originalColorEnabled) + await cleanup() + } +}) + // Test validation: --team + --all-teams conflict Deno.test("Issue Query Command - rejects --team with --all-teams", async () => { const errorLogs: string[] = [] diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index 3cfe402d..5db4ae4d 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -77,6 +77,7 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = projectMilestone: null, cycle: null, labels: { nodes: [] }, + inverseRelations: { nodes: [] }, metadata: {}, }, ], @@ -124,6 +125,7 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = projectMilestone: null, cycle: null, labels: { nodes: [] }, + inverseRelations: { nodes: [] }, metadata: {}, }, ],