@bdsqqq/lnr-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@bdsqqq/lnr-core",
3
+ "version": "0.1.0",
4
+ "description": "core business logic for linear-cli",
5
+ "type": "module",
6
+ "private": false,
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/bdsqqq/lnr.git",
15
+ "directory": "packages/core"
16
+ },
17
+ "license": "MIT",
18
+ "author": "bdsqqq",
19
+ "scripts": {
20
+ "check": "tsc --noEmit",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "@linear/sdk": "^68.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "^1.1.0"
28
+ },
29
+ "peerDependencies": {
30
+ "typescript": "^5"
31
+ }
32
+ }
package/src/client.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { LinearClient } from "@linear/sdk";
2
+ import { getApiKey } from "./config";
3
+
4
+ let clientInstance: LinearClient | null = null;
5
+
6
+ export class NotAuthenticatedError extends Error {
7
+ constructor() {
8
+ super("not authenticated");
9
+ this.name = "NotAuthenticatedError";
10
+ }
11
+ }
12
+
13
+ export function getClient(): LinearClient {
14
+ if (clientInstance) {
15
+ return clientInstance;
16
+ }
17
+
18
+ const apiKey = getApiKey();
19
+ if (!apiKey) {
20
+ throw new NotAuthenticatedError();
21
+ }
22
+
23
+ clientInstance = new LinearClient({ apiKey });
24
+ return clientInstance;
25
+ }
26
+
27
+ export function createClientWithKey(apiKey: string): LinearClient {
28
+ return new LinearClient({ apiKey });
29
+ }
30
+
31
+ export function resetClient(): void {
32
+ clientInstance = null;
33
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, test, expect, afterEach, beforeAll } from "bun:test";
2
+ import {
3
+ loadConfig,
4
+ saveConfig,
5
+ getConfigValue,
6
+ setConfigValue,
7
+ type Config,
8
+ } from "./config";
9
+
10
+ describe("config core", () => {
11
+ let originalConfig: Config;
12
+
13
+ beforeAll(() => {
14
+ originalConfig = loadConfig();
15
+ });
16
+
17
+ afterEach(() => {
18
+ saveConfig(originalConfig);
19
+ });
20
+
21
+ test("loadConfig returns object", () => {
22
+ const config = loadConfig();
23
+ expect(typeof config).toBe("object");
24
+ });
25
+
26
+ test("setConfigValue and getConfigValue work", () => {
27
+ const testValue = "TEST_TEAM_VALUE";
28
+ setConfigValue("default_team", testValue);
29
+ expect(getConfigValue("default_team")).toBe(testValue);
30
+ });
31
+
32
+ test("saveConfig and loadConfig roundtrip", () => {
33
+ const testConfig: Config = {
34
+ api_key: "test_key",
35
+ default_team: "TEST",
36
+ output_format: "json",
37
+ };
38
+ saveConfig(testConfig);
39
+ const loaded = loadConfig();
40
+ expect(loaded.api_key).toBe(testConfig.api_key);
41
+ expect(loaded.default_team).toBe(testConfig.default_team);
42
+ expect(loaded.output_format).toBe(testConfig.output_format);
43
+ });
44
+ });
package/src/config.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+
5
+ export interface Config {
6
+ api_key?: string;
7
+ default_team?: string;
8
+ output_format?: "table" | "json" | "quiet";
9
+ }
10
+
11
+ const CONFIG_DIR = join(homedir(), ".linear-cli");
12
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
13
+
14
+ export function ensureConfigDir(): void {
15
+ if (!existsSync(CONFIG_DIR)) {
16
+ mkdirSync(CONFIG_DIR, { recursive: true });
17
+ }
18
+ }
19
+
20
+ export function loadConfig(): Config {
21
+ ensureConfigDir();
22
+ if (!existsSync(CONFIG_PATH)) {
23
+ return {};
24
+ }
25
+ try {
26
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
27
+ return JSON.parse(raw);
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ export function saveConfig(config: Config): void {
34
+ ensureConfigDir();
35
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
36
+ }
37
+
38
+ export function getApiKey(): string | undefined {
39
+ return loadConfig().api_key;
40
+ }
41
+
42
+ export function setApiKey(key: string): void {
43
+ const config = loadConfig();
44
+ config.api_key = key;
45
+ saveConfig(config);
46
+ }
47
+
48
+ export function clearApiKey(): void {
49
+ const config = loadConfig();
50
+ delete config.api_key;
51
+ saveConfig(config);
52
+ }
53
+
54
+ export function getConfigValue<K extends keyof Config>(key: K): Config[K] {
55
+ return loadConfig()[key];
56
+ }
57
+
58
+ export function setConfigValue<K extends keyof Config>(
59
+ key: K,
60
+ value: Config[K]
61
+ ): void {
62
+ const config = loadConfig();
63
+ config[key] = value;
64
+ saveConfig(config);
65
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { listCycles, getCurrentCycle } from "./cycles";
5
+ import { getAvailableTeamKeys } from "./teams";
6
+
7
+ function getTestClient(): LinearClient {
8
+ const apiKey = getApiKey();
9
+ if (!apiKey) {
10
+ throw new Error("no api key configured - run: li auth <api-key>");
11
+ }
12
+ return new LinearClient({ apiKey });
13
+ }
14
+
15
+ describe("cycles core", () => {
16
+ let client: LinearClient;
17
+ let teamKey: string;
18
+
19
+ beforeAll(async () => {
20
+ client = getTestClient();
21
+ const keys = await getAvailableTeamKeys(client);
22
+ teamKey = keys.includes("BDSQ") ? "BDSQ" : keys[0] ?? "UNKNOWN";
23
+ });
24
+
25
+ test("listCycles returns cycle array", async () => {
26
+ const cycles = await listCycles(client, teamKey);
27
+ expect(Array.isArray(cycles)).toBe(true);
28
+ if (cycles.length > 0) {
29
+ expect(cycles[0]).toHaveProperty("id");
30
+ expect(cycles[0]).toHaveProperty("number");
31
+ expect(cycles[0]).toHaveProperty("startsAt");
32
+ expect(cycles[0]).toHaveProperty("endsAt");
33
+ }
34
+ });
35
+
36
+ test("listCycles returns empty for invalid team", async () => {
37
+ const cycles = await listCycles(client, "INVALID_KEY_XYZ");
38
+ expect(cycles).toEqual([]);
39
+ });
40
+
41
+ test("getCurrentCycle returns null for invalid team", async () => {
42
+ const cycle = await getCurrentCycle(client, "INVALID_KEY_XYZ");
43
+ expect(cycle).toBeNull();
44
+ });
45
+ });
package/src/cycles.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { Cycle, Issue } from "./types";
3
+
4
+ export async function listCycles(
5
+ client: LinearClient,
6
+ teamKey: string
7
+ ): Promise<Cycle[]> {
8
+ try {
9
+ const team = await client.team(teamKey);
10
+
11
+ if (!team) {
12
+ return [];
13
+ }
14
+
15
+ const cyclesConnection = await team.cycles();
16
+ return cyclesConnection.nodes.map((c) => ({
17
+ id: c.id,
18
+ number: c.number,
19
+ name: c.name,
20
+ startsAt: c.startsAt,
21
+ endsAt: c.endsAt,
22
+ }));
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ export async function getCurrentCycle(
29
+ client: LinearClient,
30
+ teamKey: string
31
+ ): Promise<Cycle | null> {
32
+ try {
33
+ const team = await client.team(teamKey);
34
+
35
+ if (!team) {
36
+ return null;
37
+ }
38
+
39
+ const activeCycle = await team.activeCycle;
40
+
41
+ if (!activeCycle) {
42
+ return null;
43
+ }
44
+
45
+ return {
46
+ id: activeCycle.id,
47
+ number: activeCycle.number,
48
+ name: activeCycle.name,
49
+ startsAt: activeCycle.startsAt,
50
+ endsAt: activeCycle.endsAt,
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export async function getCycleIssues(
58
+ client: LinearClient,
59
+ teamKey: string
60
+ ): Promise<Issue[]> {
61
+ try {
62
+ const team = await client.team(teamKey);
63
+
64
+ if (!team) {
65
+ return [];
66
+ }
67
+
68
+ const activeCycle = await team.activeCycle;
69
+
70
+ if (!activeCycle) {
71
+ return [];
72
+ }
73
+
74
+ const issuesConnection = await activeCycle.issues();
75
+
76
+ return Promise.all(
77
+ issuesConnection.nodes.map(async (i) => ({
78
+ id: i.id,
79
+ identifier: i.identifier,
80
+ title: i.title,
81
+ description: i.description,
82
+ state: (await i.state)?.name ?? null,
83
+ assignee: (await i.assignee)?.name ?? null,
84
+ priority: i.priority,
85
+ createdAt: i.createdAt,
86
+ updatedAt: i.updatedAt,
87
+ url: i.url,
88
+ }))
89
+ );
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ // types
2
+ export type {
3
+ Issue,
4
+ Project,
5
+ Team,
6
+ TeamMember,
7
+ Cycle,
8
+ User,
9
+ ListIssuesFilter,
10
+ CreateIssueInput,
11
+ UpdateIssueInput,
12
+ CreateProjectInput,
13
+ } from "./types";
14
+
15
+ // client
16
+ export {
17
+ getClient,
18
+ createClientWithKey,
19
+ resetClient,
20
+ NotAuthenticatedError,
21
+ } from "./client";
22
+
23
+ // config
24
+ export {
25
+ loadConfig,
26
+ saveConfig,
27
+ getApiKey,
28
+ setApiKey,
29
+ clearApiKey,
30
+ getConfigValue,
31
+ setConfigValue,
32
+ ensureConfigDir,
33
+ type Config,
34
+ } from "./config";
35
+
36
+ // issues
37
+ export {
38
+ listIssues,
39
+ getIssue,
40
+ createIssue,
41
+ updateIssue,
42
+ addComment,
43
+ priorityFromString,
44
+ getTeamStates,
45
+ getTeamLabels,
46
+ } from "./issues";
47
+
48
+ // projects
49
+ export {
50
+ listProjects,
51
+ getProject,
52
+ getProjectIssues,
53
+ createProject,
54
+ deleteProject,
55
+ } from "./projects";
56
+
57
+ // teams
58
+ export {
59
+ listTeams,
60
+ getTeam,
61
+ getTeamMembers,
62
+ findTeamByKeyOrName,
63
+ getAvailableTeamKeys,
64
+ } from "./teams";
65
+
66
+ // cycles
67
+ export { listCycles, getCurrentCycle, getCycleIssues } from "./cycles";
68
+
69
+ // me
70
+ export { getViewer, getMyIssues, getMyCreatedIssues } from "./me";
71
+
72
+ // search
73
+ export { searchIssues } from "./search";
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { listIssues, getIssue, createIssue, priorityFromString } from "./issues";
5
+
6
+ const TEST_PREFIX = "[TEST-CLI]";
7
+
8
+ function getTestClient(): LinearClient {
9
+ const apiKey = getApiKey();
10
+ if (!apiKey) {
11
+ throw new Error("no api key configured - run: li auth <api-key>");
12
+ }
13
+ return new LinearClient({ apiKey });
14
+ }
15
+
16
+ async function getFirstTeamKey(): Promise<string> {
17
+ const client = getTestClient();
18
+ const teams = await client.teams();
19
+ const bdsq = teams.nodes.find((t) => t.key === "BDSQ");
20
+ if (bdsq) return bdsq.key;
21
+ const first = teams.nodes[0];
22
+ if (!first) throw new Error("no teams found");
23
+ return first.key;
24
+ }
25
+
26
+ describe("issues core", () => {
27
+ let client: LinearClient;
28
+ let teamKey: string;
29
+
30
+ beforeAll(async () => {
31
+ client = getTestClient();
32
+ teamKey = await getFirstTeamKey();
33
+ });
34
+
35
+ test("priorityFromString converts priority names", () => {
36
+ expect(priorityFromString("urgent")).toBe(1);
37
+ expect(priorityFromString("high")).toBe(2);
38
+ expect(priorityFromString("medium")).toBe(3);
39
+ expect(priorityFromString("low")).toBe(4);
40
+ expect(priorityFromString("none")).toBe(0);
41
+ expect(priorityFromString("unknown")).toBe(0);
42
+ });
43
+
44
+ test("listIssues returns issue array", async () => {
45
+ const issues = await listIssues(client);
46
+ expect(Array.isArray(issues)).toBe(true);
47
+ if (issues.length > 0) {
48
+ expect(issues[0]).toHaveProperty("id");
49
+ expect(issues[0]).toHaveProperty("identifier");
50
+ expect(issues[0]).toHaveProperty("title");
51
+ }
52
+ });
53
+
54
+ test("listIssues filters by team", async () => {
55
+ const issues = await listIssues(client, { team: teamKey });
56
+ expect(Array.isArray(issues)).toBe(true);
57
+ for (const issue of issues) {
58
+ expect(issue.identifier.startsWith(teamKey + "-")).toBe(true);
59
+ }
60
+ });
61
+
62
+ test("getIssue returns null for nonexistent issue", async () => {
63
+ const issue = await getIssue(client, "NONEXISTENT-99999");
64
+ expect(issue).toBeNull();
65
+ });
66
+ });
package/src/issues.ts ADDED
@@ -0,0 +1,182 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { Issue, ListIssuesFilter, CreateIssueInput, UpdateIssueInput } from "./types";
3
+
4
+ export function priorityFromString(priority: string): number {
5
+ switch (priority.toLowerCase()) {
6
+ case "urgent":
7
+ return 1;
8
+ case "high":
9
+ return 2;
10
+ case "medium":
11
+ return 3;
12
+ case "low":
13
+ return 4;
14
+ case "none":
15
+ return 0;
16
+ default:
17
+ return 0;
18
+ }
19
+ }
20
+
21
+ export async function listIssues(
22
+ client: LinearClient,
23
+ filter: ListIssuesFilter = {}
24
+ ): Promise<Issue[]> {
25
+ const apiFilter: Record<string, unknown> = {};
26
+
27
+ if (filter.team) {
28
+ apiFilter.team = { key: { eq: filter.team } };
29
+ }
30
+
31
+ if (filter.state) {
32
+ apiFilter.state = { name: { eqIgnoreCase: filter.state } };
33
+ }
34
+
35
+ if (filter.assignee) {
36
+ if (filter.assignee === "@me") {
37
+ const viewer = await client.viewer;
38
+ apiFilter.assignee = { id: { eq: viewer.id } };
39
+ } else {
40
+ apiFilter.assignee = { email: { eq: filter.assignee } };
41
+ }
42
+ }
43
+
44
+ if (filter.label) {
45
+ apiFilter.labels = { some: { name: { eqIgnoreCase: filter.label } } };
46
+ }
47
+
48
+ if (filter.project) {
49
+ apiFilter.project = { name: { eqIgnoreCase: filter.project } };
50
+ }
51
+
52
+ const issues = await client.issues({ filter: apiFilter });
53
+ const nodes = issues.nodes;
54
+
55
+ return Promise.all(
56
+ nodes.map(async (n) => ({
57
+ id: n.id,
58
+ identifier: n.identifier,
59
+ title: n.title,
60
+ description: n.description,
61
+ state: (await n.state)?.name ?? null,
62
+ assignee: (await n.assignee)?.name ?? null,
63
+ priority: n.priority,
64
+ createdAt: n.createdAt,
65
+ updatedAt: n.updatedAt,
66
+ url: n.url,
67
+ }))
68
+ );
69
+ }
70
+
71
+ export async function getIssue(
72
+ client: LinearClient,
73
+ identifier: string
74
+ ): Promise<Issue | null> {
75
+ try {
76
+ const issue = await client.issue(identifier);
77
+
78
+ if (!issue) {
79
+ return null;
80
+ }
81
+
82
+ const state = await issue.state;
83
+ const assignee = await issue.assignee;
84
+
85
+ return {
86
+ id: issue.id,
87
+ identifier: issue.identifier,
88
+ title: issue.title,
89
+ description: issue.description,
90
+ state: state?.name ?? null,
91
+ assignee: assignee?.name ?? null,
92
+ priority: issue.priority,
93
+ createdAt: issue.createdAt,
94
+ updatedAt: issue.updatedAt,
95
+ url: issue.url,
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export async function createIssue(
103
+ client: LinearClient,
104
+ input: CreateIssueInput
105
+ ): Promise<Issue | null> {
106
+ const result = await client.createIssue(input);
107
+
108
+ if (!result.success) {
109
+ return null;
110
+ }
111
+
112
+ const issueData = (result as unknown as { _issue?: { id: string } })._issue;
113
+ if (!issueData?.id) {
114
+ return null;
115
+ }
116
+
117
+ for (let attempt = 0; attempt < 3; attempt++) {
118
+ try {
119
+ const createdIssue = await client.issue(issueData.id);
120
+ if (createdIssue) {
121
+ const state = await createdIssue.state;
122
+ const assignee = await createdIssue.assignee;
123
+ return {
124
+ id: createdIssue.id,
125
+ identifier: createdIssue.identifier,
126
+ title: createdIssue.title,
127
+ description: createdIssue.description,
128
+ state: state?.name ?? null,
129
+ assignee: assignee?.name ?? null,
130
+ priority: createdIssue.priority,
131
+ createdAt: createdIssue.createdAt,
132
+ updatedAt: createdIssue.updatedAt,
133
+ url: createdIssue.url,
134
+ };
135
+ }
136
+ } catch {
137
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 300));
138
+ }
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ export async function updateIssue(
145
+ client: LinearClient,
146
+ issueId: string,
147
+ input: UpdateIssueInput
148
+ ): Promise<boolean> {
149
+ const result = await client.updateIssue(issueId, input);
150
+ return result.success;
151
+ }
152
+
153
+ export async function addComment(
154
+ client: LinearClient,
155
+ issueId: string,
156
+ body: string
157
+ ): Promise<boolean> {
158
+ const result = await client.createComment({ issueId, body });
159
+ return result.success;
160
+ }
161
+
162
+ export async function getTeamStates(
163
+ client: LinearClient,
164
+ teamId: string
165
+ ): Promise<Array<{ id: string; name: string }>> {
166
+ const team = await client.team(teamId);
167
+ if (!team) return [];
168
+
169
+ const states = await team.states();
170
+ return states.nodes.map((s) => ({ id: s.id, name: s.name }));
171
+ }
172
+
173
+ export async function getTeamLabels(
174
+ client: LinearClient,
175
+ teamId: string
176
+ ): Promise<Array<{ id: string; name: string }>> {
177
+ const team = await client.team(teamId);
178
+ if (!team) return [];
179
+
180
+ const labels = await team.labels();
181
+ return labels.nodes.map((l) => ({ id: l.id, name: l.name }));
182
+ }
package/src/me.test.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { getViewer, getMyIssues, getMyCreatedIssues } from "./me";
5
+
6
+ function getTestClient(): LinearClient {
7
+ const apiKey = getApiKey();
8
+ if (!apiKey) {
9
+ throw new Error("no api key configured - run: li auth <api-key>");
10
+ }
11
+ return new LinearClient({ apiKey });
12
+ }
13
+
14
+ describe("me core", () => {
15
+ let client: LinearClient;
16
+
17
+ beforeAll(() => {
18
+ client = getTestClient();
19
+ });
20
+
21
+ test("getViewer returns current user", async () => {
22
+ const viewer = await getViewer(client);
23
+ expect(viewer).toHaveProperty("id");
24
+ expect(viewer).toHaveProperty("name");
25
+ expect(viewer).toHaveProperty("email");
26
+ expect(viewer).toHaveProperty("active");
27
+ expect(viewer).toHaveProperty("admin");
28
+ });
29
+
30
+ test("getMyIssues returns issue array", async () => {
31
+ const issues = await getMyIssues(client);
32
+ expect(Array.isArray(issues)).toBe(true);
33
+ if (issues.length > 0) {
34
+ expect(issues[0]).toHaveProperty("id");
35
+ expect(issues[0]).toHaveProperty("identifier");
36
+ expect(issues[0]).toHaveProperty("title");
37
+ }
38
+ });
39
+
40
+ test("getMyCreatedIssues returns issue array", async () => {
41
+ const issues = await getMyCreatedIssues(client);
42
+ expect(Array.isArray(issues)).toBe(true);
43
+ if (issues.length > 0) {
44
+ expect(issues[0]).toHaveProperty("id");
45
+ expect(issues[0]).toHaveProperty("identifier");
46
+ expect(issues[0]).toHaveProperty("title");
47
+ }
48
+ });
49
+ });
package/src/me.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { User, Issue } from "./types";
3
+
4
+ export async function getViewer(client: LinearClient): Promise<User> {
5
+ const viewer = await client.viewer;
6
+
7
+ return {
8
+ id: viewer.id,
9
+ name: viewer.name,
10
+ email: viewer.email,
11
+ displayName: viewer.displayName,
12
+ active: viewer.active,
13
+ admin: viewer.admin,
14
+ };
15
+ }
16
+
17
+ export async function getMyIssues(client: LinearClient): Promise<Issue[]> {
18
+ const viewer = await client.viewer;
19
+ const issuesConnection = await viewer.assignedIssues({
20
+ filter: { state: { type: { nin: ["completed", "canceled"] } } },
21
+ });
22
+
23
+ return Promise.all(
24
+ issuesConnection.nodes.map(async (i) => ({
25
+ id: i.id,
26
+ identifier: i.identifier,
27
+ title: i.title,
28
+ description: i.description,
29
+ state: (await i.state)?.name ?? null,
30
+ assignee: (await i.assignee)?.name ?? null,
31
+ priority: i.priority,
32
+ createdAt: i.createdAt,
33
+ updatedAt: i.updatedAt,
34
+ url: i.url,
35
+ }))
36
+ );
37
+ }
38
+
39
+ export async function getMyCreatedIssues(
40
+ client: LinearClient
41
+ ): Promise<Issue[]> {
42
+ const viewer = await client.viewer;
43
+ const issuesConnection = await viewer.createdIssues({
44
+ filter: { state: { type: { nin: ["completed", "canceled"] } } },
45
+ });
46
+
47
+ return Promise.all(
48
+ issuesConnection.nodes.map(async (i) => ({
49
+ id: i.id,
50
+ identifier: i.identifier,
51
+ title: i.title,
52
+ description: i.description,
53
+ state: (await i.state)?.name ?? null,
54
+ assignee: (await i.assignee)?.name ?? null,
55
+ priority: i.priority,
56
+ createdAt: i.createdAt,
57
+ updatedAt: i.updatedAt,
58
+ url: i.url,
59
+ }))
60
+ );
61
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { listProjects, getProject, createProject, deleteProject } from "./projects";
5
+
6
+ const TEST_PREFIX = "[TEST-CLI]";
7
+
8
+ function getTestClient(): LinearClient {
9
+ const apiKey = getApiKey();
10
+ if (!apiKey) {
11
+ throw new Error("no api key configured - run: li auth <api-key>");
12
+ }
13
+ return new LinearClient({ apiKey });
14
+ }
15
+
16
+ describe("projects core", () => {
17
+ let client: LinearClient;
18
+
19
+ beforeAll(() => {
20
+ client = getTestClient();
21
+ });
22
+
23
+ test("listProjects returns project array", async () => {
24
+ const projects = await listProjects(client);
25
+ expect(Array.isArray(projects)).toBe(true);
26
+ if (projects.length > 0) {
27
+ expect(projects[0]).toHaveProperty("id");
28
+ expect(projects[0]).toHaveProperty("name");
29
+ expect(projects[0]).toHaveProperty("state");
30
+ }
31
+ });
32
+
33
+ test("getProject returns null for nonexistent project", async () => {
34
+ const project = await getProject(client, "NONEXISTENT_PROJECT_XYZ_123");
35
+ expect(project).toBeNull();
36
+ });
37
+
38
+ test("deleteProject returns false for nonexistent project", async () => {
39
+ const result = await deleteProject(client, "NONEXISTENT_PROJECT_XYZ_123");
40
+ expect(result).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,147 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { Issue, Project, CreateProjectInput } from "./types";
3
+
4
+ export async function listProjects(
5
+ client: LinearClient,
6
+ options: { team?: string; status?: string } = {}
7
+ ): Promise<Project[]> {
8
+ let projectsConnection = await client.projects();
9
+
10
+ if (options.team) {
11
+ const teams = await client.teams();
12
+ const team = teams.nodes.find(
13
+ (t) =>
14
+ t.key.toLowerCase() === options.team!.toLowerCase() ||
15
+ t.name.toLowerCase() === options.team!.toLowerCase()
16
+ );
17
+ if (!team) {
18
+ return [];
19
+ }
20
+ projectsConnection = await team.projects();
21
+ }
22
+
23
+ let nodes = projectsConnection.nodes;
24
+
25
+ if (options.status) {
26
+ const statusLower = options.status.toLowerCase();
27
+ nodes = nodes.filter((p) => {
28
+ const state = p.state?.toLowerCase() ?? "";
29
+ return state === statusLower || state.includes(statusLower);
30
+ });
31
+ }
32
+
33
+ return nodes.map((p) => ({
34
+ id: p.id,
35
+ name: p.name,
36
+ description: p.description,
37
+ state: p.state,
38
+ progress: p.progress,
39
+ targetDate: p.targetDate,
40
+ startDate: p.startDate,
41
+ createdAt: p.createdAt,
42
+ }));
43
+ }
44
+
45
+ export async function getProject(
46
+ client: LinearClient,
47
+ nameOrId: string
48
+ ): Promise<Project | null> {
49
+ const projects = await client.projects();
50
+ const project = projects.nodes.find(
51
+ (p) => p.name.toLowerCase() === nameOrId.toLowerCase() || p.id === nameOrId
52
+ );
53
+
54
+ if (!project) {
55
+ return null;
56
+ }
57
+
58
+ return {
59
+ id: project.id,
60
+ name: project.name,
61
+ description: project.description,
62
+ state: project.state,
63
+ progress: project.progress,
64
+ targetDate: project.targetDate,
65
+ startDate: project.startDate,
66
+ createdAt: project.createdAt,
67
+ };
68
+ }
69
+
70
+ export async function getProjectIssues(
71
+ client: LinearClient,
72
+ nameOrId: string
73
+ ): Promise<Issue[]> {
74
+ const projects = await client.projects();
75
+ const project = projects.nodes.find(
76
+ (p) => p.name.toLowerCase() === nameOrId.toLowerCase() || p.id === nameOrId
77
+ );
78
+
79
+ if (!project) {
80
+ return [];
81
+ }
82
+
83
+ const issues = await project.issues();
84
+
85
+ return Promise.all(
86
+ issues.nodes.map(async (i) => ({
87
+ id: i.id,
88
+ identifier: i.identifier,
89
+ title: i.title,
90
+ description: i.description,
91
+ state: (await i.state)?.name ?? null,
92
+ assignee: (await i.assignee)?.name ?? null,
93
+ priority: i.priority,
94
+ createdAt: i.createdAt,
95
+ updatedAt: i.updatedAt,
96
+ url: i.url,
97
+ }))
98
+ );
99
+ }
100
+
101
+ export async function createProject(
102
+ client: LinearClient,
103
+ input: CreateProjectInput
104
+ ): Promise<Project | null> {
105
+ const result = await client.createProject({
106
+ name: input.name,
107
+ description: input.description,
108
+ teamIds: input.teamIds ?? [],
109
+ });
110
+
111
+ if (!result.success) {
112
+ return null;
113
+ }
114
+
115
+ const projectData = await result.project;
116
+ if (!projectData) {
117
+ return null;
118
+ }
119
+
120
+ return {
121
+ id: projectData.id,
122
+ name: projectData.name,
123
+ description: projectData.description,
124
+ state: projectData.state,
125
+ progress: projectData.progress,
126
+ targetDate: projectData.targetDate,
127
+ startDate: projectData.startDate,
128
+ createdAt: projectData.createdAt,
129
+ };
130
+ }
131
+
132
+ export async function deleteProject(
133
+ client: LinearClient,
134
+ nameOrId: string
135
+ ): Promise<boolean> {
136
+ const projects = await client.projects();
137
+ const project = projects.nodes.find(
138
+ (p) => p.name.toLowerCase() === nameOrId.toLowerCase() || p.id === nameOrId
139
+ );
140
+
141
+ if (!project) {
142
+ return false;
143
+ }
144
+
145
+ const result = await client.deleteProject(project.id);
146
+ return result.success;
147
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { searchIssues } from "./search";
5
+
6
+ function getTestClient(): LinearClient {
7
+ const apiKey = getApiKey();
8
+ if (!apiKey) {
9
+ throw new Error("no api key configured - run: li auth <api-key>");
10
+ }
11
+ return new LinearClient({ apiKey });
12
+ }
13
+
14
+ describe("search core", () => {
15
+ let client: LinearClient;
16
+
17
+ beforeAll(() => {
18
+ client = getTestClient();
19
+ });
20
+
21
+ test("searchIssues returns issue array", async () => {
22
+ const issues = await searchIssues(client, "issue");
23
+ expect(Array.isArray(issues)).toBe(true);
24
+ if (issues.length > 0) {
25
+ expect(issues[0]).toHaveProperty("id");
26
+ expect(issues[0]).toHaveProperty("identifier");
27
+ expect(issues[0]).toHaveProperty("title");
28
+ }
29
+ });
30
+
31
+ test("searchIssues filters by team", async () => {
32
+ const issues = await searchIssues(client, "issue", { team: "BDSQ" });
33
+ expect(Array.isArray(issues)).toBe(true);
34
+ for (const issue of issues) {
35
+ expect(issue.identifier.startsWith("BDSQ-")).toBe(true);
36
+ }
37
+ });
38
+ });
package/src/search.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { Issue } from "./types";
3
+
4
+ export async function searchIssues(
5
+ client: LinearClient,
6
+ query: string,
7
+ options: { team?: string } = {}
8
+ ): Promise<Issue[]> {
9
+ const searchResults = await client.searchIssues(query);
10
+ let issues = searchResults.nodes;
11
+
12
+ if (options.team) {
13
+ const teamKey = options.team.toUpperCase();
14
+ issues = issues.filter((issue) =>
15
+ issue.identifier.startsWith(teamKey + "-")
16
+ );
17
+ }
18
+
19
+ return Promise.all(
20
+ issues.map(async (i) => ({
21
+ id: i.id,
22
+ identifier: i.identifier,
23
+ title: i.title,
24
+ description: i.description,
25
+ state: (await i.state)?.name ?? null,
26
+ assignee: (await i.assignee)?.name ?? null,
27
+ priority: i.priority,
28
+ createdAt: i.createdAt,
29
+ updatedAt: i.updatedAt,
30
+ url: i.url,
31
+ }))
32
+ );
33
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect, beforeAll } from "bun:test";
2
+ import { LinearClient } from "@linear/sdk";
3
+ import { getApiKey } from "./config";
4
+ import { listTeams, getTeam, getTeamMembers, getAvailableTeamKeys } from "./teams";
5
+
6
+ function getTestClient(): LinearClient {
7
+ const apiKey = getApiKey();
8
+ if (!apiKey) {
9
+ throw new Error("no api key configured - run: li auth <api-key>");
10
+ }
11
+ return new LinearClient({ apiKey });
12
+ }
13
+
14
+ describe("teams core", () => {
15
+ let client: LinearClient;
16
+ let teamKey: string;
17
+
18
+ beforeAll(async () => {
19
+ client = getTestClient();
20
+ const keys = await getAvailableTeamKeys(client);
21
+ teamKey = keys.includes("BDSQ") ? "BDSQ" : keys[0] ?? "UNKNOWN";
22
+ });
23
+
24
+ test("listTeams returns team array", async () => {
25
+ const teams = await listTeams(client);
26
+ expect(Array.isArray(teams)).toBe(true);
27
+ expect(teams.length).toBeGreaterThan(0);
28
+ expect(teams[0]).toHaveProperty("id");
29
+ expect(teams[0]).toHaveProperty("key");
30
+ expect(teams[0]).toHaveProperty("name");
31
+ });
32
+
33
+ test("getTeam returns team by key", async () => {
34
+ const team = await getTeam(client, teamKey);
35
+ expect(team).not.toBeNull();
36
+ expect(team?.key).toBe(teamKey);
37
+ });
38
+
39
+ test("getTeam returns null for invalid key", async () => {
40
+ const team = await getTeam(client, "INVALID_KEY_XYZ");
41
+ expect(team).toBeNull();
42
+ });
43
+
44
+ test("getTeamMembers returns members array", async () => {
45
+ const members = await getTeamMembers(client, teamKey);
46
+ expect(Array.isArray(members)).toBe(true);
47
+ if (members.length > 0) {
48
+ expect(members[0]).toHaveProperty("id");
49
+ expect(members[0]).toHaveProperty("name");
50
+ expect(members[0]).toHaveProperty("email");
51
+ }
52
+ });
53
+
54
+ test("getAvailableTeamKeys returns string array", async () => {
55
+ const keys = await getAvailableTeamKeys(client);
56
+ expect(Array.isArray(keys)).toBe(true);
57
+ expect(keys.length).toBeGreaterThan(0);
58
+ expect(typeof keys[0]).toBe("string");
59
+ });
60
+ });
package/src/teams.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import type { Team, TeamMember } from "./types";
3
+
4
+ export async function listTeams(client: LinearClient): Promise<Team[]> {
5
+ const teamsConnection = await client.teams();
6
+ return teamsConnection.nodes.map((t) => ({
7
+ id: t.id,
8
+ key: t.key,
9
+ name: t.name,
10
+ description: t.description,
11
+ private: t.private,
12
+ timezone: t.timezone,
13
+ }));
14
+ }
15
+
16
+ export async function getTeam(
17
+ client: LinearClient,
18
+ key: string
19
+ ): Promise<Team | null> {
20
+ const teamsConnection = await client.teams({
21
+ filter: { key: { eq: key.toUpperCase() } },
22
+ });
23
+ const team = teamsConnection.nodes[0];
24
+
25
+ if (!team) {
26
+ return null;
27
+ }
28
+
29
+ return {
30
+ id: team.id,
31
+ key: team.key,
32
+ name: team.name,
33
+ description: team.description,
34
+ private: team.private,
35
+ timezone: team.timezone,
36
+ };
37
+ }
38
+
39
+ export async function getTeamMembers(
40
+ client: LinearClient,
41
+ key: string
42
+ ): Promise<TeamMember[]> {
43
+ const teamsConnection = await client.teams({
44
+ filter: { key: { eq: key.toUpperCase() } },
45
+ });
46
+ const team = teamsConnection.nodes[0];
47
+
48
+ if (!team) {
49
+ return [];
50
+ }
51
+
52
+ const membersConnection = await team.members();
53
+ return membersConnection.nodes.map((m) => ({
54
+ id: m.id,
55
+ name: m.name,
56
+ email: m.email,
57
+ displayName: m.displayName,
58
+ active: m.active,
59
+ }));
60
+ }
61
+
62
+ export async function findTeamByKeyOrName(
63
+ client: LinearClient,
64
+ keyOrName: string
65
+ ): Promise<Team | null> {
66
+ const teams = await client.teams();
67
+ const team = teams.nodes.find(
68
+ (t) =>
69
+ t.key.toLowerCase() === keyOrName.toLowerCase() ||
70
+ t.name.toLowerCase() === keyOrName.toLowerCase()
71
+ );
72
+
73
+ if (!team) {
74
+ return null;
75
+ }
76
+
77
+ return {
78
+ id: team.id,
79
+ key: team.key,
80
+ name: team.name,
81
+ description: team.description,
82
+ private: team.private,
83
+ timezone: team.timezone,
84
+ };
85
+ }
86
+
87
+ export async function getAvailableTeamKeys(
88
+ client: LinearClient
89
+ ): Promise<string[]> {
90
+ const teams = await client.teams();
91
+ return teams.nodes.map((t) => t.key);
92
+ }
package/src/types.ts ADDED
@@ -0,0 +1,87 @@
1
+ export interface Issue {
2
+ id: string;
3
+ identifier: string;
4
+ title: string;
5
+ description?: string | null;
6
+ state?: string | null;
7
+ assignee?: string | null;
8
+ priority?: number;
9
+ createdAt: Date;
10
+ updatedAt: Date;
11
+ url: string;
12
+ }
13
+
14
+ export interface Project {
15
+ id: string;
16
+ name: string;
17
+ description?: string | null;
18
+ state?: string | null;
19
+ progress?: number | null;
20
+ targetDate?: Date | null;
21
+ startDate?: Date | null;
22
+ createdAt: Date;
23
+ }
24
+
25
+ export interface Team {
26
+ id: string;
27
+ key: string;
28
+ name: string;
29
+ description?: string | null;
30
+ private: boolean;
31
+ timezone?: string | null;
32
+ }
33
+
34
+ export interface TeamMember {
35
+ id: string;
36
+ name: string;
37
+ email?: string | null;
38
+ displayName?: string | null;
39
+ active: boolean;
40
+ }
41
+
42
+ export interface Cycle {
43
+ id: string;
44
+ number: number;
45
+ name?: string | null;
46
+ startsAt: Date;
47
+ endsAt: Date;
48
+ }
49
+
50
+ export interface User {
51
+ id: string;
52
+ name: string;
53
+ email?: string | null;
54
+ displayName?: string | null;
55
+ active: boolean;
56
+ admin: boolean;
57
+ }
58
+
59
+ export interface ListIssuesFilter {
60
+ team?: string;
61
+ state?: string;
62
+ assignee?: string;
63
+ label?: string;
64
+ project?: string;
65
+ }
66
+
67
+ export interface CreateIssueInput {
68
+ teamId: string;
69
+ title: string;
70
+ description?: string;
71
+ assigneeId?: string;
72
+ priority?: number;
73
+ labelIds?: string[];
74
+ }
75
+
76
+ export interface UpdateIssueInput {
77
+ stateId?: string;
78
+ assigneeId?: string;
79
+ priority?: number;
80
+ labelIds?: string[];
81
+ }
82
+
83
+ export interface CreateProjectInput {
84
+ name: string;
85
+ description?: string;
86
+ teamIds?: string[];
87
+ }