@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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/auth/gitlab.d.ts +37 -0
  3. package/dist/auth/gitlab.js +111 -0
  4. package/dist/auth/gitlab.js.map +1 -0
  5. package/dist/backends/availability.js +1 -1
  6. package/dist/backends/availability.js.map +1 -1
  7. package/dist/backends/factory.js +1 -1
  8. package/dist/backends/factory.js.map +1 -1
  9. package/dist/backends/gitlab/api.d.ts +14 -0
  10. package/dist/backends/gitlab/api.js +53 -0
  11. package/dist/backends/gitlab/api.js.map +1 -0
  12. package/dist/backends/gitlab/index.d.ts +15 -13
  13. package/dist/backends/gitlab/index.js +425 -357
  14. package/dist/backends/gitlab/index.js.map +1 -1
  15. package/dist/backends/gitlab/mappers.d.ts +50 -28
  16. package/dist/backends/gitlab/mappers.js +30 -31
  17. package/dist/backends/gitlab/mappers.js.map +1 -1
  18. package/dist/backends/gitlab/remote.d.ts +7 -0
  19. package/dist/backends/gitlab/remote.js +32 -0
  20. package/dist/backends/gitlab/remote.js.map +1 -0
  21. package/dist/cli/commands/auth.d.ts +1 -1
  22. package/dist/cli/commands/auth.js +24 -2
  23. package/dist/cli/commands/auth.js.map +1 -1
  24. package/dist/cli/index.js +2 -2
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/components/HelpScreen.js +1 -2
  27. package/dist/components/HelpScreen.js.map +1 -1
  28. package/dist/components/OverlayPanel.d.ts +4 -1
  29. package/dist/components/OverlayPanel.js +5 -3
  30. package/dist/components/OverlayPanel.js.map +1 -1
  31. package/dist/components/WorkItemList.js +62 -51
  32. package/dist/components/WorkItemList.js.map +1 -1
  33. package/dist/stores/backendDataStore.js +28 -12
  34. package/dist/stores/backendDataStore.js.map +1 -1
  35. package/dist/stores/uiStore.d.ts +1 -3
  36. package/dist/stores/uiStore.js.map +1 -1
  37. package/package.json +1 -1
  38. package/dist/backends/gitlab/glab.d.ts +0 -6
  39. package/dist/backends/gitlab/glab.js +0 -43
  40. package/dist/backends/gitlab/glab.js.map +0 -1
  41. package/dist/backends/gitlab/group.d.ts +0 -1
  42. package/dist/backends/gitlab/group.js +0 -33
  43. package/dist/backends/gitlab/group.js.map +0 -1
@@ -1,11 +1,121 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
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 { glab, glabExec, glabExecSync, glabSync } from './glab.js';
6
- import { detectGroup } from './group.js';
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
- import { mapIssueToWorkItem, mapEpicToWorkItem, mapNoteToComment, } from './mappers.js';
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
- group;
20
- cachedIterations = null;
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
- glabExecSync(['auth', 'status'], cwd);
27
- this.group = detectGroup(cwd);
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.cachedIterations = null;
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 = await glab(['api', 'projects/:fullpath/members/all', '--paginate'], this.cwd);
71
- return members.map((m) => m.username);
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 iterations = await this.fetchIterations();
82
- return iterations.map((i) => i.title);
228
+ const ms = await this.fetchMilestones();
229
+ return ms.map((m) => m.title);
83
230
  }
84
231
  async getCurrentIteration() {
85
- const iterations = await this.fetchIterations();
232
+ const ms = await this.fetchMilestones();
86
233
  const today = new Date().toISOString().split('T')[0];
87
- const current = iterations.find((i) => i.start_date <= today && today <= i.due_date);
88
- return current?.title ?? '';
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 — current iteration is determined by date range
239
+ // No-op — determined by date range
93
240
  }
94
241
  async listWorkItems(iteration) {
95
- // Fetch issues
96
- const issueArgs = [
97
- 'issue',
98
- 'list',
99
- '-F',
100
- 'json',
101
- '--per-page',
102
- '100',
103
- '--all',
104
- ];
105
- const issues = await glab(issueArgs, this.cwd);
106
- let issueItems = issues.map(mapIssueToWorkItem);
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
- issueItems = issueItems.filter((item) => item.iteration === iteration);
259
+ filtered = items.filter((i) => i.iteration === iteration);
109
260
  }
