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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Anyone can also install straight from a repository, without the registry:
| [Ports](extensions/ports) | What listens on each port, with its process and project; stop it | `ss`, `ps`, `readlink`, `kill` |
| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs, VPN and Tailscale | `nmcli`, `bluetoothctl`, `pactl`, `tailscale` |
| [AI](extensions/ai) | Ask AI, fix, improve, summarize, translate, explain copied text | Claude, OpenAI, Mistral, Groq, OpenRouter, Ollama |
| [Linear](extensions/linear) | My issues by status, change status, search, create an issue | `api.linear.app` |
| [Todoist](extensions/todoist) | Today and overdue tasks, complete them, search, quick add | `api.todoist.com` |

Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged.

Expand Down
34 changes: 34 additions & 0 deletions extensions/linear/backslash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[extension]
id = "linear"
title = "Linear"
description = "Your issues, search and quick issue creation for Linear"
version = "1.0.0"
author = "Backslash"
icon = "📐"
license = "MIT"

[permissions]
net = ["api.linear.app"]

[[commands]]
name = "my-issues"
title = "My Linear Issues"
description = "Issues assigned to you, by status"
keywords = ["linear", "issues", "tickets", "tasks", "todo"]

[[commands]]
name = "search-issues"
title = "Search Linear Issues"
keywords = ["linear", "issues", "find", "tickets"]

[[commands]]
name = "create-issue"
title = "Create Linear Issue"
keywords = ["linear", "new", "issue", "bug", "ticket"]

[[preferences]]
name = "apiKey"
title = "Personal API key"
description = "Linear → Settings → Security & access → Personal API keys"
type = "secret"
required = true
55 changes: 55 additions & 0 deletions extensions/linear/commands/create-issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { action, close, type Context, copy, detail, field, form, open } from "@backslash/api";
import { linear, PRIORITIES } from "../lib.ts";

interface Team {
id: string;
key: string;
name: string;
}

export default async ({ preferences }: Context) => {
const apiKey = preferences.apiKey as string;
const { teams } = await linear<{ teams: { nodes: Team[] } }>(apiKey, `query { teams { nodes { id key name } } }`);
if (!teams.nodes.length) throw new Error("No Linear team found for this API key");
return form({
title: "New Linear issue",
submitTitle: "Create Issue",
fields: [
field.text("title", "Title", { required: true, placeholder: "What needs doing?" }),
field.textArea("description", "Description", { placeholder: "Markdown" }),
field.dropdown("team", "Team", teams.nodes.map((t) => ({ value: t.id, title: `${t.name} (${t.key})` })), {
value: teams.nodes[0].id,
}),
field.dropdown("priority", "Priority", PRIORITIES, { value: "0" }),
field.checkbox("assignToMe", "Assign to me", { value: true }),
],
async submit(values) {
let assigneeId: string | undefined;
if (values.assignToMe) {
assigneeId = (await linear<{ viewer: { id: string } }>(apiKey, `query { viewer { id } }`)).viewer.id;
}
const { issueCreate } = await linear<
{ issueCreate: { success: boolean; issue: { identifier: string; url: string } } }
>(
apiKey,
`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }`,
{
input: {
teamId: values.team,
title: String(values.title).trim(),
description: String(values.description ?? "") || undefined,
priority: Number(values.priority),
assigneeId,
},
},
);
if (!issueCreate.success) throw new Error("Linear did not create the issue");
const { identifier, url } = issueCreate.issue;
return detail({
title: identifier,
markdown: `# ${identifier} created\n\n${String(values.title)}`,
actions: [open(url, "Open in Linear"), copy(url, "Copy URL"), action("Done", () => close())],
});
},
});
};
25 changes: 25 additions & 0 deletions extensions/linear/commands/my-issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type Context, list } from "@backslash/api";
import { type Issue, ISSUE_FIELDS, issueItem, linear, live, sortIssues } from "../lib.ts";
import { statusPicker } from "../statuses.ts";

const QUERY = `query { viewer { assignedIssues(first: 100, orderBy: updatedAt,
filter: { state: { type: { nin: ["completed", "canceled"] } } }) { nodes { ${ISSUE_FIELDS} } } } }`;

export default (ctx: Context) =>
list({
placeholder: "Filter your issues…",
empty: { title: "No open issues assigned to you", icon: "🎉" },
// Reloads after a status change made from this list.
items: ({ preferences, signal }) =>
live(async () => {
const data = await linear<{ viewer: { assignedIssues: { nodes: Issue[] } } }>(
preferences.apiKey as string,
QUERY,
{},
signal,
);
return sortIssues(data.viewer.assignedIssues.nodes).map((issue) =>
issueItem(issue, { section: issue.state.name, moveTo: (i) => statusPicker(i, ctx) })
);
}, signal),
});
19 changes: 19 additions & 0 deletions extensions/linear/commands/search-issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Context, list } from "@backslash/api";
import { type Issue, ISSUE_FIELDS, issueItem, linear } from "../lib.ts";
import { statusPicker } from "../statuses.ts";

