@bdsqqq/lnr-core 1.1.1 → 1.3.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 +1 -1
- package/src/client.ts +10 -6
- package/src/comments.test.ts +119 -0
- package/src/comments.ts +193 -0
- package/src/config.test.ts +73 -1
- package/src/config.ts +9 -1
- package/src/documents.ts +110 -0
- package/src/index.ts +51 -1
- package/src/issues.ts +36 -0
- package/src/labels.ts +97 -0
- package/src/me.ts +36 -2
- package/src/reactions.ts +18 -0
- package/src/relations.ts +17 -0
- package/src/types.ts +12 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -10,16 +10,20 @@ export class NotAuthenticatedError extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function getClient(): LinearClient {
|
|
14
|
-
|
|
15
|
-
return clientInstance;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const apiKey = getApiKey();
|
|
13
|
+
export function getClient(apiKeyOverride?: string): LinearClient {
|
|
14
|
+
const apiKey = apiKeyOverride ?? getApiKey();
|
|
19
15
|
if (!apiKey) {
|
|
20
16
|
throw new NotAuthenticatedError();
|
|
21
17
|
}
|
|
22
18
|
|
|
19
|
+
if (apiKeyOverride) {
|
|
20
|
+
return new LinearClient({ apiKey });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (clientInstance) {
|
|
24
|
+
return clientInstance;
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
clientInstance = new LinearClient({ apiKey });
|
|
24
28
|
return clientInstance;
|
|
25
29
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { aggregateReactions, extractSyncMeta } from "./comments";
|
|
3
|
+
|
|
4
|
+
describe("aggregateReactions", () => {
|
|
5
|
+
test("empty array → empty", () => {
|
|
6
|
+
expect(aggregateReactions([])).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("single reaction → count 1", () => {
|
|
10
|
+
expect(aggregateReactions([{ emoji: "👍" }])).toEqual([
|
|
11
|
+
{ emoji: "👍", count: 1 },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("duplicates aggregate", () => {
|
|
16
|
+
const result = aggregateReactions([
|
|
17
|
+
{ emoji: "👍" },
|
|
18
|
+
{ emoji: "👍" },
|
|
19
|
+
{ emoji: "👍" },
|
|
20
|
+
]);
|
|
21
|
+
expect(result).toEqual([{ emoji: "👍", count: 3 }]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("different emojis get separate entries", () => {
|
|
25
|
+
const result = aggregateReactions([
|
|
26
|
+
{ emoji: "👍" },
|
|
27
|
+
{ emoji: "👎" },
|
|
28
|
+
{ emoji: "👍" },
|
|
29
|
+
]);
|
|
30
|
+
expect(result).toContainEqual({ emoji: "👍", count: 2 });
|
|
31
|
+
expect(result).toContainEqual({ emoji: "👎", count: 1 });
|
|
32
|
+
expect(result).toHaveLength(2);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("extractSyncMeta", () => {
|
|
37
|
+
test("slack with valid metadata", () => {
|
|
38
|
+
const result = extractSyncMeta("slack", {
|
|
39
|
+
channelName: "frontend",
|
|
40
|
+
messageUrl: "https://slack.com/msg/123",
|
|
41
|
+
});
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
type: "slack",
|
|
44
|
+
channelName: "frontend",
|
|
45
|
+
messageUrl: "https://slack.com/msg/123",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("slack with missing metadata", () => {
|
|
50
|
+
expect(extractSyncMeta("slack", null)).toEqual({
|
|
51
|
+
type: "slack",
|
|
52
|
+
channelName: undefined,
|
|
53
|
+
messageUrl: undefined,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("slack with partial metadata", () => {
|
|
58
|
+
expect(extractSyncMeta("slack", { channelName: "general" })).toEqual({
|
|
59
|
+
type: "slack",
|
|
60
|
+
channelName: "general",
|
|
61
|
+
messageUrl: undefined,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("github extracts owner/repo/number", () => {
|
|
66
|
+
const result = extractSyncMeta("github", {
|
|
67
|
+
owner: "bdsqqq",
|
|
68
|
+
repo: "lnr",
|
|
69
|
+
number: 42,
|
|
70
|
+
});
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
type: "github",
|
|
73
|
+
owner: "bdsqqq",
|
|
74
|
+
repo: "lnr",
|
|
75
|
+
number: 42,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("github partial (only repo)", () => {
|
|
80
|
+
expect(extractSyncMeta("github", { repo: "lnr" })).toEqual({
|
|
81
|
+
type: "github",
|
|
82
|
+
owner: undefined,
|
|
83
|
+
repo: "lnr",
|
|
84
|
+
number: undefined,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("jira extracts issueKey/projectId", () => {
|
|
89
|
+
const result = extractSyncMeta("jira", {
|
|
90
|
+
issueKey: "PROJ-123",
|
|
91
|
+
projectId: "proj-id",
|
|
92
|
+
});
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
type: "jira",
|
|
95
|
+
issueKey: "PROJ-123",
|
|
96
|
+
projectId: "proj-id",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("unknown service → { type: 'unknown' }", () => {
|
|
101
|
+
expect(extractSyncMeta("notion", { some: "data" })).toEqual({
|
|
102
|
+
type: "unknown",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("case insensitive", () => {
|
|
107
|
+
expect(extractSyncMeta("SLACK", { channelName: "test" })).toEqual({
|
|
108
|
+
type: "slack",
|
|
109
|
+
channelName: "test",
|
|
110
|
+
messageUrl: undefined,
|
|
111
|
+
});
|
|
112
|
+
expect(extractSyncMeta("GitHub", { owner: "x" })).toEqual({
|
|
113
|
+
type: "github",
|
|
114
|
+
owner: "x",
|
|
115
|
+
repo: undefined,
|
|
116
|
+
number: undefined,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/comments.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { LinearClient, ExternalEntityInfo } from "@linear/sdk";
|
|
2
|
+
|
|
3
|
+
/** aggregated reaction count for display (e.g., 👍 x 3) */
|
|
4
|
+
export interface CommentReaction {
|
|
5
|
+
emoji: string;
|
|
6
|
+
count: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** slack-specific sync metadata */
|
|
10
|
+
export interface SlackSyncMeta {
|
|
11
|
+
type: "slack";
|
|
12
|
+
channelName?: string;
|
|
13
|
+
messageUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** github-specific sync metadata */
|
|
17
|
+
export interface GithubSyncMeta {
|
|
18
|
+
type: "github";
|
|
19
|
+
owner?: string;
|
|
20
|
+
repo?: string;
|
|
21
|
+
number?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** jira-specific sync metadata */
|
|
25
|
+
export interface JiraSyncMeta {
|
|
26
|
+
type: "jira";
|
|
27
|
+
issueKey?: string;
|
|
28
|
+
projectId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** generic sync metadata for unknown services */
|
|
32
|
+
export interface GenericSyncMeta {
|
|
33
|
+
type: "unknown";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SyncMeta = SlackSyncMeta | GithubSyncMeta | JiraSyncMeta | GenericSyncMeta;
|
|
37
|
+
|
|
38
|
+
/** external service sync info — extracted from linear's ExternalEntityInfo */
|
|
39
|
+
export interface CommentSyncInfo {
|
|
40
|
+
service: string;
|
|
41
|
+
meta: SyncMeta;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CommentsResult {
|
|
45
|
+
comments: Comment[];
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Comment {
|
|
50
|
+
id: string;
|
|
51
|
+
body: string;
|
|
52
|
+
createdAt: Date;
|
|
53
|
+
updatedAt: Date;
|
|
54
|
+
/** linear user who authored the comment, null if external */
|
|
55
|
+
user: string | null;
|
|
56
|
+
/** parent comment id for threading, null if root */
|
|
57
|
+
parentId: string | null;
|
|
58
|
+
url: string;
|
|
59
|
+
reactions: CommentReaction[];
|
|
60
|
+
/** external services this comment syncs with (slack channels, etc.) */
|
|
61
|
+
syncedWith: CommentSyncInfo[];
|
|
62
|
+
/** bot that created this comment (linear integrations) */
|
|
63
|
+
botActor?: string | null;
|
|
64
|
+
/** external user (e.g., slack user) who authored the comment */
|
|
65
|
+
externalUser?: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function aggregateReactions(reactions: readonly { emoji: string }[]): CommentReaction[] {
|
|
69
|
+
const counts = new Map<string, number>();
|
|
70
|
+
for (const r of reactions) {
|
|
71
|
+
counts.set(r.emoji, (counts.get(r.emoji) ?? 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
return Array.from(counts.entries()).map(([emoji, count]) => ({ emoji, count }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function extractSyncMeta(service: string, metadata: unknown): SyncMeta {
|
|
77
|
+
const svc = service.toLowerCase();
|
|
78
|
+
const isObject = typeof metadata === "object" && metadata !== null;
|
|
79
|
+
|
|
80
|
+
if (svc === "slack") {
|
|
81
|
+
const channelName =
|
|
82
|
+
isObject && "channelName" in metadata && typeof metadata.channelName === "string"
|
|
83
|
+
? metadata.channelName
|
|
84
|
+
: undefined;
|
|
85
|
+
const messageUrl =
|
|
86
|
+
isObject && "messageUrl" in metadata && typeof metadata.messageUrl === "string"
|
|
87
|
+
? metadata.messageUrl
|
|
88
|
+
: undefined;
|
|
89
|
+
return { type: "slack", channelName, messageUrl } satisfies SyncMeta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (svc === "github") {
|
|
93
|
+
const owner =
|
|
94
|
+
isObject && "owner" in metadata && typeof metadata.owner === "string"
|
|
95
|
+
? metadata.owner
|
|
96
|
+
: undefined;
|
|
97
|
+
const repo =
|
|
98
|
+
isObject && "repo" in metadata && typeof metadata.repo === "string"
|
|
99
|
+
? metadata.repo
|
|
100
|
+
: undefined;
|
|
101
|
+
const number =
|
|
102
|
+
isObject && "number" in metadata && typeof metadata.number === "number"
|
|
103
|
+
? metadata.number
|
|
104
|
+
: undefined;
|
|
105
|
+
return { type: "github", owner, repo, number } satisfies SyncMeta;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (svc === "jira") {
|
|
109
|
+
const issueKey =
|
|
110
|
+
isObject && "issueKey" in metadata && typeof metadata.issueKey === "string"
|
|
111
|
+
? metadata.issueKey
|
|
112
|
+
: undefined;
|
|
113
|
+
const projectId =
|
|
114
|
+
isObject && "projectId" in metadata && typeof metadata.projectId === "string"
|
|
115
|
+
? metadata.projectId
|
|
116
|
+
: undefined;
|
|
117
|
+
return { type: "jira", issueKey, projectId } satisfies SyncMeta;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { type: "unknown" } satisfies SyncMeta;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractSyncInfo(syncedWith?: ExternalEntityInfo[]): CommentSyncInfo[] {
|
|
124
|
+
if (!syncedWith) return [];
|
|
125
|
+
return syncedWith.map((s) => ({
|
|
126
|
+
service: s.service,
|
|
127
|
+
meta: extractSyncMeta(s.service, s.metadata),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function getIssueComments(
|
|
132
|
+
client: LinearClient,
|
|
133
|
+
issueId: string
|
|
134
|
+
): Promise<CommentsResult> {
|
|
135
|
+
try {
|
|
136
|
+
const issue = await client.issue(issueId);
|
|
137
|
+
if (!issue) return { comments: [] };
|
|
138
|
+
|
|
139
|
+
const commentsData = await issue.comments();
|
|
140
|
+
const comments = await Promise.all(
|
|
141
|
+
commentsData.nodes.map(async (c) => {
|
|
142
|
+
const user = await c.user;
|
|
143
|
+
const externalUser = c.externalUserId ? await c.externalUser : null;
|
|
144
|
+
const botActor = c.botActor;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
id: c.id,
|
|
148
|
+
body: c.body,
|
|
149
|
+
createdAt: c.createdAt,
|
|
150
|
+
updatedAt: c.updatedAt,
|
|
151
|
+
user: user?.name ?? null,
|
|
152
|
+
parentId: c.parentId ?? null,
|
|
153
|
+
url: c.url,
|
|
154
|
+
reactions: aggregateReactions(c.reactions ?? []),
|
|
155
|
+
syncedWith: extractSyncInfo(c.syncedWith),
|
|
156
|
+
botActor: botActor?.name ?? null,
|
|
157
|
+
externalUser: externalUser?.name ?? null,
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
return { comments };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
164
|
+
return { comments: [], error: message };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function updateComment(
|
|
169
|
+
client: LinearClient,
|
|
170
|
+
commentId: string,
|
|
171
|
+
body: string
|
|
172
|
+
): Promise<boolean> {
|
|
173
|
+
const result = await client.updateComment(commentId, { body });
|
|
174
|
+
return result.success;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function replyToComment(
|
|
178
|
+
client: LinearClient,
|
|
179
|
+
issueId: string,
|
|
180
|
+
parentId: string,
|
|
181
|
+
body: string
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
const result = await client.createComment({ issueId, body, parentId });
|
|
184
|
+
return result.success;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function deleteComment(
|
|
188
|
+
client: LinearClient,
|
|
189
|
+
commentId: string
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
const result = await client.deleteComment(commentId);
|
|
192
|
+
return result.success;
|
|
193
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { describe, test, expect, afterEach, beforeAll } from "bun:test";
|
|
1
|
+
import { describe, test, expect, afterEach, beforeAll, afterAll } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
loadConfig,
|
|
4
4
|
saveConfig,
|
|
5
5
|
getConfigValue,
|
|
6
6
|
setConfigValue,
|
|
7
|
+
getApiKey,
|
|
8
|
+
listConfig,
|
|
9
|
+
getConfigPath,
|
|
7
10
|
type Config,
|
|
8
11
|
} from "./config";
|
|
9
12
|
|
|
@@ -41,4 +44,73 @@ describe("config core", () => {
|
|
|
41
44
|
expect(loaded.default_team).toBe(testConfig.default_team);
|
|
42
45
|
expect(loaded.output_format).toBe(testConfig.output_format);
|
|
43
46
|
});
|
|
47
|
+
|
|
48
|
+
test("listConfig returns full config", () => {
|
|
49
|
+
const testConfig: Config = {
|
|
50
|
+
api_key: "list_test_key",
|
|
51
|
+
default_team: "LIST_TEST",
|
|
52
|
+
};
|
|
53
|
+
saveConfig(testConfig);
|
|
54
|
+
const listed = listConfig();
|
|
55
|
+
expect(listed.api_key).toBe(testConfig.api_key);
|
|
56
|
+
expect(listed.default_team).toBe(testConfig.default_team);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("getConfigPath returns path string", () => {
|
|
60
|
+
const path = getConfigPath();
|
|
61
|
+
expect(typeof path).toBe("string");
|
|
62
|
+
expect(path).toContain(".lnr");
|
|
63
|
+
expect(path).toContain("config.json");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("getApiKey precedence", () => {
|
|
68
|
+
let originalConfig: Config;
|
|
69
|
+
let originalEnv: string | undefined;
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
originalConfig = loadConfig();
|
|
73
|
+
originalEnv = process.env.LINEAR_API_KEY;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
saveConfig(originalConfig);
|
|
78
|
+
if (originalEnv !== undefined) {
|
|
79
|
+
process.env.LINEAR_API_KEY = originalEnv;
|
|
80
|
+
} else {
|
|
81
|
+
delete process.env.LINEAR_API_KEY;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
if (originalEnv !== undefined) {
|
|
87
|
+
process.env.LINEAR_API_KEY = originalEnv;
|
|
88
|
+
} else {
|
|
89
|
+
delete process.env.LINEAR_API_KEY;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("getApiKey returns config value when env not set", () => {
|
|
94
|
+
delete process.env.LINEAR_API_KEY;
|
|
95
|
+
saveConfig({ api_key: "config_key_123" });
|
|
96
|
+
expect(getApiKey()).toBe("config_key_123");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("getApiKey returns env var when set", () => {
|
|
100
|
+
saveConfig({ api_key: "config_key_123" });
|
|
101
|
+
process.env.LINEAR_API_KEY = "env_key_456";
|
|
102
|
+
expect(getApiKey()).toBe("env_key_456");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("getApiKey prefers env over config", () => {
|
|
106
|
+
saveConfig({ api_key: "should_not_return" });
|
|
107
|
+
process.env.LINEAR_API_KEY = "should_return";
|
|
108
|
+
expect(getApiKey()).toBe("should_return");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("getApiKey returns undefined when neither set", () => {
|
|
112
|
+
delete process.env.LINEAR_API_KEY;
|
|
113
|
+
saveConfig({});
|
|
114
|
+
expect(getApiKey()).toBeUndefined();
|
|
115
|
+
});
|
|
44
116
|
});
|
package/src/config.ts
CHANGED
|
@@ -36,7 +36,15 @@ export function saveConfig(config: Config): void {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function getApiKey(): string | undefined {
|
|
39
|
-
return loadConfig().api_key;
|
|
39
|
+
return process.env.LINEAR_API_KEY ?? loadConfig().api_key;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listConfig(): Config {
|
|
43
|
+
return loadConfig();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getConfigPath(): string {
|
|
47
|
+
return CONFIG_PATH;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export function setApiKey(key: string): void {
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { LinearClient } from "@linear/sdk";
|
|
2
|
+
|
|
3
|
+
export interface Document {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
content: string | null;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
updatedAt: Date;
|
|
9
|
+
url: string;
|
|
10
|
+
project: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function listDocuments(
|
|
14
|
+
client: LinearClient,
|
|
15
|
+
projectId?: string
|
|
16
|
+
): Promise<Document[]> {
|
|
17
|
+
try {
|
|
18
|
+
const filter = projectId
|
|
19
|
+
? { project: { id: { eq: projectId } } }
|
|
20
|
+
: undefined;
|
|
21
|
+
|
|
22
|
+
const documentsConnection = await client.documents({ filter });
|
|
23
|
+
const nodes = documentsConnection.nodes;
|
|
24
|
+
|
|
25
|
+
return Promise.all(
|
|
26
|
+
nodes.map(async (d) => ({
|
|
27
|
+
id: d.id,
|
|
28
|
+
title: d.title,
|
|
29
|
+
content: d.content ?? null,
|
|
30
|
+
createdAt: d.createdAt,
|
|
31
|
+
updatedAt: d.updatedAt,
|
|
32
|
+
url: d.url,
|
|
33
|
+
project: (await d.project)?.name ?? null,
|
|
34
|
+
}))
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getDocument(
|
|
42
|
+
client: LinearClient,
|
|
43
|
+
id: string
|
|
44
|
+
): Promise<Document | null> {
|
|
45
|
+
try {
|
|
46
|
+
const doc = await client.document(id);
|
|
47
|
+
if (!doc) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: doc.id,
|
|
53
|
+
title: doc.title,
|
|
54
|
+
content: doc.content ?? null,
|
|
55
|
+
createdAt: doc.createdAt,
|
|
56
|
+
updatedAt: doc.updatedAt,
|
|
57
|
+
url: doc.url,
|
|
58
|
+
project: (await doc.project)?.name ?? null,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function createDocument(
|
|
66
|
+
client: LinearClient,
|
|
67
|
+
input: { title: string; content?: string; projectId?: string }
|
|
68
|
+
): Promise<Document | null> {
|
|
69
|
+
const result = await client.createDocument({
|
|
70
|
+
title: input.title,
|
|
71
|
+
content: input.content,
|
|
72
|
+
projectId: input.projectId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const doc = await result.document;
|
|
80
|
+
if (!doc) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id: doc.id,
|
|
86
|
+
title: doc.title,
|
|
87
|
+
content: doc.content ?? null,
|
|
88
|
+
createdAt: doc.createdAt,
|
|
89
|
+
updatedAt: doc.updatedAt,
|
|
90
|
+
url: doc.url,
|
|
91
|
+
project: (await doc.project)?.name ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function updateDocument(
|
|
96
|
+
client: LinearClient,
|
|
97
|
+
id: string,
|
|
98
|
+
input: { title?: string; content?: string }
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const result = await client.updateDocument(id, input);
|
|
101
|
+
return result.success;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function deleteDocument(
|
|
105
|
+
client: LinearClient,
|
|
106
|
+
id: string
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
const result = await client.deleteDocument(id);
|
|
109
|
+
return result.success;
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export {
|
|
|
30
30
|
getConfigValue,
|
|
31
31
|
setConfigValue,
|
|
32
32
|
ensureConfigDir,
|
|
33
|
+
listConfig,
|
|
34
|
+
getConfigPath,
|
|
33
35
|
type Config,
|
|
34
36
|
} from "./config";
|
|
35
37
|
|
|
@@ -43,6 +45,8 @@ export {
|
|
|
43
45
|
priorityFromString,
|
|
44
46
|
getTeamStates,
|
|
45
47
|
getTeamLabels,
|
|
48
|
+
archiveIssue,
|
|
49
|
+
getSubIssues,
|
|
46
50
|
} from "./issues";
|
|
47
51
|
|
|
48
52
|
// projects
|
|
@@ -67,7 +71,53 @@ export {
|
|
|
67
71
|
export { listCycles, getCurrentCycle, getCycleIssues } from "./cycles";
|
|
68
72
|
|
|
69
73
|
// me
|
|
70
|
-
export { getViewer, getMyIssues, getMyCreatedIssues } from "./me";
|
|
74
|
+
export { getViewer, getMyIssues, getMyCreatedIssues, getMyActivity } from "./me";
|
|
75
|
+
export type { Activity } from "./types";
|
|
71
76
|
|
|
72
77
|
// search
|
|
73
78
|
export { searchIssues } from "./search";
|
|
79
|
+
|
|
80
|
+
// relations
|
|
81
|
+
export { createIssueRelation } from "./relations";
|
|
82
|
+
|
|
83
|
+
// documents
|
|
84
|
+
export type { Document } from "./documents";
|
|
85
|
+
export {
|
|
86
|
+
listDocuments,
|
|
87
|
+
getDocument,
|
|
88
|
+
createDocument,
|
|
89
|
+
updateDocument,
|
|
90
|
+
deleteDocument,
|
|
91
|
+
} from "./documents";
|
|
92
|
+
|
|
93
|
+
// labels
|
|
94
|
+
export type { Label } from "./labels";
|
|
95
|
+
export {
|
|
96
|
+
listLabels,
|
|
97
|
+
getLabel,
|
|
98
|
+
createLabel,
|
|
99
|
+
updateLabel,
|
|
100
|
+
deleteLabel,
|
|
101
|
+
} from "./labels";
|
|
102
|
+
|
|
103
|
+
// comments
|
|
104
|
+
export type {
|
|
105
|
+
Comment,
|
|
106
|
+
CommentReaction,
|
|
107
|
+
CommentSyncInfo,
|
|
108
|
+
CommentsResult,
|
|
109
|
+
SyncMeta,
|
|
110
|
+
SlackSyncMeta,
|
|
111
|
+
GithubSyncMeta,
|
|
112
|
+
JiraSyncMeta,
|
|
113
|
+
GenericSyncMeta,
|
|
114
|
+
} from "./comments";
|
|
115
|
+
export {
|
|
116
|
+
getIssueComments,
|
|
117
|
+
updateComment,
|
|
118
|
+
replyToComment,
|
|
119
|
+
deleteComment,
|
|
120
|
+
} from "./comments";
|
|
121
|
+
|
|
122
|
+
// reactions
|
|
123
|
+
export { createReaction, deleteReaction } from "./reactions";
|
package/src/issues.ts
CHANGED
|
@@ -64,6 +64,7 @@ export async function listIssues(
|
|
|
64
64
|
createdAt: n.createdAt,
|
|
65
65
|
updatedAt: n.updatedAt,
|
|
66
66
|
url: n.url,
|
|
67
|
+
parentId: (await n.parent)?.id ?? null,
|
|
67
68
|
}))
|
|
68
69
|
);
|
|
69
70
|
}
|
|
@@ -81,6 +82,7 @@ export async function getIssue(
|
|
|
81
82
|
|
|
82
83
|
const state = await issue.state;
|
|
83
84
|
const assignee = await issue.assignee;
|
|
85
|
+
const parent = await issue.parent;
|
|
84
86
|
|
|
85
87
|
return {
|
|
86
88
|
id: issue.id,
|
|
@@ -93,6 +95,7 @@ export async function getIssue(
|
|
|
93
95
|
createdAt: issue.createdAt,
|
|
94
96
|
updatedAt: issue.updatedAt,
|
|
95
97
|
url: issue.url,
|
|
98
|
+
parentId: parent?.id ?? null,
|
|
96
99
|
};
|
|
97
100
|
} catch {
|
|
98
101
|
return null;
|
|
@@ -180,3 +183,36 @@ export async function getTeamLabels(
|
|
|
180
183
|
const labels = await team.labels();
|
|
181
184
|
return labels.nodes.map((l) => ({ id: l.id, name: l.name }));
|
|
182
185
|
}
|
|
186
|
+
|
|
187
|
+
export async function archiveIssue(
|
|
188
|
+
client: LinearClient,
|
|
189
|
+
issueId: string
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
const result = await client.archiveIssue(issueId);
|
|
192
|
+
return result.success;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function getSubIssues(
|
|
196
|
+
client: LinearClient,
|
|
197
|
+
parentIdentifier: string
|
|
198
|
+
): Promise<Issue[]> {
|
|
199
|
+
const parent = await client.issue(parentIdentifier);
|
|
200
|
+
if (!parent) return [];
|
|
201
|
+
|
|
202
|
+
const children = await parent.children();
|
|
203
|
+
return Promise.all(
|
|
204
|
+
children.nodes.map(async (n) => ({
|
|
205
|
+
id: n.id,
|
|
206
|
+
identifier: n.identifier,
|
|
207
|
+
title: n.title,
|
|
208
|
+
description: n.description,
|
|
209
|
+
state: (await n.state)?.name ?? null,
|
|
210
|
+
assignee: (await n.assignee)?.name ?? null,
|
|
211
|
+
priority: n.priority,
|
|
212
|
+
createdAt: n.createdAt,
|
|
213
|
+
updatedAt: n.updatedAt,
|
|
214
|
+
url: n.url,
|
|
215
|
+
parentId: parent.id,
|
|
216
|
+
}))
|
|
217
|
+
);
|
|
218
|
+
}
|
package/src/labels.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { LinearClient } from "@linear/sdk";
|
|
2
|
+
|
|
3
|
+
export interface Label {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
color: string;
|
|
7
|
+
description: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function listLabels(
|
|
11
|
+
client: LinearClient,
|
|
12
|
+
teamId?: string
|
|
13
|
+
): Promise<Label[]> {
|
|
14
|
+
if (teamId) {
|
|
15
|
+
const team = await client.team(teamId);
|
|
16
|
+
if (!team) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const labelsConnection = await team.labels();
|
|
20
|
+
return labelsConnection.nodes.map((l) => ({
|
|
21
|
+
id: l.id,
|
|
22
|
+
name: l.name,
|
|
23
|
+
color: l.color,
|
|
24
|
+
description: l.description ?? null,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const labelsConnection = await client.issueLabels();
|
|
29
|
+
return labelsConnection.nodes.map((l) => ({
|
|
30
|
+
id: l.id,
|
|
31
|
+
name: l.name,
|
|
32
|
+
color: l.color,
|
|
33
|
+
description: l.description ?? null,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getLabel(
|
|
38
|
+
client: LinearClient,
|
|
39
|
+
id: string
|
|
40
|
+
): Promise<Label | null> {
|
|
41
|
+
try {
|
|
42
|
+
const label = await client.issueLabel(id);
|
|
43
|
+
return {
|
|
44
|
+
id: label.id,
|
|
45
|
+
name: label.name,
|
|
46
|
+
color: label.color,
|
|
47
|
+
description: label.description ?? null,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createLabel(
|
|
55
|
+
client: LinearClient,
|
|
56
|
+
input: { name: string; teamId?: string; color?: string; description?: string }
|
|
57
|
+
): Promise<Label | null> {
|
|
58
|
+
const payload = await client.createIssueLabel({
|
|
59
|
+
name: input.name,
|
|
60
|
+
teamId: input.teamId,
|
|
61
|
+
color: input.color,
|
|
62
|
+
description: input.description,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!payload.success) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const label = await payload.issueLabel;
|
|
70
|
+
if (!label) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: label.id,
|
|
76
|
+
name: label.name,
|
|
77
|
+
color: label.color,
|
|
78
|
+
description: label.description ?? null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function updateLabel(
|
|
83
|
+
client: LinearClient,
|
|
84
|
+
id: string,
|
|
85
|
+
input: { name?: string; color?: string; description?: string }
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
const payload = await client.updateIssueLabel(id, input);
|
|
88
|
+
return payload.success;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function deleteLabel(
|
|
92
|
+
client: LinearClient,
|
|
93
|
+
id: string
|
|
94
|
+
): Promise<boolean> {
|
|
95
|
+
const payload = await client.deleteIssueLabel(id);
|
|
96
|
+
return payload.success;
|
|
97
|
+
}
|
package/src/me.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type { User, Issue } from "./types";
|
|
1
|
+
import { LinearClient, PaginationOrderBy } from "@linear/sdk";
|
|
2
|
+
import type { User, Issue, Activity } from "./types";
|
|
3
3
|
|
|
4
4
|
export async function getViewer(client: LinearClient): Promise<User> {
|
|
5
5
|
const viewer = await client.viewer;
|
|
@@ -59,3 +59,37 @@ export async function getMyCreatedIssues(
|
|
|
59
59
|
}))
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
export async function getMyActivity(
|
|
64
|
+
client: LinearClient,
|
|
65
|
+
limit = 20
|
|
66
|
+
): Promise<Activity[]> {
|
|
67
|
+
const viewer = await client.viewer;
|
|
68
|
+
const assignedConnection = await viewer.assignedIssues({
|
|
69
|
+
first: limit * 2,
|
|
70
|
+
orderBy: PaginationOrderBy.UpdatedAt,
|
|
71
|
+
});
|
|
72
|
+
const createdConnection = await viewer.createdIssues({
|
|
73
|
+
first: limit * 2,
|
|
74
|
+
orderBy: PaginationOrderBy.UpdatedAt,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const issueMap = new Map<string, Activity>();
|
|
78
|
+
|
|
79
|
+
for (const i of [...assignedConnection.nodes, ...createdConnection.nodes]) {
|
|
80
|
+
if (!issueMap.has(i.id)) {
|
|
81
|
+
issueMap.set(i.id, {
|
|
82
|
+
id: i.id,
|
|
83
|
+
identifier: i.identifier,
|
|
84
|
+
title: i.title,
|
|
85
|
+
state: (await i.state)?.name ?? null,
|
|
86
|
+
updatedAt: i.updatedAt,
|
|
87
|
+
url: i.url,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [...issueMap.values()]
|
|
93
|
+
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
94
|
+
.slice(0, limit);
|
|
95
|
+
}
|
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LinearClient } from "@linear/sdk";
|
|
2
|
+
|
|
3
|
+
export async function createReaction(
|
|
4
|
+
client: LinearClient,
|
|
5
|
+
commentId: string,
|
|
6
|
+
emoji: string
|
|
7
|
+
): Promise<boolean> {
|
|
8
|
+
const result = await client.createReaction({ commentId, emoji });
|
|
9
|
+
return result.success;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deleteReaction(
|
|
13
|
+
client: LinearClient,
|
|
14
|
+
reactionId: string
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
const result = await client.deleteReaction(reactionId);
|
|
17
|
+
return result.success;
|
|
18
|
+
}
|
package/src/relations.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IssueRelationType, type LinearClient } from "@linear/sdk";
|
|
2
|
+
|
|
3
|
+
export async function createIssueRelation(
|
|
4
|
+
client: LinearClient,
|
|
5
|
+
issueId: string,
|
|
6
|
+
relatedIssueId: string,
|
|
7
|
+
type: "blocks" | "related"
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
const relationType =
|
|
10
|
+
type === "blocks" ? IssueRelationType.Blocks : IssueRelationType.Related;
|
|
11
|
+
const result = await client.createIssueRelation({
|
|
12
|
+
issueId,
|
|
13
|
+
relatedIssueId,
|
|
14
|
+
type: relationType,
|
|
15
|
+
});
|
|
16
|
+
return result.success;
|
|
17
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Issue {
|
|
|
9
9
|
createdAt: Date;
|
|
10
10
|
updatedAt: Date;
|
|
11
11
|
url: string;
|
|
12
|
+
parentId?: string | null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface Project {
|
|
@@ -71,6 +72,7 @@ export interface CreateIssueInput {
|
|
|
71
72
|
assigneeId?: string;
|
|
72
73
|
priority?: number;
|
|
73
74
|
labelIds?: string[];
|
|
75
|
+
parentId?: string;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface UpdateIssueInput {
|
|
@@ -78,6 +80,7 @@ export interface UpdateIssueInput {
|
|
|
78
80
|
assigneeId?: string;
|
|
79
81
|
priority?: number;
|
|
80
82
|
labelIds?: string[];
|
|
83
|
+
parentId?: string;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export interface CreateProjectInput {
|
|
@@ -85,3 +88,12 @@ export interface CreateProjectInput {
|
|
|
85
88
|
description?: string;
|
|
86
89
|
teamIds?: string[];
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
export interface Activity {
|
|
93
|
+
id: string;
|
|
94
|
+
identifier: string;
|
|
95
|
+
title: string;
|
|
96
|
+
state?: string | null;
|
|
97
|
+
updatedAt: Date;
|
|
98
|
+
url: string;
|
|
99
|
+
}
|