@andypai/agent-kanban 0.5.1 → 0.6.1
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/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1188
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/mcp/core.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { KanbanProvider } from '../providers/types'
|
|
2
2
|
import type { Task, TaskComment } from '../types'
|
|
3
|
+
import * as useCases from '../use-cases'
|
|
3
4
|
import { TrackerMcpError, toTrackerMcpError } from './errors'
|
|
4
5
|
import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
|
|
5
6
|
|
|
@@ -122,7 +123,7 @@ export function createTrackerCore<TScope>(input: {
|
|
|
122
123
|
ticketId,
|
|
123
124
|
execute: async () => {
|
|
124
125
|
await policy.canReadTicket(scope, ticketId)
|
|
125
|
-
return
|
|
126
|
+
return useCases.getTask(provider, ticketId)
|
|
126
127
|
},
|
|
127
128
|
})
|
|
128
129
|
},
|
|
@@ -134,7 +135,7 @@ export function createTrackerCore<TScope>(input: {
|
|
|
134
135
|
ticketId,
|
|
135
136
|
execute: async () => {
|
|
136
137
|
await policy.canReadTicket(scope, ticketId)
|
|
137
|
-
const comments = await
|
|
138
|
+
const comments = await useCases.listComments(provider, ticketId)
|
|
138
139
|
return filterComments(scope, comments, policy)
|
|
139
140
|
},
|
|
140
141
|
resultMeta: (comments) => ({ commentCount: comments.length }),
|
|
@@ -145,7 +146,7 @@ export function createTrackerCore<TScope>(input: {
|
|
|
145
146
|
return runTool({
|
|
146
147
|
scope,
|
|
147
148
|
tool: 'getBoard',
|
|
148
|
-
execute: async () =>
|
|
149
|
+
execute: async () => useCases.getBoard(provider),
|
|
149
150
|
})
|
|
150
151
|
},
|
|
151
152
|
|
|
@@ -156,7 +157,7 @@ export function createTrackerCore<TScope>(input: {
|
|
|
156
157
|
ticketId,
|
|
157
158
|
execute: async () => {
|
|
158
159
|
await policy.canPostComment(scope, ticketId, body)
|
|
159
|
-
return
|
|
160
|
+
return useCases.addComment(provider, ticketId, body)
|
|
160
161
|
},
|
|
161
162
|
resultMeta: (comment) => ({ commentId: comment.id }),
|
|
162
163
|
})
|
|
@@ -168,9 +169,9 @@ export function createTrackerCore<TScope>(input: {
|
|
|
168
169
|
tool: 'updateComment',
|
|
169
170
|
ticketId,
|
|
170
171
|
execute: async () => {
|
|
171
|
-
const existing = await
|
|
172
|
+
const existing = await useCases.getComment(provider, ticketId, commentId)
|
|
172
173
|
await policy.canUpdateComment(scope, ticketId, existing, body)
|
|
173
|
-
return
|
|
174
|
+
return useCases.updateComment(provider, ticketId, commentId, body)
|
|
174
175
|
},
|
|
175
176
|
resultMeta: (comment) => ({ commentId: comment.id }),
|
|
176
177
|
})
|
|
@@ -183,7 +184,7 @@ export function createTrackerCore<TScope>(input: {
|
|
|
183
184
|
ticketId,
|
|
184
185
|
execute: async () => {
|
|
185
186
|
await policy.canMoveTicket(scope, ticketId, column)
|
|
186
|
-
await
|
|
187
|
+
await useCases.moveTask(provider, ticketId, column)
|
|
187
188
|
},
|
|
188
189
|
resultMeta: { movedTo: column },
|
|
189
190
|
})
|
package/src/metrics.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite'
|
|
2
2
|
import type { ActivityEntry, BoardMetrics } from './types'
|
|
3
|
+
import { selectDoneColumnIds, selectInProgressColumnIds } from './column-roles'
|
|
3
4
|
|
|
4
5
|
function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
|
|
5
6
|
return (
|
|
@@ -22,13 +23,28 @@ export function getDiscoveredProjects(db: Database): string[] {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function getBoardMetrics(db: Database): BoardMetrics {
|
|
25
|
-
const
|
|
26
|
+
const columnCounts = db
|
|
26
27
|
.query(
|
|
27
|
-
`SELECT c.name as column_name, COUNT(t.id) as count
|
|
28
|
+
`SELECT c.id as id, c.name as column_name, c.position as position, COUNT(t.id) as count
|
|
28
29
|
FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
|
|
29
30
|
GROUP BY c.id ORDER BY c.position`,
|
|
30
31
|
)
|
|
31
|
-
.all() as { column_name: string; count: number }[]
|
|
32
|
+
.all() as { id: string; column_name: string; position: number; count: number }[]
|
|
33
|
+
|
|
34
|
+
const tasksByColumn = columnCounts.map((row) => ({
|
|
35
|
+
column_name: row.column_name,
|
|
36
|
+
count: row.count,
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
// Classify columns by role (handles custom names / KANBAN_DEFAULT_COLUMNS)
|
|
40
|
+
// instead of matching the literal strings 'done' / 'in-progress'.
|
|
41
|
+
const columns = columnCounts.map((row) => ({
|
|
42
|
+
id: row.id,
|
|
43
|
+
name: row.column_name,
|
|
44
|
+
position: row.position,
|
|
45
|
+
}))
|
|
46
|
+
const doneColumnIds = new Set(selectDoneColumnIds(columns))
|
|
47
|
+
const inProgressColumnIds = new Set(selectInProgressColumnIds(columns))
|
|
32
48
|
|
|
33
49
|
const tasksByPriority = db
|
|
34
50
|
.query(
|
|
@@ -41,26 +57,37 @@ export function getBoardMetrics(db: Database): BoardMetrics {
|
|
|
41
57
|
|
|
42
58
|
const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
|
|
43
59
|
|
|
44
|
-
const completedTasks =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'done'`,
|
|
48
|
-
)
|
|
60
|
+
const completedTasks = columnCounts
|
|
61
|
+
.filter((row) => doneColumnIds.has(row.id))
|
|
62
|
+
.reduce((sum, row) => sum + row.count, 0)
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
// Average completion time = first time a task was tracked (creation) until the
|
|
65
|
+
// first time it entered a Done column. Measuring the Done *entry* (not exit)
|
|
66
|
+
// means tasks that reach Done and stay there are counted; MIN() over Done rows
|
|
67
|
+
// handles tasks that re-enter Done. Kept in lockstep with the Postgres copy in
|
|
68
|
+
// postgres-local.ts:getMetrics — update both together.
|
|
69
|
+
const donePlaceholders = [...doneColumnIds].map(() => '?').join(', ')
|
|
70
|
+
const avgResult = (
|
|
71
|
+
doneColumnIds.size === 0
|
|
72
|
+
? { avg_hours: null }
|
|
73
|
+
: (db
|
|
74
|
+
.query(
|
|
75
|
+
`SELECT AVG(
|
|
76
|
+
(julianday(done_enter.entered_at) - julianday(first_enter.entered_at)) * 24
|
|
54
77
|
) as avg_hours
|
|
55
|
-
FROM
|
|
56
|
-
|
|
78
|
+
FROM (
|
|
79
|
+
SELECT ct.task_id, MIN(ct.entered_at) as entered_at
|
|
80
|
+
FROM column_time_tracking ct
|
|
81
|
+
WHERE ct.column_id IN (${donePlaceholders})
|
|
82
|
+
GROUP BY ct.task_id
|
|
83
|
+
) done_enter
|
|
57
84
|
JOIN (
|
|
58
85
|
SELECT task_id, MIN(entered_at) as entered_at
|
|
59
86
|
FROM column_time_tracking GROUP BY task_id
|
|
60
|
-
) first_enter ON first_enter.task_id =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
) first_enter ON first_enter.task_id = done_enter.task_id`,
|
|
88
|
+
)
|
|
89
|
+
.get(...doneColumnIds) as { avg_hours: number | null })
|
|
90
|
+
) as { avg_hours: number | null }
|
|
64
91
|
|
|
65
92
|
const recentActivity = db
|
|
66
93
|
.query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
|
|
@@ -71,11 +98,9 @@ export function getBoardMetrics(db: Database): BoardMetrics {
|
|
|
71
98
|
"SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
|
|
72
99
|
)
|
|
73
100
|
|
|
74
|
-
const inProgressCount =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'in-progress'`,
|
|
78
|
-
)
|
|
101
|
+
const inProgressCount = columnCounts
|
|
102
|
+
.filter((row) => inProgressColumnIds.has(row.id))
|
|
103
|
+
.reduce((sum, row) => sum + row.count, 0)
|
|
79
104
|
|
|
80
105
|
const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
81
106
|
|
package/src/provider-runtime.ts
CHANGED
|
@@ -22,7 +22,12 @@ export interface KanbanRuntime {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function openKanbanRuntime(
|
|
25
|
-
opts: {
|
|
25
|
+
opts: {
|
|
26
|
+
dbPath?: string
|
|
27
|
+
storage?: KanbanStorageConfig
|
|
28
|
+
tracker?: TrackerConfig
|
|
29
|
+
seedLocalColumns?: boolean
|
|
30
|
+
} = {},
|
|
26
31
|
): Promise<KanbanRuntime> {
|
|
27
32
|
const dbPath = opts.dbPath ?? getDbPath()
|
|
28
33
|
const storage =
|
|
@@ -98,7 +103,9 @@ export async function openKanbanRuntime(
|
|
|
98
103
|
const db = openDb(storage.sqlitePath)
|
|
99
104
|
migrateSchema(db)
|
|
100
105
|
return {
|
|
101
|
-
provider: createProvider(db, trackerConfig, storage.sqlitePath
|
|
106
|
+
provider: createProvider(db, trackerConfig, storage.sqlitePath, {
|
|
107
|
+
seedLocalColumns: opts.seedLocalColumns,
|
|
108
|
+
}),
|
|
102
109
|
dbPath: storage.sqlitePath,
|
|
103
110
|
trackerConfig,
|
|
104
111
|
sqliteDb: db,
|
package/src/providers/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export function createProvider(
|
|
|
10
10
|
db: Database,
|
|
11
11
|
config: TrackerConfig,
|
|
12
12
|
dbPath = getDbPath(),
|
|
13
|
+
options: { seedLocalColumns?: boolean } = {},
|
|
13
14
|
): KanbanProvider {
|
|
14
15
|
if (config.provider === 'linear') {
|
|
15
16
|
return new LinearProvider(db, config.teamId, config.apiKey, config.syncIntervalMs)
|
|
@@ -28,6 +29,8 @@ export function createProvider(
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
initSchema(db)
|
|
31
|
-
|
|
32
|
+
if (options.seedLocalColumns !== false) {
|
|
33
|
+
seedDefaultColumns(db, config.defaultColumns)
|
|
34
|
+
}
|
|
32
35
|
return new LocalProvider(db, dbPath)
|
|
33
36
|
}
|
|
@@ -297,12 +297,21 @@ export function replaceJiraColumns(
|
|
|
297
297
|
statusIds: string[]
|
|
298
298
|
source: 'board' | 'status'
|
|
299
299
|
}>,
|
|
300
|
+
prune = true,
|
|
300
301
|
): void {
|
|
302
|
+
// prune=true (full reconcile / direct callers): replace the whole catalog.
|
|
303
|
+
// prune=false (delta sync): upsert the current rows and leave the rest, so a
|
|
304
|
+
// stale delta snapshot never deletes a row a fresher sync just added.
|
|
301
305
|
const run = db.transaction(() => {
|
|
302
|
-
db.run('DELETE FROM jira_columns')
|
|
306
|
+
if (prune) db.run('DELETE FROM jira_columns')
|
|
303
307
|
const stmt = db.prepare(
|
|
304
308
|
`INSERT INTO jira_columns (id, name, position, status_ids, source)
|
|
305
|
-
VALUES ($id, $name, $position, $status_ids, $source)
|
|
309
|
+
VALUES ($id, $name, $position, $status_ids, $source)
|
|
310
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
311
|
+
name = excluded.name,
|
|
312
|
+
position = excluded.position,
|
|
313
|
+
status_ids = excluded.status_ids,
|
|
314
|
+
source = excluded.source`,
|
|
306
315
|
)
|
|
307
316
|
for (const column of columns) {
|
|
308
317
|
stmt.run({
|
|
@@ -341,10 +350,14 @@ export function upsertJiraUsers(
|
|
|
341
350
|
export function replaceJiraPriorities(
|
|
342
351
|
db: Database,
|
|
343
352
|
priorities: Array<{ id: string; name: string }>,
|
|
353
|
+
prune = true,
|
|
344
354
|
): void {
|
|
345
355
|
const run = db.transaction(() => {
|
|
346
|
-
db.run('DELETE FROM jira_priorities')
|
|
347
|
-
const stmt = db.prepare(
|
|
356
|
+
if (prune) db.run('DELETE FROM jira_priorities')
|
|
357
|
+
const stmt = db.prepare(
|
|
358
|
+
`INSERT INTO jira_priorities (id, name) VALUES ($id, $name)
|
|
359
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name`,
|
|
360
|
+
)
|
|
348
361
|
for (const priority of priorities) {
|
|
349
362
|
stmt.run({ $id: priority.id, $name: priority.name })
|
|
350
363
|
}
|
|
@@ -355,10 +368,14 @@ export function replaceJiraPriorities(
|
|
|
355
368
|
export function replaceJiraIssueTypes(
|
|
356
369
|
db: Database,
|
|
357
370
|
types: Array<{ id: string; name: string }>,
|
|
371
|
+
prune = true,
|
|
358
372
|
): void {
|
|
359
373
|
const run = db.transaction(() => {
|
|
360
|
-
db.run('DELETE FROM jira_issue_types')
|
|
361
|
-
const stmt = db.prepare(
|
|
374
|
+
if (prune) db.run('DELETE FROM jira_issue_types')
|
|
375
|
+
const stmt = db.prepare(
|
|
376
|
+
`INSERT INTO jira_issue_types (id, name) VALUES ($id, $name)
|
|
377
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name`,
|
|
378
|
+
)
|
|
362
379
|
for (const type of types) {
|
|
363
380
|
stmt.run({ $id: type.id, $name: type.name })
|
|
364
381
|
}
|
|
@@ -45,12 +45,73 @@ export interface JiraIssue {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface JiraSearchPage {
|
|
48
|
-
startAt
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// The legacy /rest/api/3/search endpoint returned startAt/maxResults/total.
|
|
49
|
+
// The current /rest/api/3/search/jql endpoint omits `total` and paginates by
|
|
50
|
+
// an opaque `nextPageToken` (with `isLast`), so these are optional now.
|
|
51
|
+
startAt?: number
|
|
52
|
+
maxResults?: number
|
|
53
|
+
total?: number
|
|
54
|
+
nextPageToken?: string
|
|
55
|
+
isLast?: boolean
|
|
51
56
|
issues: JiraIssue[]
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
export interface JiraPaginationDecision {
|
|
60
|
+
// When set, advance the scan to this cursor. When absent, the scan is over and
|
|
61
|
+
// `complete` says whether it ended definitively (safe to prune against the
|
|
62
|
+
// accumulated issue set) or was cut short (must NOT prune — issues on unfetched
|
|
63
|
+
// pages would be wrongly deleted).
|
|
64
|
+
nextToken?: string
|
|
65
|
+
complete: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Decide how a `/rest/api/3/search/jql` cursor scan should proceed after a page.
|
|
69
|
+
// The endpoint signals continuation with `isLast`/`nextPageToken`, but real and
|
|
70
|
+
// degraded servers vary, so termination must be conservative: a scan is reported
|
|
71
|
+
// `complete` (safe to prune against the accumulated issue set) ONLY when the
|
|
72
|
+
// server proves the end, never from a page-size guess.
|
|
73
|
+
// - usable cursor (fresh `nextPageToken`, isLast !== true) → advance.
|
|
74
|
+
// - isLast === true → definitive end (complete).
|
|
75
|
+
// - isLast === false → more pages exist; if the
|
|
76
|
+
// cursor is missing/stale we
|
|
77
|
+
// cannot fetch them, so the
|
|
78
|
+
// scan is incomplete.
|
|
79
|
+
// - isLast absent, `total` present → legacy total/startAt proof:
|
|
80
|
+
// complete once startAt+count
|
|
81
|
+
// reaches `total`.
|
|
82
|
+
// - isLast absent, no `total`, no usable cursor → NO completeness proof. A
|
|
83
|
+
// short/empty page must NOT be
|
|
84
|
+
// read as "the end": a degraded
|
|
85
|
+
// `{ issues: [] }` would then
|
|
86
|
+
// prune the entire cache on a
|
|
87
|
+
// full reconcile. Treat as
|
|
88
|
+
// incomplete and retry instead.
|
|
89
|
+
// A cursor already in `seenPageTokens` counts as not usable (a stalled cursor
|
|
90
|
+
// that would otherwise loop forever).
|
|
91
|
+
export function decideJiraPagination(
|
|
92
|
+
page: Pick<JiraSearchPage, 'isLast' | 'nextPageToken' | 'issues' | 'total' | 'startAt'>,
|
|
93
|
+
seenPageTokens: ReadonlySet<string>,
|
|
94
|
+
): JiraPaginationDecision {
|
|
95
|
+
const token = page.nextPageToken
|
|
96
|
+
const canAdvance = !!token && page.isLast !== true && !seenPageTokens.has(token)
|
|
97
|
+
if (canAdvance) return { nextToken: token, complete: false }
|
|
98
|
+
if (page.isLast === true) return { complete: true }
|
|
99
|
+
if (page.isLast === false) return { complete: false }
|
|
100
|
+
// isLast absent below. Prefer the legacy total/startAt signal when a
|
|
101
|
+
// (non-standard) server supplies it: complete once we have fetched everything
|
|
102
|
+
// `total` promises. The live /search/jql endpoint omits `total` and ignores
|
|
103
|
+
// `startAt`, so the loop never advances by offset — a `total` promising more
|
|
104
|
+
// than this page is reported incomplete rather than fetched.
|
|
105
|
+
if (typeof page.total === 'number') {
|
|
106
|
+
const issueCount = page.issues?.length ?? 0
|
|
107
|
+
return { complete: (page.startAt ?? 0) + issueCount >= page.total }
|
|
108
|
+
}
|
|
109
|
+
// No completeness signal at all (no isLast, no total, no usable cursor). We
|
|
110
|
+
// cannot prove the scan reached the end, so never guess `complete` from page
|
|
111
|
+
// size — that would prune the whole cache on a degraded short/empty response.
|
|
112
|
+
return { complete: false }
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
export interface JiraCreatePayload {
|
|
55
116
|
fields: Record<string, unknown>
|
|
56
117
|
}
|
|
@@ -254,12 +315,17 @@ export class JiraClient {
|
|
|
254
315
|
startAt: number
|
|
255
316
|
maxResults: number
|
|
256
317
|
fields?: string[]
|
|
318
|
+
nextPageToken?: string
|
|
257
319
|
}): Promise<JiraSearchPage> {
|
|
258
320
|
const query: QueryParams = {
|
|
259
321
|
jql: params.jql,
|
|
260
|
-
startAt: params.startAt,
|
|
261
322
|
maxResults: params.maxResults,
|
|
262
323
|
}
|
|
324
|
+
// /rest/api/3/search/jql ignores startAt and paginates by nextPageToken.
|
|
325
|
+
// Send the cursor when we have one; only send startAt on the first page for
|
|
326
|
+
// back-compat with the legacy endpoint.
|
|
327
|
+
if (params.nextPageToken) query.nextPageToken = params.nextPageToken
|
|
328
|
+
else query.startAt = params.startAt
|
|
263
329
|
if (params.fields && params.fields.length > 0) {
|
|
264
330
|
query.fields = params.fields.join(',')
|
|
265
331
|
}
|