@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,1381 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { initialStatus, RESOLVED_CATEGORIES } from '@kernhq/workflow';
|
|
3
|
+
import { and, asc, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm';
|
|
4
|
+
import { trackerEvents } from '../../contract/events.js';
|
|
5
|
+
import { MODULE_ID, RELATION_INVERSE, } from '../../contract/models.js';
|
|
6
|
+
import { initialRank, rankBetween } from '../rank.js';
|
|
7
|
+
import { docToText, extractMentions, preview } from '../rich.js';
|
|
8
|
+
import { attachments, comments, components, cycles, issueApprovals, issueCounters, issueHistory, issueStatusHistory, issues, issueTemplates, labels, links, milestones, projects, recurringIssues, relations, timers, versions, workItemTypes, worklogs, } from '../schema.js';
|
|
9
|
+
import { added, EMPTY_RELATION_SUMMARY, issueUrl, parseSettings, removed, toAttachment, toHistoryEntry, toIssue, toIssueSummary, toIssueTemplate, toLink, toRecurringIssue, toStatusHistoryEntry, uniq, } from './db.js';
|
|
10
|
+
const objectRef = (id) => ({ module: MODULE_ID, type: 'issue', id });
|
|
11
|
+
/** Issues and everything attached to them: relations, attachments, links, watchers, history. */
|
|
12
|
+
export class IssueService {
|
|
13
|
+
kernel;
|
|
14
|
+
access;
|
|
15
|
+
config;
|
|
16
|
+
notify;
|
|
17
|
+
constructor(kernel, access, config, notify) {
|
|
18
|
+
this.kernel = kernel;
|
|
19
|
+
this.access = access;
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.notify = notify;
|
|
22
|
+
}
|
|
23
|
+
// ------------------------------------------------------------------ reads
|
|
24
|
+
async row(tx, workspaceId, issueId) {
|
|
25
|
+
const [row] = await tx
|
|
26
|
+
.select()
|
|
27
|
+
.from(issues)
|
|
28
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
|
|
29
|
+
.limit(1);
|
|
30
|
+
if (!row)
|
|
31
|
+
throw KernError.notFound('Issue');
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
/** Relation and sub-item counters for a page of issues, in two aggregate queries. */
|
|
35
|
+
async relationSummaries(tx, ids) {
|
|
36
|
+
const out = new Map();
|
|
37
|
+
if (!ids.length)
|
|
38
|
+
return out;
|
|
39
|
+
for (const id of ids)
|
|
40
|
+
out.set(id, { ...EMPTY_RELATION_SUMMARY });
|
|
41
|
+
const rel = await tx.execute(sql `
|
|
42
|
+
select r.${sql.raw('from_issue_id')} as issue_id, r.type, count(*)::int as n,
|
|
43
|
+
count(*) filter (where t.status_category not in ('done', 'cancelled'))::int as open
|
|
44
|
+
from ${relations} r join ${issues} t on t.id = r.${sql.raw('to_issue_id')}
|
|
45
|
+
where r.${sql.raw('from_issue_id')} = any(${sql.param(ids)}::uuid[])
|
|
46
|
+
group by 1, 2`);
|
|
47
|
+
for (const r of rel.rows) {
|
|
48
|
+
const entry = out.get(r.issue_id);
|
|
49
|
+
if (!entry)
|
|
50
|
+
continue;
|
|
51
|
+
const n = Number(r.n);
|
|
52
|
+
if (r.type === 'blocks')
|
|
53
|
+
entry.blocks += n;
|
|
54
|
+
else if (r.type === 'blocked_by') {
|
|
55
|
+
entry.blockedBy += n;
|
|
56
|
+
entry.openBlockers += Number(r.open);
|
|
57
|
+
}
|
|
58
|
+
else if (r.type === 'relates')
|
|
59
|
+
entry.relates += n;
|
|
60
|
+
else if (r.type === 'duplicates' || r.type === 'duplicated_by')
|
|
61
|
+
entry.duplicates += n;
|
|
62
|
+
}
|
|
63
|
+
const subs = await tx.execute(sql `
|
|
64
|
+
select ${issues.parentId} as parent_id, count(*)::int as total,
|
|
65
|
+
count(*) filter (where ${issues.statusCategory} in ('done', 'cancelled'))::int as done
|
|
66
|
+
from ${issues}
|
|
67
|
+
where ${issues.parentId} = any(${sql.param(ids)}::uuid[]) and ${issues.archivedAt} is null
|
|
68
|
+
group by 1`);
|
|
69
|
+
for (const r of subs.rows) {
|
|
70
|
+
const entry = out.get(r.parent_id);
|
|
71
|
+
if (!entry)
|
|
72
|
+
continue;
|
|
73
|
+
entry.subItems = Number(r.total);
|
|
74
|
+
entry.subItemsDone = Number(r.done);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
async hydrate(tx, rows) {
|
|
79
|
+
const summaries = await this.relationSummaries(tx, rows.map((r) => r.id));
|
|
80
|
+
return rows.map((r) => toIssue(r, summaries.get(r.id)));
|
|
81
|
+
}
|
|
82
|
+
async get(tx, principal, workspaceId, issueId) {
|
|
83
|
+
const row = await this.row(tx, workspaceId, issueId);
|
|
84
|
+
await this.access.requireProject(tx, principal, workspaceId, row.projectId, 'tracker.issue.view');
|
|
85
|
+
return (await this.hydrate(tx, [row]))[0];
|
|
86
|
+
}
|
|
87
|
+
async getByKey(tx, principal, workspaceId, key) {
|
|
88
|
+
const [row] = await tx
|
|
89
|
+
.select()
|
|
90
|
+
.from(issues)
|
|
91
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.key, key.toUpperCase())))
|
|
92
|
+
.limit(1);
|
|
93
|
+
if (!row)
|
|
94
|
+
throw KernError.notFound('Issue');
|
|
95
|
+
await this.access.requireProject(tx, principal, workspaceId, row.projectId, 'tracker.issue.view');
|
|
96
|
+
return (await this.hydrate(tx, [row]))[0];
|
|
97
|
+
}
|
|
98
|
+
async getMany(tx, principal, workspaceId, ids) {
|
|
99
|
+
if (!ids.length)
|
|
100
|
+
return [];
|
|
101
|
+
const rows = await tx
|
|
102
|
+
.select()
|
|
103
|
+
.from(issues)
|
|
104
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, ids)));
|
|
105
|
+
const allowed = new Set(await this.access.visibleProjectIds(tx, principal, workspaceId, { includeArchived: true }));
|
|
106
|
+
const visible = rows.filter((r) => allowed.has(r.projectId));
|
|
107
|
+
const hydrated = await this.hydrate(tx, visible);
|
|
108
|
+
const byId = new Map(hydrated.map((i) => [i.id, i]));
|
|
109
|
+
return ids.map((id) => byId.get(id)).filter((i) => !!i);
|
|
110
|
+
}
|
|
111
|
+
// ------------------------------------------------------------------ create
|
|
112
|
+
async create(tx, principal, workspaceId, input, opts = {}) {
|
|
113
|
+
const project = opts.system
|
|
114
|
+
? await this.access.loadProject(tx, workspaceId, input.projectId)
|
|
115
|
+
: await this.access.requireProject(tx, principal, workspaceId, input.projectId, 'tracker.issue.create');
|
|
116
|
+
if (project.archivedAt)
|
|
117
|
+
throw KernError.conflict('Project is archived', 'tracker.project.archived');
|
|
118
|
+
const merged = input.templateId
|
|
119
|
+
? await this.applyTemplate(tx, workspaceId, input)
|
|
120
|
+
: { input, subItems: [] };
|
|
121
|
+
const data = merged.input;
|
|
122
|
+
const settings = parseSettings(project.settings);
|
|
123
|
+
const type = data.typeId
|
|
124
|
+
? await this.config.getType(tx, workspaceId, data.typeId)
|
|
125
|
+
: await this.config.defaultType(tx, workspaceId, project.id);
|
|
126
|
+
const { definition } = await this.config.workflowFor(tx, project, type.id);
|
|
127
|
+
const triage = opts.triage ?? data.triage ?? false;
|
|
128
|
+
const status = this.pickInitialStatus(definition, data.statusId, triage);
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const number = await this.allocateNumber(tx, workspaceId, project.id);
|
|
131
|
+
const key = `${project.key}-${number}`;
|
|
132
|
+
const rank = await this.rankFor(tx, workspaceId, project.id, data.rankAfterId, data.rankBeforeId);
|
|
133
|
+
const parentId = data.parentId ?? null;
|
|
134
|
+
if (parentId)
|
|
135
|
+
await this.assertParentAllowed(tx, workspaceId, project, type.level, parentId);
|
|
136
|
+
const assigneeIds = await this.defaultAssignees(tx, project, data, type.id);
|
|
137
|
+
const labelIds = await this.enforceLabelGroups(tx, workspaceId, data.labelIds ?? []);
|
|
138
|
+
await this.assertPlanningRefs(tx, project.id, data);
|
|
139
|
+
const description = (data.description ?? null);
|
|
140
|
+
const descriptionText = docToText(description);
|
|
141
|
+
const custom = await this.withFieldDefaults(tx, workspaceId, project.id, data.custom ?? {});
|
|
142
|
+
const watchers = uniq([
|
|
143
|
+
...(data.watcherIds ?? []),
|
|
144
|
+
...assigneeIds,
|
|
145
|
+
...(principal.userId ? [principal.userId] : []),
|
|
146
|
+
...(data.reporterId ? [data.reporterId] : []),
|
|
147
|
+
]);
|
|
148
|
+
const source = opts.source ?? 'app';
|
|
149
|
+
const sla = this.initialSla(settings, data.priority ?? 'none', now, triage);
|
|
150
|
+
const id = uuidv7();
|
|
151
|
+
const [row] = await tx
|
|
152
|
+
.insert(issues)
|
|
153
|
+
.values({
|
|
154
|
+
id,
|
|
155
|
+
workspaceId,
|
|
156
|
+
projectId: project.id,
|
|
157
|
+
key,
|
|
158
|
+
number,
|
|
159
|
+
typeId: type.id,
|
|
160
|
+
title: data.title,
|
|
161
|
+
description,
|
|
162
|
+
descriptionText,
|
|
163
|
+
statusId: status.id,
|
|
164
|
+
statusCategory: status.category,
|
|
165
|
+
priority: data.priority ?? 'none',
|
|
166
|
+
assigneeIds,
|
|
167
|
+
reporterId: data.reporterId ?? principal.userId ?? null,
|
|
168
|
+
creatorId: principal.userId ?? null,
|
|
169
|
+
labelIds,
|
|
170
|
+
componentIds: data.componentIds ?? [],
|
|
171
|
+
versionIds: data.versionIds ?? [],
|
|
172
|
+
affectsVersionIds: data.affectsVersionIds ?? [],
|
|
173
|
+
cycleId: data.cycleId ?? null,
|
|
174
|
+
milestoneId: data.milestoneId ?? null,
|
|
175
|
+
parentId,
|
|
176
|
+
rank,
|
|
177
|
+
estimate: data.estimate ?? null,
|
|
178
|
+
estimateUnit: settings.estimation,
|
|
179
|
+
startDate: data.startDate ?? null,
|
|
180
|
+
dueDate: data.dueDate ?? null,
|
|
181
|
+
custom,
|
|
182
|
+
watcherIds: watchers,
|
|
183
|
+
originalEstimateSec: data.originalEstimateSec ?? null,
|
|
184
|
+
remainingSec: data.originalEstimateSec ?? null,
|
|
185
|
+
sla,
|
|
186
|
+
triage,
|
|
187
|
+
source,
|
|
188
|
+
externalRef: opts.externalRef ?? null,
|
|
189
|
+
})
|
|
190
|
+
.returning();
|
|
191
|
+
await tx.insert(issueStatusHistory).values({
|
|
192
|
+
id: uuidv7(),
|
|
193
|
+
workspaceId,
|
|
194
|
+
issueId: id,
|
|
195
|
+
projectId: project.id,
|
|
196
|
+
fromStatusId: null,
|
|
197
|
+
toStatusId: status.id,
|
|
198
|
+
fromCategory: null,
|
|
199
|
+
toCategory: status.category,
|
|
200
|
+
actorId: principal.userId ?? null,
|
|
201
|
+
transitionId: null,
|
|
202
|
+
durationSec: null,
|
|
203
|
+
});
|
|
204
|
+
await this.notify.history(tx, {
|
|
205
|
+
workspaceId,
|
|
206
|
+
issueId: id,
|
|
207
|
+
actorId: principal.userId ?? null,
|
|
208
|
+
action: 'created',
|
|
209
|
+
data: { key, title: data.title },
|
|
210
|
+
});
|
|
211
|
+
if (data.attachmentIds?.length)
|
|
212
|
+
await this.attachFiles(tx, workspaceId, id, data.attachmentIds, principal.userId ?? null);
|
|
213
|
+
for (const sub of merged.subItems) {
|
|
214
|
+
const subType = sub.typeId ?? (await this.subItemType(tx, workspaceId, project.id, type.level));
|
|
215
|
+
await this.create(tx, principal, workspaceId, { projectId: project.id, title: sub.title, typeId: subType, parentId: id }, { ...opts, system: true });
|
|
216
|
+
}
|
|
217
|
+
const issue = (await this.hydrate(tx, [row]))[0];
|
|
218
|
+
await this.afterCreate(issue, principal, { mentions: extractMentions(description) });
|
|
219
|
+
if (settings.autoCreateIssueChannel)
|
|
220
|
+
void this.ensureChatChannel(workspaceId, issue, watchers);
|
|
221
|
+
return issue;
|
|
222
|
+
}
|
|
223
|
+
pickInitialStatus(definition, requested, triage) {
|
|
224
|
+
if (requested) {
|
|
225
|
+
const found = definition.statuses.find((s) => s.id === requested);
|
|
226
|
+
if (!found)
|
|
227
|
+
throw KernError.badRequest(`Unknown status "${requested}" for this workflow`);
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
if (triage) {
|
|
231
|
+
const triageStatus = definition.statuses.find((s) => s.category === 'triage');
|
|
232
|
+
if (triageStatus)
|
|
233
|
+
return triageStatus;
|
|
234
|
+
}
|
|
235
|
+
return initialStatus(definition);
|
|
236
|
+
}
|
|
237
|
+
async allocateNumber(tx, workspaceId, projectId) {
|
|
238
|
+
await tx
|
|
239
|
+
.insert(issueCounters)
|
|
240
|
+
.values({ projectId, workspaceId })
|
|
241
|
+
.onConflictDoNothing({ target: issueCounters.projectId });
|
|
242
|
+
const [row] = await tx
|
|
243
|
+
.update(issueCounters)
|
|
244
|
+
.set({ lastIssueNumber: sql `${issueCounters.lastIssueNumber} + 1` })
|
|
245
|
+
.where(eq(issueCounters.projectId, projectId))
|
|
246
|
+
.returning({ n: issueCounters.lastIssueNumber });
|
|
247
|
+
if (!row)
|
|
248
|
+
throw KernError.conflict('Could not allocate an issue number');
|
|
249
|
+
return row.n;
|
|
250
|
+
}
|
|
251
|
+
async rankFor(tx, workspaceId, projectId, afterId, beforeId) {
|
|
252
|
+
if (afterId || beforeId)
|
|
253
|
+
return this.rankBetweenIssues(tx, workspaceId, afterId ?? null, beforeId ?? null);
|
|
254
|
+
const [last] = await tx
|
|
255
|
+
.select({ rank: issues.rank })
|
|
256
|
+
.from(issues)
|
|
257
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.projectId, projectId)))
|
|
258
|
+
.orderBy(desc(issues.rank))
|
|
259
|
+
.limit(1);
|
|
260
|
+
return last ? rankBetween(last.rank, null) : initialRank();
|
|
261
|
+
}
|
|
262
|
+
async rankBetweenIssues(tx, workspaceId, afterId, beforeId) {
|
|
263
|
+
const ids = [afterId, beforeId].filter((v) => !!v);
|
|
264
|
+
const rows = ids.length
|
|
265
|
+
? await tx
|
|
266
|
+
.select({ id: issues.id, rank: issues.rank })
|
|
267
|
+
.from(issues)
|
|
268
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, ids)))
|
|
269
|
+
: [];
|
|
270
|
+
const after = afterId ? (rows.find((r) => r.id === afterId)?.rank ?? null) : null;
|
|
271
|
+
const before = beforeId ? (rows.find((r) => r.id === beforeId)?.rank ?? null) : null;
|
|
272
|
+
return rankBetween(after, before);
|
|
273
|
+
}
|
|
274
|
+
async defaultAssignees(tx, project, input, _typeId) {
|
|
275
|
+
if (input.assigneeIds?.length)
|
|
276
|
+
return uniq(input.assigneeIds);
|
|
277
|
+
if (input.componentIds?.length) {
|
|
278
|
+
const rows = await tx.select().from(components).where(inArray(components.id, input.componentIds));
|
|
279
|
+
for (const component of rows) {
|
|
280
|
+
if (component.defaultAssignee === 'lead' && component.leadId)
|
|
281
|
+
return [component.leadId];
|
|
282
|
+
if (component.defaultAssignee === 'none')
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (project.defaultAssignee === 'lead' && project.leadId)
|
|
287
|
+
return [project.leadId];
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
/** Labels that share a group are mutually exclusive: the last one wins. */
|
|
291
|
+
async enforceLabelGroups(tx, workspaceId, labelIds) {
|
|
292
|
+
if (labelIds.length < 2)
|
|
293
|
+
return uniq(labelIds);
|
|
294
|
+
const rows = await tx
|
|
295
|
+
.select()
|
|
296
|
+
.from(labels)
|
|
297
|
+
.where(and(eq(labels.workspaceId, workspaceId), inArray(labels.id, labelIds)));
|
|
298
|
+
const byGroup = new Map();
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const id of uniq(labelIds)) {
|
|
301
|
+
const label = rows.find((r) => r.id === id);
|
|
302
|
+
if (!label?.groupName) {
|
|
303
|
+
out.push(id);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
byGroup.set(label.groupName, id);
|
|
307
|
+
}
|
|
308
|
+
return [...out, ...byGroup.values()];
|
|
309
|
+
}
|
|
310
|
+
async assertPlanningRefs(tx, projectId, input) {
|
|
311
|
+
if (input.cycleId) {
|
|
312
|
+
const [row] = await tx.select({ p: cycles.projectId }).from(cycles).where(eq(cycles.id, input.cycleId));
|
|
313
|
+
if (!row || row.p !== projectId)
|
|
314
|
+
throw KernError.badRequest('Cycle belongs to another project');
|
|
315
|
+
}
|
|
316
|
+
if (input.milestoneId) {
|
|
317
|
+
const [row] = await tx
|
|
318
|
+
.select({ p: milestones.projectId })
|
|
319
|
+
.from(milestones)
|
|
320
|
+
.where(eq(milestones.id, input.milestoneId));
|
|
321
|
+
if (!row || row.p !== projectId)
|
|
322
|
+
throw KernError.badRequest('Milestone belongs to another project');
|
|
323
|
+
}
|
|
324
|
+
for (const id of [...(input.versionIds ?? []), ...(input.affectsVersionIds ?? [])]) {
|
|
325
|
+
const [row] = await tx.select({ p: versions.projectId }).from(versions).where(eq(versions.id, id));
|
|
326
|
+
if (!row || row.p !== projectId)
|
|
327
|
+
throw KernError.badRequest('Version belongs to another project');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async assertParentAllowed(tx, workspaceId, project, childLevel, parentId) {
|
|
331
|
+
const parent = await this.row(tx, workspaceId, parentId);
|
|
332
|
+
if (parent.projectId !== project.id && childLevel >= 0)
|
|
333
|
+
throw KernError.badRequest('Parent must be in the same project');
|
|
334
|
+
const parentType = await this.config.getType(tx, workspaceId, parent.typeId);
|
|
335
|
+
const rules = await this.config.hierarchyRules(tx, workspaceId);
|
|
336
|
+
if (parentType.level === childLevel && !rules.allowSameLevel)
|
|
337
|
+
throw KernError.badRequest('Same-level parenting is disabled in this workspace');
|
|
338
|
+
if (parentType.level < childLevel)
|
|
339
|
+
throw KernError.badRequest('A parent must sit above its child');
|
|
340
|
+
if (parentType.level - childLevel > 1 && !rules.allowSkipLevels)
|
|
341
|
+
throw KernError.badRequest('Skipping hierarchy levels is disabled in this workspace');
|
|
342
|
+
if (childLevel < 0) {
|
|
343
|
+
let depth = 1;
|
|
344
|
+
let cursor = parent.parentId;
|
|
345
|
+
while (cursor && depth <= rules.maxSubItemDepth + 1) {
|
|
346
|
+
const next = await this.row(tx, workspaceId, cursor);
|
|
347
|
+
const nextType = await this.config.getType(tx, workspaceId, next.typeId);
|
|
348
|
+
if (nextType.level >= 0)
|
|
349
|
+
break;
|
|
350
|
+
depth++;
|
|
351
|
+
cursor = next.parentId;
|
|
352
|
+
}
|
|
353
|
+
if (depth > rules.maxSubItemDepth)
|
|
354
|
+
throw KernError.badRequest(`Sub-items may only nest ${rules.maxSubItemDepth} level(s) deep`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async subItemType(tx, workspaceId, projectId, parentLevel) {
|
|
358
|
+
const types = await this.config.listTypes(tx, workspaceId, { projectId });
|
|
359
|
+
const below = types.filter((t) => t.level < parentLevel).sort((a, b) => b.level - a.level);
|
|
360
|
+
const chosen = below[0] ?? types.find((t) => t.level === parentLevel);
|
|
361
|
+
if (!chosen)
|
|
362
|
+
throw KernError.badRequest('No work item type available for sub-items');
|
|
363
|
+
return chosen.id;
|
|
364
|
+
}
|
|
365
|
+
async withFieldDefaults(tx, workspaceId, projectId, custom) {
|
|
366
|
+
const defs = await this.config.listFieldRows(tx, workspaceId, { projectId });
|
|
367
|
+
const out = { ...custom };
|
|
368
|
+
for (const def of defs) {
|
|
369
|
+
if (out[def.key] === undefined && def.defaultValue != null)
|
|
370
|
+
out[def.key] = def.defaultValue;
|
|
371
|
+
if (def.required && (out[def.key] === undefined || out[def.key] === null))
|
|
372
|
+
throw KernError.badRequest(`Custom field "${def.name}" is required`, { field: `cf.${def.key}` });
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
initialSla(settings, priority, now, triage) {
|
|
377
|
+
if (!settings.sla.enabled)
|
|
378
|
+
return null;
|
|
379
|
+
const goal = settings.sla.goals[priority];
|
|
380
|
+
if (!goal)
|
|
381
|
+
return null;
|
|
382
|
+
const hours = (h) => (h ? new Date(now.getTime() + h * 3_600_000).toISOString() : null);
|
|
383
|
+
return {
|
|
384
|
+
firstResponseDueAt: hours(goal.firstResponseHours),
|
|
385
|
+
firstRespondedAt: null,
|
|
386
|
+
resolveDueAt: hours(goal.resolveHours),
|
|
387
|
+
pausedAt: triage && settings.sla.pauseInCategories.includes('triage') ? now.toISOString() : null,
|
|
388
|
+
pausedSec: 0,
|
|
389
|
+
breached: false,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
async applyTemplate(tx, workspaceId, input) {
|
|
393
|
+
const [row] = await tx
|
|
394
|
+
.select()
|
|
395
|
+
.from(issueTemplates)
|
|
396
|
+
.where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, input.templateId)))
|
|
397
|
+
.limit(1);
|
|
398
|
+
if (!row)
|
|
399
|
+
throw KernError.notFound('Issue template');
|
|
400
|
+
const defaults = row.defaults ?? {};
|
|
401
|
+
const { templateId: _ignored, ...rest } = input;
|
|
402
|
+
return {
|
|
403
|
+
input: {
|
|
404
|
+
...defaults,
|
|
405
|
+
...rest,
|
|
406
|
+
typeId: input.typeId ?? row.typeId ?? defaults.typeId,
|
|
407
|
+
projectId: input.projectId,
|
|
408
|
+
title: input.title,
|
|
409
|
+
},
|
|
410
|
+
subItems: row.subItems ?? [],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
async afterCreate(issue, principal, extra) {
|
|
414
|
+
await this.kernel.emit(trackerEvents.issueCreated, {
|
|
415
|
+
workspaceId: issue.workspaceId,
|
|
416
|
+
projectId: issue.projectId,
|
|
417
|
+
issueId: issue.id,
|
|
418
|
+
key: issue.key,
|
|
419
|
+
typeId: issue.typeId,
|
|
420
|
+
title: issue.title,
|
|
421
|
+
statusId: issue.statusId,
|
|
422
|
+
priority: issue.priority,
|
|
423
|
+
assigneeIds: issue.assigneeIds,
|
|
424
|
+
triage: issue.triage,
|
|
425
|
+
source: issue.source,
|
|
426
|
+
}, { workspaceId: issue.workspaceId, actorId: principal.userId });
|
|
427
|
+
await this.notify.change(issue.workspaceId, 'issue', issue.id, 'created', {
|
|
428
|
+
scope: { projectId: issue.projectId },
|
|
429
|
+
});
|
|
430
|
+
await this.notify.notify({
|
|
431
|
+
workspaceId: issue.workspaceId,
|
|
432
|
+
userIds: issue.assigneeIds,
|
|
433
|
+
type: 'tracker.issue.assigned',
|
|
434
|
+
title: `${issue.key} assigned to you`,
|
|
435
|
+
body: issue.title,
|
|
436
|
+
object: objectRef(issue.id),
|
|
437
|
+
url: issueUrl(issue.key),
|
|
438
|
+
groupKey: issue.id,
|
|
439
|
+
actorId: principal.userId,
|
|
440
|
+
exclude: [principal.userId],
|
|
441
|
+
});
|
|
442
|
+
await this.notify.notify({
|
|
443
|
+
workspaceId: issue.workspaceId,
|
|
444
|
+
userIds: extra.mentions,
|
|
445
|
+
type: 'tracker.issue.mentioned',
|
|
446
|
+
title: `You were mentioned in ${issue.key}`,
|
|
447
|
+
body: preview(issue.descriptionText),
|
|
448
|
+
object: objectRef(issue.id),
|
|
449
|
+
url: issueUrl(issue.key),
|
|
450
|
+
groupKey: issue.id,
|
|
451
|
+
actorId: principal.userId,
|
|
452
|
+
exclude: [principal.userId],
|
|
453
|
+
});
|
|
454
|
+
await this.reindex(issue);
|
|
455
|
+
}
|
|
456
|
+
async ensureChatChannel(workspaceId, issue, memberIds) {
|
|
457
|
+
try {
|
|
458
|
+
const channel = await this.kernel.call('chat.channels.ensureObjectChannel', {
|
|
459
|
+
workspaceId,
|
|
460
|
+
objectRef: objectRef(issue.id),
|
|
461
|
+
name: `${issue.key} ${issue.title}`.slice(0, 120),
|
|
462
|
+
memberIds,
|
|
463
|
+
});
|
|
464
|
+
if (channel?.id)
|
|
465
|
+
await this.kernel.database.withWorkspace(workspaceId, (tx) => tx.update(issues).set({ chatChannelId: channel.id }).where(eq(issues.id, issue.id)));
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
this.kernel.log.debug({ err: String(err) }, 'tracker: issue chat channel unavailable');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** Push one issue into the workspace search index. */
|
|
472
|
+
async reindex(issue) {
|
|
473
|
+
await this.notify.index([
|
|
474
|
+
{
|
|
475
|
+
workspaceId: issue.workspaceId,
|
|
476
|
+
object: objectRef(issue.id),
|
|
477
|
+
title: `${issue.key} ${issue.title}`,
|
|
478
|
+
body: issue.descriptionText || null,
|
|
479
|
+
url: issueUrl(issue.key),
|
|
480
|
+
icon: 'square-check-big',
|
|
481
|
+
acl: null,
|
|
482
|
+
updatedAt: issue.updatedAt,
|
|
483
|
+
attributes: {
|
|
484
|
+
projectId: issue.projectId,
|
|
485
|
+
statusId: issue.statusId,
|
|
486
|
+
statusCategory: issue.statusCategory,
|
|
487
|
+
priority: issue.priority,
|
|
488
|
+
assigneeIds: issue.assigneeIds,
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
]);
|
|
492
|
+
}
|
|
493
|
+
// ------------------------------------------------------------------ update
|
|
494
|
+
async update(tx, principal, workspaceId, issueId, patch, opts = {}) {
|
|
495
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
496
|
+
const project = opts.system
|
|
497
|
+
? await this.access.loadProject(tx, workspaceId, current.projectId)
|
|
498
|
+
: await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
|
|
499
|
+
if (!opts.system)
|
|
500
|
+
await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
|
|
501
|
+
reporterId: current.reporterId,
|
|
502
|
+
creatorId: current.creatorId,
|
|
503
|
+
assigneeIds: current.assigneeIds ?? [],
|
|
504
|
+
});
|
|
505
|
+
const next = {};
|
|
506
|
+
const changes = [];
|
|
507
|
+
const track = (field, from, to) => {
|
|
508
|
+
if (JSON.stringify(from) === JSON.stringify(to))
|
|
509
|
+
return;
|
|
510
|
+
next[field] = to;
|
|
511
|
+
changes.push({ field, from, to });
|
|
512
|
+
};
|
|
513
|
+
if (patch.title !== undefined)
|
|
514
|
+
track('title', current.title, patch.title);
|
|
515
|
+
if (patch.description !== undefined) {
|
|
516
|
+
const text = docToText(patch.description);
|
|
517
|
+
track('description', current.descriptionText, text);
|
|
518
|
+
next.description = patch.description;
|
|
519
|
+
next.descriptionText = text;
|
|
520
|
+
}
|
|
521
|
+
if (patch.priority !== undefined)
|
|
522
|
+
track('priority', current.priority, patch.priority);
|
|
523
|
+
if (patch.reporterId !== undefined)
|
|
524
|
+
track('reporterId', current.reporterId, patch.reporterId);
|
|
525
|
+
if (patch.estimate !== undefined)
|
|
526
|
+
track('estimate', current.estimate, patch.estimate);
|
|
527
|
+
if (patch.startDate !== undefined)
|
|
528
|
+
track('startDate', current.startDate, patch.startDate);
|
|
529
|
+
if (patch.dueDate !== undefined)
|
|
530
|
+
track('dueDate', current.dueDate, patch.dueDate);
|
|
531
|
+
if (patch.resolution !== undefined)
|
|
532
|
+
track('resolution', current.resolution, patch.resolution);
|
|
533
|
+
if (patch.triage !== undefined)
|
|
534
|
+
track('triage', current.triage, patch.triage);
|
|
535
|
+
if (patch.originalEstimateSec !== undefined)
|
|
536
|
+
track('originalEstimateSec', current.originalEstimateSec, patch.originalEstimateSec);
|
|
537
|
+
if (patch.remainingSec !== undefined)
|
|
538
|
+
track('remainingSec', current.remainingSec, patch.remainingSec);
|
|
539
|
+
if (patch.componentIds !== undefined)
|
|
540
|
+
track('componentIds', current.componentIds ?? [], uniq(patch.componentIds));
|
|
541
|
+
if (patch.versionIds !== undefined)
|
|
542
|
+
track('versionIds', current.versionIds ?? [], uniq(patch.versionIds));
|
|
543
|
+
if (patch.affectsVersionIds !== undefined)
|
|
544
|
+
track('affectsVersionIds', current.affectsVersionIds ?? [], uniq(patch.affectsVersionIds));
|
|
545
|
+
if (patch.cycleId !== undefined)
|
|
546
|
+
track('cycleId', current.cycleId, patch.cycleId);
|
|
547
|
+
if (patch.milestoneId !== undefined)
|
|
548
|
+
track('milestoneId', current.milestoneId, patch.milestoneId);
|
|
549
|
+
await this.assertPlanningRefs(tx, current.projectId, {
|
|
550
|
+
cycleId: patch.cycleId ?? undefined,
|
|
551
|
+
milestoneId: patch.milestoneId ?? undefined,
|
|
552
|
+
versionIds: patch.versionIds,
|
|
553
|
+
affectsVersionIds: patch.affectsVersionIds,
|
|
554
|
+
});
|
|
555
|
+
// assignees: full replacement or add/remove deltas
|
|
556
|
+
const currentAssignees = current.assigneeIds ?? [];
|
|
557
|
+
let assignees = patch.assigneeIds ? uniq(patch.assigneeIds) : [...currentAssignees];
|
|
558
|
+
if (patch.assigneeAdd?.length)
|
|
559
|
+
assignees = uniq([...assignees, ...patch.assigneeAdd]);
|
|
560
|
+
if (patch.assigneeRemove?.length) {
|
|
561
|
+
const drop = new Set(patch.assigneeRemove);
|
|
562
|
+
assignees = assignees.filter((a) => !drop.has(a));
|
|
563
|
+
}
|
|
564
|
+
const assigneesChanged = JSON.stringify(assignees) !== JSON.stringify(currentAssignees);
|
|
565
|
+
if (assigneesChanged) {
|
|
566
|
+
if (!opts.system)
|
|
567
|
+
await this.access.require(principal, 'tracker.issue.assign', workspaceId, current.projectId);
|
|
568
|
+
track('assigneeIds', currentAssignees, assignees);
|
|
569
|
+
next.watcherIds = uniq([...(current.watcherIds ?? []), ...assignees]);
|
|
570
|
+
}
|
|
571
|
+
const currentLabels = current.labelIds ?? [];
|
|
572
|
+
let labelIds = patch.labelIds ? uniq(patch.labelIds) : [...currentLabels];
|
|
573
|
+
if (patch.labelAdd?.length)
|
|
574
|
+
labelIds = uniq([...labelIds, ...patch.labelAdd]);
|
|
575
|
+
if (patch.labelRemove?.length) {
|
|
576
|
+
const drop = new Set(patch.labelRemove);
|
|
577
|
+
labelIds = labelIds.filter((l) => !drop.has(l));
|
|
578
|
+
}
|
|
579
|
+
labelIds = await this.enforceLabelGroups(tx, workspaceId, labelIds);
|
|
580
|
+
if (JSON.stringify(labelIds) !== JSON.stringify(currentLabels))
|
|
581
|
+
track('labelIds', currentLabels, labelIds);
|
|
582
|
+
if (patch.custom !== undefined) {
|
|
583
|
+
const merged = { ...(current.custom ?? {}) };
|
|
584
|
+
for (const [key, value] of Object.entries(patch.custom)) {
|
|
585
|
+
if (value === null)
|
|
586
|
+
delete merged[key];
|
|
587
|
+
else
|
|
588
|
+
merged[key] = value;
|
|
589
|
+
}
|
|
590
|
+
track('custom', current.custom, merged);
|
|
591
|
+
}
|
|
592
|
+
if (patch.parentId !== undefined && patch.parentId !== current.parentId) {
|
|
593
|
+
if (patch.parentId) {
|
|
594
|
+
if (patch.parentId === issueId)
|
|
595
|
+
throw KernError.badRequest('An issue cannot be its own parent');
|
|
596
|
+
const type = await this.config.getType(tx, workspaceId, patch.typeId ?? current.typeId);
|
|
597
|
+
await this.assertParentAllowed(tx, workspaceId, project, type.level, patch.parentId);
|
|
598
|
+
}
|
|
599
|
+
track('parentId', current.parentId, patch.parentId);
|
|
600
|
+
}
|
|
601
|
+
// a new type may follow a different workflow; keep the issue in a status that workflow knows
|
|
602
|
+
let statusChange = null;
|
|
603
|
+
if (patch.typeId !== undefined && patch.typeId !== current.typeId) {
|
|
604
|
+
await this.config.getType(tx, workspaceId, patch.typeId);
|
|
605
|
+
track('typeId', current.typeId, patch.typeId);
|
|
606
|
+
const { definition } = await this.config.workflowFor(tx, project, patch.typeId);
|
|
607
|
+
if (!definition.statuses.some((s) => s.id === current.statusId)) {
|
|
608
|
+
const target = initialStatus(definition);
|
|
609
|
+
next.statusId = target.id;
|
|
610
|
+
next.statusCategory = target.category;
|
|
611
|
+
statusChange = {
|
|
612
|
+
fromStatusId: current.statusId,
|
|
613
|
+
toStatusId: target.id,
|
|
614
|
+
toCategory: target.category,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!changes.length && !statusChange)
|
|
619
|
+
return (await this.hydrate(tx, [current]))[0];
|
|
620
|
+
const [row] = await tx
|
|
621
|
+
.update(issues)
|
|
622
|
+
.set({ ...next, updatedAt: new Date(), lastActivityAt: new Date() })
|
|
623
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
|
|
624
|
+
.returning();
|
|
625
|
+
if (statusChange)
|
|
626
|
+
await tx.insert(issueStatusHistory).values({
|
|
627
|
+
id: uuidv7(),
|
|
628
|
+
workspaceId,
|
|
629
|
+
issueId,
|
|
630
|
+
projectId: current.projectId,
|
|
631
|
+
fromStatusId: statusChange.fromStatusId,
|
|
632
|
+
toStatusId: statusChange.toStatusId,
|
|
633
|
+
fromCategory: current.statusCategory,
|
|
634
|
+
toCategory: statusChange.toCategory,
|
|
635
|
+
actorId: principal.userId ?? null,
|
|
636
|
+
transitionId: null,
|
|
637
|
+
durationSec: null,
|
|
638
|
+
});
|
|
639
|
+
await this.notify.history(tx, {
|
|
640
|
+
workspaceId,
|
|
641
|
+
issueId,
|
|
642
|
+
actorId: principal.userId ?? null,
|
|
643
|
+
action: 'updated',
|
|
644
|
+
changes,
|
|
645
|
+
});
|
|
646
|
+
const issue = (await this.hydrate(tx, [row]))[0];
|
|
647
|
+
await this.kernel.emit(trackerEvents.issueUpdated, {
|
|
648
|
+
workspaceId: issue.workspaceId,
|
|
649
|
+
projectId: issue.projectId,
|
|
650
|
+
issueId: issue.id,
|
|
651
|
+
key: issue.key,
|
|
652
|
+
changes,
|
|
653
|
+
}, { workspaceId, actorId: principal.userId });
|
|
654
|
+
if (assigneesChanged) {
|
|
655
|
+
const gained = added(currentAssignees, assignees);
|
|
656
|
+
await this.kernel.emit(trackerEvents.issueAssigned, {
|
|
657
|
+
workspaceId: issue.workspaceId,
|
|
658
|
+
projectId: issue.projectId,
|
|
659
|
+
issueId: issue.id,
|
|
660
|
+
key: issue.key,
|
|
661
|
+
assigneeIds: issue.assigneeIds,
|
|
662
|
+
added: gained,
|
|
663
|
+
removed: removed(currentAssignees, assignees),
|
|
664
|
+
}, { workspaceId, actorId: principal.userId });
|
|
665
|
+
await this.notify.notify({
|
|
666
|
+
workspaceId,
|
|
667
|
+
userIds: gained,
|
|
668
|
+
type: 'tracker.issue.assigned',
|
|
669
|
+
title: `${issue.key} assigned to you`,
|
|
670
|
+
body: issue.title,
|
|
671
|
+
object: objectRef(issue.id),
|
|
672
|
+
url: issueUrl(issue.key),
|
|
673
|
+
groupKey: issue.id,
|
|
674
|
+
actorId: principal.userId,
|
|
675
|
+
exclude: [principal.userId],
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
if (patch.description !== undefined)
|
|
679
|
+
await this.notify.notify({
|
|
680
|
+
workspaceId,
|
|
681
|
+
userIds: extractMentions(patch.description),
|
|
682
|
+
type: 'tracker.issue.mentioned',
|
|
683
|
+
title: `You were mentioned in ${issue.key}`,
|
|
684
|
+
body: preview(issue.descriptionText),
|
|
685
|
+
object: objectRef(issue.id),
|
|
686
|
+
url: issueUrl(issue.key),
|
|
687
|
+
groupKey: issue.id,
|
|
688
|
+
actorId: principal.userId,
|
|
689
|
+
exclude: [principal.userId],
|
|
690
|
+
});
|
|
691
|
+
await this.notify.change(workspaceId, 'issue', issue.id, 'updated', {
|
|
692
|
+
scope: { projectId: issue.projectId },
|
|
693
|
+
});
|
|
694
|
+
await this.reindex(issue);
|
|
695
|
+
return issue;
|
|
696
|
+
}
|
|
697
|
+
/** Each issue is updated in its own transaction so one rejection does not undo the rest. */
|
|
698
|
+
async bulkUpdate(principal, workspaceId, ids, patch) {
|
|
699
|
+
const results = [];
|
|
700
|
+
for (const id of ids) {
|
|
701
|
+
try {
|
|
702
|
+
await this.kernel.database.withWorkspace(workspaceId, (tx) => this.update(tx, principal, workspaceId, id, patch), { userId: principal.userId });
|
|
703
|
+
results.push({ id, ok: true });
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
const kern = err instanceof KernError ? err : null;
|
|
707
|
+
results.push({
|
|
708
|
+
id,
|
|
709
|
+
ok: false,
|
|
710
|
+
error: {
|
|
711
|
+
code: kern?.code ?? 'INTERNAL',
|
|
712
|
+
message: err instanceof Error ? err.message : String(err),
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
results,
|
|
719
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
720
|
+
failed: results.filter((r) => !r.ok).length,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
// ------------------------------------------------------------------ lifecycle
|
|
724
|
+
async archive(tx, principal, workspaceId, issueId, archived) {
|
|
725
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
726
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.archive');
|
|
727
|
+
const [row] = await tx
|
|
728
|
+
.update(issues)
|
|
729
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
730
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
|
|
731
|
+
.returning();
|
|
732
|
+
await this.notify.history(tx, {
|
|
733
|
+
workspaceId,
|
|
734
|
+
issueId,
|
|
735
|
+
actorId: principal.userId ?? null,
|
|
736
|
+
action: archived ? 'archived' : 'unarchived',
|
|
737
|
+
});
|
|
738
|
+
const issue = (await this.hydrate(tx, [row]))[0];
|
|
739
|
+
await this.kernel.emit(trackerEvents.issueArchived, {
|
|
740
|
+
workspaceId: issue.workspaceId,
|
|
741
|
+
projectId: issue.projectId,
|
|
742
|
+
issueId: issue.id,
|
|
743
|
+
key: issue.key,
|
|
744
|
+
archived,
|
|
745
|
+
}, { workspaceId, actorId: principal.userId });
|
|
746
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
|
|
747
|
+
scope: { projectId: issue.projectId },
|
|
748
|
+
});
|
|
749
|
+
if (archived)
|
|
750
|
+
await this.notify.unindex(workspaceId, 'issue', [issueId]);
|
|
751
|
+
else
|
|
752
|
+
await this.reindex(issue);
|
|
753
|
+
return issue;
|
|
754
|
+
}
|
|
755
|
+
async delete(tx, principal, workspaceId, issueId) {
|
|
756
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
757
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.delete_any');
|
|
758
|
+
await tx.update(issues).set({ parentId: null }).where(eq(issues.parentId, issueId));
|
|
759
|
+
await tx.delete(comments).where(eq(comments.issueId, issueId));
|
|
760
|
+
await tx.delete(attachments).where(eq(attachments.issueId, issueId));
|
|
761
|
+
await tx.delete(links).where(eq(links.issueId, issueId));
|
|
762
|
+
await tx.delete(relations).where(eq(relations.fromIssueId, issueId));
|
|
763
|
+
await tx.delete(relations).where(eq(relations.toIssueId, issueId));
|
|
764
|
+
await tx.delete(worklogs).where(eq(worklogs.issueId, issueId));
|
|
765
|
+
await tx.delete(timers).where(eq(timers.issueId, issueId));
|
|
766
|
+
await tx.delete(issueApprovals).where(eq(issueApprovals.issueId, issueId));
|
|
767
|
+
await tx.delete(issueStatusHistory).where(eq(issueStatusHistory.issueId, issueId));
|
|
768
|
+
await tx.delete(issueHistory).where(eq(issueHistory.issueId, issueId));
|
|
769
|
+
await tx.delete(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
|
|
770
|
+
await this.kernel.emit(trackerEvents.issueDeleted, {
|
|
771
|
+
workspaceId: workspaceId,
|
|
772
|
+
projectId: current.projectId,
|
|
773
|
+
issueId,
|
|
774
|
+
key: current.key,
|
|
775
|
+
}, { workspaceId, actorId: principal.userId });
|
|
776
|
+
await this.notify.unindex(workspaceId, 'issue', [issueId]);
|
|
777
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'deleted', {
|
|
778
|
+
scope: { projectId: current.projectId },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
/** Move to another project: the issue is re-keyed and its sub-items follow. */
|
|
782
|
+
async move(tx, principal, workspaceId, issueId, targetProjectId) {
|
|
783
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
784
|
+
if (current.projectId === targetProjectId)
|
|
785
|
+
return (await this.hydrate(tx, [current]))[0];
|
|
786
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.edit_any');
|
|
787
|
+
const target = await this.access.requireProject(tx, principal, workspaceId, targetProjectId, 'tracker.issue.create');
|
|
788
|
+
const moved = [];
|
|
789
|
+
const relocate = async (row) => {
|
|
790
|
+
const number = await this.allocateNumber(tx, workspaceId, target.id);
|
|
791
|
+
const type = await this.matchType(tx, workspaceId, target, row.typeId);
|
|
792
|
+
const { definition } = await this.config.workflowFor(tx, target, type);
|
|
793
|
+
const status = definition.statuses.find((s) => s.id === row.statusId) ?? initialStatus(definition);
|
|
794
|
+
const [updated] = await tx
|
|
795
|
+
.update(issues)
|
|
796
|
+
.set({
|
|
797
|
+
projectId: target.id,
|
|
798
|
+
key: `${target.key}-${number}`,
|
|
799
|
+
number,
|
|
800
|
+
typeId: type,
|
|
801
|
+
statusId: status.id,
|
|
802
|
+
statusCategory: status.category,
|
|
803
|
+
// planning objects are project-scoped and cannot travel with the issue
|
|
804
|
+
cycleId: null,
|
|
805
|
+
milestoneId: null,
|
|
806
|
+
componentIds: [],
|
|
807
|
+
versionIds: [],
|
|
808
|
+
affectsVersionIds: [],
|
|
809
|
+
rank: await this.rankFor(tx, workspaceId, target.id),
|
|
810
|
+
updatedAt: new Date(),
|
|
811
|
+
})
|
|
812
|
+
.where(eq(issues.id, row.id))
|
|
813
|
+
.returning();
|
|
814
|
+
moved.push(row.id);
|
|
815
|
+
const children = await tx.select().from(issues).where(eq(issues.parentId, row.id));
|
|
816
|
+
for (const child of children)
|
|
817
|
+
await relocate(child);
|
|
818
|
+
return updated;
|
|
819
|
+
};
|
|
820
|
+
const row = await relocate(current);
|
|
821
|
+
await this.notify.history(tx, {
|
|
822
|
+
workspaceId,
|
|
823
|
+
issueId,
|
|
824
|
+
actorId: principal.userId ?? null,
|
|
825
|
+
action: 'moved',
|
|
826
|
+
changes: [{ field: 'projectId', from: current.projectId, to: target.id }],
|
|
827
|
+
data: { fromKey: current.key, toKey: row.key },
|
|
828
|
+
});
|
|
829
|
+
const issue = (await this.hydrate(tx, [row]))[0];
|
|
830
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
|
|
831
|
+
scope: { projectId: target.id },
|
|
832
|
+
});
|
|
833
|
+
await this.reindex(issue);
|
|
834
|
+
return issue;
|
|
835
|
+
}
|
|
836
|
+
async matchType(tx, workspaceId, target, typeId) {
|
|
837
|
+
const source = await this.config.getType(tx, workspaceId, typeId);
|
|
838
|
+
const candidates = await this.config.listTypes(tx, workspaceId, { projectId: target.id });
|
|
839
|
+
const sameKey = candidates.find((t) => t.key === source.key);
|
|
840
|
+
if (sameKey)
|
|
841
|
+
return sameKey.id;
|
|
842
|
+
const sameLevel = candidates.find((t) => t.level === source.level);
|
|
843
|
+
if (sameLevel)
|
|
844
|
+
return sameLevel.id;
|
|
845
|
+
return (await this.config.defaultType(tx, workspaceId, target.id)).id;
|
|
846
|
+
}
|
|
847
|
+
async rank(tx, principal, workspaceId, issueId, afterId, beforeId) {
|
|
848
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
849
|
+
await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
|
|
850
|
+
reporterId: current.reporterId,
|
|
851
|
+
creatorId: current.creatorId,
|
|
852
|
+
assigneeIds: current.assigneeIds ?? [],
|
|
853
|
+
});
|
|
854
|
+
const rank = await this.rankBetweenIssues(tx, workspaceId, afterId ?? null, beforeId ?? null);
|
|
855
|
+
await tx
|
|
856
|
+
.update(issues)
|
|
857
|
+
.set({ rank, updatedAt: new Date() })
|
|
858
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
|
|
859
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
|
|
860
|
+
patch: { rank },
|
|
861
|
+
scope: { projectId: current.projectId },
|
|
862
|
+
});
|
|
863
|
+
return { id: issueId, rank };
|
|
864
|
+
}
|
|
865
|
+
// ------------------------------------------------------------------ watchers
|
|
866
|
+
async setWatcher(tx, principal, workspaceId, issueId, userId, watching) {
|
|
867
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
868
|
+
// Watching yourself only needs to be able to see the issue. Adding or removing *someone else*
|
|
869
|
+
// changes what lands in their inbox, so it takes the permission that manages watchers.
|
|
870
|
+
const permission = userId === principal.userId ? 'tracker.issue.view' : 'tracker.issue.manage_watchers';
|
|
871
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, permission);
|
|
872
|
+
const existing = current.watcherIds ?? [];
|
|
873
|
+
const watcherIds = watching ? uniq([...existing, userId]) : existing.filter((id) => id !== userId);
|
|
874
|
+
await tx
|
|
875
|
+
.update(issues)
|
|
876
|
+
.set({ watcherIds, updatedAt: new Date() })
|
|
877
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
|
|
878
|
+
return { watcherIds };
|
|
879
|
+
}
|
|
880
|
+
// ------------------------------------------------------------------ history
|
|
881
|
+
async history(tx, principal, workspaceId, issueId, limit, cursor) {
|
|
882
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
883
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
|
|
884
|
+
const filters = [eq(issueHistory.workspaceId, workspaceId), eq(issueHistory.issueId, issueId)];
|
|
885
|
+
if (cursor)
|
|
886
|
+
filters.push(lt(issueHistory.id, cursor));
|
|
887
|
+
const rows = await tx
|
|
888
|
+
.select()
|
|
889
|
+
.from(issueHistory)
|
|
890
|
+
.where(and(...filters))
|
|
891
|
+
.orderBy(desc(issueHistory.id))
|
|
892
|
+
.limit(limit + 1);
|
|
893
|
+
const items = rows.slice(0, limit).map(toHistoryEntry);
|
|
894
|
+
const statusRows = await tx
|
|
895
|
+
.select()
|
|
896
|
+
.from(issueStatusHistory)
|
|
897
|
+
.where(and(eq(issueStatusHistory.workspaceId, workspaceId), eq(issueStatusHistory.issueId, issueId)))
|
|
898
|
+
.orderBy(asc(issueStatusHistory.occurredAt));
|
|
899
|
+
return {
|
|
900
|
+
items,
|
|
901
|
+
statusHistory: statusRows.map(toStatusHistoryEntry),
|
|
902
|
+
nextCursor: rows.length > limit ? (items.at(-1)?.id ?? null) : null,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
// ------------------------------------------------------------------ relations
|
|
906
|
+
async listRelations(tx, principal, workspaceId, issueId) {
|
|
907
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
908
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
|
|
909
|
+
return this.relationViews(tx, workspaceId, issueId);
|
|
910
|
+
}
|
|
911
|
+
async relationViews(tx, workspaceId, issueId) {
|
|
912
|
+
const rows = await tx
|
|
913
|
+
.select()
|
|
914
|
+
.from(relations)
|
|
915
|
+
.where(and(eq(relations.workspaceId, workspaceId), eq(relations.fromIssueId, issueId)))
|
|
916
|
+
.orderBy(asc(relations.createdAt));
|
|
917
|
+
if (!rows.length)
|
|
918
|
+
return [];
|
|
919
|
+
const targets = await tx
|
|
920
|
+
.select()
|
|
921
|
+
.from(issues)
|
|
922
|
+
.where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, rows.map((r) => r.toIssueId))));
|
|
923
|
+
const hydrated = await this.hydrate(tx, targets);
|
|
924
|
+
const byId = new Map(hydrated.map((i) => [i.id, toIssueSummary(i)]));
|
|
925
|
+
return rows
|
|
926
|
+
.filter((r) => byId.has(r.toIssueId))
|
|
927
|
+
.map((r) => ({
|
|
928
|
+
id: r.id,
|
|
929
|
+
type: r.type,
|
|
930
|
+
issue: byId.get(r.toIssueId),
|
|
931
|
+
createdAt: r.createdAt.toISOString(),
|
|
932
|
+
}));
|
|
933
|
+
}
|
|
934
|
+
/** Relations are stored as a pair of rows so both issues can list theirs with one indexed lookup. */
|
|
935
|
+
async createRelation(tx, principal, workspaceId, issueId, type, targetIssueId) {
|
|
936
|
+
if (issueId === targetIssueId)
|
|
937
|
+
throw KernError.badRequest('An issue cannot relate to itself');
|
|
938
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
939
|
+
const target = await this.row(tx, workspaceId, targetIssueId);
|
|
940
|
+
await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
|
|
941
|
+
reporterId: current.reporterId,
|
|
942
|
+
creatorId: current.creatorId,
|
|
943
|
+
assigneeIds: current.assigneeIds ?? [],
|
|
944
|
+
});
|
|
945
|
+
await this.access.requireProject(tx, principal, workspaceId, target.projectId, 'tracker.issue.view');
|
|
946
|
+
const pairId = uuidv7();
|
|
947
|
+
await tx
|
|
948
|
+
.insert(relations)
|
|
949
|
+
.values([
|
|
950
|
+
{
|
|
951
|
+
id: pairId,
|
|
952
|
+
workspaceId,
|
|
953
|
+
type,
|
|
954
|
+
fromIssueId: issueId,
|
|
955
|
+
toIssueId: targetIssueId,
|
|
956
|
+
createdBy: principal.userId ?? null,
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
id: uuidv7(),
|
|
960
|
+
workspaceId,
|
|
961
|
+
type: RELATION_INVERSE[type],
|
|
962
|
+
fromIssueId: targetIssueId,
|
|
963
|
+
toIssueId: issueId,
|
|
964
|
+
createdBy: principal.userId ?? null,
|
|
965
|
+
},
|
|
966
|
+
])
|
|
967
|
+
.onConflictDoNothing();
|
|
968
|
+
await this.notify.history(tx, {
|
|
969
|
+
workspaceId,
|
|
970
|
+
issueId,
|
|
971
|
+
actorId: principal.userId ?? null,
|
|
972
|
+
action: 'relation_added',
|
|
973
|
+
data: { type, targetIssueId, targetKey: target.key },
|
|
974
|
+
});
|
|
975
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated');
|
|
976
|
+
await this.notify.change(workspaceId, 'issue', targetIssueId, 'updated');
|
|
977
|
+
return this.relationViews(tx, workspaceId, issueId);
|
|
978
|
+
}
|
|
979
|
+
async deleteRelation(tx, principal, workspaceId, relationId) {
|
|
980
|
+
const [row] = await tx
|
|
981
|
+
.select()
|
|
982
|
+
.from(relations)
|
|
983
|
+
.where(and(eq(relations.workspaceId, workspaceId), eq(relations.id, relationId)))
|
|
984
|
+
.limit(1);
|
|
985
|
+
if (!row)
|
|
986
|
+
throw KernError.notFound('Relation');
|
|
987
|
+
const source = await this.row(tx, workspaceId, row.fromIssueId);
|
|
988
|
+
await this.access.requireEditIssue(principal, workspaceId, source.projectId, {
|
|
989
|
+
reporterId: source.reporterId,
|
|
990
|
+
creatorId: source.creatorId,
|
|
991
|
+
assigneeIds: source.assigneeIds ?? [],
|
|
992
|
+
});
|
|
993
|
+
await tx.delete(relations).where(eq(relations.id, relationId));
|
|
994
|
+
await tx
|
|
995
|
+
.delete(relations)
|
|
996
|
+
.where(and(eq(relations.workspaceId, workspaceId), eq(relations.fromIssueId, row.toIssueId), eq(relations.toIssueId, row.fromIssueId), eq(relations.type, RELATION_INVERSE[row.type])));
|
|
997
|
+
await this.notify.change(workspaceId, 'issue', row.fromIssueId, 'updated');
|
|
998
|
+
await this.notify.change(workspaceId, 'issue', row.toIssueId, 'updated');
|
|
999
|
+
}
|
|
1000
|
+
// ------------------------------------------------------------------ attachments & links
|
|
1001
|
+
async listAttachments(tx, principal, workspaceId, issueId) {
|
|
1002
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
1003
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
|
|
1004
|
+
const rows = await tx
|
|
1005
|
+
.select()
|
|
1006
|
+
.from(attachments)
|
|
1007
|
+
.where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.issueId, issueId)))
|
|
1008
|
+
.orderBy(asc(attachments.createdAt));
|
|
1009
|
+
return rows.map(toAttachment);
|
|
1010
|
+
}
|
|
1011
|
+
async attachFiles(tx, workspaceId, issueId, fileIds, uploadedBy) {
|
|
1012
|
+
const rows = [];
|
|
1013
|
+
for (const fileId of uniq(fileIds)) {
|
|
1014
|
+
const file = await this.kernel
|
|
1015
|
+
.call('core.files.get', { id: fileId })
|
|
1016
|
+
.catch(() => null);
|
|
1017
|
+
rows.push({
|
|
1018
|
+
id: uuidv7(),
|
|
1019
|
+
workspaceId,
|
|
1020
|
+
issueId,
|
|
1021
|
+
fileId,
|
|
1022
|
+
name: file?.name ?? 'attachment',
|
|
1023
|
+
mimeType: file?.mimeType ?? 'application/octet-stream',
|
|
1024
|
+
size: file?.size ?? 0,
|
|
1025
|
+
uploadedBy,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
if (!rows.length)
|
|
1029
|
+
return [];
|
|
1030
|
+
const inserted = await tx
|
|
1031
|
+
.insert(attachments)
|
|
1032
|
+
.values(rows)
|
|
1033
|
+
.onConflictDoNothing({ target: [attachments.issueId, attachments.fileId] })
|
|
1034
|
+
.returning();
|
|
1035
|
+
await this.refreshAttachmentCount(tx, workspaceId, issueId);
|
|
1036
|
+
return inserted.map(toAttachment);
|
|
1037
|
+
}
|
|
1038
|
+
async addAttachments(tx, principal, workspaceId, issueId, fileIds) {
|
|
1039
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
1040
|
+
await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
|
|
1041
|
+
reporterId: current.reporterId,
|
|
1042
|
+
creatorId: current.creatorId,
|
|
1043
|
+
assigneeIds: current.assigneeIds ?? [],
|
|
1044
|
+
});
|
|
1045
|
+
const created = await this.attachFiles(tx, workspaceId, issueId, fileIds, principal.userId ?? null);
|
|
1046
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated');
|
|
1047
|
+
return created;
|
|
1048
|
+
}
|
|
1049
|
+
async removeAttachment(tx, principal, workspaceId, attachmentId) {
|
|
1050
|
+
const [row] = await tx
|
|
1051
|
+
.select()
|
|
1052
|
+
.from(attachments)
|
|
1053
|
+
.where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.id, attachmentId)))
|
|
1054
|
+
.limit(1);
|
|
1055
|
+
if (!row)
|
|
1056
|
+
throw KernError.notFound('Attachment');
|
|
1057
|
+
const issue = await this.row(tx, workspaceId, row.issueId);
|
|
1058
|
+
await this.access.requireEditIssue(principal, workspaceId, issue.projectId, {
|
|
1059
|
+
reporterId: issue.reporterId,
|
|
1060
|
+
creatorId: issue.creatorId,
|
|
1061
|
+
assigneeIds: issue.assigneeIds ?? [],
|
|
1062
|
+
});
|
|
1063
|
+
await tx.delete(attachments).where(eq(attachments.id, attachmentId));
|
|
1064
|
+
await this.refreshAttachmentCount(tx, workspaceId, row.issueId);
|
|
1065
|
+
await this.notify.change(workspaceId, 'issue', row.issueId, 'updated');
|
|
1066
|
+
}
|
|
1067
|
+
async refreshAttachmentCount(tx, workspaceId, issueId) {
|
|
1068
|
+
await tx
|
|
1069
|
+
.update(issues)
|
|
1070
|
+
.set({
|
|
1071
|
+
attachmentCount: sql `(select count(*) from ${attachments} where ${attachments.issueId} = ${issueId})`,
|
|
1072
|
+
})
|
|
1073
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
|
|
1074
|
+
}
|
|
1075
|
+
async listLinks(tx, principal, workspaceId, issueId) {
|
|
1076
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
1077
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
|
|
1078
|
+
const rows = await tx
|
|
1079
|
+
.select()
|
|
1080
|
+
.from(links)
|
|
1081
|
+
.where(and(eq(links.workspaceId, workspaceId), eq(links.issueId, issueId)))
|
|
1082
|
+
.orderBy(asc(links.createdAt));
|
|
1083
|
+
return rows.map(toLink);
|
|
1084
|
+
}
|
|
1085
|
+
async addLink(tx, principal, workspaceId, issueId, input) {
|
|
1086
|
+
const current = await this.row(tx, workspaceId, issueId);
|
|
1087
|
+
await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
|
|
1088
|
+
reporterId: current.reporterId,
|
|
1089
|
+
creatorId: current.creatorId,
|
|
1090
|
+
assigneeIds: current.assigneeIds ?? [],
|
|
1091
|
+
});
|
|
1092
|
+
const [row] = await tx
|
|
1093
|
+
.insert(links)
|
|
1094
|
+
.values({
|
|
1095
|
+
id: uuidv7(),
|
|
1096
|
+
workspaceId,
|
|
1097
|
+
issueId,
|
|
1098
|
+
url: input.url,
|
|
1099
|
+
title: input.title ?? null,
|
|
1100
|
+
kind: input.kind,
|
|
1101
|
+
createdBy: principal.userId ?? null,
|
|
1102
|
+
})
|
|
1103
|
+
.returning();
|
|
1104
|
+
await this.notify.change(workspaceId, 'issue', issueId, 'updated');
|
|
1105
|
+
return toLink(row);
|
|
1106
|
+
}
|
|
1107
|
+
async removeLink(tx, principal, workspaceId, linkId) {
|
|
1108
|
+
const [row] = await tx
|
|
1109
|
+
.select()
|
|
1110
|
+
.from(links)
|
|
1111
|
+
.where(and(eq(links.workspaceId, workspaceId), eq(links.id, linkId)))
|
|
1112
|
+
.limit(1);
|
|
1113
|
+
if (!row)
|
|
1114
|
+
throw KernError.notFound('Link');
|
|
1115
|
+
const issue = await this.row(tx, workspaceId, row.issueId);
|
|
1116
|
+
await this.access.requireEditIssue(principal, workspaceId, issue.projectId, {
|
|
1117
|
+
reporterId: issue.reporterId,
|
|
1118
|
+
creatorId: issue.creatorId,
|
|
1119
|
+
assigneeIds: issue.assigneeIds ?? [],
|
|
1120
|
+
});
|
|
1121
|
+
await tx.delete(links).where(eq(links.id, linkId));
|
|
1122
|
+
await this.notify.change(workspaceId, 'issue', row.issueId, 'updated');
|
|
1123
|
+
}
|
|
1124
|
+
// ------------------------------------------------------------------ issue templates
|
|
1125
|
+
async listTemplates(tx, principal, workspaceId, projectId) {
|
|
1126
|
+
const filters = [eq(issueTemplates.workspaceId, workspaceId)];
|
|
1127
|
+
if (projectId) {
|
|
1128
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.issue.view');
|
|
1129
|
+
filters.push(sql `(${issueTemplates.projectId} is null or ${issueTemplates.projectId} = ${projectId})`);
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
// Without a project the answer is "everything you could use": templates shared across the
|
|
1133
|
+
// workspace, plus those belonging to projects this caller can actually see.
|
|
1134
|
+
const visible = await this.access.visibleProjectIds(tx, principal, workspaceId);
|
|
1135
|
+
filters.push(visible.length
|
|
1136
|
+
? or(isNull(issueTemplates.projectId), inArray(issueTemplates.projectId, visible))
|
|
1137
|
+
: isNull(issueTemplates.projectId));
|
|
1138
|
+
}
|
|
1139
|
+
const rows = await tx
|
|
1140
|
+
.select()
|
|
1141
|
+
.from(issueTemplates)
|
|
1142
|
+
.where(and(...filters))
|
|
1143
|
+
.orderBy(asc(issueTemplates.name));
|
|
1144
|
+
return rows.map(toIssueTemplate);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Issue templates and recurring rules are project configuration, not content: they decide what
|
|
1148
|
+
* everyone else's issues look like. They take the same permission as managing the project, at
|
|
1149
|
+
* project scope when they belong to one and at workspace scope when they are shared.
|
|
1150
|
+
*/
|
|
1151
|
+
async recurringRow(tx, workspaceId, id) {
|
|
1152
|
+
const [row] = await tx
|
|
1153
|
+
.select()
|
|
1154
|
+
.from(recurringIssues)
|
|
1155
|
+
.where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)))
|
|
1156
|
+
.limit(1);
|
|
1157
|
+
if (!row)
|
|
1158
|
+
throw KernError.notFound('Recurring issue');
|
|
1159
|
+
return row;
|
|
1160
|
+
}
|
|
1161
|
+
async templateRow(tx, workspaceId, id) {
|
|
1162
|
+
const [row] = await tx
|
|
1163
|
+
.select()
|
|
1164
|
+
.from(issueTemplates)
|
|
1165
|
+
.where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)))
|
|
1166
|
+
.limit(1);
|
|
1167
|
+
if (!row)
|
|
1168
|
+
throw KernError.notFound('Issue template');
|
|
1169
|
+
return row;
|
|
1170
|
+
}
|
|
1171
|
+
async requireTemplateManage(tx, principal, workspaceId, projectId) {
|
|
1172
|
+
if (projectId)
|
|
1173
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
|
|
1174
|
+
else
|
|
1175
|
+
await this.access.requireWorkspace(principal, 'tracker.project.manage', workspaceId);
|
|
1176
|
+
}
|
|
1177
|
+
async createTemplate(tx, principal, workspaceId, input) {
|
|
1178
|
+
await this.requireTemplateManage(tx, principal, workspaceId, input.projectId ?? null);
|
|
1179
|
+
const [row] = await tx
|
|
1180
|
+
.insert(issueTemplates)
|
|
1181
|
+
.values({
|
|
1182
|
+
id: uuidv7(),
|
|
1183
|
+
workspaceId,
|
|
1184
|
+
projectId: input.projectId ?? null,
|
|
1185
|
+
name: input.name,
|
|
1186
|
+
description: input.description ?? null,
|
|
1187
|
+
typeId: input.typeId ?? null,
|
|
1188
|
+
defaults: input.defaults ?? {},
|
|
1189
|
+
subItems: input.subItems ?? [],
|
|
1190
|
+
createdBy: principal.userId ?? null,
|
|
1191
|
+
})
|
|
1192
|
+
.returning();
|
|
1193
|
+
return toIssueTemplate(row);
|
|
1194
|
+
}
|
|
1195
|
+
async updateTemplate(tx, principal, workspaceId, id, patch) {
|
|
1196
|
+
const current = await this.templateRow(tx, workspaceId, id);
|
|
1197
|
+
await this.requireTemplateManage(tx, principal, workspaceId, current.projectId);
|
|
1198
|
+
// moving a template into or out of a project needs the permission on the destination too
|
|
1199
|
+
if (patch.projectId !== undefined && patch.projectId !== current.projectId)
|
|
1200
|
+
await this.requireTemplateManage(tx, principal, workspaceId, patch.projectId ?? null);
|
|
1201
|
+
const [row] = await tx
|
|
1202
|
+
.update(issueTemplates)
|
|
1203
|
+
.set({
|
|
1204
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
1205
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
1206
|
+
...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
|
|
1207
|
+
...(patch.typeId === undefined ? {} : { typeId: patch.typeId }),
|
|
1208
|
+
...(patch.defaults === undefined ? {} : { defaults: patch.defaults }),
|
|
1209
|
+
...(patch.subItems === undefined ? {} : { subItems: patch.subItems }),
|
|
1210
|
+
updatedAt: new Date(),
|
|
1211
|
+
})
|
|
1212
|
+
.where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)))
|
|
1213
|
+
.returning();
|
|
1214
|
+
if (!row)
|
|
1215
|
+
throw KernError.notFound('Issue template');
|
|
1216
|
+
return toIssueTemplate(row);
|
|
1217
|
+
}
|
|
1218
|
+
async deleteTemplate(tx, principal, workspaceId, id) {
|
|
1219
|
+
const current = await this.templateRow(tx, workspaceId, id);
|
|
1220
|
+
await this.requireTemplateManage(tx, principal, workspaceId, current.projectId);
|
|
1221
|
+
await tx
|
|
1222
|
+
.delete(issueTemplates)
|
|
1223
|
+
.where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)));
|
|
1224
|
+
}
|
|
1225
|
+
// ------------------------------------------------------------------ recurring issues
|
|
1226
|
+
async listRecurring(tx, principal, workspaceId, projectId) {
|
|
1227
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.issue.view');
|
|
1228
|
+
const rows = await tx
|
|
1229
|
+
.select()
|
|
1230
|
+
.from(recurringIssues)
|
|
1231
|
+
.where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.projectId, projectId)))
|
|
1232
|
+
.orderBy(asc(recurringIssues.name));
|
|
1233
|
+
return rows.map(toRecurringIssue);
|
|
1234
|
+
}
|
|
1235
|
+
async createRecurring(tx, principal, workspaceId, projectId, input) {
|
|
1236
|
+
await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
|
|
1237
|
+
const [row] = await tx
|
|
1238
|
+
.insert(recurringIssues)
|
|
1239
|
+
.values({
|
|
1240
|
+
id: uuidv7(),
|
|
1241
|
+
workspaceId,
|
|
1242
|
+
projectId,
|
|
1243
|
+
name: input.name,
|
|
1244
|
+
rule: input.rule,
|
|
1245
|
+
defaults: input.defaults,
|
|
1246
|
+
enabled: input.enabled ?? true,
|
|
1247
|
+
nextRunAt: nextRun(input.rule, new Date()),
|
|
1248
|
+
createdBy: principal.userId ?? null,
|
|
1249
|
+
})
|
|
1250
|
+
.returning();
|
|
1251
|
+
return toRecurringIssue(row);
|
|
1252
|
+
}
|
|
1253
|
+
async updateRecurring(tx, principal, workspaceId, id, patch) {
|
|
1254
|
+
const current = await this.recurringRow(tx, workspaceId, id);
|
|
1255
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
|
|
1256
|
+
const [row] = await tx
|
|
1257
|
+
.update(recurringIssues)
|
|
1258
|
+
.set({
|
|
1259
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
1260
|
+
...(patch.rule === undefined ? {} : { rule: patch.rule, nextRunAt: nextRun(patch.rule, new Date()) }),
|
|
1261
|
+
...(patch.defaults === undefined ? {} : { defaults: patch.defaults }),
|
|
1262
|
+
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
1263
|
+
updatedAt: new Date(),
|
|
1264
|
+
})
|
|
1265
|
+
.where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)))
|
|
1266
|
+
.returning();
|
|
1267
|
+
if (!row)
|
|
1268
|
+
throw KernError.notFound('Recurring issue');
|
|
1269
|
+
return toRecurringIssue(row);
|
|
1270
|
+
}
|
|
1271
|
+
async deleteRecurring(tx, principal, workspaceId, id) {
|
|
1272
|
+
const current = await this.recurringRow(tx, workspaceId, id);
|
|
1273
|
+
await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
|
|
1274
|
+
await tx
|
|
1275
|
+
.delete(recurringIssues)
|
|
1276
|
+
.where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)));
|
|
1277
|
+
}
|
|
1278
|
+
/** Create the issues whose recurrence is due. Called by the `tracker.recurring` job. */
|
|
1279
|
+
async runDueRecurring(workspaceId, now = new Date()) {
|
|
1280
|
+
const due = await this.kernel.database.withWorkspace(workspaceId, (tx) => tx
|
|
1281
|
+
.select()
|
|
1282
|
+
.from(recurringIssues)
|
|
1283
|
+
.where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.enabled, true), sql `${recurringIssues.nextRunAt} is not null and ${recurringIssues.nextRunAt} <= ${now.toISOString()}`))
|
|
1284
|
+
.limit(200));
|
|
1285
|
+
let created = 0;
|
|
1286
|
+
for (const rule of due) {
|
|
1287
|
+
try {
|
|
1288
|
+
await this.kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
1289
|
+
const defaults = rule.defaults ?? {};
|
|
1290
|
+
const issue = await this.create(tx, this.kernel.system, workspaceId, {
|
|
1291
|
+
...defaults,
|
|
1292
|
+
projectId: rule.projectId,
|
|
1293
|
+
title: defaults.title ?? rule.name,
|
|
1294
|
+
}, { source: 'recurring', system: true });
|
|
1295
|
+
await tx
|
|
1296
|
+
.update(recurringIssues)
|
|
1297
|
+
.set({
|
|
1298
|
+
lastRunAt: now,
|
|
1299
|
+
lastIssueId: issue.id,
|
|
1300
|
+
runCount: sql `${recurringIssues.runCount} + 1`,
|
|
1301
|
+
nextRunAt: nextRun(rule.rule, now),
|
|
1302
|
+
updatedAt: now,
|
|
1303
|
+
})
|
|
1304
|
+
.where(eq(recurringIssues.id, rule.id));
|
|
1305
|
+
});
|
|
1306
|
+
created++;
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
this.kernel.log.warn({ err: String(err), rule: rule.id }, 'tracker: recurring issue failed');
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return created;
|
|
1313
|
+
}
|
|
1314
|
+
/** Issues whose parent is `issueId` (used by the workflow `subtasks.allDone` condition). */
|
|
1315
|
+
async subItems(tx, workspaceId, issueId) {
|
|
1316
|
+
return tx
|
|
1317
|
+
.select({ id: issues.id, statusCategory: issues.statusCategory })
|
|
1318
|
+
.from(issues)
|
|
1319
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.parentId, issueId), isNull(issues.archivedAt)));
|
|
1320
|
+
}
|
|
1321
|
+
/** Everyone who should hear about a change to this issue. */
|
|
1322
|
+
watchersOf(row) {
|
|
1323
|
+
return uniq([...(row.watcherIds ?? []), ...(row.assigneeIds ?? []), row.reporterId ?? ''].filter(Boolean));
|
|
1324
|
+
}
|
|
1325
|
+
isResolved(category) {
|
|
1326
|
+
return RESOLVED_CATEGORIES.has(category);
|
|
1327
|
+
}
|
|
1328
|
+
/** Projects table accessor shared with the reports service. */
|
|
1329
|
+
projectsTable() {
|
|
1330
|
+
return projects;
|
|
1331
|
+
}
|
|
1332
|
+
typesTable() {
|
|
1333
|
+
return workItemTypes;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/** Next occurrence of a recurrence rule after `from`. */
|
|
1337
|
+
export function nextRun(rule, from) {
|
|
1338
|
+
const [hh, mm] = (rule.at ?? '09:00').split(':').map(Number);
|
|
1339
|
+
const base = new Date(from.getTime());
|
|
1340
|
+
base.setUTCSeconds(0, 0);
|
|
1341
|
+
const at = (d) => {
|
|
1342
|
+
const copy = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hh ?? 9, mm ?? 0));
|
|
1343
|
+
return copy;
|
|
1344
|
+
};
|
|
1345
|
+
const interval = Math.max(1, rule.interval ?? 1);
|
|
1346
|
+
let candidate = at(base);
|
|
1347
|
+
if (candidate <= from)
|
|
1348
|
+
candidate = new Date(candidate.getTime() + 86_400_000);
|
|
1349
|
+
if (rule.freq === 'daily') {
|
|
1350
|
+
const days = Math.ceil((candidate.getTime() - at(base).getTime()) / 86_400_000);
|
|
1351
|
+
if (days % interval !== 0)
|
|
1352
|
+
candidate = new Date(candidate.getTime() + (interval - (days % interval)) * 86_400_000);
|
|
1353
|
+
}
|
|
1354
|
+
else if (rule.freq === 'weekly') {
|
|
1355
|
+
const weekdays = rule.byWeekday?.length ? rule.byWeekday : [candidate.getUTCDay()];
|
|
1356
|
+
for (let i = 0; i < 7 * interval + 7; i++) {
|
|
1357
|
+
const probe = new Date(candidate.getTime() + i * 86_400_000);
|
|
1358
|
+
if (weekdays.includes(probe.getUTCDay())) {
|
|
1359
|
+
candidate = probe;
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
else if (rule.freq === 'monthly') {
|
|
1365
|
+
const day = rule.byMonthDay ?? candidate.getUTCDate();
|
|
1366
|
+
let probe = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), day, hh ?? 9, mm ?? 0));
|
|
1367
|
+
if (probe <= from)
|
|
1368
|
+
probe = new Date(Date.UTC(probe.getUTCFullYear(), probe.getUTCMonth() + interval, day, hh ?? 9, mm ?? 0));
|
|
1369
|
+
candidate = probe;
|
|
1370
|
+
}
|
|
1371
|
+
else if (rule.freq === 'yearly') {
|
|
1372
|
+
let probe = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), candidate.getUTCDate(), hh ?? 9, mm ?? 0));
|
|
1373
|
+
if (probe <= from)
|
|
1374
|
+
probe = new Date(Date.UTC(probe.getUTCFullYear() + interval, probe.getUTCMonth(), probe.getUTCDate(), hh ?? 9, mm ?? 0));
|
|
1375
|
+
candidate = probe;
|
|
1376
|
+
}
|
|
1377
|
+
if (rule.until && candidate > new Date(rule.until))
|
|
1378
|
+
return null;
|
|
1379
|
+
return candidate;
|
|
1380
|
+
}
|
|
1381
|
+
//# sourceMappingURL=issues.js.map
|