@sascha384/tic 3.0.0 → 4.0.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/.claude-plugin/plugin.json +1 -1
- package/dist/auth/gitlab.d.ts +37 -0
- package/dist/auth/gitlab.js +111 -0
- package/dist/auth/gitlab.js.map +1 -0
- package/dist/backends/availability.js +1 -1
- package/dist/backends/availability.js.map +1 -1
- package/dist/backends/factory.js +1 -1
- package/dist/backends/factory.js.map +1 -1
- package/dist/backends/gitlab/api.d.ts +14 -0
- package/dist/backends/gitlab/api.js +53 -0
- package/dist/backends/gitlab/api.js.map +1 -0
- package/dist/backends/gitlab/index.d.ts +15 -13
- package/dist/backends/gitlab/index.js +425 -357
- package/dist/backends/gitlab/index.js.map +1 -1
- package/dist/backends/gitlab/mappers.d.ts +50 -28
- package/dist/backends/gitlab/mappers.js +30 -31
- package/dist/backends/gitlab/mappers.js.map +1 -1
- package/dist/backends/gitlab/remote.d.ts +7 -0
- package/dist/backends/gitlab/remote.js +32 -0
- package/dist/backends/gitlab/remote.js.map +1 -0
- package/dist/cli/commands/auth.d.ts +1 -1
- package/dist/cli/commands/auth.js +24 -2
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/components/HelpScreen.js +1 -2
- package/dist/components/HelpScreen.js.map +1 -1
- package/dist/components/OverlayPanel.d.ts +4 -1
- package/dist/components/OverlayPanel.js +5 -3
- package/dist/components/OverlayPanel.js.map +1 -1
- package/dist/components/WorkItemList.js +62 -51
- package/dist/components/WorkItemList.js.map +1 -1
- package/dist/stores/backendDataStore.js +28 -12
- package/dist/stores/backendDataStore.js.map +1 -1
- package/dist/stores/uiStore.d.ts +1 -3
- package/dist/stores/uiStore.js.map +1 -1
- package/package.json +1 -1
- package/dist/backends/gitlab/glab.d.ts +0 -6
- package/dist/backends/gitlab/glab.js +0 -43
- package/dist/backends/gitlab/glab.js.map +0 -1
- package/dist/backends/gitlab/group.d.ts +0 -1
- package/dist/backends/gitlab/group.js +0 -33
- package/dist/backends/gitlab/group.js.map +0 -1
|
@@ -1,11 +1,121 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
4
3
|
import { BaseBackend } from '../types.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { GitLabApiClient } from './api.js';
|
|
5
|
+
import { parseGitLabRemote } from './remote.js';
|
|
6
|
+
import { mapWorkItemToWorkItem, mapNoteToComment } from './mappers.js';
|
|
7
|
+
import { AuthError } from '../shared/api-client.js';
|
|
8
|
+
import { getGitLabToken, getGitLabPat, authenticateGitLab, } from '../../auth/gitlab.js';
|
|
7
9
|
import { slugifyTemplateName } from '../local/templates.js';
|
|
8
|
-
|
|
10
|
+
const WORK_ITEM_FIELDS = `
|
|
11
|
+
id iid title state createdAt updatedAt
|
|
12
|
+
workItemType { name }
|
|
13
|
+
widgets {
|
|
14
|
+
... on WorkItemWidgetDescription { description }
|
|
15
|
+
... on WorkItemWidgetAssignees { assignees { nodes { username } } }
|
|
16
|
+
... on WorkItemWidgetLabels { labels { nodes { title } } }
|
|
17
|
+
... on WorkItemWidgetMilestone { milestone { title } }
|
|
18
|
+
... on WorkItemWidgetHierarchy {
|
|
19
|
+
parent { id iid workItemType { name } }
|
|
20
|
+
}
|
|
21
|
+
__typename
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
const WORK_ITEM_DETAIL_FIELDS = `
|
|
25
|
+
id iid title state createdAt updatedAt
|
|
26
|
+
workItemType { name }
|
|
27
|
+
widgets {
|
|
28
|
+
... on WorkItemWidgetDescription { description }
|
|
29
|
+
... on WorkItemWidgetAssignees { assignees { nodes { username } } }
|
|
30
|
+
... on WorkItemWidgetLabels { labels { nodes { title } } }
|
|
31
|
+
... on WorkItemWidgetMilestone { milestone { title } }
|
|
32
|
+
... on WorkItemWidgetHierarchy {
|
|
33
|
+
parent { id iid workItemType { name } }
|
|
34
|
+
children { nodes { id iid title state workItemType { name } } }
|
|
35
|
+
}
|
|
36
|
+
... on WorkItemWidgetNotes {
|
|
37
|
+
discussions {
|
|
38
|
+
nodes { notes { nodes { author { username } createdAt body } } }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
__typename
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
const LIST_PROJECT_ISSUES = `query($fullPath: ID!, $cursor: String) {
|
|
45
|
+
project(fullPath: $fullPath) {
|
|
46
|
+
workItems(types: [ISSUE, TASK], first: 100, after: $cursor) {
|
|
47
|
+
nodes { ${WORK_ITEM_FIELDS} }
|
|
48
|
+
pageInfo { hasNextPage endCursor }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}`;
|
|
52
|
+
const LIST_GROUP_EPICS = `query($fullPath: ID!, $cursor: String) {
|
|
53
|
+
group(fullPath: $fullPath) {
|
|
54
|
+
workItems(types: [EPIC], first: 100, after: $cursor) {
|
|
55
|
+
nodes { ${WORK_ITEM_FIELDS} }
|
|
56
|
+
pageInfo { hasNextPage endCursor }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}`;
|
|
60
|
+
const GET_WORK_ITEM = `query($id: WorkItemID!) {
|
|
61
|
+
workItem(id: $id) { ${WORK_ITEM_DETAIL_FIELDS} }
|
|
62
|
+
}`;
|
|
63
|
+
const LOOKUP_PROJECT_ITEM = `query($fullPath: ID!, $iid: String!) {
|
|
64
|
+
project(fullPath: $fullPath) {
|
|
65
|
+
workItems(iid: $iid, first: 1) {
|
|
66
|
+
nodes { id iid workItemType { name } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}`;
|
|
70
|
+
const LOOKUP_GROUP_ITEM = `query($fullPath: ID!, $iid: String!) {
|
|
71
|
+
group(fullPath: $fullPath) {
|
|
72
|
+
workItems(iid: $iid, types: [EPIC], first: 1) {
|
|
73
|
+
nodes { id iid workItemType { name } }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}`;
|
|
77
|
+
const CREATE_WORK_ITEM = `mutation($input: WorkItemCreateInput!) {
|
|
78
|
+
workItemCreate(input: $input) {
|
|
79
|
+
workItem { ${WORK_ITEM_FIELDS} }
|
|
80
|
+
errors
|
|
81
|
+
}
|
|
82
|
+
}`;
|
|
83
|
+
const UPDATE_WORK_ITEM = `mutation($input: WorkItemUpdateInput!) {
|
|
84
|
+
workItemUpdate(input: $input) {
|
|
85
|
+
workItem { ${WORK_ITEM_FIELDS} }
|
|
86
|
+
errors
|
|
87
|
+
}
|
|
88
|
+
}`;
|
|
89
|
+
const DELETE_WORK_ITEM = `mutation($input: WorkItemDeleteInput!) {
|
|
90
|
+
workItemDelete(input: $input) { errors }
|
|
91
|
+
}`;
|
|
92
|
+
const CREATE_NOTE = `mutation($input: CreateNoteInput!) {
|
|
93
|
+
createNote(input: $input) {
|
|
94
|
+
note { id body author { username } createdAt }
|
|
95
|
+
errors
|
|
96
|
+
}
|
|
97
|
+
}`;
|
|
98
|
+
const WORK_ITEM_TYPES = `query($projectPath: ID!) {
|
|
99
|
+
project(fullPath: $projectPath) {
|
|
100
|
+
workItemTypes { nodes { id name } }
|
|
101
|
+
}
|
|
102
|
+
}`;
|
|
103
|
+
const PROJECT_MEMBERS = `query($fullPath: ID!, $cursor: String) {
|
|
104
|
+
project(fullPath: $fullPath) {
|
|
105
|
+
projectMembers(first: 100, after: $cursor) {
|
|
106
|
+
nodes { user { username } }
|
|
107
|
+
pageInfo { hasNextPage endCursor }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}`;
|
|
111
|
+
const PROJECT_MILESTONES = `query($fullPath: ID!, $cursor: String) {
|
|
112
|
+
project(fullPath: $fullPath) {
|
|
113
|
+
milestones(first: 100, after: $cursor) {
|
|
114
|
+
nodes { title startDate dueDate }
|
|
115
|
+
pageInfo { hasNextPage endCursor }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}`;
|
|
9
119
|
const TEMPLATES_DIR = '.gitlab/issue_templates';
|
|
10
120
|
function parseId(id) {
|
|
11
121
|
const match = id.match(/^(issue|epic)-(\d+)$/);
|
|
@@ -14,20 +124,52 @@ function parseId(id) {
|
|
|
14
124
|
}
|
|
15
125
|
return { type: match[1], iid: match[2] };
|
|
16
126
|
}
|
|
127
|
+
async function queryWorkItemTypes(api, projectPath) {
|
|
128
|
+
const data = await api.graphql(WORK_ITEM_TYPES, {
|
|
129
|
+
projectPath,
|
|
130
|
+
});
|
|
131
|
+
const m = new Map();
|
|
132
|
+
for (const t of data.project.workItemTypes.nodes) {
|
|
133
|
+
m.set(t.name.toLowerCase(), t.id);
|
|
134
|
+
}
|
|
135
|
+
return m;
|
|
136
|
+
}
|
|
17
137
|
export class GitLabBackend extends BaseBackend {
|
|
138
|
+
api;
|
|
139
|
+
remote;
|
|
140
|
+
typeIds;
|
|
141
|
+
gidCache = new Map();
|
|
18
142
|
cwd;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/** Maps template slug → original GitLab filename (without .md) */
|
|
22
|
-
templateNameCache = new Map();
|
|
23
|
-
constructor(cwd) {
|
|
143
|
+
cachedMilestones = null;
|
|
144
|
+
constructor(api, remote, typeIds, cwd) {
|
|
24
145
|
super(60_000);
|
|
146
|
+
this.api = api;
|
|
147
|
+
this.remote = remote;
|
|
148
|
+
this.typeIds = typeIds;
|
|
25
149
|
this.cwd = cwd;
|
|
26
|
-
|
|
27
|
-
|
|
150
|
+
}
|
|
151
|
+
static async create(cwd, options) {
|
|
152
|
+
const remote = parseGitLabRemote(cwd);
|
|
153
|
+
let token = getGitLabToken();
|
|
154
|
+
if (!token)
|
|
155
|
+
token = getGitLabPat();
|
|
156
|
+
if (!token) {
|
|
157
|
+
if (options?.skipAuth) {
|
|
158
|
+
throw new AuthError('GitLab authentication required. Run "tic auth login gitlab" to authenticate.');
|
|
159
|
+
}
|
|
160
|
+
token = await authenticateGitLab({
|
|
161
|
+
onCode: (code, url) => {
|
|
162
|
+
console.log(`\nGitLab authentication required.`);
|
|
163
|
+
console.log(`Visit ${url} and enter code: ${code}\n`);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const api = new GitLabApiClient(token);
|
|
168
|
+
const typeIds = await queryWorkItemTypes(api, remote.fullPath);
|
|
169
|
+
return new GitLabBackend(api, remote, typeIds, cwd);
|
|
28
170
|
}
|
|
29
171
|
onCacheInvalidate() {
|
|
30
|
-
this.
|
|
172
|
+
this.cachedMilestones = null;
|
|
31
173
|
}
|
|
32
174
|
getCapabilities() {
|
|
33
175
|
return {
|
|
@@ -67,8 +209,13 @@ export class GitLabBackend extends BaseBackend {
|
|
|
67
209
|
}
|
|
68
210
|
async getAssignees() {
|
|
69
211
|
try {
|
|
70
|
-
const members =
|
|
71
|
-
|
|
212
|
+
const members = [];
|
|
213
|
+
for await (const page of this.api.paginate(PROJECT_MEMBERS, { fullPath: this.remote.fullPath }, (data) => data.project.projectMembers)) {
|
|
214
|
+
for (const m of page) {
|
|
215
|
+
members.push(m.user.username);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return members;
|
|
72
219
|
}
|
|
73
220
|
catch {
|
|
74
221
|
return [];
|
|
@@ -78,117 +225,164 @@ export class GitLabBackend extends BaseBackend {
|
|
|
78
225
|
return this.getLabelsFromCache();
|
|
79
226
|
}
|
|
80
227
|
async getIterations() {
|
|
81
|
-
const
|
|
82
|
-
return
|
|
228
|
+
const ms = await this.fetchMilestones();
|
|
229
|
+
return ms.map((m) => m.title);
|
|
83
230
|
}
|
|
84
231
|
async getCurrentIteration() {
|
|
85
|
-
const
|
|
232
|
+
const ms = await this.fetchMilestones();
|
|
86
233
|
const today = new Date().toISOString().split('T')[0];
|
|
87
|
-
const
|
|
88
|
-
return
|
|
234
|
+
const cur = ms.find((m) => m.startDate <= today && today <= m.dueDate);
|
|
235
|
+
return cur?.title ?? '';
|
|
89
236
|
}
|
|
90
237
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
91
238
|
async setCurrentIteration(_name) {
|
|
92
|
-
// No-op —
|
|
239
|
+
// No-op — determined by date range
|
|
93
240
|
}
|
|
94
241
|
async listWorkItems(iteration) {
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
];
|
|
105
|
-
const
|
|
106
|
-
|
|
242
|
+
const issues = [];
|
|
243
|
+
for await (const page of this.api.paginate(LIST_PROJECT_ISSUES, { fullPath: this.remote.fullPath }, (d) => d.project.workItems)) {
|
|
244
|
+
issues.push(...page);
|
|
245
|
+
}
|
|
246
|
+
const epics = [];
|
|
247
|
+
for await (const page of this.api.paginate(LIST_GROUP_EPICS, { fullPath: this.remote.group }, (d) => d.group.workItems)) {
|
|
248
|
+
epics.push(...page);
|
|
249
|
+
}
|
|
250
|
+
const all = [...issues, ...epics];
|
|
251
|
+
const items = [];
|
|
252
|
+
for (const gl of all) {
|
|
253
|
+
const item = mapWorkItemToWorkItem(gl);
|
|
254
|
+
this.cacheGid(item.id, gl.id);
|
|
255
|
+
items.push(item);
|
|
256
|
+
}
|
|
257
|
+
let filtered = items;
|
|
107
258
|
if (iteration) {
|
|
108
|
-
|
|
259
|
+
filtered = items.filter((i) => i.iteration === iteration);
|
|
109
260
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const epics = await glab(['api', `groups/${encodedGroup}/epics`, '--paginate'], this.cwd);
|
|
113
|
-
const epicItems = epics.map(mapEpicToWorkItem);
|
|
114
|
-
// Merge and sort by updated descending
|
|
115
|
-
const allItems = [...epicItems, ...issueItems];
|
|
116
|
-
allItems.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
117
|
-
return allItems;
|
|
261
|
+
filtered.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
262
|
+
return filtered;
|
|
118
263
|
}
|
|
119
264
|
async getWorkItem(id) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
item.comments = notes.map(mapNoteToComment);
|
|
127
|
-
return item;
|
|
128
|
-
}
|
|
129
|
-
// Epic
|
|
130
|
-
const encodedGroup = encodeURIComponent(this.group);
|
|
131
|
-
const epic = await glab(['api', `groups/${encodedGroup}/epics/${iid}`], this.cwd);
|
|
132
|
-
const item = mapEpicToWorkItem(epic);
|
|
133
|
-
// Fetch epic notes
|
|
134
|
-
const notes = await glab(['api', `groups/${encodedGroup}/epics/${iid}/notes`, '--paginate'], this.cwd);
|
|
135
|
-
item.comments = notes.map(mapNoteToComment);
|
|
265
|
+
const gid = await this.resolveGid(id);
|
|
266
|
+
const data = await this.api.graphql(GET_WORK_ITEM, {
|
|
267
|
+
id: gid,
|
|
268
|
+
});
|
|
269
|
+
const item = mapWorkItemToWorkItem(data.workItem);
|
|
270
|
+
this.cacheGid(item.id, data.workItem.id);
|
|
136
271
|
return item;
|
|
137
272
|
}
|
|
138
273
|
async createWorkItem(data) {
|
|
139
274
|
this.validateFields(data);
|
|
140
|
-
|
|
141
|
-
|
|
275
|
+
const isEpic = data.type === 'epic';
|
|
276
|
+
const typeName = isEpic ? 'epic' : 'issue';
|
|
277
|
+
const typeId = this.typeIds.get(typeName);
|
|
278
|
+
if (!typeId) {
|
|
279
|
+
throw new Error(`Work item type "${typeName}" not found in project`);
|
|
280
|
+
}
|
|
281
|
+
const input = {
|
|
282
|
+
title: data.title,
|
|
283
|
+
workItemTypeId: typeId,
|
|
284
|
+
namespacePath: isEpic ? this.remote.group : this.remote.fullPath,
|
|
285
|
+
};
|
|
286
|
+
if (data.description) {
|
|
287
|
+
input['descriptionWidget'] = { description: data.description };
|
|
288
|
+
}
|
|
289
|
+
const res = await this.api.graphql(CREATE_WORK_ITEM, {
|
|
290
|
+
input,
|
|
291
|
+
});
|
|
292
|
+
if (res.workItemCreate.errors.length > 0) {
|
|
293
|
+
throw new Error(`Failed to create work item: ${res.workItemCreate.errors.join(', ')}`);
|
|
294
|
+
}
|
|
295
|
+
const created = res.workItemCreate.workItem;
|
|
296
|
+
const item = mapWorkItemToWorkItem(created);
|
|
297
|
+
this.cacheGid(item.id, created.id);
|
|
298
|
+
const needsUpdate = data.assignee ||
|
|
299
|
+
data.labels.length > 0 ||
|
|
300
|
+
data.iteration ||
|
|
301
|
+
data.status === 'closed';
|
|
302
|
+
if (needsUpdate) {
|
|
303
|
+
const partial = {};
|
|
304
|
+
if (data.assignee)
|
|
305
|
+
partial.assignee = data.assignee;
|
|
306
|
+
if (data.labels.length > 0)
|
|
307
|
+
partial.labels = data.labels;
|
|
308
|
+
if (data.iteration)
|
|
309
|
+
partial.iteration = data.iteration;
|
|
310
|
+
if (data.status === 'closed')
|
|
311
|
+
partial.status = 'closed';
|
|
312
|
+
const updates = this.buildWidgetUpdates(partial);
|
|
313
|
+
return this.applyUpdate(created.id, updates, data.status === 'closed' ? 'closed' : undefined);
|
|
142
314
|
}
|
|
143
|
-
return
|
|
315
|
+
return item;
|
|
144
316
|
}
|
|
145
317
|
async updateWorkItem(id, data) {
|
|
146
318
|
this.validateFields(data);
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
319
|
+
const gid = await this.resolveGid(id);
|
|
320
|
+
const updates = this.buildWidgetUpdates(data);
|
|
321
|
+
const hasChanges = updates.length > 0 ||
|
|
322
|
+
data.title !== undefined ||
|
|
323
|
+
data.status !== undefined;
|
|
324
|
+
if (hasChanges) {
|
|
325
|
+
return this.applyUpdate(gid, updates, data.status, data.title);
|
|
150
326
|
}
|
|
151
|
-
return this.
|
|
327
|
+
return this.getWorkItem(id);
|
|
152
328
|
}
|
|
153
329
|
async deleteWorkItem(id) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
330
|
+
const gid = await this.resolveGid(id);
|
|
331
|
+
const res = await this.api.graphql(DELETE_WORK_ITEM, {
|
|
332
|
+
input: { id: gid },
|
|
333
|
+
});
|
|
334
|
+
if (res.workItemDelete.errors.length > 0) {
|
|
335
|
+
throw new Error(`Failed to delete work item: ${res.workItemDelete.errors.join(', ')}`);
|
|
158
336
|
}
|
|
159
|
-
|
|
160
|
-
await glab(['api', `groups/${encodedGroup}/epics/${iid}`, '-X', 'DELETE'], this.cwd);
|
|
337
|
+
this.gidCache.delete(id);
|
|
161
338
|
}
|
|
162
339
|
async addComment(workItemId, comment) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await glab([
|
|
170
|
-
'api',
|
|
171
|
-
`groups/${encodedGroup}/epics/${iid}/notes`,
|
|
172
|
-
'-X',
|
|
173
|
-
'POST',
|
|
174
|
-
'-f',
|
|
175
|
-
`body=${comment.body}`,
|
|
176
|
-
], this.cwd);
|
|
340
|
+
const gid = await this.resolveGid(workItemId);
|
|
341
|
+
const res = await this.api.graphql(CREATE_NOTE, {
|
|
342
|
+
input: { noteableId: gid, body: comment.body },
|
|
343
|
+
});
|
|
344
|
+
if (res.createNote.errors.length > 0) {
|
|
345
|
+
throw new Error(`Failed to add comment: ${res.createNote.errors.join(', ')}`);
|
|
177
346
|
}
|
|
178
|
-
return
|
|
179
|
-
author: comment.author,
|
|
180
|
-
date: new Date().toISOString(),
|
|
181
|
-
body: comment.body,
|
|
182
|
-
};
|
|
347
|
+
return mapNoteToComment(res.createNote.note);
|
|
183
348
|
}
|
|
184
349
|
async getChildren(id) {
|
|
185
|
-
const { type
|
|
186
|
-
if (type === 'issue')
|
|
350
|
+
const { type } = parseId(id);
|
|
351
|
+
if (type === 'issue')
|
|
187
352
|
return [];
|
|
353
|
+
const gid = await this.resolveGid(id);
|
|
354
|
+
const data = await this.api.graphql(GET_WORK_ITEM, {
|
|
355
|
+
id: gid,
|
|
356
|
+
});
|
|
357
|
+
for (const w of data.workItem.widgets) {
|
|
358
|
+
if (w.__typename === 'WorkItemWidgetHierarchy' &&
|
|
359
|
+
'children' in w &&
|
|
360
|
+
w.children) {
|
|
361
|
+
const children = w.children.nodes;
|
|
362
|
+
return children.map((c) => {
|
|
363
|
+
const ct = c.workItemType.name.toLowerCase();
|
|
364
|
+
const cid = `${ct}-${c.iid}`;
|
|
365
|
+
this.cacheGid(cid, c.id);
|
|
366
|
+
return {
|
|
367
|
+
id: cid,
|
|
368
|
+
title: c.title,
|
|
369
|
+
description: '',
|
|
370
|
+
status: c.state === 'CLOSED' ? 'closed' : 'open',
|
|
371
|
+
type: ct,
|
|
372
|
+
assignee: '',
|
|
373
|
+
labels: [],
|
|
374
|
+
iteration: '',
|
|
375
|
+
priority: 'medium',
|
|
376
|
+
created: '',
|
|
377
|
+
updated: '',
|
|
378
|
+
parent: id,
|
|
379
|
+
dependsOn: [],
|
|
380
|
+
comments: [],
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
188
384
|
}
|
|
189
|
-
|
|
190
|
-
const issues = await glab(['api', `groups/${encodedGroup}/epics/${iid}/issues`, '--paginate'], this.cwd);
|
|
191
|
-
return issues.map(mapIssueToWorkItem);
|
|
385
|
+
return [];
|
|
192
386
|
}
|
|
193
387
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await
|
|
194
388
|
async getDependents(_id) {
|
|
@@ -197,315 +391,189 @@ export class GitLabBackend extends BaseBackend {
|
|
|
197
391
|
getItemUrl(id) {
|
|
198
392
|
const { type, iid } = parseId(id);
|
|
199
393
|
if (type === 'issue') {
|
|
200
|
-
|
|
201
|
-
return result.web_url;
|
|
394
|
+
return `https://${this.remote.host}/${this.remote.fullPath}/-/issues/${iid}`;
|
|
202
395
|
}
|
|
203
|
-
return `https
|
|
396
|
+
return `https://${this.remote.host}/groups/${this.remote.group}/-/epics/${iid}`;
|
|
204
397
|
}
|
|
205
398
|
async openItem(id) {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const url = `https://gitlab.com/groups/${this.group}/-/epics/${iid}`;
|
|
212
|
-
await execFileAsync('open', [url]);
|
|
399
|
+
const url = this.getItemUrl(id);
|
|
400
|
+
const { default: open } = await import('open');
|
|
401
|
+
await open(url);
|
|
213
402
|
}
|
|
214
403
|
async listTemplates() {
|
|
215
|
-
|
|
404
|
+
const dir = path.join(this.cwd, TEMPLATES_DIR);
|
|
216
405
|
try {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
406
|
+
const files = await fs.readdir(dir);
|
|
407
|
+
return Promise.all(files
|
|
408
|
+
.filter((f) => f.endsWith('.md'))
|
|
409
|
+
.map(async (f) => {
|
|
410
|
+
const name = f.replace(/\.md$/, '');
|
|
411
|
+
const slug = slugifyTemplateName(name);
|
|
412
|
+
const content = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
413
|
+
return { slug, name, description: content };
|
|
414
|
+
}));
|
|
224
415
|
}
|
|
225
416
|
catch {
|
|
226
417
|
return [];
|
|
227
418
|
}
|
|
228
|
-
if (!Array.isArray(items))
|
|
229
|
-
return [];
|
|
230
|
-
this.templateNameCache.clear();
|
|
231
|
-
const templates = [];
|
|
232
|
-
for (const item of items) {
|
|
233
|
-
if (item.type !== 'blob' || !item.name.endsWith('.md'))
|
|
234
|
-
continue;
|
|
235
|
-
const name = item.name.replace(/\.md$/, '');
|
|
236
|
-
const slug = slugifyTemplateName(name);
|
|
237
|
-
this.templateNameCache.set(slug, name);
|
|
238
|
-
let description = '';
|
|
239
|
-
try {
|
|
240
|
-
const encodedPath = encodeURIComponent(item.path);
|
|
241
|
-
const file = await glab([
|
|
242
|
-
'api',
|
|
243
|
-
`projects/:fullpath/repository/files/${encodedPath}`,
|
|
244
|
-
'-f',
|
|
245
|
-
'ref=HEAD',
|
|
246
|
-
], this.cwd);
|
|
247
|
-
description = Buffer.from(file.content, 'base64').toString('utf-8');
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
// If file read fails, leave description empty
|
|
251
|
-
}
|
|
252
|
-
templates.push({ slug, name, description });
|
|
253
|
-
}
|
|
254
|
-
return templates;
|
|
255
|
-
}
|
|
256
|
-
/** Resolve a slug to the original GitLab filename (without .md). Falls back to slug. */
|
|
257
|
-
resolveTemplateName(slug) {
|
|
258
|
-
return this.templateNameCache.get(slug) ?? slug;
|
|
259
419
|
}
|
|
260
420
|
async getTemplate(slug) {
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
`projects/:fullpath/repository/files/${encodedPath}`,
|
|
267
|
-
'-f',
|
|
268
|
-
'ref=HEAD',
|
|
269
|
-
], this.cwd);
|
|
270
|
-
const content = Buffer.from(file.content, 'base64').toString('utf-8');
|
|
271
|
-
return { slug, name, description: content };
|
|
421
|
+
const templates = await this.listTemplates();
|
|
422
|
+
const found = templates.find((tmpl) => tmpl.slug === slug);
|
|
423
|
+
if (!found)
|
|
424
|
+
throw new Error(`Template not found: ${slug}`);
|
|
425
|
+
return found;
|
|
272
426
|
}
|
|
273
427
|
async createTemplate(template) {
|
|
428
|
+
const dir = path.join(this.cwd, TEMPLATES_DIR);
|
|
429
|
+
await fs.mkdir(dir, { recursive: true });
|
|
274
430
|
const slug = slugifyTemplateName(template.name);
|
|
275
|
-
const
|
|
276
|
-
const encodedPath = encodeURIComponent(filePath);
|
|
431
|
+
const fp = path.join(dir, `${template.name}.md`);
|
|
277
432
|
const content = template.description ?? '';
|
|
278
|
-
await
|
|
279
|
-
'api',
|
|
280
|
-
`projects/:fullpath/repository/files/${encodedPath}`,
|
|
281
|
-
'-X',
|
|
282
|
-
'POST',
|
|
283
|
-
'-f',
|
|
284
|
-
'branch=main',
|
|
285
|
-
'-f',
|
|
286
|
-
`content=${content}`,
|
|
287
|
-
'-f',
|
|
288
|
-
`commit_message=Add issue template: ${template.name}`,
|
|
289
|
-
], this.cwd);
|
|
290
|
-
this.templateNameCache.set(slug, template.name);
|
|
433
|
+
await fs.writeFile(fp, content, 'utf-8');
|
|
291
434
|
return { slug, name: template.name, description: content };
|
|
292
435
|
}
|
|
293
436
|
async updateTemplate(oldSlug, template) {
|
|
294
437
|
const newSlug = slugifyTemplateName(template.name);
|
|
295
|
-
const oldName = this.resolveTemplateName(oldSlug);
|
|
296
438
|
const content = template.description ?? '';
|
|
297
439
|
if (oldSlug !== newSlug) {
|
|
298
|
-
|
|
299
|
-
const oldPath = `${
|
|
300
|
-
const encodedOldPath = encodeURIComponent(oldPath);
|
|
440
|
+
const old = await this.getTemplate(oldSlug);
|
|
441
|
+
const oldPath = path.join(this.cwd, TEMPLATES_DIR, `${old.name}.md`);
|
|
301
442
|
try {
|
|
302
|
-
await
|
|
303
|
-
'api',
|
|
304
|
-
`projects/:fullpath/repository/files/${encodedOldPath}`,
|
|
305
|
-
'-X',
|
|
306
|
-
'DELETE',
|
|
307
|
-
'-f',
|
|
308
|
-
'branch=main',
|
|
309
|
-
'-f',
|
|
310
|
-
`commit_message=Rename issue template: ${oldName} -> ${template.name}`,
|
|
311
|
-
], this.cwd);
|
|
443
|
+
await fs.unlink(oldPath);
|
|
312
444
|
}
|
|
313
445
|
catch {
|
|
314
|
-
// Old file may not exist
|
|
446
|
+
// Old file may not exist
|
|
315
447
|
}
|
|
316
|
-
const newPath = `${TEMPLATES_DIR}/${template.name}.md`;
|
|
317
|
-
const encodedNewPath = encodeURIComponent(newPath);
|
|
318
|
-
await glabExec([
|
|
319
|
-
'api',
|
|
320
|
-
`projects/:fullpath/repository/files/${encodedNewPath}`,
|
|
321
|
-
'-X',
|
|
322
|
-
'POST',
|
|
323
|
-
'-f',
|
|
324
|
-
'branch=main',
|
|
325
|
-
'-f',
|
|
326
|
-
`content=${content}`,
|
|
327
|
-
'-f',
|
|
328
|
-
`commit_message=Add issue template: ${template.name}`,
|
|
329
|
-
], this.cwd);
|
|
330
|
-
this.templateNameCache.delete(oldSlug);
|
|
331
|
-
this.templateNameCache.set(newSlug, template.name);
|
|
332
|
-
}
|
|
333
|
-
else {
|
|
334
|
-
// Same slug — update in place
|
|
335
|
-
const filePath = `${TEMPLATES_DIR}/${oldName}.md`;
|
|
336
|
-
const encodedPath = encodeURIComponent(filePath);
|
|
337
|
-
await glabExec([
|
|
338
|
-
'api',
|
|
339
|
-
`projects/:fullpath/repository/files/${encodedPath}`,
|
|
340
|
-
'-X',
|
|
341
|
-
'PUT',
|
|
342
|
-
'-f',
|
|
343
|
-
'branch=main',
|
|
344
|
-
'-f',
|
|
345
|
-
`content=${content}`,
|
|
346
|
-
'-f',
|
|
347
|
-
`commit_message=Update issue template: ${oldName}`,
|
|
348
|
-
], this.cwd);
|
|
349
448
|
}
|
|
449
|
+
const dir = path.join(this.cwd, TEMPLATES_DIR);
|
|
450
|
+
await fs.mkdir(dir, { recursive: true });
|
|
451
|
+
const fp = path.join(dir, `${template.name}.md`);
|
|
452
|
+
await fs.writeFile(fp, content, 'utf-8');
|
|
350
453
|
return { slug: newSlug, name: template.name, description: content };
|
|
351
454
|
}
|
|
352
455
|
async deleteTemplate(slug) {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
await glabExec([
|
|
357
|
-
'api',
|
|
358
|
-
`projects/:fullpath/repository/files/${encodedPath}`,
|
|
359
|
-
'-X',
|
|
360
|
-
'DELETE',
|
|
361
|
-
'-f',
|
|
362
|
-
'branch=main',
|
|
363
|
-
'-f',
|
|
364
|
-
`commit_message=Delete issue template: ${name}`,
|
|
365
|
-
], this.cwd);
|
|
366
|
-
this.templateNameCache.delete(slug);
|
|
367
|
-
}
|
|
368
|
-
async fetchIterations() {
|
|
369
|
-
if (this.cachedIterations)
|
|
370
|
-
return this.cachedIterations;
|
|
371
|
-
const encodedGroup = encodeURIComponent(this.group);
|
|
372
|
-
this.cachedIterations = await glab(['api', `groups/${encodedGroup}/iterations`, '--paginate'], this.cwd);
|
|
373
|
-
return this.cachedIterations;
|
|
374
|
-
}
|
|
375
|
-
async ensureLabels(labels) {
|
|
376
|
-
for (const label of labels) {
|
|
377
|
-
try {
|
|
378
|
-
await glabExec(['label', 'create', label], this.cwd);
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
// Label already exists — ignore
|
|
382
|
-
}
|
|
383
|
-
}
|
|
456
|
+
const found = await this.getTemplate(slug);
|
|
457
|
+
const fp = path.join(this.cwd, TEMPLATES_DIR, `${found.name}.md`);
|
|
458
|
+
await fs.unlink(fp);
|
|
384
459
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
await this.ensureLabels(data.labels);
|
|
388
|
-
}
|
|
389
|
-
const args = ['issue', 'create', '--title', data.title, '--yes'];
|
|
390
|
-
if (data.description) {
|
|
391
|
-
args.push('--description', data.description);
|
|
392
|
-
}
|
|
393
|
-
if (data.assignee) {
|
|
394
|
-
args.push('--assignee', data.assignee);
|
|
395
|
-
}
|
|
396
|
-
if (data.iteration) {
|
|
397
|
-
args.push('--milestone', data.iteration);
|
|
398
|
-
}
|
|
399
|
-
for (const label of data.labels) {
|
|
400
|
-
args.push('--label', label);
|
|
401
|
-
}
|
|
402
|
-
const output = await glabExec(args, this.cwd);
|
|
403
|
-
// glab issue create prints a URL like: https://gitlab.com/group/project/-/issues/42
|
|
404
|
-
const match = output.match(/\/issues\/(\d+)/);
|
|
405
|
-
if (!match) {
|
|
406
|
-
throw new Error('Failed to parse issue IID from glab output');
|
|
407
|
-
}
|
|
408
|
-
const iid = match[1];
|
|
409
|
-
return this.getWorkItem(`issue-${iid}`);
|
|
460
|
+
cacheGid(localId, gid) {
|
|
461
|
+
this.gidCache.set(localId, gid);
|
|
410
462
|
}
|
|
411
|
-
async
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
else if (data.status === 'open') {
|
|
442
|
-
await glabExec(['issue', 'reopen', iid], this.cwd);
|
|
463
|
+
async resolveGid(id) {
|
|
464
|
+
const cached = this.gidCache.get(id);
|
|
465
|
+
if (cached)
|
|
466
|
+
return cached;
|
|
467
|
+
const { type, iid } = parseId(id);
|
|
468
|
+
if (type === 'issue') {
|
|
469
|
+
const data = await this.api.graphql(LOOKUP_PROJECT_ITEM, { fullPath: this.remote.fullPath, iid });
|
|
470
|
+
const node = data.project.workItems.nodes[0];
|
|
471
|
+
if (!node)
|
|
472
|
+
throw new Error(`Work item not found: ${id}`);
|
|
473
|
+
this.cacheGid(id, node.id);
|
|
474
|
+
return node.id;
|
|
475
|
+
}
|
|
476
|
+
const data = await this.api.graphql(LOOKUP_GROUP_ITEM, {
|
|
477
|
+
fullPath: this.remote.group,
|
|
478
|
+
iid,
|
|
479
|
+
});
|
|
480
|
+
const node = data.group.workItems.nodes[0];
|
|
481
|
+
if (!node)
|
|
482
|
+
throw new Error(`Work item not found: ${id}`);
|
|
483
|
+
this.cacheGid(id, node.id);
|
|
484
|
+
return node.id;
|
|
485
|
+
}
|
|
486
|
+
buildWidgetUpdates(data) {
|
|
487
|
+
const updates = [];
|
|
488
|
+
if (data.description !== undefined) {
|
|
489
|
+
updates.push({
|
|
490
|
+
descriptionWidget: { description: data.description },
|
|
491
|
+
});
|
|
443
492
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
493
|
+
if (data.assignee !== undefined) {
|
|
494
|
+
if (data.assignee) {
|
|
495
|
+
updates.push({
|
|
496
|
+
assigneesWidget: {
|
|
497
|
+
assigneeIds: [],
|
|
498
|
+
usernames: [data.assignee],
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
updates.push({ assigneesWidget: { assigneeIds: [] } });
|
|
504
|
+
}
|
|
450
505
|
}
|
|
451
|
-
if (data.
|
|
452
|
-
|
|
453
|
-
|
|
506
|
+
if (data.labels !== undefined) {
|
|
507
|
+
updates.push({
|
|
508
|
+
labelsWidget: { setLabelTitles: data.labels },
|
|
509
|
+
});
|
|
454
510
|
}
|
|
455
511
|
if (data.iteration !== undefined) {
|
|
456
512
|
if (data.iteration) {
|
|
457
|
-
|
|
513
|
+
updates.push({
|
|
514
|
+
milestoneWidget: { milestoneTitle: data.iteration },
|
|
515
|
+
});
|
|
458
516
|
}
|
|
459
517
|
else {
|
|
460
|
-
|
|
518
|
+
updates.push({ milestoneWidget: { milestoneId: null } });
|
|
461
519
|
}
|
|
462
|
-
hasEdits = true;
|
|
463
520
|
}
|
|
464
|
-
if (data.
|
|
465
|
-
if (data.
|
|
466
|
-
|
|
521
|
+
if (data.parent !== undefined) {
|
|
522
|
+
if (data.parent) {
|
|
523
|
+
updates.push({ hierarchyWidget: { parentId: data.parent } });
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
updates.push({ hierarchyWidget: { parentId: null } });
|
|
467
527
|
}
|
|
468
|
-
hasEdits = true;
|
|
469
528
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
529
|
+
return updates;
|
|
530
|
+
}
|
|
531
|
+
async applyUpdate(gid, widgetUpdates, status, title) {
|
|
532
|
+
const input = { id: gid };
|
|
533
|
+
if (title !== undefined)
|
|
534
|
+
input['title'] = title;
|
|
535
|
+
if (status !== undefined) {
|
|
536
|
+
input['stateEvent'] = status === 'closed' ? 'CLOSE' : 'REOPEN';
|
|
537
|
+
}
|
|
538
|
+
for (const update of widgetUpdates) {
|
|
539
|
+
for (const [key, value] of Object.entries(update)) {
|
|
540
|
+
if (key === 'hierarchyWidget' && value && typeof value === 'object') {
|
|
541
|
+
const hv = value;
|
|
542
|
+
if (hv.parentId &&
|
|
543
|
+
typeof hv.parentId === 'string' &&
|
|
544
|
+
!hv.parentId.startsWith('gid://')) {
|
|
545
|
+
const parentGid = await this.resolveGid(hv.parentId);
|
|
546
|
+
input[key] = { parentId: parentGid };
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
input[key] = value;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
input[key] = value;
|
|
554
|
+
}
|
|
473
555
|
}
|
|
474
|
-
hasEdits = true;
|
|
475
556
|
}
|
|
476
|
-
|
|
477
|
-
|
|
557
|
+
const res = await this.api.graphql(UPDATE_WORK_ITEM, {
|
|
558
|
+
input,
|
|
559
|
+
});
|
|
560
|
+
if (res.workItemUpdate.errors.length > 0) {
|
|
561
|
+
throw new Error(`Failed to update work item: ${res.workItemUpdate.errors.join(', ')}`);
|
|
478
562
|
}
|
|
479
|
-
|
|
563
|
+
const updated = res.workItemUpdate.workItem;
|
|
564
|
+
const item = mapWorkItemToWorkItem(updated);
|
|
565
|
+
this.cacheGid(item.id, updated.id);
|
|
566
|
+
return item;
|
|
480
567
|
}
|
|
481
|
-
async
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
let hasEdits = false;
|
|
488
|
-
if (data.title !== undefined) {
|
|
489
|
-
args.push('-f', `title=${data.title}`);
|
|
490
|
-
hasEdits = true;
|
|
491
|
-
}
|
|
492
|
-
if (data.description !== undefined) {
|
|
493
|
-
args.push('-f', `description=${data.description}`);
|
|
494
|
-
hasEdits = true;
|
|
495
|
-
}
|
|
496
|
-
if (data.status !== undefined) {
|
|
497
|
-
const stateEvent = data.status === 'closed' ? 'close' : 'reopen';
|
|
498
|
-
args.push('-f', `state_event=${stateEvent}`);
|
|
499
|
-
hasEdits = true;
|
|
500
|
-
}
|
|
501
|
-
if (data.labels !== undefined) {
|
|
502
|
-
args.push('-f', `labels=${data.labels.join(',')}`);
|
|
503
|
-
hasEdits = true;
|
|
504
|
-
}
|
|
505
|
-
if (hasEdits) {
|
|
506
|
-
await glab(args, this.cwd);
|
|
568
|
+
async fetchMilestones() {
|
|
569
|
+
if (this.cachedMilestones)
|
|
570
|
+
return this.cachedMilestones;
|
|
571
|
+
const ms = [];
|
|
572
|
+
for await (const page of this.api.paginate(PROJECT_MILESTONES, { fullPath: this.remote.fullPath }, (d) => d.project.milestones)) {
|
|
573
|
+
ms.push(...page);
|
|
507
574
|
}
|
|
508
|
-
|
|
575
|
+
this.cachedMilestones = ms;
|
|
576
|
+
return ms;
|
|
509
577
|
}
|
|
510
578
|
}
|
|
511
579
|
//# sourceMappingURL=index.js.map
|