@kernhq/module-tracker 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 +662 -0
- package/README.md +265 -0
- package/dist/contract/events.d.ts +140 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +51 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +6 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +6 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +2547 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +1214 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +4 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +46 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +161 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +192 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +9659 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +732 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/kql/ast.d.ts +90 -0
- package/dist/kql/ast.d.ts.map +1 -0
- package/dist/kql/ast.js +80 -0
- package/dist/kql/ast.js.map +1 -0
- package/dist/kql/dates.d.ts +16 -0
- package/dist/kql/dates.d.ts.map +1 -0
- package/dist/kql/dates.js +54 -0
- package/dist/kql/dates.js.map +1 -0
- package/dist/kql/fields.d.ts +61 -0
- package/dist/kql/fields.d.ts.map +1 -0
- package/dist/kql/fields.js +106 -0
- package/dist/kql/fields.js.map +1 -0
- package/dist/kql/index.d.ts +16 -0
- package/dist/kql/index.d.ts.map +1 -0
- package/dist/kql/index.js +16 -0
- package/dist/kql/index.js.map +1 -0
- package/dist/kql/lexer.d.ts +28 -0
- package/dist/kql/lexer.d.ts.map +1 -0
- package/dist/kql/lexer.js +127 -0
- package/dist/kql/lexer.js.map +1 -0
- package/dist/kql/parser.d.ts +14 -0
- package/dist/kql/parser.d.ts.map +1 -0
- package/dist/kql/parser.js +290 -0
- package/dist/kql/parser.js.map +1 -0
- package/dist/kql/suggest.d.ts +8 -0
- package/dist/kql/suggest.d.ts.map +1 -0
- package/dist/kql/suggest.js +96 -0
- package/dist/kql/suggest.js.map +1 -0
- package/dist/kql/validate.d.ts +10 -0
- package/dist/kql/validate.d.ts.map +1 -0
- package/dist/kql/validate.js +84 -0
- package/dist/kql/validate.js.map +1 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +583 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/kql/compile.d.ts +49 -0
- package/dist/server/kql/compile.d.ts.map +1 -0
- package/dist/server/kql/compile.js +372 -0
- package/dist/server/kql/compile.js.map +1 -0
- package/dist/server/rank.d.ts +23 -0
- package/dist/server/rank.d.ts.map +1 -0
- package/dist/server/rank.js +66 -0
- package/dist/server/rank.js.map +1 -0
- package/dist/server/rich.d.ts +30 -0
- package/dist/server/rich.d.ts.map +1 -0
- package/dist/server/rich.js +97 -0
- package/dist/server/rich.js.map +1 -0
- package/dist/server/router.d.ts +10589 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +431 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +6622 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +581 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/access.d.ts +53 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +146 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/comments.d.ts +36 -0
- package/dist/server/services/comments.d.ts.map +1 -0
- package/dist/server/services/comments.js +231 -0
- package/dist/server/services/comments.js.map +1 -0
- package/dist/server/services/config.d.ts +130 -0
- package/dist/server/services/config.d.ts.map +1 -0
- package/dist/server/services/config.js +797 -0
- package/dist/server/services/config.js.map +1 -0
- package/dist/server/services/db.d.ts +93 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +519 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/imports.d.ts +37 -0
- package/dist/server/services/imports.d.ts.map +1 -0
- package/dist/server/services/imports.js +324 -0
- package/dist/server/services/imports.js.map +1 -0
- package/dist/server/services/index.d.ts +36 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +57 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/intake.d.ts +47 -0
- package/dist/server/services/intake.d.ts.map +1 -0
- package/dist/server/services/intake.js +268 -0
- package/dist/server/services/intake.js.map +1 -0
- package/dist/server/services/issues.d.ts +763 -0
- package/dist/server/services/issues.d.ts.map +1 -0
- package/dist/server/services/issues.js +1381 -0
- package/dist/server/services/issues.js.map +1 -0
- package/dist/server/services/notify.d.ts +51 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +90 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/planning.d.ts +62 -0
- package/dist/server/services/planning.d.ts.map +1 -0
- package/dist/server/services/planning.js +614 -0
- package/dist/server/services/planning.js.map +1 -0
- package/dist/server/services/projects.d.ts +43 -0
- package/dist/server/services/projects.d.ts.map +1 -0
- package/dist/server/services/projects.js +375 -0
- package/dist/server/services/projects.js.map +1 -0
- package/dist/server/services/query.d.ts +24 -0
- package/dist/server/services/query.d.ts.map +1 -0
- package/dist/server/services/query.js +312 -0
- package/dist/server/services/query.js.map +1 -0
- package/dist/server/services/reports.d.ts +30 -0
- package/dist/server/services/reports.d.ts.map +1 -0
- package/dist/server/services/reports.js +295 -0
- package/dist/server/services/reports.js.map +1 -0
- package/dist/server/services/time.d.ts +53 -0
- package/dist/server/services/time.d.ts.map +1 -0
- package/dist/server/services/time.js +192 -0
- package/dist/server/services/time.js.map +1 -0
- package/dist/server/services/transitions.d.ts +61 -0
- package/dist/server/services/transitions.d.ts.map +1 -0
- package/dist/server/services/transitions.js +493 -0
- package/dist/server/services/transitions.js.map +1 -0
- package/dist/server/services/views.d.ts +33 -0
- package/dist/server/services/views.d.ts.map +1 -0
- package/dist/server/services/views.js +195 -0
- package/dist/server/services/views.js.map +1 -0
- package/migrations/0000_init.sql +529 -0
- package/migrations/0001_rls.sql +259 -0
- package/migrations/meta/0000_snapshot.json +4140 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +82 -0
- package/src/client/api.ts +15 -0
- package/src/client/format.ts +164 -0
- package/src/client/group.ts +150 -0
- package/src/client/index.ts +60 -0
- package/src/client/kql.ts +492 -0
- package/src/client/rank.ts +87 -0
- package/src/client/types.ts +57 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
3
|
+
import { trackerEvents } from '../../contract/events.js';
|
|
4
|
+
import { components, cycles, issueCounters, issues, labels, milestones, versions } from '../schema.js';
|
|
5
|
+
import { EMPTY_CYCLE_STATS, toComponent, toCycle, toLabel, toMilestone, toVersion, } from './db.js';
|
|
6
|
+
const RESOLVED = ['done', 'cancelled'];
|
|
7
|
+
export class PlanningService {
|
|
8
|
+
kernel;
|
|
9
|
+
access;
|
|
10
|
+
notify;
|
|
11
|
+
constructor(kernel, access, notify) {
|
|
12
|
+
this.kernel = kernel;
|
|
13
|
+
this.access = access;
|
|
14
|
+
this.notify = notify;
|
|
15
|
+
}
|
|
16
|
+
// ------------------------------------------------------------------ cycles
|
|
17
|
+
async cycleStats(tx, workspaceId, cycleIds) {
|
|
18
|
+
const out = new Map();
|
|
19
|
+
for (const id of cycleIds)
|
|
20
|
+
out.set(id, { ...EMPTY_CYCLE_STATS });
|
|
21
|
+
if (!cycleIds.length)
|
|
22
|
+
return out;
|
|
23
|
+
const rows = await tx
|
|
24
|
+
.select({
|
|
25
|
+
cycleId: issues.cycleId,
|
|
26
|
+
total: sql `count(*)::int`,
|
|
27
|
+
done: sql `count(*) filter (where ${issues.statusCategory} in ('done','cancelled'))::int`,
|
|
28
|
+
estimateTotal: sql `coalesce(sum(${issues.estimate}), 0)::float8`,
|
|
29
|
+
estimateDone: sql `coalesce(sum(${issues.estimate}) filter (where ${issues.statusCategory} in ('done','cancelled')), 0)::float8`,
|
|
30
|
+
})
|
|
31
|
+
.from(issues)
|
|
32
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(issues.cycleId, cycleIds), isNull(issues.archivedAt)))
|
|
33
|
+
.groupBy(issues.cycleId);
|
|
34
|
+
for (const row of rows)
|
|
35
|
+
if (row.cycleId)
|
|
36
|
+
out.set(row.cycleId, {
|
|
37
|
+
total: Number(row.total),
|
|
38
|
+
done: Number(row.done),
|
|
39
|
+
estimateTotal: Number(row.estimateTotal),
|
|
40
|
+
estimateDone: Number(row.estimateDone),
|
|
41
|
+
});
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
async listCycles(tx, principal, workspaceId, projectId, status) {
|
|
45
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId);
|
|
46
|
+
const filters = [eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, projectId)];
|
|
47
|
+
if (status)
|
|
48
|
+
filters.push(eq(cycles.status, status));
|
|
49
|
+
const rows = await tx
|
|
50
|
+
.select()
|
|
51
|
+
.from(cycles)
|
|
52
|
+
.where(and(...filters))
|
|
53
|
+
.orderBy(asc(cycles.number));
|
|
54
|
+
const stats = await this.cycleStats(tx, workspaceId, rows.map((r) => r.id));
|
|
55
|
+
return rows.map((r) => toCycle(r, stats.get(r.id)));
|
|
56
|
+
}
|
|
57
|
+
async getCycle(tx, principal, workspaceId, id) {
|
|
58
|
+
const row = await this.cycleRow(tx, workspaceId, id);
|
|
59
|
+
await this.access.requireProject(tx, principal, workspaceId, row.projectId);
|
|
60
|
+
const stats = await this.cycleStats(tx, workspaceId, [id]);
|
|
61
|
+
return toCycle(row, stats.get(id));
|
|
62
|
+
}
|
|
63
|
+
async cycleRow(tx, workspaceId, id) {
|
|
64
|
+
const [row] = await tx
|
|
65
|
+
.select()
|
|
66
|
+
.from(cycles)
|
|
67
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
|
|
68
|
+
.limit(1);
|
|
69
|
+
if (!row)
|
|
70
|
+
throw KernError.notFound('Cycle');
|
|
71
|
+
return row;
|
|
72
|
+
}
|
|
73
|
+
async createCycle(tx, principal, workspaceId, projectId, input) {
|
|
74
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.cycle.manage');
|
|
75
|
+
if (new Date(input.endAt) <= new Date(input.startAt))
|
|
76
|
+
throw KernError.badRequest('A cycle must end after it starts');
|
|
77
|
+
await tx
|
|
78
|
+
.insert(issueCounters)
|
|
79
|
+
.values({ projectId, workspaceId })
|
|
80
|
+
.onConflictDoNothing({ target: issueCounters.projectId });
|
|
81
|
+
const [counter] = await tx
|
|
82
|
+
.update(issueCounters)
|
|
83
|
+
.set({ lastCycleNumber: sql `${issueCounters.lastCycleNumber} + 1` })
|
|
84
|
+
.where(and(eq(issueCounters.workspaceId, workspaceId), eq(issueCounters.projectId, projectId)))
|
|
85
|
+
.returning({ n: issueCounters.lastCycleNumber });
|
|
86
|
+
const number = counter?.n ?? 1;
|
|
87
|
+
const [row] = await tx
|
|
88
|
+
.insert(cycles)
|
|
89
|
+
.values({
|
|
90
|
+
id: uuidv7(),
|
|
91
|
+
workspaceId,
|
|
92
|
+
projectId,
|
|
93
|
+
number,
|
|
94
|
+
name: input.name ?? `Cycle ${number}`,
|
|
95
|
+
goal: input.goal ?? null,
|
|
96
|
+
startAt: new Date(input.startAt),
|
|
97
|
+
endAt: new Date(input.endAt),
|
|
98
|
+
status: 'upcoming',
|
|
99
|
+
})
|
|
100
|
+
.returning();
|
|
101
|
+
await this.notify.change(workspaceId, 'cycle', row.id, 'created', { scope: { projectId } });
|
|
102
|
+
return toCycle(row);
|
|
103
|
+
}
|
|
104
|
+
async updateCycle(tx, principal, workspaceId, id, patch) {
|
|
105
|
+
const current = await this.cycleRow(tx, workspaceId, id);
|
|
106
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
107
|
+
const [row] = await tx
|
|
108
|
+
.update(cycles)
|
|
109
|
+
.set({
|
|
110
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
111
|
+
...(patch.goal === undefined ? {} : { goal: patch.goal }),
|
|
112
|
+
...(patch.startAt === undefined ? {} : { startAt: new Date(patch.startAt) }),
|
|
113
|
+
...(patch.endAt === undefined ? {} : { endAt: new Date(patch.endAt) }),
|
|
114
|
+
updatedAt: new Date(),
|
|
115
|
+
})
|
|
116
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
|
|
117
|
+
.returning();
|
|
118
|
+
const stats = await this.cycleStats(tx, workspaceId, [id]);
|
|
119
|
+
await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
|
|
120
|
+
return toCycle(row, stats.get(id));
|
|
121
|
+
}
|
|
122
|
+
async deleteCycle(tx, principal, workspaceId, id) {
|
|
123
|
+
const current = await this.cycleRow(tx, workspaceId, id);
|
|
124
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
125
|
+
await tx
|
|
126
|
+
.update(issues)
|
|
127
|
+
.set({ cycleId: null })
|
|
128
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.cycleId, id)));
|
|
129
|
+
await tx.delete(cycles).where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)));
|
|
130
|
+
await this.notify.change(workspaceId, 'cycle', id, 'deleted', { scope: { projectId: current.projectId } });
|
|
131
|
+
}
|
|
132
|
+
/** Starting a cycle snapshots its scope so velocity can compare committed against completed. */
|
|
133
|
+
async startCycle(tx, principal, workspaceId, id) {
|
|
134
|
+
const current = await this.cycleRow(tx, workspaceId, id);
|
|
135
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
136
|
+
if (current.status === 'completed')
|
|
137
|
+
throw KernError.conflict('Cycle is already completed');
|
|
138
|
+
const [running] = await tx
|
|
139
|
+
.select({ id: cycles.id })
|
|
140
|
+
.from(cycles)
|
|
141
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, current.projectId), eq(cycles.status, 'active')))
|
|
142
|
+
.limit(1);
|
|
143
|
+
if (running && running.id !== id)
|
|
144
|
+
throw KernError.conflict('Another cycle is already active in this project', 'tracker.cycle.active');
|
|
145
|
+
const stats = (await this.cycleStats(tx, workspaceId, [id])).get(id) ?? EMPTY_CYCLE_STATS;
|
|
146
|
+
const [row] = await tx
|
|
147
|
+
.update(cycles)
|
|
148
|
+
.set({
|
|
149
|
+
status: 'active',
|
|
150
|
+
startedAt: new Date(),
|
|
151
|
+
committed: { total: stats.total, estimateTotal: stats.estimateTotal },
|
|
152
|
+
updatedAt: new Date(),
|
|
153
|
+
})
|
|
154
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
|
|
155
|
+
.returning();
|
|
156
|
+
await this.kernel.emit(trackerEvents.cycleStarted, {
|
|
157
|
+
workspaceId: workspaceId,
|
|
158
|
+
projectId: current.projectId,
|
|
159
|
+
cycleId: id,
|
|
160
|
+
number: current.number,
|
|
161
|
+
}, { workspaceId, actorId: principal.userId });
|
|
162
|
+
await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
|
|
163
|
+
return toCycle(row, stats);
|
|
164
|
+
}
|
|
165
|
+
/** Complete a cycle; unfinished work rolls into `rollToCycleId`, the next upcoming cycle, or the backlog. */
|
|
166
|
+
async completeCycle(tx, principal, workspaceId, id, rollToCycleId) {
|
|
167
|
+
const current = await this.cycleRow(tx, workspaceId, id);
|
|
168
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
169
|
+
if (current.status === 'completed')
|
|
170
|
+
return this.getCycle(tx, principal, workspaceId, id);
|
|
171
|
+
let target = rollToCycleId ?? null;
|
|
172
|
+
if (target === undefined || target === null) {
|
|
173
|
+
const [next] = await tx
|
|
174
|
+
.select({ id: cycles.id })
|
|
175
|
+
.from(cycles)
|
|
176
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, current.projectId), eq(cycles.status, 'upcoming')))
|
|
177
|
+
.orderBy(asc(cycles.startAt))
|
|
178
|
+
.limit(1);
|
|
179
|
+
target = next?.id ?? null;
|
|
180
|
+
}
|
|
181
|
+
const unfinished = await tx
|
|
182
|
+
.select({ id: issues.id })
|
|
183
|
+
.from(issues)
|
|
184
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.cycleId, id), isNull(issues.archivedAt), sql `${issues.statusCategory} not in ('done','cancelled')`));
|
|
185
|
+
if (unfinished.length)
|
|
186
|
+
await tx
|
|
187
|
+
.update(issues)
|
|
188
|
+
.set({ cycleId: target, updatedAt: new Date() })
|
|
189
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, unfinished.map((r) => r.id))));
|
|
190
|
+
if (target && unfinished.length)
|
|
191
|
+
await tx
|
|
192
|
+
.update(cycles)
|
|
193
|
+
.set({ carryOverCount: sql `${cycles.carryOverCount} + ${unfinished.length}` })
|
|
194
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, target)));
|
|
195
|
+
const stats = (await this.cycleStats(tx, workspaceId, [id])).get(id) ?? EMPTY_CYCLE_STATS;
|
|
196
|
+
const [row] = await tx
|
|
197
|
+
.update(cycles)
|
|
198
|
+
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
|
|
199
|
+
.where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
|
|
200
|
+
.returning();
|
|
201
|
+
await this.kernel.emit(trackerEvents.cycleCompleted, {
|
|
202
|
+
workspaceId: workspaceId,
|
|
203
|
+
projectId: current.projectId,
|
|
204
|
+
cycleId: id,
|
|
205
|
+
number: current.number,
|
|
206
|
+
carriedOver: unfinished.length,
|
|
207
|
+
}, { workspaceId, actorId: principal.userId });
|
|
208
|
+
await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
|
|
209
|
+
return toCycle(row, stats);
|
|
210
|
+
}
|
|
211
|
+
// ------------------------------------------------------------------ milestones
|
|
212
|
+
async countByColumn(tx, workspaceId, column, ids) {
|
|
213
|
+
const out = new Map();
|
|
214
|
+
for (const id of ids)
|
|
215
|
+
out.set(id, { total: 0, done: 0 });
|
|
216
|
+
if (!ids.length)
|
|
217
|
+
return out;
|
|
218
|
+
const rows = await tx
|
|
219
|
+
.select({
|
|
220
|
+
key: column,
|
|
221
|
+
total: sql `count(*)::int`,
|
|
222
|
+
done: sql `count(*) filter (where ${issues.statusCategory} in ('done','cancelled'))::int`,
|
|
223
|
+
})
|
|
224
|
+
.from(issues)
|
|
225
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(column, ids), isNull(issues.archivedAt)))
|
|
226
|
+
.groupBy(column);
|
|
227
|
+
for (const row of rows)
|
|
228
|
+
if (row.key)
|
|
229
|
+
out.set(row.key, { total: Number(row.total), done: Number(row.done) });
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
async listMilestones(tx, principal, workspaceId, projectId) {
|
|
233
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId);
|
|
234
|
+
const rows = await tx
|
|
235
|
+
.select()
|
|
236
|
+
.from(milestones)
|
|
237
|
+
.where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.projectId, projectId)))
|
|
238
|
+
.orderBy(asc(milestones.targetDate), asc(milestones.name));
|
|
239
|
+
const stats = await this.countByColumn(tx, workspaceId, issues.milestoneId, rows.map((r) => r.id));
|
|
240
|
+
return rows.map((r) => toMilestone(r, stats.get(r.id)));
|
|
241
|
+
}
|
|
242
|
+
async createMilestone(tx, principal, workspaceId, projectId, input) {
|
|
243
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.cycle.manage');
|
|
244
|
+
const [row] = await tx
|
|
245
|
+
.insert(milestones)
|
|
246
|
+
.values({
|
|
247
|
+
id: uuidv7(),
|
|
248
|
+
workspaceId,
|
|
249
|
+
projectId,
|
|
250
|
+
name: input.name,
|
|
251
|
+
description: input.description ?? null,
|
|
252
|
+
targetDate: input.targetDate ?? null,
|
|
253
|
+
status: input.status ?? 'open',
|
|
254
|
+
})
|
|
255
|
+
.returning();
|
|
256
|
+
await this.notify.change(workspaceId, 'milestone', row.id, 'created', { scope: { projectId } });
|
|
257
|
+
return toMilestone(row);
|
|
258
|
+
}
|
|
259
|
+
async updateMilestone(tx, principal, workspaceId, id, patch) {
|
|
260
|
+
const [current] = await tx
|
|
261
|
+
.select()
|
|
262
|
+
.from(milestones)
|
|
263
|
+
.where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
|
|
264
|
+
.limit(1);
|
|
265
|
+
if (!current)
|
|
266
|
+
throw KernError.notFound('Milestone');
|
|
267
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
268
|
+
const [row] = await tx
|
|
269
|
+
.update(milestones)
|
|
270
|
+
.set({
|
|
271
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
272
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
273
|
+
...(patch.targetDate === undefined ? {} : { targetDate: patch.targetDate }),
|
|
274
|
+
...(patch.status === undefined
|
|
275
|
+
? {}
|
|
276
|
+
: { status: patch.status, completedAt: patch.status === 'completed' ? new Date() : null }),
|
|
277
|
+
updatedAt: new Date(),
|
|
278
|
+
})
|
|
279
|
+
.where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
|
|
280
|
+
.returning();
|
|
281
|
+
const stats = await this.countByColumn(tx, workspaceId, issues.milestoneId, [id]);
|
|
282
|
+
await this.notify.change(workspaceId, 'milestone', id, 'updated', {
|
|
283
|
+
scope: { projectId: current.projectId },
|
|
284
|
+
});
|
|
285
|
+
return toMilestone(row, stats.get(id));
|
|
286
|
+
}
|
|
287
|
+
async deleteMilestone(tx, principal, workspaceId, id) {
|
|
288
|
+
const [current] = await tx
|
|
289
|
+
.select()
|
|
290
|
+
.from(milestones)
|
|
291
|
+
.where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
|
|
292
|
+
.limit(1);
|
|
293
|
+
if (!current)
|
|
294
|
+
throw KernError.notFound('Milestone');
|
|
295
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
|
|
296
|
+
await tx
|
|
297
|
+
.update(issues)
|
|
298
|
+
.set({ milestoneId: null })
|
|
299
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.milestoneId, id)));
|
|
300
|
+
await tx.delete(milestones).where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)));
|
|
301
|
+
await this.notify.change(workspaceId, 'milestone', id, 'deleted', {
|
|
302
|
+
scope: { projectId: current.projectId },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// ------------------------------------------------------------------ versions
|
|
306
|
+
async versionStats(tx, ids) {
|
|
307
|
+
const out = new Map();
|
|
308
|
+
for (const id of ids)
|
|
309
|
+
out.set(id, { total: 0, done: 0 });
|
|
310
|
+
if (!ids.length)
|
|
311
|
+
return out;
|
|
312
|
+
const rows = await tx.execute(sql `
|
|
313
|
+
select v.id, count(i.id)::int as total,
|
|
314
|
+
count(i.id) filter (where i.status_category in ('done','cancelled'))::int as done
|
|
315
|
+
from unnest(${sql.param(ids)}::uuid[]) as v(id)
|
|
316
|
+
left join ${issues} i on v.id = any(i.version_ids) and i.archived_at is null
|
|
317
|
+
group by v.id`);
|
|
318
|
+
for (const row of rows.rows)
|
|
319
|
+
out.set(row.id, { total: Number(row.total), done: Number(row.done) });
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
async listVersions(tx, principal, workspaceId, projectId) {
|
|
323
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId);
|
|
324
|
+
const rows = await tx
|
|
325
|
+
.select()
|
|
326
|
+
.from(versions)
|
|
327
|
+
.where(and(eq(versions.workspaceId, workspaceId), eq(versions.projectId, projectId)))
|
|
328
|
+
.orderBy(asc(versions.order), asc(versions.name));
|
|
329
|
+
const stats = await this.versionStats(tx, rows.map((r) => r.id));
|
|
330
|
+
return rows.map((r) => toVersion(r, stats.get(r.id)));
|
|
331
|
+
}
|
|
332
|
+
async createVersion(tx, principal, workspaceId, projectId, input) {
|
|
333
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.version.manage');
|
|
334
|
+
const [row] = await tx
|
|
335
|
+
.insert(versions)
|
|
336
|
+
.values({
|
|
337
|
+
id: uuidv7(),
|
|
338
|
+
workspaceId,
|
|
339
|
+
projectId,
|
|
340
|
+
name: input.name,
|
|
341
|
+
description: input.description ?? null,
|
|
342
|
+
startDate: input.startDate ?? null,
|
|
343
|
+
releaseDate: input.releaseDate ?? null,
|
|
344
|
+
order: input.order ?? 0,
|
|
345
|
+
})
|
|
346
|
+
.returning();
|
|
347
|
+
await this.notify.change(workspaceId, 'version', row.id, 'created', { scope: { projectId } });
|
|
348
|
+
return toVersion(row);
|
|
349
|
+
}
|
|
350
|
+
async updateVersion(tx, principal, workspaceId, id, patch) {
|
|
351
|
+
const current = await this.versionRow(tx, workspaceId, id);
|
|
352
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
|
|
353
|
+
const [row] = await tx
|
|
354
|
+
.update(versions)
|
|
355
|
+
.set({
|
|
356
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
357
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
358
|
+
...(patch.startDate === undefined ? {} : { startDate: patch.startDate }),
|
|
359
|
+
...(patch.releaseDate === undefined ? {} : { releaseDate: patch.releaseDate }),
|
|
360
|
+
...(patch.order === undefined ? {} : { order: patch.order }),
|
|
361
|
+
updatedAt: new Date(),
|
|
362
|
+
})
|
|
363
|
+
.where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
|
|
364
|
+
.returning();
|
|
365
|
+
const stats = await this.versionStats(tx, [id]);
|
|
366
|
+
await this.notify.change(workspaceId, 'version', id, 'updated', {
|
|
367
|
+
scope: { projectId: current.projectId },
|
|
368
|
+
});
|
|
369
|
+
return toVersion(row, stats.get(id));
|
|
370
|
+
}
|
|
371
|
+
async versionRow(tx, workspaceId, id) {
|
|
372
|
+
const [row] = await tx
|
|
373
|
+
.select()
|
|
374
|
+
.from(versions)
|
|
375
|
+
.where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
|
|
376
|
+
.limit(1);
|
|
377
|
+
if (!row)
|
|
378
|
+
throw KernError.notFound('Version');
|
|
379
|
+
return row;
|
|
380
|
+
}
|
|
381
|
+
async deleteVersion(tx, principal, workspaceId, id) {
|
|
382
|
+
const current = await this.versionRow(tx, workspaceId, id);
|
|
383
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
|
|
384
|
+
await tx
|
|
385
|
+
.update(issues)
|
|
386
|
+
.set({
|
|
387
|
+
versionIds: sql `array_remove(${issues.versionIds}, ${id}::uuid)`,
|
|
388
|
+
affectsVersionIds: sql `array_remove(${issues.affectsVersionIds}, ${id}::uuid)`,
|
|
389
|
+
})
|
|
390
|
+
.where(sql `${id}::uuid = any(${issues.versionIds}) or ${id}::uuid = any(${issues.affectsVersionIds})`);
|
|
391
|
+
await tx.delete(versions).where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)));
|
|
392
|
+
await this.notify.change(workspaceId, 'version', id, 'deleted', {
|
|
393
|
+
scope: { projectId: current.projectId },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async releaseVersion(tx, principal, workspaceId, id, released) {
|
|
397
|
+
const current = await this.versionRow(tx, workspaceId, id);
|
|
398
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
|
|
399
|
+
const [row] = await tx
|
|
400
|
+
.update(versions)
|
|
401
|
+
.set({
|
|
402
|
+
status: released ? 'released' : 'unreleased',
|
|
403
|
+
releasedAt: released ? new Date() : null,
|
|
404
|
+
updatedAt: new Date(),
|
|
405
|
+
})
|
|
406
|
+
.where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
|
|
407
|
+
.returning();
|
|
408
|
+
if (released)
|
|
409
|
+
await this.kernel.emit(trackerEvents.versionReleased, {
|
|
410
|
+
workspaceId: workspaceId,
|
|
411
|
+
projectId: current.projectId,
|
|
412
|
+
versionId: id,
|
|
413
|
+
name: current.name,
|
|
414
|
+
}, { workspaceId, actorId: principal.userId });
|
|
415
|
+
const stats = await this.versionStats(tx, [id]);
|
|
416
|
+
await this.notify.change(workspaceId, 'version', id, 'updated', {
|
|
417
|
+
scope: { projectId: current.projectId },
|
|
418
|
+
});
|
|
419
|
+
return toVersion(row, stats.get(id));
|
|
420
|
+
}
|
|
421
|
+
// ------------------------------------------------------------------ components
|
|
422
|
+
async listComponents(tx, principal, workspaceId, projectId) {
|
|
423
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId);
|
|
424
|
+
const rows = await tx
|
|
425
|
+
.select()
|
|
426
|
+
.from(components)
|
|
427
|
+
.where(and(eq(components.workspaceId, workspaceId), eq(components.projectId, projectId)))
|
|
428
|
+
.orderBy(asc(components.name));
|
|
429
|
+
const counts = await this.arrayCounts(tx, workspaceId, 'component_ids', rows.map((r) => r.id));
|
|
430
|
+
return rows.map((r) => toComponent(r, counts.get(r.id) ?? 0));
|
|
431
|
+
}
|
|
432
|
+
async arrayCounts(tx, workspaceId, column, ids) {
|
|
433
|
+
const out = new Map();
|
|
434
|
+
if (!ids.length)
|
|
435
|
+
return out;
|
|
436
|
+
const rows = await tx.execute(sql `
|
|
437
|
+
select v.id, count(i.id)::int as n
|
|
438
|
+
from unnest(${sql.param(ids)}::uuid[]) as v(id)
|
|
439
|
+
left join ${issues} i on v.id = any(i.${sql.raw(column)}) and i.archived_at is null
|
|
440
|
+
group by v.id`);
|
|
441
|
+
for (const row of rows.rows)
|
|
442
|
+
out.set(row.id, Number(row.n));
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
async createComponent(tx, principal, workspaceId, projectId, input) {
|
|
446
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
|
|
447
|
+
const [row] = await tx
|
|
448
|
+
.insert(components)
|
|
449
|
+
.values({
|
|
450
|
+
id: uuidv7(),
|
|
451
|
+
workspaceId,
|
|
452
|
+
projectId,
|
|
453
|
+
name: input.name,
|
|
454
|
+
description: input.description ?? null,
|
|
455
|
+
leadId: input.leadId ?? null,
|
|
456
|
+
defaultAssignee: input.defaultAssignee ?? 'project',
|
|
457
|
+
})
|
|
458
|
+
.returning();
|
|
459
|
+
await this.notify.change(workspaceId, 'component', row.id, 'created', { scope: { projectId } });
|
|
460
|
+
return toComponent(row);
|
|
461
|
+
}
|
|
462
|
+
async updateComponent(tx, principal, workspaceId, id, patch) {
|
|
463
|
+
const [current] = await tx
|
|
464
|
+
.select()
|
|
465
|
+
.from(components)
|
|
466
|
+
.where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
|
|
467
|
+
.limit(1);
|
|
468
|
+
if (!current)
|
|
469
|
+
throw KernError.notFound('Component');
|
|
470
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
|
|
471
|
+
const [row] = await tx
|
|
472
|
+
.update(components)
|
|
473
|
+
.set({
|
|
474
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
475
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
476
|
+
...(patch.leadId === undefined ? {} : { leadId: patch.leadId }),
|
|
477
|
+
...(patch.defaultAssignee === undefined ? {} : { defaultAssignee: patch.defaultAssignee }),
|
|
478
|
+
updatedAt: new Date(),
|
|
479
|
+
})
|
|
480
|
+
.where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
|
|
481
|
+
.returning();
|
|
482
|
+
const counts = await this.arrayCounts(tx, workspaceId, 'component_ids', [id]);
|
|
483
|
+
await this.notify.change(workspaceId, 'component', id, 'updated', {
|
|
484
|
+
scope: { projectId: current.projectId },
|
|
485
|
+
});
|
|
486
|
+
return toComponent(row, counts.get(id) ?? 0);
|
|
487
|
+
}
|
|
488
|
+
async deleteComponent(tx, principal, workspaceId, id) {
|
|
489
|
+
const [current] = await tx
|
|
490
|
+
.select()
|
|
491
|
+
.from(components)
|
|
492
|
+
.where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
|
|
493
|
+
.limit(1);
|
|
494
|
+
if (!current)
|
|
495
|
+
throw KernError.notFound('Component');
|
|
496
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
|
|
497
|
+
await tx
|
|
498
|
+
.update(issues)
|
|
499
|
+
.set({ componentIds: sql `array_remove(${issues.componentIds}, ${id}::uuid)` })
|
|
500
|
+
.where(sql `${id}::uuid = any(${issues.componentIds})`);
|
|
501
|
+
await tx.delete(components).where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)));
|
|
502
|
+
await this.notify.change(workspaceId, 'component', id, 'deleted', {
|
|
503
|
+
scope: { projectId: current.projectId },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// ------------------------------------------------------------------ labels
|
|
507
|
+
async listLabels(tx, principal, workspaceId, projectId, includeArchived = false) {
|
|
508
|
+
if (projectId)
|
|
509
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId);
|
|
510
|
+
const filters = [eq(labels.workspaceId, workspaceId)];
|
|
511
|
+
if (!includeArchived)
|
|
512
|
+
filters.push(isNull(labels.archivedAt));
|
|
513
|
+
if (projectId)
|
|
514
|
+
filters.push(sql `(${labels.projectId} is null or ${labels.projectId} = ${projectId})`);
|
|
515
|
+
const rows = await tx
|
|
516
|
+
.select()
|
|
517
|
+
.from(labels)
|
|
518
|
+
.where(and(...filters))
|
|
519
|
+
.orderBy(asc(labels.groupName), asc(labels.name));
|
|
520
|
+
const counts = await this.arrayCounts(tx, workspaceId, 'label_ids', rows.map((r) => r.id));
|
|
521
|
+
return rows.map((r) => toLabel(r, counts.get(r.id) ?? 0));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Labels are project configuration. A label attached to a project needs `tracker.project.manage`
|
|
525
|
+
* on that project; a workspace-wide label (`projectId === null`) has no project whose scheme could
|
|
526
|
+
* grant it, so it takes the same permission at workspace scope.
|
|
527
|
+
*/
|
|
528
|
+
async requireLabelManage(tx, principal, workspaceId, projectId) {
|
|
529
|
+
if (projectId)
|
|
530
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
|
|
531
|
+
else
|
|
532
|
+
await this.access.requireWorkspace(principal, 'tracker.project.manage', workspaceId);
|
|
533
|
+
}
|
|
534
|
+
async createLabel(tx, principal, workspaceId, input) {
|
|
535
|
+
await this.requireLabelManage(tx, principal, workspaceId, input.projectId ?? null);
|
|
536
|
+
const [row] = await tx
|
|
537
|
+
.insert(labels)
|
|
538
|
+
.values({
|
|
539
|
+
id: uuidv7(),
|
|
540
|
+
workspaceId,
|
|
541
|
+
projectId: input.projectId ?? null,
|
|
542
|
+
name: input.name,
|
|
543
|
+
color: input.color ?? null,
|
|
544
|
+
description: input.description ?? null,
|
|
545
|
+
groupName: input.groupName ?? null,
|
|
546
|
+
})
|
|
547
|
+
.returning();
|
|
548
|
+
await this.notify.change(workspaceId, 'label', row.id, 'created');
|
|
549
|
+
return toLabel(row);
|
|
550
|
+
}
|
|
551
|
+
async updateLabel(tx, principal, workspaceId, id, patch) {
|
|
552
|
+
const current = await this.labelRow(tx, workspaceId, id);
|
|
553
|
+
await this.requireLabelManage(tx, principal, workspaceId, current.projectId);
|
|
554
|
+
// moving a label into (or out of) a project needs the permission on the destination too
|
|
555
|
+
if (patch.projectId !== undefined && (patch.projectId ?? null) !== current.projectId)
|
|
556
|
+
await this.requireLabelManage(tx, principal, workspaceId, patch.projectId ?? null);
|
|
557
|
+
const [row] = await tx
|
|
558
|
+
.update(labels)
|
|
559
|
+
.set({
|
|
560
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
561
|
+
...(patch.color === undefined ? {} : { color: patch.color }),
|
|
562
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
563
|
+
...(patch.groupName === undefined ? {} : { groupName: patch.groupName }),
|
|
564
|
+
...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
|
|
565
|
+
})
|
|
566
|
+
.where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)))
|
|
567
|
+
.returning();
|
|
568
|
+
if (!row)
|
|
569
|
+
throw KernError.notFound('Label');
|
|
570
|
+
const counts = await this.arrayCounts(tx, workspaceId, 'label_ids', [id]);
|
|
571
|
+
await this.notify.change(workspaceId, 'label', id, 'updated');
|
|
572
|
+
return toLabel(row, counts.get(id) ?? 0);
|
|
573
|
+
}
|
|
574
|
+
async deleteLabel(tx, principal, workspaceId, id) {
|
|
575
|
+
const current = await this.labelRow(tx, workspaceId, id);
|
|
576
|
+
await this.requireLabelManage(tx, principal, workspaceId, current.projectId);
|
|
577
|
+
await tx
|
|
578
|
+
.update(issues)
|
|
579
|
+
.set({ labelIds: sql `array_remove(${issues.labelIds}, ${id}::uuid)` })
|
|
580
|
+
.where(and(eq(issues.workspaceId, workspaceId), sql `${id}::uuid = any(${issues.labelIds})`));
|
|
581
|
+
await tx.delete(labels).where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)));
|
|
582
|
+
await this.notify.change(workspaceId, 'label', id, 'deleted');
|
|
583
|
+
}
|
|
584
|
+
async labelRow(tx, workspaceId, id) {
|
|
585
|
+
const [row] = await tx
|
|
586
|
+
.select()
|
|
587
|
+
.from(labels)
|
|
588
|
+
.where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)))
|
|
589
|
+
.limit(1);
|
|
590
|
+
if (!row)
|
|
591
|
+
throw KernError.notFound('Label');
|
|
592
|
+
return row;
|
|
593
|
+
}
|
|
594
|
+
/** Find or create a label by name, used by CSV import and email ingest. */
|
|
595
|
+
async ensureLabel(tx, workspaceId, projectId, name) {
|
|
596
|
+
const [existing] = await tx
|
|
597
|
+
.select({ id: labels.id })
|
|
598
|
+
.from(labels)
|
|
599
|
+
.where(and(eq(labels.workspaceId, workspaceId), eq(labels.name, name), projectId ? eq(labels.projectId, projectId) : isNull(labels.projectId)))
|
|
600
|
+
.limit(1);
|
|
601
|
+
if (existing)
|
|
602
|
+
return existing.id;
|
|
603
|
+
const [row] = await tx
|
|
604
|
+
.insert(labels)
|
|
605
|
+
.values({ id: uuidv7(), workspaceId, projectId, name })
|
|
606
|
+
.returning({ id: labels.id });
|
|
607
|
+
return row.id;
|
|
608
|
+
}
|
|
609
|
+
/** Statuses considered resolved when rolling a cycle over. */
|
|
610
|
+
resolvedCategories() {
|
|
611
|
+
return RESOLVED;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
//# sourceMappingURL=planning.js.map
|