export default (ctx: Context) =>
list({
placeholder: "Search issues by title, identifier or text…",
empty: { title: "Type to search Linear" },
async search(query, { preferences, signal }) {
if (query.trim().length < 2) return [];
const data = await linear<{ searchIssues: { nodes: Issue[] } }>(
preferences.apiKey as string,
`query($term: String!) { searchIssues(term: $term, first: 30) { nodes { ${ISSUE_FIELDS} } } }`,
{ term: query.trim() },
signal,
);
return data.searchIssues.nodes.map((issue) => issueItem(issue, { moveTo: (i) => statusPicker(i, ctx) }));
},
});
149 changes: 149 additions & 0 deletions extensions/linear/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Linear GraphQL helper and the mapping from issues to launcher items.

import { action, copy, type Item, open, type View } from "@backslash/api";

export const API = "https://api.linear.app/graphql";

export async function linear<T>(
apiKey: string,
query: string,
variables: Record<string, unknown> = {},
signal?: AbortSignal,
): Promise<T> {
const res = await fetch(API, {
method: "POST",
signal,
// Personal API keys go as is; OAuth tokens would be "Bearer …".
headers: { "Content-Type": "application/json", Authorization: apiKey.trim() },
body: JSON.stringify({ query, variables }),
});
if (res.status === 401) throw new Error("Linear rejected the API key: check it in Settings → Extensions → Linear");
const body = await res.json().catch(() => ({})) as { data?: T; errors?: { message: string }[] };
if (body.errors?.length) throw new Error(`Linear: ${body.errors[0].message}`);
if (!res.ok || !body.data) throw new Error(`Linear answered ${res.status}`);
return body.data;
}

export const ISSUE_FIELDS = `
id identifier title url priority priorityLabel updatedAt
state { id name type }
team { id key name }
project { name }
assignee { name }
`;

export interface Issue {
id: string;
identifier: string;
title: string;
url: string;
priority: number;
priorityLabel: string;
updatedAt: string;
state: { id: string; name: string; type: string };
team: { id: string; key: string; name: string };
project?: { name: string } | null;
assignee?: { name: string } | null;
}

export interface WorkflowState {
id: string;
name: string;
type: string;
position: number;
}

/** Order of the sections in "My Issues": what's moving first. */
const STATE_ORDER = ["started", "unstarted", "backlog", "triage", "completed", "canceled"];

export function stateRank(type: string): number {
const i = STATE_ORDER.indexOf(type);
return i === -1 ? STATE_ORDER.length : i;
}

/** Urgent, high, medium, low first; no priority (0) last. */
export function sortIssues(issues: Issue[]): Issue[] {
const p = (n: number) => n === 0 ? 5 : n;
return [...issues].sort((a, b) =>
stateRank(a.state.type) - stateRank(b.state.type) || a.state.name.localeCompare(b.state.name) ||
p(a.priority) - p(b.priority) || b.updatedAt.localeCompare(a.updatedAt)
);
}

const PRIORITY_COLOR: Record<number, "red" | "orange" | "yellow" | "blue"> = {
1: "red",
2: "orange",
3: "yellow",
4: "blue",
};

const STATE_ICON: Record<string, string> = {
started: "🟡",
unstarted: "⚪",
backlog: "◌",
triage: "🔶",
completed: "✅",
canceled: "⛔",
};

export function issueItem(
issue: Issue,
options: { section?: string; moveTo?: (issue: Issue) => Promise<View> } = {},
): Item {
const where = [issue.team.key, issue.project?.name].filter(Boolean).join(" · ");
const actions = [
open(issue.url, "Open in Linear"),
copy(issue.identifier, "Copy Identifier", { shortcut: "<Ctrl>period" }),
copy(issue.url, "Copy URL", { shortcut: "<Ctrl><Shift>c" }),
copy(`[${issue.identifier}: ${issue.title}](${issue.url})`, "Copy as Markdown Link"),
];
if (options.moveTo) {
const moveTo = options.moveTo;
actions.splice(1, 0, action("Change Status…", () => moveTo(issue), { icon: "go-next", shortcut: "<Ctrl>s" }));
}
return {
id: issue.id,
title: issue.title,
subtitle: where,
icon: STATE_ICON[issue.state.type] ?? "📐",
section: options.section,
keywords: [issue.identifier, issue.state.name, issue.team.name, issue.assignee?.name ?? ""].filter(Boolean),
accessories: [
...(issue.priority ? [{ tag: issue.priorityLabel, color: PRIORITY_COLOR[issue.priority] }] : []),
{ text: issue.identifier },
],
actions,
};
}

