@andypai/agent-kanban 0.6.2 → 0.6.3
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/__tests__/api.test.ts +48 -0
- package/src/__tests__/conflict.test.ts +25 -1
- package/src/__tests__/linear-provider-sync.test.ts +517 -40
- package/src/__tests__/mcp-core.test.ts +54 -0
- package/src/__tests__/mcp-server.test.ts +17 -0
- package/src/__tests__/metrics-spec.test.ts +121 -0
- package/src/__tests__/postgres-linear-provider.test.ts +10 -2
- package/src/__tests__/postgres-local-provider.test.ts +140 -0
- package/src/__tests__/tracker-config-columns.test.ts +23 -0
- package/src/__tests__/transport-input.test.ts +35 -0
- package/src/api.ts +35 -31
- package/src/commands/mcp.ts +2 -1
- package/src/db.ts +1 -1
- package/src/errors.ts +2 -0
- package/src/index.ts +6 -1
- package/src/mcp/core.ts +24 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/types.ts +12 -1
- package/src/metrics-spec.ts +87 -0
- package/src/metrics.ts +29 -60
- package/src/providers/capabilities.ts +12 -0
- package/src/providers/linear-client.ts +172 -95
- package/src/providers/linear-core.ts +89 -59
- package/src/providers/postgres-local.ts +136 -89
- package/src/tracker-config.ts +17 -1
- package/src/transport-input.ts +30 -0
- package/src/version.ts +8 -0
- package/ui/dist/assets/index-Cigv8a9S.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-65LcV0R7.js +0 -40
package/src/mcp/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
|
2
2
|
import type { JsonSchemaType } from '@modelcontextprotocol/sdk/validation'
|
|
3
|
-
import type { TaskComment } from '../types'
|
|
3
|
+
import type { Task, TaskComment } from '../types'
|
|
4
4
|
import type { TrackerMcpError, TrackerMcpErrorCode } from './errors'
|
|
5
5
|
|
|
6
6
|
export type TrackerMcpAuthResolver<TScope> = (ctx: {
|
|
@@ -20,6 +20,17 @@ export interface TrackerMcpPolicy<TScope> {
|
|
|
20
20
|
): Promise<void> | void
|
|
21
21
|
canMoveTicket(scope: TScope, ticketId: string, destinationColumn: string): Promise<void> | void
|
|
22
22
|
filterComment?(scope: TScope, comment: TaskComment): Promise<boolean> | boolean
|
|
23
|
+
/**
|
|
24
|
+
* Gate the aggregate board read. Throw to deny access to the whole board.
|
|
25
|
+
* Omit to allow board reads (individual tickets can still be hidden via `filterTask`).
|
|
26
|
+
*/
|
|
27
|
+
canReadBoard?(scope: TScope): Promise<void> | void
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether a single board task is visible to this scope. Return `false`
|
|
30
|
+
* to drop it from `getBoard`. Without this, the board exposes every ticket,
|
|
31
|
+
* bypassing per-ticket `canReadTicket` gates.
|
|
32
|
+
*/
|
|
33
|
+
filterTask?(scope: TScope, task: Task): Promise<boolean> | boolean
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface TrackerMcpHooks<TScope> {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ActivityEntry, BoardMetrics } from './types'
|
|
2
|
+
import {
|
|
3
|
+
type ClassifiableColumn,
|
|
4
|
+
selectDoneColumnIds,
|
|
5
|
+
selectInProgressColumnIds,
|
|
6
|
+
} from './column-roles'
|
|
7
|
+
|
|
8
|
+
// One source of truth for how raw board aggregates become BoardMetrics. The
|
|
9
|
+
// SQLite (metrics.ts) and Postgres (postgres-local.ts) backends each gather the
|
|
10
|
+
// primitive rows with their own dialect, then feed them through this assembler
|
|
11
|
+
// so the derived fields — done/in-progress classification, completion math, and
|
|
12
|
+
// priority ordering — can never drift between backends.
|
|
13
|
+
|
|
14
|
+
const PRIORITY_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
|
|
15
|
+
|
|
16
|
+
/** A column plus how many tasks currently sit in it. */
|
|
17
|
+
export interface MetricsColumnCount extends ClassifiableColumn {
|
|
18
|
+
count: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MetricsInputs {
|
|
22
|
+
/** Every column with its live task count, in display (position) order. */
|
|
23
|
+
columnCounts: MetricsColumnCount[]
|
|
24
|
+
/** Raw per-priority counts; ordering is normalized here. */
|
|
25
|
+
priorityCounts: Array<{ priority: string; count: number }>
|
|
26
|
+
totalTasks: number
|
|
27
|
+
tasksCreatedThisWeek: number
|
|
28
|
+
/** Average creation→Done hours, or null when no Done column / no completions. */
|
|
29
|
+
avgCompletionHours: number | null
|
|
30
|
+
recentActivity: ActivityEntry[]
|
|
31
|
+
assignees: string[]
|
|
32
|
+
projects: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify a board's columns into done / in-progress roles. Backends call this
|
|
37
|
+
* to scope their average-completion SQL (which needs the done column ids before
|
|
38
|
+
* querying column_time_tracking); assembleBoardMetrics() reclassifies from the
|
|
39
|
+
* same column set so the two never disagree.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyColumnRoles(columns: ClassifiableColumn[]): {
|
|
42
|
+
doneColumnIds: string[]
|
|
43
|
+
inProgressColumnIds: string[]
|
|
44
|
+
} {
|
|
45
|
+
return {
|
|
46
|
+
doneColumnIds: selectDoneColumnIds(columns),
|
|
47
|
+
inProgressColumnIds: selectInProgressColumnIds(columns),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function assembleBoardMetrics(inputs: MetricsInputs): BoardMetrics {
|
|
52
|
+
const { doneColumnIds, inProgressColumnIds } = classifyColumnRoles(inputs.columnCounts)
|
|
53
|
+
const done = new Set(doneColumnIds)
|
|
54
|
+
const inProgress = new Set(inProgressColumnIds)
|
|
55
|
+
|
|
56
|
+
const tasksByColumn = inputs.columnCounts.map((column) => ({
|
|
57
|
+
column_name: column.name,
|
|
58
|
+
count: column.count,
|
|
59
|
+
}))
|
|
60
|
+
const completedTasks = inputs.columnCounts
|
|
61
|
+
.filter((column) => done.has(column.id))
|
|
62
|
+
.reduce((sum, column) => sum + column.count, 0)
|
|
63
|
+
const inProgressCount = inputs.columnCounts
|
|
64
|
+
.filter((column) => inProgress.has(column.id))
|
|
65
|
+
.reduce((sum, column) => sum + column.count, 0)
|
|
66
|
+
|
|
67
|
+
const tasksByPriority = [...inputs.priorityCounts].sort(
|
|
68
|
+
(a, b) => (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const completionPercent =
|
|
72
|
+
inputs.totalTasks > 0 ? Math.round((completedTasks / inputs.totalTasks) * 100) : 0
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
tasksByColumn,
|
|
76
|
+
tasksByPriority,
|
|
77
|
+
totalTasks: inputs.totalTasks,
|
|
78
|
+
completedTasks,
|
|
79
|
+
avgCompletionHours: inputs.avgCompletionHours,
|
|
80
|
+
recentActivity: inputs.recentActivity,
|
|
81
|
+
tasksCreatedThisWeek: inputs.tasksCreatedThisWeek,
|
|
82
|
+
inProgressCount,
|
|
83
|
+
completionPercent,
|
|
84
|
+
assignees: inputs.assignees,
|
|
85
|
+
projects: inputs.projects,
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite'
|
|
2
2
|
import type { ActivityEntry, BoardMetrics } from './types'
|
|
3
|
-
import {
|
|
3
|
+
import { assembleBoardMetrics, classifyColumnRoles } from './metrics-spec'
|
|
4
4
|
|
|
5
5
|
function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
|
|
6
6
|
return (
|
|
@@ -23,52 +23,34 @@ export function getDiscoveredProjects(db: Database): string[] {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function getBoardMetrics(db: Database): BoardMetrics {
|
|
26
|
-
const columnCounts =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
name: row.column_name,
|
|
44
|
-
position: row.position,
|
|
45
|
-
}))
|
|
46
|
-
const doneColumnIds = new Set(selectDoneColumnIds(columns))
|
|
47
|
-
const inProgressColumnIds = new Set(selectInProgressColumnIds(columns))
|
|
48
|
-
|
|
49
|
-
const tasksByPriority = db
|
|
50
|
-
.query(
|
|
51
|
-
`SELECT priority, COUNT(*) as count FROM tasks
|
|
52
|
-
GROUP BY priority ORDER BY CASE priority
|
|
53
|
-
WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
|
|
54
|
-
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`,
|
|
55
|
-
)
|
|
26
|
+
const columnCounts = (
|
|
27
|
+
db
|
|
28
|
+
.query(
|
|
29
|
+
`SELECT c.id as id, c.name as name, c.position as position, COUNT(t.id) as count
|
|
30
|
+
FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
|
|
31
|
+
GROUP BY c.id ORDER BY c.position`,
|
|
32
|
+
)
|
|
33
|
+
.all() as { id: string; name: string; position: number; count: number }[]
|
|
34
|
+
).map((row) => ({ id: row.id, name: row.name, position: row.position, count: row.count }))
|
|
35
|
+
|
|
36
|
+
// Done/in-progress classification and all derived fields live in metrics-spec;
|
|
37
|
+
// here we only gather the raw aggregates this backend's SQL produces. The done
|
|
38
|
+
// ids are needed up front to scope the average-completion query.
|
|
39
|
+
const { doneColumnIds } = classifyColumnRoles(columnCounts)
|
|
40
|
+
|
|
41
|
+
const priorityCounts = db
|
|
42
|
+
.query(`SELECT priority, COUNT(*) as count FROM tasks GROUP BY priority`)
|
|
56
43
|
.all() as { priority: string; count: number }[]
|
|
57
44
|
|
|
58
45
|
const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
|
|
59
46
|
|
|
60
|
-
const completedTasks = columnCounts
|
|
61
|
-
.filter((row) => doneColumnIds.has(row.id))
|
|
62
|
-
.reduce((sum, row) => sum + row.count, 0)
|
|
63
|
-
|
|
64
47
|
// Average completion time = first time a task was tracked (creation) until the
|
|
65
48
|
// first time it entered a Done column. Measuring the Done *entry* (not exit)
|
|
66
49
|
// means tasks that reach Done and stay there are counted; MIN() over Done rows
|
|
67
|
-
// handles tasks that re-enter Done.
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
doneColumnIds.size === 0
|
|
50
|
+
// handles tasks that re-enter Done.
|
|
51
|
+
const donePlaceholders = doneColumnIds.map(() => '?').join(', ')
|
|
52
|
+
const avgResult =
|
|
53
|
+
doneColumnIds.length === 0
|
|
72
54
|
? { avg_hours: null }
|
|
73
55
|
: (db
|
|
74
56
|
.query(
|
|
@@ -87,7 +69,6 @@ export function getBoardMetrics(db: Database): BoardMetrics {
|
|
|
87
69
|
) first_enter ON first_enter.task_id = done_enter.task_id`,
|
|
88
70
|
)
|
|
89
71
|
.get(...doneColumnIds) as { avg_hours: number | null })
|
|
90
|
-
) as { avg_hours: number | null }
|
|
91
72
|
|
|
92
73
|
const recentActivity = db
|
|
93
74
|
.query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
|
|
@@ -98,26 +79,14 @@ export function getBoardMetrics(db: Database): BoardMetrics {
|
|
|
98
79
|
"SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
|
|
99
80
|
)
|
|
100
81
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
106
|
-
|
|
107
|
-
const assignees = getDiscoveredAssignees(db)
|
|
108
|
-
const projects = getDiscoveredProjects(db)
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
tasksByColumn,
|
|
112
|
-
tasksByPriority,
|
|
82
|
+
return assembleBoardMetrics({
|
|
83
|
+
columnCounts,
|
|
84
|
+
priorityCounts,
|
|
113
85
|
totalTasks,
|
|
114
|
-
|
|
86
|
+
tasksCreatedThisWeek,
|
|
115
87
|
avgCompletionHours: avgResult.avg_hours,
|
|
116
88
|
recentActivity,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
assignees,
|
|
121
|
-
projects,
|
|
122
|
-
}
|
|
89
|
+
assignees: getDiscoveredAssignees(db),
|
|
90
|
+
projects: getDiscoveredProjects(db),
|
|
91
|
+
})
|
|
123
92
|
}
|
|
@@ -25,6 +25,18 @@ export const LOCAL_CAPABILITIES: ProviderCapabilities = capabilities({
|
|
|
25
25
|
configEdit: true,
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
// Postgres-local shares most local capabilities, but board administration
|
|
29
|
+
// (column CRUD, bulk ops) and config editing have no Postgres provider path —
|
|
30
|
+
// the CLI implements column/bulk against a raw SQLite Database and blocks them
|
|
31
|
+
// under KANBAN_STORAGE=postgres. Advertise that honestly so clients don't offer
|
|
32
|
+
// operations the provider can't perform.
|
|
33
|
+
export const POSTGRES_LOCAL_CAPABILITIES: ProviderCapabilities = {
|
|
34
|
+
...LOCAL_CAPABILITIES,
|
|
35
|
+
columnCrud: false,
|
|
36
|
+
bulk: false,
|
|
37
|
+
configEdit: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
export const LINEAR_CAPABILITIES: ProviderCapabilities = capabilities()
|
|
29
41
|
|
|
30
42
|
export const JIRA_CAPABILITIES: ProviderCapabilities = capabilities()
|
|
@@ -33,6 +33,7 @@ export interface LinearIssue {
|
|
|
33
33
|
state: { id: string; name: string; position: number }
|
|
34
34
|
labels?: string[]
|
|
35
35
|
commentCount?: number
|
|
36
|
+
teamId?: string | null
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface LinearComment {
|
|
@@ -65,6 +66,7 @@ interface LinearIssueNode {
|
|
|
65
66
|
nodes: Array<{ id: string }>
|
|
66
67
|
pageInfo?: { hasNextPage: boolean; endCursor: string | null }
|
|
67
68
|
} | null
|
|
69
|
+
team?: { id: string } | null
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
interface LinearCommentNode {
|
|
@@ -86,6 +88,7 @@ function toLinearIssue(node: LinearIssueNode): LinearIssue {
|
|
|
86
88
|
: null,
|
|
87
89
|
labels: node.labels?.nodes.map((label) => label.name) ?? [],
|
|
88
90
|
commentCount: node.comments?.nodes?.length ?? undefined,
|
|
91
|
+
teamId: node.team?.id ?? null,
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -97,6 +100,30 @@ const COMMENT_FIELDS = `
|
|
|
97
100
|
user { id name displayName }
|
|
98
101
|
`
|
|
99
102
|
|
|
103
|
+
// Shared selection set for an issue node, used by listIssues, createIssue, and
|
|
104
|
+
// getIssue so a single-issue hydrate returns exactly the same shape the bulk
|
|
105
|
+
// sync caches. The inline comments page is id-only and capped; callers that
|
|
106
|
+
// need an accurate count follow up via countIssueComments when hasNextPage.
|
|
107
|
+
const ISSUE_NODE_FIELDS = `
|
|
108
|
+
id
|
|
109
|
+
identifier
|
|
110
|
+
title
|
|
111
|
+
description
|
|
112
|
+
priority
|
|
113
|
+
url
|
|
114
|
+
createdAt
|
|
115
|
+
updatedAt
|
|
116
|
+
assignee { id name displayName }
|
|
117
|
+
project { id name url state }
|
|
118
|
+
state { id name position }
|
|
119
|
+
labels { nodes { id name } }
|
|
120
|
+
comments(first: 250) {
|
|
121
|
+
nodes { id }
|
|
122
|
+
pageInfo { hasNextPage endCursor }
|
|
123
|
+
}
|
|
124
|
+
team { id }
|
|
125
|
+
`
|
|
126
|
+
|
|
100
127
|
export class LinearClient {
|
|
101
128
|
private readonly endpoint = 'https://api.linear.app/graphql'
|
|
102
129
|
|
|
@@ -183,58 +210,80 @@ export class LinearClient {
|
|
|
183
210
|
}
|
|
184
211
|
|
|
185
212
|
async listUsers(): Promise<Array<{ id: string; name: string; active?: boolean }>> {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
213
|
+
type UserNode = {
|
|
214
|
+
id: string
|
|
215
|
+
name?: string | null
|
|
216
|
+
displayName?: string | null
|
|
217
|
+
active?: boolean | null
|
|
218
|
+
}
|
|
219
|
+
let after: string | null = null
|
|
220
|
+
const users: Array<{ id: string; name: string; active?: boolean }> = []
|
|
221
|
+
|
|
222
|
+
do {
|
|
223
|
+
const data: { users: { nodes: UserNode[]; pageInfo: PageInfo } } = await this.query(
|
|
224
|
+
`
|
|
225
|
+
query Users($after: String) {
|
|
226
|
+
users(first: 100, after: $after) {
|
|
227
|
+
nodes {
|
|
228
|
+
id
|
|
229
|
+
name
|
|
230
|
+
displayName
|
|
231
|
+
active
|
|
232
|
+
}
|
|
233
|
+
pageInfo {
|
|
234
|
+
hasNextPage
|
|
235
|
+
endCursor
|
|
236
|
+
}
|
|
204
237
|
}
|
|
205
238
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
239
|
+
`,
|
|
240
|
+
{ after },
|
|
241
|
+
)
|
|
242
|
+
for (const user of data.users.nodes) {
|
|
243
|
+
users.push({
|
|
244
|
+
id: user.id,
|
|
245
|
+
name: user.displayName || user.name || user.id,
|
|
246
|
+
active: user.active ?? true,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
after = data.users.pageInfo.hasNextPage ? data.users.pageInfo.endCursor : null
|
|
250
|
+
} while (after)
|
|
251
|
+
|
|
252
|
+
return users
|
|
214
253
|
}
|
|
215
254
|
|
|
216
255
|
async listProjects(): Promise<
|
|
217
256
|
Array<{ id: string; name: string; url?: string | null; state?: string | null }>
|
|
218
257
|
> {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
type ProjectNode = { id: string; name: string; url?: string | null; state?: string | null }
|
|
259
|
+
let after: string | null = null
|
|
260
|
+
const projects: ProjectNode[] = []
|
|
261
|
+
|
|
262
|
+
do {
|
|
263
|
+
const data: { projects: { nodes: ProjectNode[]; pageInfo: PageInfo } } = await this.query(
|
|
264
|
+
`
|
|
265
|
+
query Projects($after: String) {
|
|
266
|
+
projects(first: 100, after: $after) {
|
|
267
|
+
nodes {
|
|
268
|
+
id
|
|
269
|
+
name
|
|
270
|
+
url
|
|
271
|
+
state
|
|
272
|
+
}
|
|
273
|
+
pageInfo {
|
|
274
|
+
hasNextPage
|
|
275
|
+
endCursor
|
|
276
|
+
}
|
|
232
277
|
}
|
|
233
278
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
279
|
+
`,
|
|
280
|
+
{ after },
|
|
281
|
+
)
|
|
282
|
+
projects.push(...data.projects.nodes)
|
|
283
|
+
after = data.projects.pageInfo.hasNextPage ? data.projects.pageInfo.endCursor : null
|
|
284
|
+
} while (after)
|
|
285
|
+
|
|
286
|
+
return projects
|
|
238
287
|
}
|
|
239
288
|
|
|
240
289
|
async listIssueLabels(): Promise<LinearIssueLabel[]> {
|
|
@@ -293,39 +342,7 @@ export class LinearClient {
|
|
|
293
342
|
updatedAt: { gte: $updatedAfter }
|
|
294
343
|
}
|
|
295
344
|
) {
|
|
296
|
-
nodes {
|
|
297
|
-
id
|
|
298
|
-
identifier
|
|
299
|
-
title
|
|
300
|
-
description
|
|
301
|
-
priority
|
|
302
|
-
url
|
|
303
|
-
createdAt
|
|
304
|
-
updatedAt
|
|
305
|
-
assignee {
|
|
306
|
-
id
|
|
307
|
-
name
|
|
308
|
-
displayName
|
|
309
|
-
}
|
|
310
|
-
project {
|
|
311
|
-
id
|
|
312
|
-
name
|
|
313
|
-
url
|
|
314
|
-
state
|
|
315
|
-
}
|
|
316
|
-
state {
|
|
317
|
-
id
|
|
318
|
-
name
|
|
319
|
-
position
|
|
320
|
-
}
|
|
321
|
-
labels {
|
|
322
|
-
nodes { id name }
|
|
323
|
-
}
|
|
324
|
-
comments(first: 250) {
|
|
325
|
-
nodes { id }
|
|
326
|
-
pageInfo { hasNextPage endCursor }
|
|
327
|
-
}
|
|
328
|
-
}
|
|
345
|
+
nodes { ${ISSUE_NODE_FIELDS} }
|
|
329
346
|
pageInfo {
|
|
330
347
|
hasNextPage
|
|
331
348
|
endCursor
|
|
@@ -335,13 +352,90 @@ export class LinearClient {
|
|
|
335
352
|
`,
|
|
336
353
|
{ teamId, after, updatedAfter: updatedAfter ?? '1970-01-01T00:00:00.000Z' },
|
|
337
354
|
)
|
|
338
|
-
|
|
355
|
+
const pageIssues = data.issues.nodes.map(toLinearIssue)
|
|
356
|
+
// Linear connections expose no totalCount, and the inline comments page is
|
|
357
|
+
// capped at 250. When an issue has more, the loaded length undercounts, so
|
|
358
|
+
// continue paging the (id-only) comment cursor to get an accurate count
|
|
359
|
+
// instead of advertising a partial page length as the total.
|
|
360
|
+
await Promise.all(
|
|
361
|
+
data.issues.nodes.map(async (node, index) => {
|
|
362
|
+
const info = node.comments?.pageInfo
|
|
363
|
+
if (info?.hasNextPage && info.endCursor) {
|
|
364
|
+
pageIssues[index]!.commentCount = await this.countIssueComments(
|
|
365
|
+
node.id,
|
|
366
|
+
info.endCursor,
|
|
367
|
+
node.comments?.nodes?.length ?? 0,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
}),
|
|
371
|
+
)
|
|
372
|
+
issues.push(...pageIssues)
|
|
339
373
|
after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
|
|
340
374
|
} while (after)
|
|
341
375
|
|
|
342
376
|
return issues
|
|
343
377
|
}
|
|
344
378
|
|
|
379
|
+
// Continue paging an issue's comment cursor (id-only) to count comments beyond
|
|
380
|
+
// the inline first page. Only invoked when the first page reported hasNextPage.
|
|
381
|
+
private async countIssueComments(
|
|
382
|
+
issueId: string,
|
|
383
|
+
startCursor: string,
|
|
384
|
+
loaded: number,
|
|
385
|
+
): Promise<number> {
|
|
386
|
+
let count = loaded
|
|
387
|
+
let after: string | null = startCursor
|
|
388
|
+
|
|
389
|
+
while (after) {
|
|
390
|
+
const data: {
|
|
391
|
+
issue: { comments: { nodes: Array<{ id: string }>; pageInfo: PageInfo } } | null
|
|
392
|
+
} = await this.query(
|
|
393
|
+
`
|
|
394
|
+
query IssueCommentCount($id: String!, $after: String) {
|
|
395
|
+
issue(id: $id) {
|
|
396
|
+
comments(first: 250, after: $after) {
|
|
397
|
+
nodes { id }
|
|
398
|
+
pageInfo { hasNextPage endCursor }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
`,
|
|
403
|
+
{ id: issueId, after },
|
|
404
|
+
)
|
|
405
|
+
const comments = data.issue?.comments
|
|
406
|
+
if (!comments) break
|
|
407
|
+
count += comments.nodes.length
|
|
408
|
+
after = comments.pageInfo.hasNextPage ? comments.pageInfo.endCursor : null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return count
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Fetch a single issue with the same selection set as the bulk sync so a
|
|
415
|
+
// read-after-write hydrate can refresh one cache row without a full team
|
|
416
|
+
// reconcile. Returns null when the issue no longer exists.
|
|
417
|
+
async getIssue(issueId: string): Promise<LinearIssue | null> {
|
|
418
|
+
const data = await this.query<{ issue: LinearIssueNode | null }>(
|
|
419
|
+
`
|
|
420
|
+
query IssueById($id: String!) {
|
|
421
|
+
issue(id: $id) { ${ISSUE_NODE_FIELDS} }
|
|
422
|
+
}
|
|
423
|
+
`,
|
|
424
|
+
{ id: issueId },
|
|
425
|
+
)
|
|
426
|
+
if (!data.issue) return null
|
|
427
|
+
const issue = toLinearIssue(data.issue)
|
|
428
|
+
const info = data.issue.comments?.pageInfo
|
|
429
|
+
if (info?.hasNextPage && info.endCursor) {
|
|
430
|
+
issue.commentCount = await this.countIssueComments(
|
|
431
|
+
data.issue.id,
|
|
432
|
+
info.endCursor,
|
|
433
|
+
data.issue.comments?.nodes?.length ?? 0,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
return issue
|
|
437
|
+
}
|
|
438
|
+
|
|
345
439
|
async createIssue(input: {
|
|
346
440
|
teamId: string
|
|
347
441
|
stateId?: string
|
|
@@ -359,24 +453,7 @@ export class LinearClient {
|
|
|
359
453
|
mutation CreateIssue($input: IssueCreateInput!) {
|
|
360
454
|
issueCreate(input: $input) {
|
|
361
455
|
success
|
|
362
|
-
issue {
|
|
363
|
-
id
|
|
364
|
-
identifier
|
|
365
|
-
title
|
|
366
|
-
description
|
|
367
|
-
priority
|
|
368
|
-
url
|
|
369
|
-
createdAt
|
|
370
|
-
updatedAt
|
|
371
|
-
assignee { id name displayName }
|
|
372
|
-
project { id name url state }
|
|
373
|
-
state { id name position }
|
|
374
|
-
labels { nodes { id name } }
|
|
375
|
-
comments(first: 250) {
|
|
376
|
-
nodes { id }
|
|
377
|
-
pageInfo { hasNextPage endCursor }
|
|
378
|
-
}
|
|
379
|
-
}
|
|
456
|
+
issue { ${ISSUE_NODE_FIELDS} }
|
|
380
457
|
}
|
|
381
458
|
}
|
|
382
459
|
`,
|