@kietdev/taiga-mcp 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +285 -2
- package/dist/config.js +59 -0
- package/dist/config.js.map +1 -0
- package/dist/format.js +104 -0
- package/dist/format.js.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/remote/app.js +238 -0
- package/dist/remote/app.js.map +1 -0
- package/dist/remote/config.js +52 -0
- package/dist/remote/config.js.map +1 -0
- package/dist/remote/guard.js +120 -0
- package/dist/remote/guard.js.map +1 -0
- package/dist/remote/pages.js +472 -0
- package/dist/remote/pages.js.map +1 -0
- package/dist/remote/provider.js +211 -0
- package/dist/remote/provider.js.map +1 -0
- package/dist/remote/seal.js +50 -0
- package/dist/remote/seal.js.map +1 -0
- package/dist/remote/taiga-auth.js +70 -0
- package/dist/remote/taiga-auth.js.map +1 -0
- package/dist/remote.js +23 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/taiga/client.js +1274 -0
- package/dist/taiga/client.js.map +1 -0
- package/dist/taiga/files.js +331 -0
- package/dist/taiga/files.js.map +1 -0
- package/dist/taiga/http.js +237 -0
- package/dist/taiga/http.js.map +1 -0
- package/dist/taiga/types.js +6 -0
- package/dist/taiga/types.js.map +1 -0
- package/dist/tools/add-comment.js +24 -0
- package/dist/tools/add-comment.js.map +1 -0
- package/dist/tools/create-item.js +63 -0
- package/dist/tools/create-item.js.map +1 -0
- package/dist/tools/file-view.js +300 -0
- package/dist/tools/file-view.js.map +1 -0
- package/dist/tools/get-attachment.js +76 -0
- package/dist/tools/get-attachment.js.map +1 -0
- package/dist/tools/get-item.js +120 -0
- package/dist/tools/get-item.js.map +1 -0
- package/dist/tools/get-project.js +47 -0
- package/dist/tools/get-project.js.map +1 -0
- package/dist/tools/get-sprint.js +56 -0
- package/dist/tools/get-sprint.js.map +1 -0
- package/dist/tools/list-activity.js +102 -0
- package/dist/tools/list-activity.js.map +1 -0
- package/dist/tools/list-epics.js +58 -0
- package/dist/tools/list-epics.js.map +1 -0
- package/dist/tools/list-issues.js +78 -0
- package/dist/tools/list-issues.js.map +1 -0
- package/dist/tools/list-projects.js +22 -0
- package/dist/tools/list-projects.js.map +1 -0
- package/dist/tools/list-sprints.js +46 -0
- package/dist/tools/list-sprints.js.map +1 -0
- package/dist/tools/list-tasks.js +84 -0
- package/dist/tools/list-tasks.js.map +1 -0
- package/dist/tools/list-user-stories.js +85 -0
- package/dist/tools/list-user-stories.js.map +1 -0
- package/dist/tools/search.js +83 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/shared.js +90 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/update-item.js +74 -0
- package/dist/tools/update-item.js.map +1 -0
- package/dist/tools/wiki.js +62 -0
- package/dist/tools/wiki.js.map +1 -0
- package/dist/tools/written.js +18 -0
- package/dist/tools/written.js.map +1 -0
- package/package.json +78 -4
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
import { fold, progress } from '../format.js';
|
|
2
|
+
import { TaigaError, TaigaHttp } from './http.js';
|
|
3
|
+
import { readFile } from './files.js';
|
|
4
|
+
export { TaigaError } from './http.js';
|
|
5
|
+
/** Where each kind of item lives in Taiga's API, and what Taiga calls it elsewhere. */
|
|
6
|
+
const API = {
|
|
7
|
+
story: {
|
|
8
|
+
list: '/userstories',
|
|
9
|
+
history: 'userstory',
|
|
10
|
+
label: 'user story',
|
|
11
|
+
resolved: 'us',
|
|
12
|
+
finished: 'finish_date',
|
|
13
|
+
},
|
|
14
|
+
issue: { list: '/issues', history: 'issue', label: 'issue', resolved: 'issue', finished: 'finished_date' },
|
|
15
|
+
epic: { list: '/epics', history: 'epic', label: 'epic', resolved: 'epic', finished: undefined },
|
|
16
|
+
task: { list: '/tasks', history: 'task', label: 'task', resolved: 'task', finished: 'finished_date' },
|
|
17
|
+
};
|
|
18
|
+
const KINDS = Object.keys(API);
|
|
19
|
+
/** What `assigned_to` may say for "assigned to nobody". */
|
|
20
|
+
const UNASSIGNED = new Set(['unassigned', 'nobody', 'no one', 'none']);
|
|
21
|
+
/** What `assigned_to` may say for the signed-in user. */
|
|
22
|
+
const ME = new Set(['me', 'myself']);
|
|
23
|
+
/** A comment shown back in the answer is cut to this many characters. */
|
|
24
|
+
const COMMENT_ECHO = 300;
|
|
25
|
+
/** An error that lists every member is no help in a project of hundreds. */
|
|
26
|
+
const MEMBERS_LISTED = 40;
|
|
27
|
+
/** Statuses and sprints change rarely; one question to the model often needs the project several times. */
|
|
28
|
+
const PROJECT_CACHE_MS = 60_000;
|
|
29
|
+
const COMMENTS_SHOWN = 5;
|
|
30
|
+
const CHANGES_SHOWN = 20;
|
|
31
|
+
/** Activity is read 100 events at a time, newest first, and at most this many times per question. */
|
|
32
|
+
const ACTIVITY_BATCH = 100;
|
|
33
|
+
const ACTIVITY_BATCHES = 10;
|
|
34
|
+
/** The largest file read; a hosted server must also fit the answer in its response. */
|
|
35
|
+
export const FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
36
|
+
/** Images go to the model as they are, base64 in the answer, so they are held to less. */
|
|
37
|
+
export const IMAGE_MAX_BYTES = 3 * 1024 * 1024;
|
|
38
|
+
/** Fields of a history entry that say nothing a reader wants: positions on a board, and what the status already tells. */
|
|
39
|
+
const SILENT_CHANGES = new Set([
|
|
40
|
+
'is_closed',
|
|
41
|
+
'finish_date',
|
|
42
|
+
'finished_date',
|
|
43
|
+
'description_html',
|
|
44
|
+
'blocked_note_html',
|
|
45
|
+
]);
|
|
46
|
+
const optional = (value, use) => value === undefined ? undefined : use(value);
|
|
47
|
+
const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
48
|
+
const article = (label) => (/^[aei]/.test(label) ? `an ${label}` : `a ${label}`);
|
|
49
|
+
const day = (timestamp) => timestamp?.slice(0, 10);
|
|
50
|
+
/** Today as YYYY-MM-DD in the machine's own time zone, which is how sprint dates are meant. */
|
|
51
|
+
function localDay(date) {
|
|
52
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Files already read, by the checksum of their content, so that reading a large file a part at a
|
|
57
|
+
* time downloads and parses it once. Keyed by content, the cache cannot hand anyone a file they
|
|
58
|
+
* could not read: only someone who can see a file with that checksum asks for it.
|
|
59
|
+
*/
|
|
60
|
+
class FileCache {
|
|
61
|
+
ttlMs;
|
|
62
|
+
maxBytes;
|
|
63
|
+
#entries = new Map();
|
|
64
|
+
#size = 0;
|
|
65
|
+
constructor(ttlMs, maxBytes) {
|
|
66
|
+
this.ttlMs = ttlMs;
|
|
67
|
+
this.maxBytes = maxBytes;
|
|
68
|
+
}
|
|
69
|
+
get(key, now) {
|
|
70
|
+
const entry = this.#entries.get(key);
|
|
71
|
+
if (entry === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (now - entry.at > this.ttlMs) {
|
|
74
|
+
this.#drop(key);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return entry.content;
|
|
78
|
+
}
|
|
79
|
+
set(key, content, size, now) {
|
|
80
|
+
if (size > this.maxBytes)
|
|
81
|
+
return;
|
|
82
|
+
this.#drop(key);
|
|
83
|
+
this.#entries.set(key, { at: now, size, content });
|
|
84
|
+
this.#size += size;
|
|
85
|
+
// A Map keeps insertion order: the first entries are the oldest.
|
|
86
|
+
for (const old of this.#entries.keys()) {
|
|
87
|
+
if (this.#size <= this.maxBytes)
|
|
88
|
+
break;
|
|
89
|
+
this.#drop(old);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
clear() {
|
|
93
|
+
this.#entries.clear();
|
|
94
|
+
this.#size = 0;
|
|
95
|
+
}
|
|
96
|
+
#drop(key) {
|
|
97
|
+
const entry = this.#entries.get(key);
|
|
98
|
+
if (entry === undefined)
|
|
99
|
+
return;
|
|
100
|
+
this.#size -= entry.size;
|
|
101
|
+
this.#entries.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const fileCache = new FileCache(10 * 60_000, 64 * 1024 * 1024);
|
|
105
|
+
/** For tests: forget every file read so far. */
|
|
106
|
+
export function forgetFiles() {
|
|
107
|
+
fileCache.clear();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* What the tools ask Taiga, in the tools' own terms: project slugs, status names and reference
|
|
111
|
+
* numbers go in, trimmed summaries come out. Ids never leave this class.
|
|
112
|
+
*/
|
|
113
|
+
export class TaigaClient {
|
|
114
|
+
#http;
|
|
115
|
+
#baseUrl;
|
|
116
|
+
#now;
|
|
117
|
+
#projects = new Map();
|
|
118
|
+
#me;
|
|
119
|
+
constructor(config, fetchImpl = fetch, now = () => new Date()) {
|
|
120
|
+
this.#http = new TaigaHttp(config, fetchImpl);
|
|
121
|
+
this.#baseUrl = config.baseUrl;
|
|
122
|
+
this.#now = now;
|
|
123
|
+
}
|
|
124
|
+
/** Whether this connection may create and change items. */
|
|
125
|
+
get writable() {
|
|
126
|
+
return this.#http.writable;
|
|
127
|
+
}
|
|
128
|
+
async currentUser() {
|
|
129
|
+
this.#me ??= await this.#http.get('/users/me');
|
|
130
|
+
return this.#me;
|
|
131
|
+
}
|
|
132
|
+
/** The projects the signed-in user is a member of, by name. */
|
|
133
|
+
async listProjects() {
|
|
134
|
+
const me = await this.currentUser();
|
|
135
|
+
const projects = await this.#http.get('/projects', { member: me.id }, { everything: true });
|
|
136
|
+
return projects
|
|
137
|
+
.map((project) => ({
|
|
138
|
+
id: project.id,
|
|
139
|
+
name: project.name,
|
|
140
|
+
slug: project.slug,
|
|
141
|
+
isPrivate: project.is_private ?? false,
|
|
142
|
+
}))
|
|
143
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
144
|
+
}
|
|
145
|
+
/** Sprints, newest first. Open ones unless `closed` is true. */
|
|
146
|
+
async listSprints(slug, options) {
|
|
147
|
+
const project = await this.#project(slug);
|
|
148
|
+
const { items, total } = await this.#http.page('/milestones', { project: project.id, closed: options.closed ?? false }, options.limit, options.page);
|
|
149
|
+
const today = localDay(this.#now());
|
|
150
|
+
return { project: project.name, total, items: items.map((sprint) => toSprint(sprint, today)) };
|
|
151
|
+
}
|
|
152
|
+
/** User stories in board order. */
|
|
153
|
+
async listUserStories(slug, filters) {
|
|
154
|
+
const project = await this.#project(slug);
|
|
155
|
+
const sprint = filters.sprint === undefined ? undefined : await this.#sprintFilter(project, filters.sprint, 'story');
|
|
156
|
+
const epic = filters.epic === undefined
|
|
157
|
+
? undefined
|
|
158
|
+
: await this.#idOf(project, filters.epic, 'epic', 'Call list_epics for the epic numbers.');
|
|
159
|
+
const common = await this.#commonFilters(project, 'story', filters);
|
|
160
|
+
const { items, total } = await this.#http.page(API.story.list, { ...common.query, ...sprint?.query, epic }, filters.limit, filters.page);
|
|
161
|
+
return {
|
|
162
|
+
project: project.name,
|
|
163
|
+
assignee: common.assignee,
|
|
164
|
+
sprint: sprint?.label,
|
|
165
|
+
total,
|
|
166
|
+
items: items.map((story) => ({
|
|
167
|
+
...toSummary(story),
|
|
168
|
+
sprint: story.milestone_name ?? undefined,
|
|
169
|
+
points: story.total_points ?? undefined,
|
|
170
|
+
finished: day(story.finish_date ?? undefined),
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Issues, most recently created first. */
|
|
175
|
+
async listIssues(slug, filters) {
|
|
176
|
+
const project = await this.#project(slug);
|
|
177
|
+
const common = await this.#commonFilters(project, 'issue', filters);
|
|
178
|
+
const query = {
|
|
179
|
+
...common.query,
|
|
180
|
+
type: optional(filters.type, (name) => find(project, project.issueTypes, name, 'issue type')),
|
|
181
|
+
severity: optional(filters.severity, (name) => find(project, project.severities, name, 'severity')),
|
|
182
|
+
priority: optional(filters.priority, (name) => find(project, project.priorities, name, 'priority')),
|
|
183
|
+
order_by: '-created_date',
|
|
184
|
+
};
|
|
185
|
+
const { items, total } = await this.#http.page(API.issue.list, query, filters.limit, filters.page);
|
|
186
|
+
return {
|
|
187
|
+
project: project.name,
|
|
188
|
+
assignee: common.assignee,
|
|
189
|
+
total,
|
|
190
|
+
items: items.map((issue) => ({
|
|
191
|
+
...toSummary(issue),
|
|
192
|
+
...issueNames(project, issue),
|
|
193
|
+
finished: day(issue.finished_date ?? undefined),
|
|
194
|
+
})),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Epics in board order, each with how many of its stories are done. */
|
|
198
|
+
async listEpics(slug, filters) {
|
|
199
|
+
const project = await this.#project(slug);
|
|
200
|
+
const common = await this.#commonFilters(project, 'epic', filters);
|
|
201
|
+
const { items, total } = await this.#http.page(API.epic.list, common.query, filters.limit, filters.page);
|
|
202
|
+
return {
|
|
203
|
+
project: project.name,
|
|
204
|
+
assignee: common.assignee,
|
|
205
|
+
total,
|
|
206
|
+
items: items.map((epic) => ({
|
|
207
|
+
...toSummary(epic),
|
|
208
|
+
stories: epic.user_stories_counts?.total ?? 0,
|
|
209
|
+
storiesDone: epic.user_stories_counts?.progress ?? 0,
|
|
210
|
+
})),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Tasks, the small pieces of work inside a user story. */
|
|
214
|
+
async listTasks(slug, filters) {
|
|
215
|
+
const project = await this.#project(slug);
|
|
216
|
+
const sprint = filters.sprint === undefined ? undefined : await this.#sprintFilter(project, filters.sprint, 'task');
|
|
217
|
+
const story = filters.story === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: await this.#idOf(project, filters.story, 'story', 'Call list_user_stories for the story numbers.');
|
|
220
|
+
const common = await this.#commonFilters(project, 'task', filters);
|
|
221
|
+
const { items, total } = await this.#http.page(API.task.list, { ...common.query, ...sprint?.query, user_story: story }, filters.limit, filters.page);
|
|
222
|
+
return {
|
|
223
|
+
project: project.name,
|
|
224
|
+
assignee: common.assignee,
|
|
225
|
+
sprint: sprint?.label,
|
|
226
|
+
total,
|
|
227
|
+
items: items.map((task) => toTask(project, task)),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* One story, issue, epic or task by its reference number. Taiga numbers all four from one
|
|
232
|
+
* sequence per project, so the number alone says which kind it is.
|
|
233
|
+
*/
|
|
234
|
+
async getItem(slug, ref, options = {}) {
|
|
235
|
+
const project = await this.#project(slug);
|
|
236
|
+
const { kind, id } = await this.#resolve(project, ref);
|
|
237
|
+
if (kind === undefined) {
|
|
238
|
+
throw new TaigaError(`Project "${project.name}" has no item #${ref}. Use a list tool to find the reference number.`, 404);
|
|
239
|
+
}
|
|
240
|
+
const api = API[kind];
|
|
241
|
+
const fields = project.customFields[kind];
|
|
242
|
+
const commentsWanted = options.comments ?? COMMENTS_SHOWN;
|
|
243
|
+
const changesPage = options.changesPage ?? 1;
|
|
244
|
+
const historyPath = `/history/${api.history}/${id}`;
|
|
245
|
+
const [item, custom, history, activity, tasks, attachments] = await Promise.all([
|
|
246
|
+
this.#http.get(`${api.list}/${id}`),
|
|
247
|
+
// Most projects define no custom fields: skip the request then.
|
|
248
|
+
fields.length === 0
|
|
249
|
+
? undefined
|
|
250
|
+
: this.#http.get(`${api.list}/custom-attributes-values/${id}`),
|
|
251
|
+
// A deleted comment stays in the history, so ask for a few more than are shown.
|
|
252
|
+
commentsWanted === 0
|
|
253
|
+
? undefined
|
|
254
|
+
: this.#http.page(historyPath, { type: 'comment' }, commentsWanted * 2),
|
|
255
|
+
options.changes || options.changesPage !== undefined
|
|
256
|
+
? this.#http
|
|
257
|
+
.page(historyPath, { type: 'activity' }, CHANGES_SHOWN, changesPage)
|
|
258
|
+
.catch((error) => {
|
|
259
|
+
if (changesPage > 1 && error instanceof TaigaError && error.status === 404) {
|
|
260
|
+
throw new TaigaError(`The history of #${ref} has no page ${changesPage}. Call with \`changes_page\`: 1 to see how many pages there are.`, 404);
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
})
|
|
264
|
+
: undefined,
|
|
265
|
+
kind === 'story'
|
|
266
|
+
? this.#http.get(API.task.list, { project: project.id, user_story: id }, { everything: true })
|
|
267
|
+
: undefined,
|
|
268
|
+
this.#attachments(api.list, project, id),
|
|
269
|
+
]);
|
|
270
|
+
const deleted = history?.items.filter((entry) => entry.delete_comment_date).length ?? 0;
|
|
271
|
+
const comments = history?.items
|
|
272
|
+
.filter((entry) => entry.comment && !entry.delete_comment_date)
|
|
273
|
+
.slice(0, commentsWanted)
|
|
274
|
+
.map((entry) => ({
|
|
275
|
+
author: authorOf(entry),
|
|
276
|
+
date: day(entry.created_at) ?? '',
|
|
277
|
+
text: entry.comment ?? '',
|
|
278
|
+
}));
|
|
279
|
+
const changes = activity?.items.flatMap((entry) => {
|
|
280
|
+
const what = describeChange(entry.values_diff ?? {});
|
|
281
|
+
return what === undefined ? [] : [{ author: authorOf(entry), date: day(entry.created_at) ?? '', what }];
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
...toSummary(item),
|
|
285
|
+
kind,
|
|
286
|
+
project: project.name,
|
|
287
|
+
description: item.description?.trim() ?? '',
|
|
288
|
+
createdBy: item.owner_extra_info?.full_name_display,
|
|
289
|
+
created: day(item.created_date),
|
|
290
|
+
modified: day(item.modified_date),
|
|
291
|
+
blockedNote: item.is_blocked ? item.blocked_note?.trim() || 'no reason given' : undefined,
|
|
292
|
+
tags: (item.tags ?? []).map(tagName),
|
|
293
|
+
facts: factsOf(kind, project, item),
|
|
294
|
+
customFields: fields.flatMap((field) => {
|
|
295
|
+
const value = custom?.attributes_values?.[String(field.id)];
|
|
296
|
+
return value === undefined || value === null || value === ''
|
|
297
|
+
? []
|
|
298
|
+
: [[field.name, String(value)]];
|
|
299
|
+
}),
|
|
300
|
+
comments,
|
|
301
|
+
totalComments: Math.max((history?.total ?? 0) - deleted, comments?.length ?? 0),
|
|
302
|
+
tasks: tasks?.map((task) => toTask(project, task)),
|
|
303
|
+
attachments,
|
|
304
|
+
changes,
|
|
305
|
+
totalChanges: Math.max(activity?.total ?? 0, changes?.length ?? 0),
|
|
306
|
+
changesPage,
|
|
307
|
+
changesPages: Math.max(1, Math.ceil((activity?.total ?? 0) / CHANGES_SHOWN)),
|
|
308
|
+
version: item.version,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The text or image of one file attached to an item. `file` is its number in the item's list
|
|
313
|
+
* of files, or its name (or a part of it that fits one file only).
|
|
314
|
+
*/
|
|
315
|
+
async getAttachment(slug, ref, file) {
|
|
316
|
+
const project = await this.#project(slug);
|
|
317
|
+
const { kind, id } = await this.#resolve(project, ref);
|
|
318
|
+
if (kind === undefined || id === undefined) {
|
|
319
|
+
throw new TaigaError(`Project "${project.name}" has no item #${ref}.`, 404);
|
|
320
|
+
}
|
|
321
|
+
const files = await this.#attachments(API[kind].list, project, id);
|
|
322
|
+
const label = `${API[kind].label} #${ref}`;
|
|
323
|
+
if (files.length === 0)
|
|
324
|
+
throw new TaigaError(`${capital(label)} has no attached files.`, 404);
|
|
325
|
+
const wanted = file.trim();
|
|
326
|
+
const byNumber = /^#?\d+$/.test(wanted) ? files[Number(wanted.replace('#', '')) - 1] : undefined;
|
|
327
|
+
const exact = files.filter((f) => same(f.name, wanted));
|
|
328
|
+
const partial = files.filter((f) => f.name.toLowerCase().includes(wanted.toLowerCase()));
|
|
329
|
+
const found = byNumber ?? (exact.length === 1 ? exact[0] : partial.length === 1 ? partial[0] : undefined);
|
|
330
|
+
if (found === undefined) {
|
|
331
|
+
const list = files.map((f) => `${f.number}. ${f.name}`).join(', ');
|
|
332
|
+
throw new TaigaError(partial.length > 1
|
|
333
|
+
? `"${file}" fits several files of ${label}: ${list}. Pass the number.`
|
|
334
|
+
: `${capital(label)} has no file "${file}". Its files: ${list}.`, 404);
|
|
335
|
+
}
|
|
336
|
+
if (found.url === undefined)
|
|
337
|
+
throw new TaigaError(`Taiga gave no address for "${found.name}".`);
|
|
338
|
+
const now = this.#now().getTime();
|
|
339
|
+
const cached = found.sha1 === undefined ? undefined : fileCache.get(found.sha1, now);
|
|
340
|
+
if (cached !== undefined)
|
|
341
|
+
return { item: `${capital(label)} of ${project.name}`, file: found, content: cached };
|
|
342
|
+
const { bytes, type } = await this.#http.download(found.url, FILE_MAX_BYTES);
|
|
343
|
+
let content = await readFile(found.name, bytes, type);
|
|
344
|
+
if (content.kind === 'image' && bytes.byteLength > IMAGE_MAX_BYTES) {
|
|
345
|
+
content = {
|
|
346
|
+
kind: 'unreadable',
|
|
347
|
+
reason: `The image is ${(bytes.byteLength / (1024 * 1024)).toFixed(1)} MB; images are passed on up to ${IMAGE_MAX_BYTES / (1024 * 1024)} MB. Open it in Taiga instead.`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (found.sha1 !== undefined)
|
|
351
|
+
fileCache.set(found.sha1, content, bytes.byteLength, now);
|
|
352
|
+
return { item: `${capital(label)} of ${project.name}`, file: found, content };
|
|
353
|
+
}
|
|
354
|
+
/* ------------------------------- activity ------------------------------- */
|
|
355
|
+
/**
|
|
356
|
+
* What happened in a project, newest first: items created, changed, commented on, deleted.
|
|
357
|
+
* Taiga can only list activity from the newest event backwards, so filters are applied while
|
|
358
|
+
* reading, up to ACTIVITY_BATCHES batches; the answer says how far back it looked.
|
|
359
|
+
*/
|
|
360
|
+
async listActivity(slug, filters) {
|
|
361
|
+
const project = await this.#project(slug);
|
|
362
|
+
if (filters.byMe && filters.by !== undefined) {
|
|
363
|
+
throw new TaigaError('Pass either `by_me` or `by`, not both.');
|
|
364
|
+
}
|
|
365
|
+
const who = filters.byMe
|
|
366
|
+
? { id: (await this.currentUser()).id, label: 'me' }
|
|
367
|
+
: filters.by === undefined
|
|
368
|
+
? undefined
|
|
369
|
+
: (({ id, name }) => ({ id, label: name }))(findMember(project, filters.by));
|
|
370
|
+
// A person's own activity is a much shorter list to go through than the whole project's.
|
|
371
|
+
const path = who === undefined ? `/timeline/project/${project.id}` : `/timeline/user/${who.id}`;
|
|
372
|
+
const page = filters.page ?? 1;
|
|
373
|
+
const skip = (page - 1) * filters.limit;
|
|
374
|
+
const until = optional(filters.to, endOfDay);
|
|
375
|
+
const matches = [];
|
|
376
|
+
let newest;
|
|
377
|
+
let oldest;
|
|
378
|
+
let complete = false;
|
|
379
|
+
reading: for (let batch = 1; batch <= ACTIVITY_BATCHES; batch++) {
|
|
380
|
+
let events;
|
|
381
|
+
try {
|
|
382
|
+
events = (await this.#http.page(path, {}, ACTIVITY_BATCH, batch)).items;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
// Past the last page: everything has been read.
|
|
386
|
+
if (batch > 1 && error instanceof TaigaError && error.status === 404) {
|
|
387
|
+
complete = true;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
for (const event of events) {
|
|
393
|
+
newest ??= event.created;
|
|
394
|
+
oldest = event.created;
|
|
395
|
+
if (until !== undefined && event.created > until)
|
|
396
|
+
continue;
|
|
397
|
+
if (filters.from !== undefined && event.created < filters.from) {
|
|
398
|
+
complete = true;
|
|
399
|
+
break reading;
|
|
400
|
+
}
|
|
401
|
+
if (event.data?.project?.id !== undefined && event.data.project.id !== project.id)
|
|
402
|
+
continue;
|
|
403
|
+
const activity = describeEvent(event, filters);
|
|
404
|
+
if (activity === undefined)
|
|
405
|
+
continue;
|
|
406
|
+
matches.push(activity);
|
|
407
|
+
// One more than asked for says whether there are more.
|
|
408
|
+
if (matches.length > skip + filters.limit)
|
|
409
|
+
break reading;
|
|
410
|
+
}
|
|
411
|
+
if (events.length < ACTIVITY_BATCH) {
|
|
412
|
+
complete = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
project: project.name,
|
|
418
|
+
by: who?.label,
|
|
419
|
+
items: matches.slice(skip, skip + filters.limit),
|
|
420
|
+
first: skip + 1,
|
|
421
|
+
more: matches.length > skip + filters.limit,
|
|
422
|
+
checkedBackTo: complete || matches.length > skip + filters.limit ? undefined : day(oldest),
|
|
423
|
+
logEndsOn: logEnd(newest, filters.from, this.#now()),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/* -------------------------------- sprints -------------------------------- */
|
|
427
|
+
/** One sprint in full: progress by stories, tasks and points, and the burndown. */
|
|
428
|
+
async getSprint(slug, wanted) {
|
|
429
|
+
const project = await this.#project(slug);
|
|
430
|
+
const sprint = await this.#findSprint(project, wanted);
|
|
431
|
+
const [detail, stats] = await Promise.all([
|
|
432
|
+
this.#http.get(`/milestones/${sprint.id}`),
|
|
433
|
+
this.#http.get(`/milestones/${sprint.id}/stats`),
|
|
434
|
+
]);
|
|
435
|
+
const today = localDay(this.#now());
|
|
436
|
+
const summary = toSprint(detail, today);
|
|
437
|
+
const sum = (value) => typeof value === 'number'
|
|
438
|
+
? value
|
|
439
|
+
: Object.values(value ?? {}).reduce((a, b) => a + (b ?? 0), 0);
|
|
440
|
+
const done = stats.completed_points;
|
|
441
|
+
const points = Math.max(sum(stats.total_points), summary.totalPoints);
|
|
442
|
+
return {
|
|
443
|
+
project: project.name,
|
|
444
|
+
name: summary.name,
|
|
445
|
+
start: summary.start,
|
|
446
|
+
finish: summary.finish,
|
|
447
|
+
closed: summary.closed,
|
|
448
|
+
current: summary.current,
|
|
449
|
+
stories: summary.stories,
|
|
450
|
+
storiesDone: summary.storiesDone,
|
|
451
|
+
tasks: stats.total_tasks ?? 0,
|
|
452
|
+
tasksDone: stats.completed_tasks ?? 0,
|
|
453
|
+
points,
|
|
454
|
+
pointsDone: Math.max(typeof done === 'number' ? done : (done ?? []).reduce((a, b) => a + (b ?? 0), 0), summary.closedPoints),
|
|
455
|
+
iocaineDoses: stats.iocaine_doses ?? 0,
|
|
456
|
+
burndown: points === 0
|
|
457
|
+
? []
|
|
458
|
+
: (stats.days ?? [])
|
|
459
|
+
.filter((d) => d.day <= today)
|
|
460
|
+
.map((d) => ({
|
|
461
|
+
day: d.day,
|
|
462
|
+
open: round(d.open_points ?? 0),
|
|
463
|
+
ideal: round(d.optimal_points ?? 0),
|
|
464
|
+
})),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** A sprint by name, or the one running today. */
|
|
468
|
+
async #findSprint(project, wanted) {
|
|
469
|
+
const named = project.sprints.find((sprint) => same(sprint.name, wanted) || same(sprint.slug, wanted));
|
|
470
|
+
if (named !== undefined)
|
|
471
|
+
return named;
|
|
472
|
+
if (!same(wanted, 'current')) {
|
|
473
|
+
throw new TaigaError(`Project "${project.name}" has no sprint "${wanted}". Pass a sprint name from list_sprints, or "current".`);
|
|
474
|
+
}
|
|
475
|
+
return this.#currentSprint(project);
|
|
476
|
+
}
|
|
477
|
+
/** The files attached to an item. An item the user may read but whose files they may not is shown without them. */
|
|
478
|
+
async #attachments(list, project, id) {
|
|
479
|
+
const raw = await this.#http
|
|
480
|
+
.get(`${list}/attachments`, { project: project.id, object_id: id }, { everything: true })
|
|
481
|
+
.catch((error) => {
|
|
482
|
+
if (error instanceof TaigaError && (error.status === 403 || error.status === 404))
|
|
483
|
+
return [];
|
|
484
|
+
throw error;
|
|
485
|
+
});
|
|
486
|
+
return raw
|
|
487
|
+
.filter((file) => !file.is_deprecated)
|
|
488
|
+
.map((file, i) => ({
|
|
489
|
+
number: i + 1,
|
|
490
|
+
name: file.name,
|
|
491
|
+
size: file.size ?? undefined,
|
|
492
|
+
url: file.url ?? undefined,
|
|
493
|
+
note: file.description?.trim() || undefined,
|
|
494
|
+
sha1: file.sha1 || undefined,
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
/* -------------------------------- project -------------------------------- */
|
|
498
|
+
/** The people of a project and the names its items use: statuses, severities, tags… */
|
|
499
|
+
async getProject(slug) {
|
|
500
|
+
const project = await this.#project(slug);
|
|
501
|
+
const { sprints, customFields } = project;
|
|
502
|
+
return {
|
|
503
|
+
name: project.name,
|
|
504
|
+
slug: project.slug,
|
|
505
|
+
description: project.description,
|
|
506
|
+
isPrivate: project.isPrivate,
|
|
507
|
+
modules: project.modules,
|
|
508
|
+
members: project.members,
|
|
509
|
+
statuses: project.statuses,
|
|
510
|
+
severities: project.severities,
|
|
511
|
+
priorities: project.priorities,
|
|
512
|
+
issueTypes: project.issueTypes,
|
|
513
|
+
tags: project.tags,
|
|
514
|
+
customFields: {
|
|
515
|
+
story: customFields.story.map((field) => field.name),
|
|
516
|
+
issue: customFields.issue.map((field) => field.name),
|
|
517
|
+
epic: customFields.epic.map((field) => field.name),
|
|
518
|
+
task: customFields.task.map((field) => field.name),
|
|
519
|
+
},
|
|
520
|
+
openSprints: sprints.filter((sprint) => !sprint.closed).length,
|
|
521
|
+
closedSprints: sprints.filter((sprint) => sprint.closed).length,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/* ---------------------------------- wiki ---------------------------------- */
|
|
525
|
+
/** The wiki pages of a project, most recently changed first. */
|
|
526
|
+
async listWikiPages(slug, paging) {
|
|
527
|
+
const project = await this.#project(slug);
|
|
528
|
+
const [pages, links] = await Promise.all([
|
|
529
|
+
this.#http.page('/wiki', { project: project.id, order_by: '-modified_date' }, paging.limit, paging.page),
|
|
530
|
+
this.#wikiLinks(project),
|
|
531
|
+
]);
|
|
532
|
+
return {
|
|
533
|
+
project: project.name,
|
|
534
|
+
total: pages.total,
|
|
535
|
+
items: pages.items.map((page) => toWikiSummary(page, links)),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** One wiki page, by its slug or by its title in the wiki's menu. */
|
|
539
|
+
async getWikiPage(slug, page) {
|
|
540
|
+
const project = await this.#project(slug);
|
|
541
|
+
const links = await this.#wikiLinks(project);
|
|
542
|
+
const wanted = links.find((link) => same(link.title, page))?.href ?? page.trim().toLowerCase();
|
|
543
|
+
const raw = await this.#http
|
|
544
|
+
.get('/wiki/by_slug', { project: project.id, slug: wanted })
|
|
545
|
+
.catch((error) => {
|
|
546
|
+
if (error instanceof TaigaError && error.status === 404) {
|
|
547
|
+
throw new TaigaError(`Project "${project.name}" has no wiki page "${page}". Call list_wiki_pages to see the pages.`, 404);
|
|
548
|
+
}
|
|
549
|
+
throw error;
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
...toWikiSummary(raw, links),
|
|
553
|
+
project: project.name,
|
|
554
|
+
content: raw.content?.trim() ?? '',
|
|
555
|
+
created: day(raw.created_date),
|
|
556
|
+
modifiedBy: project.members.find((member) => member.id === raw.last_modifier)?.name,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/** The wiki pages whose text contains the words. */
|
|
560
|
+
async searchWiki(slug, words) {
|
|
561
|
+
const project = await this.#project(slug);
|
|
562
|
+
const [found, links] = await Promise.all([
|
|
563
|
+
this.#http.get('/search', { project: project.id, text: words.trim() }),
|
|
564
|
+
this.#wikiLinks(project),
|
|
565
|
+
]);
|
|
566
|
+
return (found.wikipages ?? []).map((page) => toWikiSummary(page, links));
|
|
567
|
+
}
|
|
568
|
+
#wikiLinks(project) {
|
|
569
|
+
return this.#http.get('/wiki-links', { project: project.id }, { everything: true });
|
|
570
|
+
}
|
|
571
|
+
/* ------------------------------ projects ------------------------------ */
|
|
572
|
+
/** The project behind a slug, remembered for a minute. */
|
|
573
|
+
/* -------------------------------- writes -------------------------------- */
|
|
574
|
+
/** Add a comment to a story, issue, epic or task. */
|
|
575
|
+
async addComment(slug, ref, text) {
|
|
576
|
+
const comment = text.trim();
|
|
577
|
+
if (comment === '')
|
|
578
|
+
throw new TaigaError('The comment is empty: pass the text to post.');
|
|
579
|
+
const { project, kind, id, current } = await this.#current(slug, ref);
|
|
580
|
+
// A comment changes no field, so the version read just now can never conflict.
|
|
581
|
+
const updated = await this.#http.write('PATCH', `${API[kind].list}/${id}`, {
|
|
582
|
+
comment,
|
|
583
|
+
version: current.version,
|
|
584
|
+
});
|
|
585
|
+
return {
|
|
586
|
+
...this.#written(project, kind, updated),
|
|
587
|
+
changed: [],
|
|
588
|
+
facts: [['Comment', truncateText(comment, COMMENT_ECHO)]],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/** Create a user story, task or issue. Every name is checked before anything is sent. */
|
|
592
|
+
async createItem(slug, input) {
|
|
593
|
+
const project = await this.#project(slug);
|
|
594
|
+
const { kind } = input;
|
|
595
|
+
const subject = input.subject.trim();
|
|
596
|
+
if (subject === '')
|
|
597
|
+
throw new TaigaError('The title is empty: pass a `subject`.');
|
|
598
|
+
const only = (given, field, kinds) => {
|
|
599
|
+
if (given !== undefined)
|
|
600
|
+
throw new TaigaError(`\`${field}\` is for ${kinds} only.`);
|
|
601
|
+
};
|
|
602
|
+
if (kind !== 'task')
|
|
603
|
+
only(input.story, 'story', 'tasks');
|
|
604
|
+
if (kind === 'task' && input.story === undefined && input.sprint === undefined) {
|
|
605
|
+
// Taiga shows a task on the taskboard of a sprint or under its story; with neither, nobody sees it.
|
|
606
|
+
throw new TaigaError('A task needs a user story (`story`) or a sprint (`sprint`), or it appears on no board. ' +
|
|
607
|
+
'For work that belongs to no story, create a user story or an issue instead.');
|
|
608
|
+
}
|
|
609
|
+
if (kind !== 'story')
|
|
610
|
+
only(input.epic, 'epic', 'user stories');
|
|
611
|
+
if (kind !== 'issue') {
|
|
612
|
+
only(input.type, 'type', 'issues');
|
|
613
|
+
only(input.severity, 'severity', 'issues');
|
|
614
|
+
only(input.priority, 'priority', 'issues');
|
|
615
|
+
}
|
|
616
|
+
const body = { project: project.id, subject };
|
|
617
|
+
if (input.description?.trim())
|
|
618
|
+
body['description'] = input.description;
|
|
619
|
+
if (input.status !== undefined) {
|
|
620
|
+
body['status'] = find(project, project.statuses[kind], input.status, `${API[kind].label} status`);
|
|
621
|
+
}
|
|
622
|
+
if (input.assignedTo !== undefined)
|
|
623
|
+
Object.assign(body, assignment(kind, await this.#person(project, input.assignedTo)));
|
|
624
|
+
const tags = tagList(input.tags ?? []);
|
|
625
|
+
if (tags.length > 0)
|
|
626
|
+
body['tags'] = tags;
|
|
627
|
+
if (input.dueDate !== undefined)
|
|
628
|
+
body['due_date'] = input.dueDate;
|
|
629
|
+
if (input.sprint !== undefined)
|
|
630
|
+
body['milestone'] = (await this.#sprintFor(project, input.sprint)).id;
|
|
631
|
+
if (input.story !== undefined) {
|
|
632
|
+
const storyId = await this.#idOf(project, input.story, 'story', 'Pass the number of a user story as `story`.');
|
|
633
|
+
body['user_story'] = storyId;
|
|
634
|
+
// A task lives on the taskboard of its story's sprint, unless told otherwise.
|
|
635
|
+
if (input.sprint === undefined) {
|
|
636
|
+
body['milestone'] =
|
|
637
|
+
(await this.#http.get(`${API.story.list}/${storyId}`)).milestone ?? null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (kind === 'issue') {
|
|
641
|
+
if (input.type !== undefined)
|
|
642
|
+
body['type'] = find(project, project.issueTypes, input.type, 'issue type');
|
|
643
|
+
if (input.severity !== undefined)
|
|
644
|
+
body['severity'] = find(project, project.severities, input.severity, 'severity');
|
|
645
|
+
if (input.priority !== undefined)
|
|
646
|
+
body['priority'] = find(project, project.priorities, input.priority, 'priority');
|
|
647
|
+
}
|
|
648
|
+
const epicId = input.epic === undefined
|
|
649
|
+
? undefined
|
|
650
|
+
: await this.#idOf(project, input.epic, 'epic', 'Call list_epics for the epic numbers.');
|
|
651
|
+
const created = await this.#http.write('POST', API[kind].list, body);
|
|
652
|
+
const facts = factsOfNew(kind, project, created);
|
|
653
|
+
if (epicId !== undefined) {
|
|
654
|
+
try {
|
|
655
|
+
await this.#http.write('POST', `/epics/${epicId}/related_userstories`, {
|
|
656
|
+
epic: epicId,
|
|
657
|
+
user_story: created.id,
|
|
658
|
+
});
|
|
659
|
+
facts.push(['Epic', `#${input.epic}`]);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
663
|
+
throw new TaigaError(`User story #${created.ref} was created, but adding it to epic #${input.epic} failed (${reason}). ` +
|
|
664
|
+
'Do not create the story again: tell the user, who can add it to the epic in Taiga.');
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { ...this.#written(project, kind, created), changed: [], facts };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Change some fields of an item. Taiga refuses the change when someone else changed the same
|
|
671
|
+
* fields after `version`; fields nobody else touched are merged.
|
|
672
|
+
*/
|
|
673
|
+
async updateItem(slug, ref, change) {
|
|
674
|
+
const { project, kind, id, current } = await this.#current(slug, ref);
|
|
675
|
+
const label = API[kind].label;
|
|
676
|
+
const body = {};
|
|
677
|
+
const changed = [];
|
|
678
|
+
const set = (field, value, name, before, after) => {
|
|
679
|
+
if (before === after)
|
|
680
|
+
return;
|
|
681
|
+
body[field] = value;
|
|
682
|
+
changed.push([name, before, after]);
|
|
683
|
+
};
|
|
684
|
+
const notFor = (given, kinds, why) => {
|
|
685
|
+
if (given !== undefined && kinds.includes(kind))
|
|
686
|
+
throw new TaigaError(`#${ref} is ${article(label)}: ${why}`);
|
|
687
|
+
};
|
|
688
|
+
notFor(change.sprint, ['epic'], 'epics are not planned in sprints.');
|
|
689
|
+
notFor(change.dueDate, ['epic'], 'Taiga keeps no due date for epics.');
|
|
690
|
+
for (const field of ['type', 'severity', 'priority']) {
|
|
691
|
+
notFor(change[field], ['story', 'task', 'epic'], `only issues have a ${field}.`);
|
|
692
|
+
}
|
|
693
|
+
if (change.subject !== undefined) {
|
|
694
|
+
const subject = change.subject.trim();
|
|
695
|
+
if (subject === '')
|
|
696
|
+
throw new TaigaError('The title cannot be empty.');
|
|
697
|
+
set('subject', subject, 'Title', current.subject, subject);
|
|
698
|
+
}
|
|
699
|
+
if (change.description !== undefined) {
|
|
700
|
+
const before = current.description ?? '';
|
|
701
|
+
if (before.trim() !== change.description.trim()) {
|
|
702
|
+
body['description'] = change.description;
|
|
703
|
+
changed.push(['Description', characters(before), characters(change.description)]);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (change.status !== undefined) {
|
|
707
|
+
const statusId = find(project, project.statuses[kind], change.status, `${label} status`);
|
|
708
|
+
const after = nameOf(project.statuses[kind], statusId) ?? change.status;
|
|
709
|
+
set('status', statusId, 'Status', current.status_extra_info?.name ?? 'Unknown', after);
|
|
710
|
+
}
|
|
711
|
+
if (change.assignedTo !== undefined) {
|
|
712
|
+
const who = await this.#person(project, change.assignedTo);
|
|
713
|
+
const before = current.assigned_to_extra_info?.full_name_display ?? 'nobody';
|
|
714
|
+
if (before !== who.name) {
|
|
715
|
+
Object.assign(body, assignment(kind, who));
|
|
716
|
+
changed.push(['Assigned to', before, who.name]);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (change.sprint !== undefined) {
|
|
720
|
+
const sprint = await this.#sprintFor(project, change.sprint);
|
|
721
|
+
const before = nameOf(project.sprints, current.milestone) ??
|
|
722
|
+
(current.milestone ? 'another sprint' : 'none (backlog)');
|
|
723
|
+
set('milestone', sprint.id, 'Sprint', before, sprint.name);
|
|
724
|
+
}
|
|
725
|
+
if (change.addTags !== undefined || change.removeTags !== undefined) {
|
|
726
|
+
const before = (current.tags ?? []).map(tagName);
|
|
727
|
+
const removed = new Set(tagList(change.removeTags ?? []));
|
|
728
|
+
const after = tagList([
|
|
729
|
+
...before.filter((tag) => !removed.has(tag.toLowerCase())),
|
|
730
|
+
...(change.addTags ?? []),
|
|
731
|
+
]);
|
|
732
|
+
const show = (tags) => tags.join(', ') || 'none';
|
|
733
|
+
set('tags', after, 'Tags', show(before), show(after));
|
|
734
|
+
}
|
|
735
|
+
if (change.dueDate !== undefined) {
|
|
736
|
+
set('due_date', change.dueDate, 'Due', day(current.due_date ?? undefined) ?? 'none', change.dueDate ?? 'none');
|
|
737
|
+
}
|
|
738
|
+
if (change.blocked !== undefined) {
|
|
739
|
+
const yesNo = (value) => (value ? 'yes' : 'no');
|
|
740
|
+
set('is_blocked', change.blocked, 'Blocked', yesNo(current.is_blocked ?? false), yesNo(change.blocked));
|
|
741
|
+
}
|
|
742
|
+
if (change.blockedNote !== undefined || change.blocked === false) {
|
|
743
|
+
const note = change.blocked === false ? '' : (change.blockedNote ?? '').trim();
|
|
744
|
+
set('blocked_note', note, 'Reason blocked', current.blocked_note?.trim() || 'none', note || 'none');
|
|
745
|
+
}
|
|
746
|
+
if (kind === 'issue') {
|
|
747
|
+
const names = issueNames(project, current);
|
|
748
|
+
if (change.type !== undefined) {
|
|
749
|
+
const typeId = find(project, project.issueTypes, change.type, 'issue type');
|
|
750
|
+
set('type', typeId, 'Type', names.type ?? 'none', nameOf(project.issueTypes, typeId) ?? change.type);
|
|
751
|
+
}
|
|
752
|
+
if (change.severity !== undefined) {
|
|
753
|
+
const severityId = find(project, project.severities, change.severity, 'severity');
|
|
754
|
+
set('severity', severityId, 'Severity', names.severity ?? 'none', nameOf(project.severities, severityId) ?? change.severity);
|
|
755
|
+
}
|
|
756
|
+
if (change.priority !== undefined) {
|
|
757
|
+
const priorityId = find(project, project.priorities, change.priority, 'priority');
|
|
758
|
+
set('priority', priorityId, 'Priority', names.priority ?? 'none', nameOf(project.priorities, priorityId) ?? change.priority);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (changed.length === 0) {
|
|
762
|
+
return { ...this.#written(project, kind, current), changed: [], facts: [] };
|
|
763
|
+
}
|
|
764
|
+
const version = change.version ?? current.version;
|
|
765
|
+
// Taiga answers a version from the future with the same field name as a conflict: tell them apart here.
|
|
766
|
+
if (change.version !== undefined && current.version !== undefined && change.version > current.version) {
|
|
767
|
+
throw new TaigaError(`#${ref} has no version ${change.version}: it is at version ${current.version}. Pass the version ` +
|
|
768
|
+
'get_item showed, or leave `version` out.');
|
|
769
|
+
}
|
|
770
|
+
let updated;
|
|
771
|
+
try {
|
|
772
|
+
updated = await this.#http.write('PATCH', `${API[kind].list}/${id}`, { ...body, version });
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
if (error instanceof TaigaError && error.status === 400 && /version/i.test(error.message)) {
|
|
776
|
+
throw new TaigaError(`Not changed: someone changed the same fields of #${ref} after version ${version} was read ` +
|
|
777
|
+
`(it is now at version ${current.version ?? 'unknown'}). Read it again with get_item, check with ` +
|
|
778
|
+
'the user that the change still makes sense, and retry with the new version.', 409);
|
|
779
|
+
}
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
return { ...this.#written(project, kind, updated), changed, facts: [] };
|
|
783
|
+
}
|
|
784
|
+
/** The item behind a reference number, read in full just now. */
|
|
785
|
+
async #current(slug, ref) {
|
|
786
|
+
const project = await this.#project(slug);
|
|
787
|
+
const { kind, id } = await this.#resolve(project, ref);
|
|
788
|
+
if (kind === undefined || id === undefined) {
|
|
789
|
+
throw new TaigaError(`Project "${project.name}" has no item #${ref}. Use a list tool to find the reference number.`, 404);
|
|
790
|
+
}
|
|
791
|
+
const current = await this.#http.get(`${API[kind].list}/${id}`);
|
|
792
|
+
return { project, kind, id, current };
|
|
793
|
+
}
|
|
794
|
+
#written(project, kind, item) {
|
|
795
|
+
return {
|
|
796
|
+
kind,
|
|
797
|
+
ref: item.ref,
|
|
798
|
+
subject: item.subject,
|
|
799
|
+
project: project.name,
|
|
800
|
+
url: webAddress(this.#baseUrl, project.slug, kind, item.ref),
|
|
801
|
+
version: item.version,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** Who `assigned_to` points at in a change: a member, the signed-in user, or nobody. */
|
|
805
|
+
async #person(project, wanted) {
|
|
806
|
+
const key = fold(wanted);
|
|
807
|
+
if (UNASSIGNED.has(key))
|
|
808
|
+
return { id: null, name: 'nobody' };
|
|
809
|
+
if (ME.has(key)) {
|
|
810
|
+
const me = await this.currentUser();
|
|
811
|
+
const member = project.members.find((m) => m.id === me.id);
|
|
812
|
+
if (member === undefined) {
|
|
813
|
+
throw new TaigaError(`The signed-in user is not a member of project "${project.name}", so cannot be assigned.`);
|
|
814
|
+
}
|
|
815
|
+
return { id: member.id, name: member.name };
|
|
816
|
+
}
|
|
817
|
+
const member = findMember(project, wanted);
|
|
818
|
+
return { id: member.id, name: member.name };
|
|
819
|
+
}
|
|
820
|
+
/** The sprint a change puts an item in: one by name, the running one, or none (the backlog). */
|
|
821
|
+
async #sprintFor(project, wanted) {
|
|
822
|
+
// A sprint that really is named "Backlog" or "Current" wins over the keyword.
|
|
823
|
+
const named = project.sprints.find((sprint) => same(sprint.name, wanted) || same(sprint.slug, wanted));
|
|
824
|
+
if (named !== undefined)
|
|
825
|
+
return { id: named.id, name: named.name };
|
|
826
|
+
if (same(wanted, 'backlog') || same(wanted, 'none'))
|
|
827
|
+
return { id: null, name: 'none (backlog)' };
|
|
828
|
+
if (same(wanted, 'current')) {
|
|
829
|
+
const running = await this.#currentSprint(project);
|
|
830
|
+
return { id: running.id, name: running.name };
|
|
831
|
+
}
|
|
832
|
+
const open = project.sprints.filter((sprint) => !sprint.closed).map((sprint) => `"${sprint.name}"`);
|
|
833
|
+
throw new TaigaError(`Project "${project.name}" has no sprint "${wanted}". Open sprints: ${open.join(', ') || 'none'}. ` +
|
|
834
|
+
'Or pass "current" for the running sprint, or "backlog" for none.');
|
|
835
|
+
}
|
|
836
|
+
#project(slug) {
|
|
837
|
+
const key = slug.trim().toLowerCase();
|
|
838
|
+
const cached = this.#projects.get(key);
|
|
839
|
+
const now = this.#now().getTime();
|
|
840
|
+
if (cached !== undefined && now - cached.at < PROJECT_CACHE_MS)
|
|
841
|
+
return cached.project;
|
|
842
|
+
const project = this.#http
|
|
843
|
+
.get('/projects/by_slug', { slug: key })
|
|
844
|
+
.then(toProject, (error) => {
|
|
845
|
+
this.#projects.delete(key);
|
|
846
|
+
if (error instanceof TaigaError && error.status === 404) {
|
|
847
|
+
throw new TaigaError(`No project with slug "${slug}" that the signed-in user can read. Call list_projects to see the valid slugs.`, 404);
|
|
848
|
+
}
|
|
849
|
+
throw error;
|
|
850
|
+
});
|
|
851
|
+
this.#projects.set(key, { at: now, project });
|
|
852
|
+
return project;
|
|
853
|
+
}
|
|
854
|
+
/* ------------------------------- filters ------------------------------- */
|
|
855
|
+
/** The filters every kind of item has, as Taiga's query, and whose items they select in words. */
|
|
856
|
+
async #commonFilters(project, kind, filters) {
|
|
857
|
+
const who = await this.#assignee(project, filters);
|
|
858
|
+
const finished = API[kind].finished;
|
|
859
|
+
if (finished === undefined && (filters.finishedFrom ?? filters.finishedTo) !== undefined) {
|
|
860
|
+
throw new TaigaError(`Taiga keeps no finish date for ${API[kind].label}s. Filter them with \`closed\`.`);
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
assignee: who?.label,
|
|
864
|
+
query: {
|
|
865
|
+
project: project.id,
|
|
866
|
+
status: optional(filters.status, (name) => find(project, project.statuses[kind], name, `${API[kind].label} status`)),
|
|
867
|
+
status__is_closed: filters.closed,
|
|
868
|
+
assigned_to: who?.value,
|
|
869
|
+
q: searchWords(filters.search),
|
|
870
|
+
tags: filters.tag?.trim().toLowerCase() || undefined,
|
|
871
|
+
created_date__gte: filters.createdFrom,
|
|
872
|
+
created_date__lte: optional(filters.createdTo, endOfDay),
|
|
873
|
+
...(finished === undefined
|
|
874
|
+
? {}
|
|
875
|
+
: {
|
|
876
|
+
[`${finished}__gte`]: filters.finishedFrom,
|
|
877
|
+
[`${finished}__lte`]: optional(filters.finishedTo, endOfDay),
|
|
878
|
+
}),
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
async #assignee(project, filters) {
|
|
883
|
+
if (filters.assignedTo === undefined) {
|
|
884
|
+
return filters.assignedToMe ? { value: (await this.currentUser()).id, label: 'me' } : undefined;
|
|
885
|
+
}
|
|
886
|
+
if (filters.assignedToMe) {
|
|
887
|
+
throw new TaigaError('Pass either `assigned_to_me` or `assigned_to`, not both.');
|
|
888
|
+
}
|
|
889
|
+
if (UNASSIGNED.has(fold(filters.assignedTo)))
|
|
890
|
+
return { value: 'null', label: 'nobody' };
|
|
891
|
+
const member = findMember(project, filters.assignedTo);
|
|
892
|
+
return { value: member.id, label: member.name };
|
|
893
|
+
}
|
|
894
|
+
/** Which kind of item a reference number belongs to, and its id. Both missing: no such item. */
|
|
895
|
+
async #resolve(project, ref) {
|
|
896
|
+
const resolved = await this.#http.get('/resolver', { project: project.slug, ref });
|
|
897
|
+
const kind = KINDS.find((candidate) => resolved[API[candidate].resolved] !== undefined);
|
|
898
|
+
return kind === undefined ? {} : { kind, id: resolved[API[kind].resolved] };
|
|
899
|
+
}
|
|
900
|
+
/** The id behind a reference number that must be of one kind, e.g. the epic to filter stories by. */
|
|
901
|
+
async #idOf(project, ref, wanted, hint) {
|
|
902
|
+
const { kind, id } = await this.#resolve(project, ref);
|
|
903
|
+
if (kind === wanted && id !== undefined)
|
|
904
|
+
return id;
|
|
905
|
+
const label = API[wanted].label;
|
|
906
|
+
throw new TaigaError(kind === undefined
|
|
907
|
+
? `Project "${project.name}" has no ${label} #${ref}. ${hint}`
|
|
908
|
+
: `#${ref} in project "${project.name}" is ${article(API[kind].label)}, not ${article(label)}. ${hint}`, 404);
|
|
909
|
+
}
|
|
910
|
+
/** Turn what the user called a sprint into Taiga's filter, and into words for the answer. */
|
|
911
|
+
async #sprintFilter(project, wanted,
|
|
912
|
+
/** Stories can be in no sprint (the backlog); Taiga cannot list tasks that way. */
|
|
913
|
+
of) {
|
|
914
|
+
// A sprint that really is named "Backlog" or "Current" wins over the keyword.
|
|
915
|
+
const named = project.sprints.find((sprint) => same(sprint.name, wanted) || same(sprint.slug, wanted));
|
|
916
|
+
if (named !== undefined)
|
|
917
|
+
return { query: { milestone: named.id }, label: `sprint "${named.name}"` };
|
|
918
|
+
if (same(wanted, 'backlog')) {
|
|
919
|
+
if (of === 'story')
|
|
920
|
+
return { query: { milestone__isnull: true }, label: 'backlog (no sprint)' };
|
|
921
|
+
throw new TaigaError('Tasks cannot be listed by backlog. List the backlog stories with list_user_stories (sprint: "backlog"), then the tasks of one story with `story`.');
|
|
922
|
+
}
|
|
923
|
+
if (!same(wanted, 'current')) {
|
|
924
|
+
throw new TaigaError(`Project "${project.name}" has no sprint "${wanted}". Pass a sprint name from list_sprints` +
|
|
925
|
+
(of === 'story' ? ', "current" or "backlog".' : ' or "current".'));
|
|
926
|
+
}
|
|
927
|
+
const running = await this.#currentSprint(project);
|
|
928
|
+
return { query: { milestone: running.id }, label: `current sprint "${running.name}"` };
|
|
929
|
+
}
|
|
930
|
+
/** The open sprint whose dates include today, or an error that lists the open ones. */
|
|
931
|
+
async #currentSprint(project) {
|
|
932
|
+
const open = await this.#http.get('/milestones', { project: project.id, closed: false }, { everything: true });
|
|
933
|
+
const today = localDay(this.#now());
|
|
934
|
+
// Newest first, so with overlapping sprints the one that started last is taken.
|
|
935
|
+
const running = open.find((sprint) => isRunning(sprint, today));
|
|
936
|
+
if (running === undefined) {
|
|
937
|
+
const others = open.map((s) => `"${s.name}" (${s.estimated_start} to ${s.estimated_finish})`);
|
|
938
|
+
throw new TaigaError(`No sprint of project "${project.name}" is running today (${today}). ` +
|
|
939
|
+
(others.length > 0
|
|
940
|
+
? `Open sprints: ${others.join(', ')}. Pass one of these names as \`sprint\`.`
|
|
941
|
+
: 'It has no open sprint. Call list_sprints with closed: true to see past ones.'));
|
|
942
|
+
}
|
|
943
|
+
return running;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/* -------------------------- raw → trimmed shapes -------------------------- */
|
|
947
|
+
/** The id behind a name the user said, or an error that lists the names that exist. */
|
|
948
|
+
function find(project, options, wanted, what) {
|
|
949
|
+
const match = options.find((option) => same(option.name, wanted));
|
|
950
|
+
if (match !== undefined)
|
|
951
|
+
return match.id;
|
|
952
|
+
throw new TaigaError(`Project "${project.name}" has no ${what} "${wanted}". It has: ${options.map((o) => o.name).join(', ') || 'none'}.`);
|
|
953
|
+
}
|
|
954
|
+
/** The one member a name, part of a name, or a username points at; or an error that says who there is. */
|
|
955
|
+
function findMember(project, wanted) {
|
|
956
|
+
const key = fold(wanted);
|
|
957
|
+
const show = (member) => `${member.name} (${member.username})`;
|
|
958
|
+
const exact = project.members.filter((m) => fold(m.name) === key || fold(m.username) === key);
|
|
959
|
+
const matches = exact.length > 0
|
|
960
|
+
? exact
|
|
961
|
+
: project.members.filter((m) => fold(m.name).includes(key) || fold(m.username).includes(key));
|
|
962
|
+
const [only, ...others] = matches;
|
|
963
|
+
if (only !== undefined && others.length === 0)
|
|
964
|
+
return only;
|
|
965
|
+
if (only === undefined) {
|
|
966
|
+
const listed = project.members.slice(0, MEMBERS_LISTED).map(show).join(', ');
|
|
967
|
+
const more = project.members.length - MEMBERS_LISTED;
|
|
968
|
+
throw new TaigaError(`Project "${project.name}" has no member "${wanted}". Members: ${listed || 'none'}${more > 0 ? `, and ${more} more` : ''}.`);
|
|
969
|
+
}
|
|
970
|
+
throw new TaigaError(`"${wanted}" matches several members of project "${project.name}": ${matches.map(show).join(', ')}. Pass the full name or the username.`);
|
|
971
|
+
}
|
|
972
|
+
/** What Taiga's `q` filter gets: the words, or nothing when there are none. */
|
|
973
|
+
const searchWords = (search) => search?.trim() || undefined;
|
|
974
|
+
/** The last moment of a day, so that "to 2026-03-31" includes what happened on the 31st. */
|
|
975
|
+
const endOfDay = (date) => `${date}T23:59:59Z`;
|
|
976
|
+
/** A week without a single event in a project people work in is worth pointing out. */
|
|
977
|
+
const LOG_SILENT_DAYS = 7;
|
|
978
|
+
/** The day the activity log ends, when the question reaches past it or the log has gone quiet. */
|
|
979
|
+
function logEnd(newest, from, now) {
|
|
980
|
+
if (newest === undefined)
|
|
981
|
+
return undefined;
|
|
982
|
+
const quiet = now.getTime() - Date.parse(newest) > LOG_SILENT_DAYS * 86_400_000;
|
|
983
|
+
const askedPast = from !== undefined && newest.slice(0, 10) < from;
|
|
984
|
+
return quiet || askedPast ? day(newest) : undefined;
|
|
985
|
+
}
|
|
986
|
+
const authorOf = (entry) => entry.user?.name || entry.user?.username || 'Unknown';
|
|
987
|
+
/** A value of a history entry, short enough for one line. */
|
|
988
|
+
function shown(value) {
|
|
989
|
+
if (value === null || value === undefined || value === '' || value === 'None')
|
|
990
|
+
return 'none';
|
|
991
|
+
if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) {
|
|
992
|
+
return value.length === 0 ? 'none' : value.map(String).join(', ');
|
|
993
|
+
}
|
|
994
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
995
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
996
|
+
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
997
|
+
}
|
|
998
|
+
/** What one history entry changed, in words: `status: New → In progress; assigned to: none → Linh Tran`. */
|
|
999
|
+
function describeChange(diff) {
|
|
1000
|
+
const label = (key) => (key === 'milestone' ? 'sprint' : key.replaceAll('_', ' '));
|
|
1001
|
+
const parts = [];
|
|
1002
|
+
for (const [key, value] of Object.entries(diff)) {
|
|
1003
|
+
// Board positions say nothing, and an `_html` field repeats the change of the field it renders.
|
|
1004
|
+
if (SILENT_CHANGES.has(key) || key.endsWith('_order') || key.endsWith('_html'))
|
|
1005
|
+
continue;
|
|
1006
|
+
if (key.endsWith('_diff')) {
|
|
1007
|
+
parts.push(`${label(key.slice(0, -'_diff'.length))} edited`);
|
|
1008
|
+
}
|
|
1009
|
+
else if (Array.isArray(value) && value.length === 2) {
|
|
1010
|
+
parts.push(`${label(key)}: ${shown(value[0])} → ${shown(value[1])}`);
|
|
1011
|
+
}
|
|
1012
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1013
|
+
// Points are kept per role: { "Back": ["?", "3"] }. Attachments: { new: [...], deleted: [...] }.
|
|
1014
|
+
const inner = Object.entries(value).flatMap(([name, pair]) => Array.isArray(pair) && pair.length === 2 && !Array.isArray(pair[0]) && typeof pair[0] !== 'object'
|
|
1015
|
+
? [`${label(key)} (${name}): ${shown(pair[0])} → ${shown(pair[1])}`]
|
|
1016
|
+
: []);
|
|
1017
|
+
parts.push(...(inner.length > 0 ? inner : [`${label(key)} changed`]));
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
parts.push(`${label(key)} changed`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return parts.length === 0 ? undefined : parts.join('; ');
|
|
1024
|
+
}
|
|
1025
|
+
const capital = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
1026
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
1027
|
+
const MODELS = {
|
|
1028
|
+
userstory: { kind: 'story', label: 'story' },
|
|
1029
|
+
task: { kind: 'task', label: 'task' },
|
|
1030
|
+
issue: { kind: 'issue', label: 'issue' },
|
|
1031
|
+
epic: { kind: 'epic', label: 'epic' },
|
|
1032
|
+
relateduserstory: { kind: 'epic', label: 'story' },
|
|
1033
|
+
wikipage: { kind: 'wiki', label: 'wiki page' },
|
|
1034
|
+
milestone: { kind: 'sprint', label: 'sprint' },
|
|
1035
|
+
};
|
|
1036
|
+
const isRef = (value) => typeof value === 'object' && value !== null;
|
|
1037
|
+
/** "story #42 Retry failed card payments", "wiki page release-steps", "sprint "Sprint 7"". */
|
|
1038
|
+
function itemName(label, object) {
|
|
1039
|
+
if (object === undefined)
|
|
1040
|
+
return label;
|
|
1041
|
+
if (object.ref !== undefined)
|
|
1042
|
+
return `${label} #${object.ref}${object.subject ? ` ${oneLine(object.subject)}` : ''}`;
|
|
1043
|
+
if (object.slug !== undefined && label === 'wiki page')
|
|
1044
|
+
return `${label} ${object.slug}`;
|
|
1045
|
+
if (object.name !== undefined)
|
|
1046
|
+
return `${label} "${object.name}"`;
|
|
1047
|
+
return label;
|
|
1048
|
+
}
|
|
1049
|
+
const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
1050
|
+
const COMMENT_IN_ACTIVITY = 300;
|
|
1051
|
+
/** One timeline event in words, or nothing when it does not match or only reorders a board. */
|
|
1052
|
+
function describeEvent(event, filters) {
|
|
1053
|
+
const [, model = '', action = ''] = event.event_type.split('.');
|
|
1054
|
+
const known = MODELS[model];
|
|
1055
|
+
const data = event.data ?? {};
|
|
1056
|
+
if (filters.kind !== undefined && known?.kind !== filters.kind)
|
|
1057
|
+
return undefined;
|
|
1058
|
+
const comment = data.comment?.trim();
|
|
1059
|
+
if (filters.commentsOnly && !comment)
|
|
1060
|
+
return undefined;
|
|
1061
|
+
const own = isRef(data[model]) ? data[model] : undefined;
|
|
1062
|
+
let subject;
|
|
1063
|
+
let verb;
|
|
1064
|
+
if (model === 'relateduserstory') {
|
|
1065
|
+
const story = isRef(data['userstory']) ? data['userstory'] : undefined;
|
|
1066
|
+
const epic = isRef(data['epic']) ? data['epic'] : undefined;
|
|
1067
|
+
subject = `${itemName('story', { ref: story?.ref, subject: own?.subject ?? story?.subject })} ${action === 'delete' ? 'from' : 'to'} ${itemName('epic', epic === undefined ? undefined : { ref: epic.ref })}`;
|
|
1068
|
+
verb = action === 'create' ? 'linked' : action === 'delete' ? 'unlinked' : 'changed the link of';
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
subject = itemName(known?.label ?? model, own);
|
|
1072
|
+
verb =
|
|
1073
|
+
action === 'create'
|
|
1074
|
+
? 'created'
|
|
1075
|
+
: action === 'delete'
|
|
1076
|
+
? 'deleted'
|
|
1077
|
+
: comment
|
|
1078
|
+
? 'commented on'
|
|
1079
|
+
: 'changed';
|
|
1080
|
+
}
|
|
1081
|
+
const refs = [own?.ref, isRef(data['userstory']) ? data['userstory'].ref : undefined];
|
|
1082
|
+
if (filters.ref !== undefined && !refs.includes(filters.ref))
|
|
1083
|
+
return undefined;
|
|
1084
|
+
const changes = describeChange(data.values_diff ?? {});
|
|
1085
|
+
// Moving cards around a board is recorded too; it says nothing.
|
|
1086
|
+
if (action === 'change' && !comment && changes === undefined)
|
|
1087
|
+
return undefined;
|
|
1088
|
+
const parts = [
|
|
1089
|
+
`${verb} ${subject}`,
|
|
1090
|
+
changes,
|
|
1091
|
+
comment && `"${truncateText(oneLine(comment), COMMENT_IN_ACTIVITY)}"`,
|
|
1092
|
+
];
|
|
1093
|
+
return {
|
|
1094
|
+
at: event.created.slice(0, 16).replace('T', ' '),
|
|
1095
|
+
author: data.user?.name || data.user?.username || 'Unknown',
|
|
1096
|
+
what: parts.filter(Boolean).join(' · '),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
const truncateText = (text, max) => text.length <= max ? text : `${text.slice(0, max).trimEnd()}…`;
|
|
1100
|
+
function toTask(project, task) {
|
|
1101
|
+
return {
|
|
1102
|
+
...toSummary(task),
|
|
1103
|
+
story: task.user_story_extra_info?.ref,
|
|
1104
|
+
sprint: nameOf(project.sprints, task.milestone),
|
|
1105
|
+
finished: day(task.finished_date ?? undefined),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function toWikiSummary(page, links) {
|
|
1109
|
+
return {
|
|
1110
|
+
slug: page.slug,
|
|
1111
|
+
title: links.find((link) => link.href === page.slug)?.title,
|
|
1112
|
+
modified: day(page.modified_date),
|
|
1113
|
+
edits: page.editions ?? 0,
|
|
1114
|
+
length: page.content?.length ?? 0,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const nameOf = (options, id) => options.find((option) => option.id === id)?.name;
|
|
1118
|
+
const named = (list) => (list ?? []).map(({ id, name }) => ({ id, name }));
|
|
1119
|
+
/** Statuses also say whether they count as done. */
|
|
1120
|
+
const statuses = (list) => (list ?? []).map(({ id, name, is_closed }) => ({ id, name, closed: is_closed ?? false }));
|
|
1121
|
+
function toProject(raw) {
|
|
1122
|
+
return {
|
|
1123
|
+
id: raw.id,
|
|
1124
|
+
name: raw.name,
|
|
1125
|
+
slug: raw.slug,
|
|
1126
|
+
statuses: {
|
|
1127
|
+
story: statuses(raw.us_statuses),
|
|
1128
|
+
issue: statuses(raw.issue_statuses),
|
|
1129
|
+
epic: statuses(raw.epic_statuses),
|
|
1130
|
+
task: statuses(raw.task_statuses),
|
|
1131
|
+
},
|
|
1132
|
+
customFields: {
|
|
1133
|
+
story: named(raw.userstory_custom_attributes),
|
|
1134
|
+
issue: named(raw.issue_custom_attributes),
|
|
1135
|
+
epic: named(raw.epic_custom_attributes),
|
|
1136
|
+
task: named(raw.task_custom_attributes),
|
|
1137
|
+
},
|
|
1138
|
+
severities: named(raw.severities),
|
|
1139
|
+
priorities: named(raw.priorities),
|
|
1140
|
+
issueTypes: named(raw.issue_types),
|
|
1141
|
+
sprints: (raw.milestones ?? []).map(({ id, name, slug, closed }) => ({ id, name, slug, closed })),
|
|
1142
|
+
members: (raw.members ?? []).map((member) => ({
|
|
1143
|
+
id: member.id,
|
|
1144
|
+
username: member.username,
|
|
1145
|
+
name: member.full_name_display || member.full_name || member.username,
|
|
1146
|
+
role: member.role_name || undefined,
|
|
1147
|
+
})),
|
|
1148
|
+
description: raw.description?.trim() ?? '',
|
|
1149
|
+
isPrivate: raw.is_private ?? false,
|
|
1150
|
+
tags: Object.keys(raw.tags_colors ?? {}).sort((a, b) => a.localeCompare(b)),
|
|
1151
|
+
modules: [
|
|
1152
|
+
['backlog', raw.is_backlog_activated],
|
|
1153
|
+
['kanban', raw.is_kanban_activated],
|
|
1154
|
+
['issues', raw.is_issues_activated],
|
|
1155
|
+
['epics', raw.is_epics_activated],
|
|
1156
|
+
['wiki', raw.is_wiki_activated],
|
|
1157
|
+
].flatMap(([name, on]) => (on ? [name] : [])),
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
const isRunning = (sprint, today) => !sprint.closed && sprint.estimated_start <= today && today <= sprint.estimated_finish;
|
|
1161
|
+
function toSprint(sprint, today) {
|
|
1162
|
+
return {
|
|
1163
|
+
name: sprint.name,
|
|
1164
|
+
start: sprint.estimated_start,
|
|
1165
|
+
finish: sprint.estimated_finish,
|
|
1166
|
+
closed: sprint.closed,
|
|
1167
|
+
current: isRunning(sprint, today),
|
|
1168
|
+
totalPoints: sprint.total_points ?? 0,
|
|
1169
|
+
closedPoints: sprint.closed_points ?? 0,
|
|
1170
|
+
stories: sprint.user_stories?.length ?? 0,
|
|
1171
|
+
// By status, like the `closed` filter of the list tools. A story's own `is_closed` flag also
|
|
1172
|
+
// turns on when all its tasks are closed, which would disagree with the status shown.
|
|
1173
|
+
storiesDone: sprint.user_stories?.filter((story) => story.status_extra_info?.is_closed).length ?? 0,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function toSummary(item) {
|
|
1177
|
+
return {
|
|
1178
|
+
ref: item.ref,
|
|
1179
|
+
subject: item.subject,
|
|
1180
|
+
status: item.status_extra_info?.name ?? 'Unknown',
|
|
1181
|
+
closed: item.status_extra_info?.is_closed ?? false,
|
|
1182
|
+
assignee: item.assigned_to_extra_info?.full_name_display,
|
|
1183
|
+
blocked: item.is_blocked ?? false,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
function issueNames(project, issue) {
|
|
1187
|
+
return {
|
|
1188
|
+
type: nameOf(project.issueTypes, issue.type),
|
|
1189
|
+
severity: nameOf(project.severities, issue.severity),
|
|
1190
|
+
priority: nameOf(project.priorities, issue.priority),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const tagName = (tag) => (typeof tag === 'string' ? tag : tag[0]);
|
|
1194
|
+
/** Taiga's web app keeps its items at these paths. */
|
|
1195
|
+
const WEB_PATH = {
|
|
1196
|
+
story: 'us',
|
|
1197
|
+
task: 'task',
|
|
1198
|
+
issue: 'issue',
|
|
1199
|
+
epic: 'epic',
|
|
1200
|
+
};
|
|
1201
|
+
/**
|
|
1202
|
+
* The item in Taiga's web app. A self-hosted Taiga serves the app and the API from one address;
|
|
1203
|
+
* Taiga cloud serves its API from another host.
|
|
1204
|
+
*/
|
|
1205
|
+
function webAddress(apiBase, slug, kind, ref) {
|
|
1206
|
+
try {
|
|
1207
|
+
const url = new URL(apiBase);
|
|
1208
|
+
if (url.hostname === 'api.taiga.io')
|
|
1209
|
+
url.hostname = 'tree.taiga.io';
|
|
1210
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}/project/${encodeURIComponent(slug)}/${WEB_PATH[kind]}/${ref}`;
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/** The fields that set who an item is assigned to. A story also keeps a list of assignees. */
|
|
1217
|
+
function assignment(kind, who) {
|
|
1218
|
+
return kind === 'story'
|
|
1219
|
+
? { assigned_to: who.id, assigned_users: who.id === null ? [] : [who.id] }
|
|
1220
|
+
: { assigned_to: who.id };
|
|
1221
|
+
}
|
|
1222
|
+
/** Tags as Taiga stores them: trimmed, lower case, each once. */
|
|
1223
|
+
function tagList(tags) {
|
|
1224
|
+
return [...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))];
|
|
1225
|
+
}
|
|
1226
|
+
/** A description in a line of the answer: how long it is, not what it says. */
|
|
1227
|
+
const characters = (text) => (text.trim() === '' ? '(empty)' : `(${text.trim().length} characters)`);
|
|
1228
|
+
/** What the answer says about a new item: where Taiga put it and with what. */
|
|
1229
|
+
function factsOfNew(kind, project, item) {
|
|
1230
|
+
const facts = [
|
|
1231
|
+
['Status', item.status_extra_info?.name],
|
|
1232
|
+
['Assigned to', item.assigned_to_extra_info?.full_name_display ?? 'nobody'],
|
|
1233
|
+
[
|
|
1234
|
+
'User story',
|
|
1235
|
+
item.user_story_extra_info
|
|
1236
|
+
? `#${item.user_story_extra_info.ref} ${item.user_story_extra_info.subject}`
|
|
1237
|
+
: undefined,
|
|
1238
|
+
],
|
|
1239
|
+
['Sprint', nameOf(project.sprints, item.milestone) ?? (kind === 'issue' ? undefined : 'none (backlog)')],
|
|
1240
|
+
['Tags', (item.tags ?? []).map(tagName).join(', ') || undefined],
|
|
1241
|
+
['Due', day(item.due_date ?? undefined)],
|
|
1242
|
+
];
|
|
1243
|
+
if (kind === 'issue') {
|
|
1244
|
+
const names = issueNames(project, item);
|
|
1245
|
+
facts.push(['Type', names.type], ['Severity', names.severity], ['Priority', names.priority]);
|
|
1246
|
+
}
|
|
1247
|
+
return facts.filter((fact) => fact[1] !== undefined);
|
|
1248
|
+
}
|
|
1249
|
+
/** The facts that only one kind of item has, as label and value. Missing ones are left out. */
|
|
1250
|
+
function factsOf(kind, project, item) {
|
|
1251
|
+
const facts = [
|
|
1252
|
+
['Due', day(item.due_date ?? undefined)],
|
|
1253
|
+
['Finished', day((kind === 'story' ? item.finish_date : item.finished_date) ?? undefined)],
|
|
1254
|
+
];
|
|
1255
|
+
if (kind === 'story') {
|
|
1256
|
+
facts.push(['Sprint', item.milestone_name ?? 'none (backlog)'], ['Points', item.total_points == null ? undefined : String(item.total_points)], ['Epics', item.epics?.map((epic) => `#${epic.ref} ${epic.subject}`).join('; ') || undefined]);
|
|
1257
|
+
}
|
|
1258
|
+
else if (kind === 'issue') {
|
|
1259
|
+
const names = issueNames(project, item);
|
|
1260
|
+
facts.push(['Type', names.type], ['Severity', names.severity], ['Priority', names.priority]);
|
|
1261
|
+
}
|
|
1262
|
+
else if (kind === 'task') {
|
|
1263
|
+
const story = item.user_story_extra_info;
|
|
1264
|
+
facts.push(['User story', story ? `#${story.ref} ${story.subject}` : 'none'], ['Sprint', nameOf(project.sprints, item.milestone)]);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
const counts = item.user_stories_counts;
|
|
1268
|
+
facts.push(['Stories', counts ? progress(counts.progress, counts.total) : undefined]);
|
|
1269
|
+
}
|
|
1270
|
+
const watchers = (item.watchers ?? []).map((id) => project.members.find((member) => member.id === id)?.name ?? 'a former member');
|
|
1271
|
+
facts.push(['Watchers', watchers.join(', ') || undefined], ['Votes', item.total_voters ? String(item.total_voters) : undefined]);
|
|
1272
|
+
return facts.filter((fact) => fact[1] !== undefined);
|
|
1273
|
+
}
|
|
1274
|
+
//# sourceMappingURL=client.js.map
|