export const PRIORITIES = [
{ value: "0", title: "No priority" },
{ value: "1", title: "Urgent" },
{ value: "2", title: "High" },
{ value: "3", title: "Medium" },
{ value: "4", title: "Low" },
];

// Views of this extension run in one process: a change made in one view
// (a new status) reloads the lists still open under it.
const changes = new EventTarget();

export function notifyChanged() {
changes.dispatchEvent(new Event("change"));
}

/** Yields `load()`, then again after each change, until `signal` aborts. */
export async function* live<T>(load: () => Promise<T>, signal: AbortSignal): AsyncGenerator<T> {
while (!signal.aborted) {
yield await load();
await new Promise<void>((resolve) => {
const done = () => {
changes.removeEventListener("change", done);
signal.removeEventListener("abort", done);
resolve();
};
changes.addEventListener("change", done);
signal.addEventListener("abort", done);
});
}
}
42 changes: 42 additions & 0 deletions extensions/linear/lib_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { assertEquals } from "@std/assert";
import { type Issue, issueItem, sortIssues } from "./lib.ts";

const issue = (identifier: string, type: string, name: string, priority: number, updatedAt = "2026-10-01"): Issue => ({
id: identifier,
identifier,
title: `Title ${identifier}`,
url: `https://linear.app/acme/issue/${identifier}`,
priority,
priorityLabel: ["No priority", "Urgent", "High", "Medium", "Low"][priority],
updatedAt,
state: { id: name, name, type },
team: { id: "t", key: "ENG", name: "Engineering" },
project: identifier === "ENG-1" ? { name: "Launch" } : null,
});

Deno.test("issues: in progress first, then by priority, no priority last", () => {
const sorted = sortIssues([
issue("ENG-1", "backlog", "Backlog", 1),
issue("ENG-2", "unstarted", "Todo", 0),
issue("ENG-3", "unstarted", "Todo", 3),
issue("ENG-4", "started", "In Review", 4),
issue("ENG-5", "started", "In Progress", 2, "2026-09-01"),
issue("ENG-6", "started", "In Progress", 2, "2026-10-02"),
]);
assertEquals(sorted.map((i) => i.identifier), ["ENG-6", "ENG-5", "ENG-4", "ENG-3", "ENG-2", "ENG-1"]);
});

Deno.test("issue item", () => {
const item = issueItem(issue("ENG-1", "started", "In Progress", 1), { section: "In Progress" });
assertEquals(item.subtitle, "ENG · Launch");
assertEquals(item.accessories, [{ tag: "Urgent", color: "red" }, { text: "ENG-1" }]);
assertEquals(item.actions?.map((a) => a.title), [
"Open in Linear",
"Copy Identifier",
"Copy URL",
"Copy as Markdown Link",
]);
const movable = issueItem(issue("ENG-2", "unstarted", "Todo", 0), { moveTo: () => Promise.reject() });
assertEquals(movable.accessories, [{ text: "ENG-2" }]);
assertEquals(movable.actions?.[1].title, "Change Status…");
});
39 changes: 39 additions & 0 deletions extensions/linear/statuses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// "Change Status…": the workflow states of the issue's team.

import { action, type Context, list, pop, toast, type View } from "@backslash/api";
import { type Issue, linear, notifyChanged, stateRank, type WorkflowState } from "./lib.ts";

export async function statusPicker(issue: Issue, ctx: Context): Promise<View> {
const apiKey = ctx.preferences.apiKey as string;
const data = await linear<{ team: { states: { nodes: WorkflowState[] } } }>(
apiKey,
`query($id: String!) { team(id: $id) { states { nodes { id name type position } } } }`,
{ id: issue.team.id },
);
const states = data.team.states.nodes.sort((a, b) =>
stateRank(a.type) - stateRank(b.type) || a.position - b.position
);
return list({
title: `${issue.identifier} → status`,
placeholder: "Move to…",
items: states.map((s) => ({
id: s.id,
title: s.name,
icon: s.id === issue.state.id ? "object-select" : undefined,
accessories: s.id === issue.state.id ? [{ tag: "Current", color: "gray" as const }] : [],
actions: [
action(`Move to ${s.name}`, async () => {
const res = await linear<{ issueUpdate: { success: boolean } }>(
apiKey,
`mutation($id: String!, $state: String!) { issueUpdate(id: $id, input: { stateId: $state }) { success } }`,
{ id: issue.id, state: s.id },
);
if (!res.issueUpdate.success) throw new Error("Linear did not update the issue");
notifyChanged();
await toast(`${issue.identifier} → ${s.name}`, { style: "success" });
return pop();
}),
],
})),
});
}
Loading
Loading