110
- // Fetch epics
111
- const encodedGroup = encodeURIComponent(this.group);
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 { type, iid } = parseId(id);
121
- if (type === 'issue') {
122
- const issue = await glab(['issue', 'view', iid, '-F', 'json'], this.cwd);
123
- const item = mapIssueToWorkItem(issue);
124
- // Fetch notes via API
125
- const notes = await glab(['api', `projects/:fullpath/issues/${iid}/notes`, '--paginate'], this.cwd);
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
- if (data.type === 'epic') {
141
- return this.createEpic(data);
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 this.createIssue(data);
315
+ return item;
144
316
  }
145
317
  async updateWorkItem(id, data) {
146
318
  this.validateFields(data);
147
- const { type, iid } = parseId(id);
148
- if (type === 'issue') {
149
- return this.updateIssue(iid, data);
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.updateEpic(iid, data);
327
+ return this.getWorkItem(id);
152
328
  }
153
329
  async deleteWorkItem(id) {
154
- const { type, iid } = parseId(id);
155
- if (type === 'issue') {
156
- await glabExec(['issue', 'delete', iid, '--yes'], this.cwd);
157
- return;
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
- const encodedGroup = encodeURIComponent(this.group);
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 { type, iid } = parseId(workItemId);
164
- if (type === 'issue') {
165
- await glabExec(['issue', 'note', iid, '-m', comment.body], this.cwd);
166
- }
167
- else {
168
- const encodedGroup = encodeURIComponent(this.group);
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, iid } = parseId(id);
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
- const encodedGroup = encodeURIComponent(this.group);
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
- const result = glabSync(['issue', 'view', iid, '-F', 'json'], this.cwd);
201
- return result.web_url;
394
+ return `https://${this.remote.host}/${this.remote.fullPath}/-/issues/${iid}`;
202
395
  }
203
- return `https://gitlab.com/groups/${this.group}/-/epics/${iid}`;
396
+ return `https://${this.remote.host}/groups/${this.remote.group}/-/epics/${iid}`;
204
397
  }
205
398
  async openItem(id) {
206
- const { type, iid } = parseId(id);
207
- if (type === 'issue') {
208
- await glabExec(['issue', 'view', iid, '--web'], this.cwd);
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
- let items;
404
+ const dir = path.join(this.cwd, TEMPLATES_DIR);
216
405
  try {
217
- items = await glab([
218
- 'api',
219
- 'projects/:fullpath/repository/tree',
220
- '--paginate',
221
- '-f',
222
- `path=${TEMPLATES_DIR}`,
223
- ], this.cwd);
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 name = this.resolveTemplateName(slug);
262
- const filePath = `${TEMPLATES_DIR}/${name}.md`;
263
- const encodedPath = encodeURIComponent(filePath);
264
- const file = await glab([
265
- 'api',
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 filePath = `${TEMPLATES_DIR}/${template.name}.md`;
276
- const encodedPath = encodeURIComponent(filePath);
431
+ const fp = path.join(dir, `${template.name}.md`);
277
432
  const content = template.description ?? '';
278
- await glabExec([
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
- // Name changed — delete old file and create new one
299
- const oldPath = `${TEMPLATES_DIR}/${oldName}.md`;
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 glabExec([
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; continue with create
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 name = this.resolveTemplateName(slug);
354
- const filePath = `${TEMPLATES_DIR}/${name}.md`;
355
- const encodedPath = encodeURIComponent(filePath);
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
- async createIssue(data) {
386
- if (data.labels.length > 0) {
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 createEpic(data) {
412
- if (data.labels.length > 0) {
413
- await this.ensureLabels(data.labels);
414
- }
415
- const encodedGroup = encodeURIComponent(this.group);
416
- const args = [
417
- 'api',
418
- `groups/${encodedGroup}/epics`,
419
- '-X',
420
- 'POST',
421
- '-f',
422
- `title=${data.title}`,
423
- ];
424
- if (data.description) {
425
- args.push('-f', `description=${data.description}`);
426
- }
427
- for (const label of data.labels) {
428
- args.push('-f', `labels[]=${label}`);
429
- }
430
- const epic = await glab(args, this.cwd);
431
- return mapEpicToWorkItem(epic);
432
- }
433
- async updateIssue(iid, data) {
434
- if (data.labels !== undefined && data.labels.length > 0) {
435
- await this.ensureLabels(data.labels);
436
- }
437
- // Handle status changes via close/reopen
438
- if (data.status === 'closed') {
439
- await glabExec(['issue', 'close', iid], this.cwd);
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
- // Handle field edits
445
- const editArgs = ['issue', 'update', iid];
446
- let hasEdits = false;
447
- if (data.title !== undefined) {
448
- editArgs.push('--title', data.title);
449
- hasEdits = true;
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.description !== undefined) {
452
- editArgs.push('--description', data.description);
453
- hasEdits = true;
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
- editArgs.push('--milestone', data.iteration);
513
+ updates.push({
514
+ milestoneWidget: { milestoneTitle: data.iteration },
515
+ });
458
516
  }
459
517
  else {
460
- editArgs.push('--unlabel', '');
518
+ updates.push({ milestoneWidget: { milestoneId: null } });
461
519
  }
462
- hasEdits = true;
463
520
  }
464
- if (data.assignee !== undefined) {
465
- if (data.assignee) {
466
- editArgs.push('--assignee', data.assignee);
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
- if (data.labels !== undefined) {
471
- for (const label of data.labels) {
472
- editArgs.push('--label', label);
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
- if (hasEdits) {
477
- await glabExec(editArgs, this.cwd);
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
- return this.getWorkItem(`issue-${iid}`);
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 updateEpic(iid, data) {
482
- if (data.labels !== undefined && data.labels.length > 0) {
483
- await this.ensureLabels(data.labels);
484
- }
485
- const encodedGroup = encodeURIComponent(this.group);
486
- const args = ['api', `groups/${encodedGroup}/epics/${iid}`, '-X', 'PUT'];
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
- return this.getWorkItem(`epic-${iid}`);
575
+ this.cachedMilestones = ms;
576
+ return ms;
509
577
  }
510
578
  }
511
579
  //# sourceMappingURL=index.js.map