@andypai/agent-kanban 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/LICENSE +21 -0
- package/README.md +306 -0
- package/package.json +80 -0
- package/src/__tests__/activity.test.ts +139 -0
- package/src/__tests__/api.test.ts +74 -0
- package/src/__tests__/commands/board.test.ts +51 -0
- package/src/__tests__/commands/bulk.test.ts +51 -0
- package/src/__tests__/commands/column.test.ts +78 -0
- package/src/__tests__/commands/task.test.ts +144 -0
- package/src/__tests__/db.test.ts +327 -0
- package/src/__tests__/id.test.ts +19 -0
- package/src/__tests__/index.test.ts +75 -0
- package/src/__tests__/metrics.test.ts +64 -0
- package/src/__tests__/output.test.ts +39 -0
- package/src/activity.ts +73 -0
- package/src/api.ts +209 -0
- package/src/commands/board.ts +29 -0
- package/src/commands/bulk.ts +19 -0
- package/src/commands/column.ts +60 -0
- package/src/commands/task.ts +117 -0
- package/src/config.ts +29 -0
- package/src/db.ts +587 -0
- package/src/errors.ts +32 -0
- package/src/fixtures.ts +128 -0
- package/src/id.ts +8 -0
- package/src/index.ts +413 -0
- package/src/metrics.ts +98 -0
- package/src/output.ts +105 -0
- package/src/providers/capabilities.ts +25 -0
- package/src/providers/errors.ts +16 -0
- package/src/providers/index.ts +24 -0
- package/src/providers/linear-cache.ts +385 -0
- package/src/providers/linear-client.ts +329 -0
- package/src/providers/linear.ts +305 -0
- package/src/providers/local.ts +135 -0
- package/src/providers/types.ts +65 -0
- package/src/server.ts +91 -0
- package/src/types.ts +123 -0
- package/ui/dist/assets/index-DEnUD0fq.css +1 -0
- package/ui/dist/assets/index-DMRjw1nI.js +40 -0
- package/ui/dist/index.html +13 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { ErrorCode } from '../errors.ts'
|
|
2
|
+
import { providerUpstreamError } from './errors.ts'
|
|
3
|
+
|
|
4
|
+
interface GraphQLResponse<T> {
|
|
5
|
+
data?: T
|
|
6
|
+
errors?: Array<{ message?: string; extensions?: { code?: string } }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PageInfo {
|
|
10
|
+
hasNextPage: boolean
|
|
11
|
+
endCursor: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LinearTeamState {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
position: number
|
|
18
|
+
color?: string | null
|
|
19
|
+
type?: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LinearIssue {
|
|
23
|
+
id: string
|
|
24
|
+
identifier: string
|
|
25
|
+
title: string
|
|
26
|
+
description?: string | null
|
|
27
|
+
priority?: number | null
|
|
28
|
+
url?: string | null
|
|
29
|
+
createdAt: string
|
|
30
|
+
updatedAt: string
|
|
31
|
+
assignee?: { id: string; name?: string | null; displayName?: string | null } | null
|
|
32
|
+
project?: { id: string; name: string; url?: string | null; state?: string | null } | null
|
|
33
|
+
state: { id: string; name: string; position: number }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LinearIssueNode {
|
|
37
|
+
id: string
|
|
38
|
+
identifier: string
|
|
39
|
+
title: string
|
|
40
|
+
description?: string | null
|
|
41
|
+
priority?: number | null
|
|
42
|
+
url?: string | null
|
|
43
|
+
createdAt: string
|
|
44
|
+
updatedAt: string
|
|
45
|
+
assignee?: { id: string; name?: string | null; displayName?: string | null } | null
|
|
46
|
+
project?: { id: string; name: string; url?: string | null; state?: string | null } | null
|
|
47
|
+
state: { id: string; name: string; position: number }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class LinearClient {
|
|
51
|
+
private readonly endpoint = 'https://api.linear.app/graphql'
|
|
52
|
+
|
|
53
|
+
constructor(private readonly apiKey: string) {}
|
|
54
|
+
|
|
55
|
+
private async query<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
|
56
|
+
const response = await fetch(this.endpoint, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
Authorization: this.apiKey,
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({ query, variables }),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (response.status === 401 || response.status === 403) {
|
|
66
|
+
providerUpstreamError('Linear authentication failed', ErrorCode.PROVIDER_AUTH_FAILED)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (response.status === 429) {
|
|
70
|
+
providerUpstreamError('Linear API rate limit exceeded', ErrorCode.PROVIDER_RATE_LIMITED)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
providerUpstreamError(`Linear API request failed with ${response.status}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const body = (await response.json()) as GraphQLResponse<T>
|
|
78
|
+
if (body.errors?.length) {
|
|
79
|
+
const first = body.errors[0]
|
|
80
|
+
if (first?.extensions?.code === 'RATELIMITED') {
|
|
81
|
+
providerUpstreamError('Linear API rate limit exceeded', ErrorCode.PROVIDER_RATE_LIMITED)
|
|
82
|
+
}
|
|
83
|
+
providerUpstreamError(first?.message ?? 'Linear API request failed')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!body.data) {
|
|
87
|
+
providerUpstreamError('Linear API returned no data')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return body.data
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getTeam(
|
|
94
|
+
teamId: string,
|
|
95
|
+
): Promise<{ id: string; key: string; name: string; states: LinearTeamState[] }> {
|
|
96
|
+
const data = await this.query<{
|
|
97
|
+
team: {
|
|
98
|
+
id: string
|
|
99
|
+
key: string
|
|
100
|
+
name: string
|
|
101
|
+
states: { nodes: LinearTeamState[] }
|
|
102
|
+
} | null
|
|
103
|
+
}>(
|
|
104
|
+
`
|
|
105
|
+
query TeamSnapshot($teamId: String!) {
|
|
106
|
+
team(id: $teamId) {
|
|
107
|
+
id
|
|
108
|
+
key
|
|
109
|
+
name
|
|
110
|
+
states {
|
|
111
|
+
nodes {
|
|
112
|
+
id
|
|
113
|
+
name
|
|
114
|
+
position
|
|
115
|
+
color
|
|
116
|
+
type
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
{ teamId },
|
|
123
|
+
)
|
|
124
|
+
if (!data.team) {
|
|
125
|
+
providerUpstreamError(`Linear team '${teamId}' was not found`)
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
id: data.team.id,
|
|
129
|
+
key: data.team.key,
|
|
130
|
+
name: data.team.name,
|
|
131
|
+
states: data.team.states.nodes,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async listUsers(): Promise<Array<{ id: string; name: string; active?: boolean }>> {
|
|
136
|
+
const data = await this.query<{
|
|
137
|
+
users: {
|
|
138
|
+
nodes: Array<{
|
|
139
|
+
id: string
|
|
140
|
+
name?: string | null
|
|
141
|
+
displayName?: string | null
|
|
142
|
+
active?: boolean | null
|
|
143
|
+
}>
|
|
144
|
+
}
|
|
145
|
+
}>(
|
|
146
|
+
`
|
|
147
|
+
query Users {
|
|
148
|
+
users {
|
|
149
|
+
nodes {
|
|
150
|
+
id
|
|
151
|
+
name
|
|
152
|
+
displayName
|
|
153
|
+
active
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
`,
|
|
158
|
+
)
|
|
159
|
+
return data.users.nodes.map((user) => ({
|
|
160
|
+
id: user.id,
|
|
161
|
+
name: user.displayName || user.name || user.id,
|
|
162
|
+
active: user.active ?? true,
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async listProjects(): Promise<
|
|
167
|
+
Array<{ id: string; name: string; url?: string | null; state?: string | null }>
|
|
168
|
+
> {
|
|
169
|
+
const data = await this.query<{
|
|
170
|
+
projects: {
|
|
171
|
+
nodes: Array<{ id: string; name: string; url?: string | null; state?: string | null }>
|
|
172
|
+
}
|
|
173
|
+
}>(
|
|
174
|
+
`
|
|
175
|
+
query Projects {
|
|
176
|
+
projects {
|
|
177
|
+
nodes {
|
|
178
|
+
id
|
|
179
|
+
name
|
|
180
|
+
url
|
|
181
|
+
state
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`,
|
|
186
|
+
)
|
|
187
|
+
return data.projects.nodes
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async listIssues(teamId: string, updatedAfter?: string): Promise<LinearIssue[]> {
|
|
191
|
+
let after: string | null = null
|
|
192
|
+
const issues: LinearIssue[] = []
|
|
193
|
+
|
|
194
|
+
do {
|
|
195
|
+
const data: {
|
|
196
|
+
issues: {
|
|
197
|
+
nodes: LinearIssueNode[]
|
|
198
|
+
pageInfo: PageInfo
|
|
199
|
+
}
|
|
200
|
+
} = await this.query(
|
|
201
|
+
`
|
|
202
|
+
query Issues($teamId: ID!, $after: String, $updatedAfter: DateTimeOrDuration) {
|
|
203
|
+
issues(
|
|
204
|
+
first: 100
|
|
205
|
+
after: $after
|
|
206
|
+
orderBy: updatedAt
|
|
207
|
+
filter: {
|
|
208
|
+
team: { id: { eq: $teamId } }
|
|
209
|
+
updatedAt: { gte: $updatedAfter }
|
|
210
|
+
}
|
|
211
|
+
) {
|
|
212
|
+
nodes {
|
|
213
|
+
id
|
|
214
|
+
identifier
|
|
215
|
+
title
|
|
216
|
+
description
|
|
217
|
+
priority
|
|
218
|
+
url
|
|
219
|
+
createdAt
|
|
220
|
+
updatedAt
|
|
221
|
+
assignee {
|
|
222
|
+
id
|
|
223
|
+
name
|
|
224
|
+
displayName
|
|
225
|
+
}
|
|
226
|
+
project {
|
|
227
|
+
id
|
|
228
|
+
name
|
|
229
|
+
url
|
|
230
|
+
state
|
|
231
|
+
}
|
|
232
|
+
state {
|
|
233
|
+
id
|
|
234
|
+
name
|
|
235
|
+
position
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
pageInfo {
|
|
239
|
+
hasNextPage
|
|
240
|
+
endCursor
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
`,
|
|
245
|
+
{ teamId, after, updatedAfter: updatedAfter ?? '1970-01-01T00:00:00.000Z' },
|
|
246
|
+
)
|
|
247
|
+
issues.push(
|
|
248
|
+
...data.issues.nodes.map((issue: LinearIssueNode) => ({
|
|
249
|
+
...issue,
|
|
250
|
+
assignee: issue.assignee
|
|
251
|
+
? {
|
|
252
|
+
id: issue.assignee.id,
|
|
253
|
+
name: issue.assignee.displayName || issue.assignee.name,
|
|
254
|
+
}
|
|
255
|
+
: null,
|
|
256
|
+
})),
|
|
257
|
+
)
|
|
258
|
+
after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
|
|
259
|
+
} while (after)
|
|
260
|
+
|
|
261
|
+
return issues
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async createIssue(input: {
|
|
265
|
+
teamId: string
|
|
266
|
+
stateId?: string
|
|
267
|
+
title: string
|
|
268
|
+
description?: string
|
|
269
|
+
priority?: number
|
|
270
|
+
assigneeId?: string
|
|
271
|
+
projectId?: string
|
|
272
|
+
}): Promise<{ success: boolean; issue: LinearIssue | null }> {
|
|
273
|
+
const data = await this.query<{
|
|
274
|
+
issueCreate: { success: boolean; issue: LinearIssue | null }
|
|
275
|
+
}>(
|
|
276
|
+
`
|
|
277
|
+
mutation CreateIssue($input: IssueCreateInput!) {
|
|
278
|
+
issueCreate(input: $input) {
|
|
279
|
+
success
|
|
280
|
+
issue {
|
|
281
|
+
id
|
|
282
|
+
identifier
|
|
283
|
+
title
|
|
284
|
+
description
|
|
285
|
+
priority
|
|
286
|
+
url
|
|
287
|
+
createdAt
|
|
288
|
+
updatedAt
|
|
289
|
+
assignee { id name displayName }
|
|
290
|
+
project { id name url state }
|
|
291
|
+
state { id name position }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
`,
|
|
296
|
+
{
|
|
297
|
+
input: {
|
|
298
|
+
teamId: input.teamId,
|
|
299
|
+
stateId: input.stateId,
|
|
300
|
+
title: input.title,
|
|
301
|
+
description: input.description,
|
|
302
|
+
priority: input.priority,
|
|
303
|
+
assigneeId: input.assigneeId,
|
|
304
|
+
projectId: input.projectId,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
return data.issueCreate
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async updateIssue(
|
|
312
|
+
issueId: string,
|
|
313
|
+
input: Record<string, unknown>,
|
|
314
|
+
): Promise<{ success: boolean }> {
|
|
315
|
+
const data = await this.query<{
|
|
316
|
+
issueUpdate: { success: boolean }
|
|
317
|
+
}>(
|
|
318
|
+
`
|
|
319
|
+
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
320
|
+
issueUpdate(id: $id, input: $input) {
|
|
321
|
+
success
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
`,
|
|
325
|
+
{ id: issueId, input },
|
|
326
|
+
)
|
|
327
|
+
return data.issueUpdate
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
3
|
+
import type {
|
|
4
|
+
ActivityEntry,
|
|
5
|
+
BoardBootstrap,
|
|
6
|
+
BoardConfig,
|
|
7
|
+
BoardMetrics,
|
|
8
|
+
Column,
|
|
9
|
+
Task,
|
|
10
|
+
} from '../types.ts'
|
|
11
|
+
import { LINEAR_CAPABILITIES } from './capabilities.ts'
|
|
12
|
+
import {
|
|
13
|
+
getCachedBoard,
|
|
14
|
+
getCachedColumns,
|
|
15
|
+
getCachedConfig,
|
|
16
|
+
getCachedTask,
|
|
17
|
+
getCachedTasks,
|
|
18
|
+
initLinearCacheSchema,
|
|
19
|
+
loadSyncMeta,
|
|
20
|
+
replaceStates,
|
|
21
|
+
saveSyncMeta,
|
|
22
|
+
upsertIssues,
|
|
23
|
+
upsertProjects,
|
|
24
|
+
upsertUsers,
|
|
25
|
+
} from './linear-cache.ts'
|
|
26
|
+
import { LinearClient } from './linear-client.ts'
|
|
27
|
+
import { unsupportedOperation } from './errors.ts'
|
|
28
|
+
import type {
|
|
29
|
+
CreateTaskInput,
|
|
30
|
+
KanbanProvider,
|
|
31
|
+
ProviderContext,
|
|
32
|
+
TaskListFilters,
|
|
33
|
+
UpdateTaskInput,
|
|
34
|
+
} from './types.ts'
|
|
35
|
+
|
|
36
|
+
const SYNC_INTERVAL_MS = 30_000
|
|
37
|
+
|
|
38
|
+
function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
|
|
39
|
+
switch (priority) {
|
|
40
|
+
case 'urgent':
|
|
41
|
+
return 1
|
|
42
|
+
case 'high':
|
|
43
|
+
return 2
|
|
44
|
+
case 'medium':
|
|
45
|
+
return 3
|
|
46
|
+
case 'low':
|
|
47
|
+
return 4
|
|
48
|
+
default:
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class LinearProvider implements KanbanProvider {
|
|
54
|
+
readonly type = 'linear' as const
|
|
55
|
+
private readonly client: LinearClient
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly db: Database,
|
|
59
|
+
private readonly teamId: string,
|
|
60
|
+
apiKey: string,
|
|
61
|
+
) {
|
|
62
|
+
initLinearCacheSchema(db)
|
|
63
|
+
this.client = new LinearClient(apiKey)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async sync(force = false): Promise<void> {
|
|
67
|
+
const meta = loadSyncMeta(this.db)
|
|
68
|
+
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
69
|
+
if (!force && lastSyncAtMs && Date.now() - lastSyncAtMs < SYNC_INTERVAL_MS) return
|
|
70
|
+
|
|
71
|
+
const [team, users, projects, issues] = await Promise.all([
|
|
72
|
+
this.client.getTeam(this.teamId),
|
|
73
|
+
this.client.listUsers(),
|
|
74
|
+
this.client.listProjects(),
|
|
75
|
+
this.client.listIssues(
|
|
76
|
+
this.teamId,
|
|
77
|
+
force ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
|
|
78
|
+
),
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
replaceStates(this.db, team.states)
|
|
82
|
+
upsertUsers(this.db, users)
|
|
83
|
+
upsertProjects(this.db, projects)
|
|
84
|
+
upsertIssues(
|
|
85
|
+
this.db,
|
|
86
|
+
issues.map((issue) => ({
|
|
87
|
+
id: issue.id,
|
|
88
|
+
identifier: issue.identifier,
|
|
89
|
+
title: issue.title,
|
|
90
|
+
description: issue.description ?? '',
|
|
91
|
+
priority: issue.priority ?? 0,
|
|
92
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
93
|
+
assigneeName: issue.assignee?.name ?? null,
|
|
94
|
+
projectId: issue.project?.id ?? null,
|
|
95
|
+
projectName: issue.project?.name ?? null,
|
|
96
|
+
stateId: issue.state.id,
|
|
97
|
+
stateName: issue.state.name,
|
|
98
|
+
statePosition: issue.state.position,
|
|
99
|
+
url: issue.url ?? null,
|
|
100
|
+
createdAt: issue.createdAt,
|
|
101
|
+
updatedAt: issue.updatedAt,
|
|
102
|
+
})),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const newestIssueTimestamp =
|
|
106
|
+
issues.length > 0
|
|
107
|
+
? issues.reduce(
|
|
108
|
+
(latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
|
|
109
|
+
issues[0]!.updatedAt,
|
|
110
|
+
)
|
|
111
|
+
: meta.lastIssueUpdatedAt
|
|
112
|
+
|
|
113
|
+
saveSyncMeta(this.db, {
|
|
114
|
+
team: { id: team.id, key: team.key, name: team.name },
|
|
115
|
+
lastSyncAt: new Date().toISOString(),
|
|
116
|
+
lastIssueUpdatedAt: newestIssueTimestamp ?? new Date().toISOString(),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private resolveTask(idOrRef: string): Task {
|
|
121
|
+
const task = getCachedTask(this.db, idOrRef)
|
|
122
|
+
if (!task) {
|
|
123
|
+
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
124
|
+
}
|
|
125
|
+
return task
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private resolveState(column: string): Column {
|
|
129
|
+
const states = getCachedColumns(this.db)
|
|
130
|
+
const match = states.find(
|
|
131
|
+
(state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
|
|
132
|
+
)
|
|
133
|
+
if (!match) {
|
|
134
|
+
throw new KanbanError(
|
|
135
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
136
|
+
`No Linear workflow state matching '${column}'`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
return match
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private resolveAssigneeId(name?: string): string | undefined {
|
|
143
|
+
if (!name) return undefined
|
|
144
|
+
const row = this.db
|
|
145
|
+
.query('SELECT id FROM linear_users WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
146
|
+
.get({ $name: name }) as { id: string } | null
|
|
147
|
+
return row?.id
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private resolveProjectId(name?: string): string | undefined {
|
|
151
|
+
if (!name) return undefined
|
|
152
|
+
const row = this.db
|
|
153
|
+
.query('SELECT id FROM linear_projects WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
154
|
+
.get({ $name: name }) as { id: string } | null
|
|
155
|
+
return row?.id
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getContext(): Promise<ProviderContext> {
|
|
159
|
+
await this.sync()
|
|
160
|
+
const meta = loadSyncMeta(this.db)
|
|
161
|
+
return {
|
|
162
|
+
provider: 'linear',
|
|
163
|
+
capabilities: LINEAR_CAPABILITIES,
|
|
164
|
+
team: meta.team,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
169
|
+
await this.sync()
|
|
170
|
+
return {
|
|
171
|
+
provider: 'linear',
|
|
172
|
+
capabilities: LINEAR_CAPABILITIES,
|
|
173
|
+
board: getCachedBoard(this.db),
|
|
174
|
+
config: getCachedConfig(this.db),
|
|
175
|
+
metrics: null,
|
|
176
|
+
activity: [],
|
|
177
|
+
team: loadSyncMeta(this.db).team,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async getBoard() {
|
|
182
|
+
await this.sync()
|
|
183
|
+
return getCachedBoard(this.db)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async listColumns() {
|
|
187
|
+
await this.sync()
|
|
188
|
+
return getCachedColumns(this.db)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async listTasks(filters: TaskListFilters = {}) {
|
|
192
|
+
await this.sync()
|
|
193
|
+
let tasks = getCachedTasks(this.db)
|
|
194
|
+
if (filters.column) {
|
|
195
|
+
const column = this.resolveState(filters.column)
|
|
196
|
+
tasks = tasks.filter((task) => task.column_id === column.id)
|
|
197
|
+
}
|
|
198
|
+
if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
|
|
199
|
+
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
200
|
+
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
201
|
+
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
202
|
+
if (filters.sort === 'updated')
|
|
203
|
+
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
204
|
+
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
205
|
+
return tasks
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async getTask(idOrRef: string) {
|
|
209
|
+
await this.sync()
|
|
210
|
+
return this.resolveTask(idOrRef)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async createTask(input: CreateTaskInput) {
|
|
214
|
+
await this.sync()
|
|
215
|
+
const state = input.column ? this.resolveState(input.column) : undefined
|
|
216
|
+
const result = await this.client.createIssue({
|
|
217
|
+
teamId: this.teamId,
|
|
218
|
+
stateId: state?.id,
|
|
219
|
+
title: input.title,
|
|
220
|
+
description: input.description,
|
|
221
|
+
priority: toLinearPriority(input.priority),
|
|
222
|
+
assigneeId: this.resolveAssigneeId(input.assignee),
|
|
223
|
+
projectId: this.resolveProjectId(input.project),
|
|
224
|
+
})
|
|
225
|
+
if (!result.success || !result.issue) {
|
|
226
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
|
|
227
|
+
}
|
|
228
|
+
const issue = result.issue
|
|
229
|
+
upsertIssues(this.db, [
|
|
230
|
+
{
|
|
231
|
+
id: issue.id,
|
|
232
|
+
identifier: issue.identifier,
|
|
233
|
+
title: issue.title,
|
|
234
|
+
description: issue.description ?? '',
|
|
235
|
+
priority: issue.priority ?? 0,
|
|
236
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
237
|
+
assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
|
|
238
|
+
projectId: issue.project?.id ?? null,
|
|
239
|
+
projectName: issue.project?.name ?? '',
|
|
240
|
+
stateId: issue.state.id,
|
|
241
|
+
stateName: issue.state.name,
|
|
242
|
+
statePosition: issue.state.position,
|
|
243
|
+
url: issue.url ?? null,
|
|
244
|
+
createdAt: issue.createdAt,
|
|
245
|
+
updatedAt: issue.updatedAt,
|
|
246
|
+
},
|
|
247
|
+
])
|
|
248
|
+
return this.resolveTask(issue.id)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async updateTask(idOrRef: string, input: UpdateTaskInput) {
|
|
252
|
+
await this.sync()
|
|
253
|
+
const task = this.resolveTask(idOrRef)
|
|
254
|
+
const updateInput: Record<string, unknown> = {}
|
|
255
|
+
if (input.title !== undefined) updateInput['title'] = input.title
|
|
256
|
+
if (input.description !== undefined) updateInput['description'] = input.description
|
|
257
|
+
if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
|
|
258
|
+
if (input.assignee !== undefined)
|
|
259
|
+
updateInput['assigneeId'] = this.resolveAssigneeId(input.assignee) ?? null
|
|
260
|
+
if (input.project !== undefined)
|
|
261
|
+
updateInput['projectId'] = this.resolveProjectId(input.project) ?? null
|
|
262
|
+
if (input.metadata !== undefined) {
|
|
263
|
+
unsupportedOperation('Linear mode does not support metadata updates')
|
|
264
|
+
}
|
|
265
|
+
const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
|
|
266
|
+
if (!result.success) {
|
|
267
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
|
|
268
|
+
}
|
|
269
|
+
await this.sync(true)
|
|
270
|
+
return this.resolveTask(task.providerId || task.id)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async moveTask(idOrRef: string, column: string) {
|
|
274
|
+
await this.sync()
|
|
275
|
+
const task = this.resolveTask(idOrRef)
|
|
276
|
+
const state = this.resolveState(column)
|
|
277
|
+
const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
|
|
278
|
+
if (!result.success) {
|
|
279
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
|
|
280
|
+
}
|
|
281
|
+
await this.sync(true)
|
|
282
|
+
return this.resolveTask(task.providerId || task.id)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
286
|
+
unsupportedOperation('Task deletion is not supported in Linear mode')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async getActivity(_limit?: number, _taskId?: string): Promise<ActivityEntry[]> {
|
|
290
|
+
unsupportedOperation('Activity is not available in Linear mode')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async getMetrics(): Promise<BoardMetrics> {
|
|
294
|
+
unsupportedOperation('Metrics are not available in Linear mode')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async getConfig(): Promise<BoardConfig> {
|
|
298
|
+
await this.sync()
|
|
299
|
+
return getCachedConfig(this.db)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
303
|
+
unsupportedOperation('Config mutation is not supported in Linear mode')
|
|
304
|
+
}
|
|
305
|
+
}
|