@koalaauto/agentchannel 0.0.0-stage → 3.0.0-rc.1

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 (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +154 -2
  3. package/dist/src/adapters/claude.d.ts +65 -0
  4. package/dist/src/adapters/claude.js +243 -0
  5. package/dist/src/adapters/codex.d.ts +74 -0
  6. package/dist/src/adapters/codex.js +237 -0
  7. package/dist/src/adapters/fake.d.ts +65 -0
  8. package/dist/src/adapters/fake.js +105 -0
  9. package/dist/src/adapters/index.d.ts +11 -0
  10. package/dist/src/adapters/index.js +26 -0
  11. package/dist/src/adapters/paths.d.ts +35 -0
  12. package/dist/src/adapters/paths.js +113 -0
  13. package/dist/src/adapters/procgroup.d.ts +14 -0
  14. package/dist/src/adapters/procgroup.js +49 -0
  15. package/dist/src/adapters/types.d.ts +57 -0
  16. package/dist/src/adapters/types.js +1 -0
  17. package/dist/src/args.d.ts +14 -0
  18. package/dist/src/args.js +257 -0
  19. package/dist/src/attachments.d.ts +63 -0
  20. package/dist/src/attachments.js +227 -0
  21. package/dist/src/attention.d.ts +85 -0
  22. package/dist/src/attention.js +262 -0
  23. package/dist/src/avatars.d.ts +40 -0
  24. package/dist/src/avatars.js +90 -0
  25. package/dist/src/cli.d.ts +45 -0
  26. package/dist/src/cli.js +1822 -0
  27. package/dist/src/client.d.ts +520 -0
  28. package/dist/src/client.js +449 -0
  29. package/dist/src/config.d.ts +62 -0
  30. package/dist/src/config.js +121 -0
  31. package/dist/src/desknotify.d.ts +154 -0
  32. package/dist/src/desknotify.js +426 -0
  33. package/dist/src/doctor.d.ts +75 -0
  34. package/dist/src/doctor.js +375 -0
  35. package/dist/src/env.d.ts +31 -0
  36. package/dist/src/env.js +52 -0
  37. package/dist/src/import.d.ts +245 -0
  38. package/dist/src/import.js +1008 -0
  39. package/dist/src/index.d.ts +13 -0
  40. package/dist/src/index.js +13 -0
  41. package/dist/src/invite.d.ts +103 -0
  42. package/dist/src/invite.js +229 -0
  43. package/dist/src/invite_person.d.ts +16 -0
  44. package/dist/src/invite_person.js +75 -0
  45. package/dist/src/keystore.d.ts +55 -0
  46. package/dist/src/keystore.js +108 -0
  47. package/dist/src/mcp.d.ts +62 -0
  48. package/dist/src/mcp.js +503 -0
  49. package/dist/src/names.d.ts +30 -0
  50. package/dist/src/names.js +108 -0
  51. package/dist/src/netproxy.d.ts +20 -0
  52. package/dist/src/netproxy.js +36 -0
  53. package/dist/src/onboard.d.ts +105 -0
  54. package/dist/src/onboard.js +270 -0
  55. package/dist/src/perm.d.ts +12 -0
  56. package/dist/src/perm.js +20 -0
  57. package/dist/src/prompts.d.ts +166 -0
  58. package/dist/src/prompts.js +409 -0
  59. package/dist/src/runner/runner.d.ts +212 -0
  60. package/dist/src/runner/runner.js +1148 -0
  61. package/dist/src/runner/state.d.ts +68 -0
  62. package/dist/src/runner/state.js +80 -0
  63. package/dist/src/runner/workspace.d.ts +70 -0
  64. package/dist/src/runner/workspace.js +265 -0
  65. package/dist/src/service.d.ts +29 -0
  66. package/dist/src/service.js +101 -0
  67. package/dist/src/version.d.ts +3 -0
  68. package/dist/src/version.js +3 -0
  69. package/dist/src/wiki.d.ts +74 -0
  70. package/dist/src/wiki.js +222 -0
  71. package/package.json +48 -4
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Team onboarding from GitHub (README "Onboard your team from GitHub").
3
+ *
4
+ * ach admin import github --org <org> [--since 60d] [--plan plan.json] [--out plan.json] [--dry-run]
5
+ * [--closed-days 7 | --all] [--exclude security,secret | --no-exclude]
6
+ * ach admin sync github [--org <org>]
7
+ * ach admin invite --feishu [--people a,b] [--yes] | ach admin invite --people a,b --yes (print links)
8
+ *
9
+ * GitHub is read on THIS machine with the admin's own `gh` login (gh also
10
+ * honors GITHUB_TOKEN / GH_TOKEN). The hub never sees a GitHub token: it gets a
11
+ * plan (channels with repo mappings, people) and compact items (issues/PRs:
12
+ * title, state, who, a short excerpt, the link; never comment threads).
13
+ * Nothing here writes to GitHub. Feishu invites go through the local
14
+ * `lark-cli` and are only sent with --yes.
15
+ */
16
+ import { execFile } from 'node:child_process';
17
+ import { readFileSync, writeFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ /** Runs `gh` / `lark-cli` (ACH_GH_CLI / ACH_LARK_CLI override the binaries). */
20
+ export function makeExec(env) {
21
+ const bins = { gh: env.ACH_GH_CLI, 'lark-cli': env.ACH_LARK_CLI };
22
+ return (cmd, args) => new Promise((res) => {
23
+ execFile(bins[cmd] || cmd, args, { env, maxBuffer: 256 * 1024 * 1024, timeout: 120_000 }, (err, stdout, stderr) => {
24
+ if (err && (err.code === 'ENOENT' || err.code === 'EACCES'))
25
+ return res({ code: 127, stdout: '', stderr: `${cmd}: not found` });
26
+ res({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
27
+ });
28
+ });
29
+ }
30
+ export class ImportError extends Error {
31
+ }
32
+ // ------------------------------------------------------------------ helpers
33
+ /** "60d", "8w", "3m" (months), or an ISO date -> ISO time. */
34
+ export function parseSince(s, now = Date.now()) {
35
+ const v = (s ?? '60d').trim();
36
+ const m = /^(\d{1,4})([dwm])$/.exec(v);
37
+ if (m) {
38
+ const n = parseInt(m[1], 10) * (m[2] === 'd' ? 1 : m[2] === 'w' ? 7 : 30);
39
+ return new Date(now - n * 86400_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
40
+ }
41
+ const t = Date.parse(v);
42
+ if (!Number.isFinite(t))
43
+ throw new ImportError(`--since: expected 60d, 8w, 3m or a date, got "${v}"`);
44
+ return new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
45
+ }
46
+ const BOT_NAMES = /^(codex|claude|claude-code|copilot|github-actions|dependabot|renovate|workbuddy|cursor|devin|openhands|sweep)(-|$)/i;
47
+ /** Bots never become people: [bot] logins, GitHub type Bot, known agent names, *bot. */
48
+ export function isBot(login, flagged = false) {
49
+ if (!login)
50
+ return false;
51
+ return flagged || /\[bot\]$/i.test(login) || BOT_NAMES.test(login) || /(^|-)bot$/i.test(login);
52
+ }
53
+ function slug(s) {
54
+ const v = s.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40).replace(/[-_]+$/, '');
55
+ return /^[a-z0-9][a-z0-9_-]{0,39}$/.test(v) ? v : 'repo-' + Math.abs(hash(s)).toString(36).slice(0, 6);
56
+ }
57
+ function hash(s) {
58
+ let h = 0;
59
+ for (let i = 0; i < s.length; i++)
60
+ h = (h * 31 + s.charCodeAt(i)) | 0;
61
+ return h;
62
+ }
63
+ export function excerptOf(body, max = 600) {
64
+ let t = String(body ?? '')
65
+ .replace(/<!--[\s\S]*?-->/g, ' ')
66
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '[image]')
67
+ .replace(/<img[^>]*>/gi, '[image]')
68
+ .replace(/\r/g, '')
69
+ .replace(/\n{3,}/g, '\n\n')
70
+ .trim();
71
+ if (t.length > max)
72
+ t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';
73
+ return t;
74
+ }
75
+ async function ghJson(exec, args, what, optional = false) {
76
+ const r = await exec('gh', args);
77
+ if (r.code === 127)
78
+ throw new ImportError('`gh` (GitHub CLI) is not installed. Install it and run `gh auth login` (or set GITHUB_TOKEN).');
79
+ if (r.code !== 0) {
80
+ if (optional)
81
+ return null;
82
+ const msg = r.stderr.trim().split('\n')[0] || `exit ${r.code}`;
83
+ if (/auth|login|401|credentials/i.test(r.stderr))
84
+ throw new ImportError(`gh is not logged in (${msg}). Run \`gh auth login\` or set GITHUB_TOKEN.`);
85
+ throw new ImportError(`gh ${what} failed: ${msg}`);
86
+ }
87
+ try {
88
+ return r.stdout.trim() ? JSON.parse(r.stdout) : null;
89
+ }
90
+ catch {
91
+ throw new ImportError(`gh ${what}: unexpected output`);
92
+ }
93
+ }
94
+ function toItem(kind, repo, x) {
95
+ return {
96
+ kind,
97
+ repo,
98
+ number: Number(x.number),
99
+ url: String(x.url ?? ''),
100
+ state: String(x.state ?? '').toLowerCase(),
101
+ title: String(x.title ?? ''),
102
+ author: String(x.author?.login ?? ''),
103
+ author_bot: !!x.author?.is_bot,
104
+ assignees: (Array.isArray(x.assignees) ? x.assignees : []).map((a) => String(a?.login ?? '')).filter(Boolean),
105
+ created_at: String(x.createdAt ?? ''),
106
+ updated_at: String(x.updatedAt ?? ''),
107
+ closed_at: String(x.mergedAt || x.closedAt || (String(x.state ?? '').toLowerCase() === 'open' ? '' : x.updatedAt ?? '')),
108
+ labels: (Array.isArray(x.labels) ? x.labels : []).map((l) => String(typeof l === 'string' ? l : l?.name ?? '')).filter(Boolean),
109
+ excerpt: excerptOf(x.body),
110
+ };
111
+ }
112
+ const ISSUE_FIELDS = 'number,title,state,author,assignees,createdAt,updatedAt,closedAt,url,body,labels';
113
+ const PR_FIELDS = ISSUE_FIELDS + ',mergedAt';
114
+ /** Items whose labels or title name one of these are never sent to the hub (case-insensitive substring). */
115
+ export const DEFAULT_EXCLUDE = ['security', '安全', '漏洞', 'secret', '密钥'];
116
+ /** Closed/merged items newer than this become posts by default; older ones only seed the wiki. */
117
+ export const DEFAULT_CLOSED_DAYS = 7;
118
+ /** Closed/merged items used to seed Memory (links only): the last N months by default, or all (--seed-all). */
119
+ export const DEFAULT_SEED_MONTHS = 6;
120
+ export function isExcluded(i, terms) {
121
+ if (!terms.length)
122
+ return false;
123
+ const hay = [i.title, ...(i.labels ?? [])].map((x) => String(x).toLowerCase());
124
+ return terms.some((t) => t && hay.some((h) => h.includes(t.toLowerCase())));
125
+ }
126
+ /**
127
+ * What gets posted. Sensitive items (labels/title match `exclude`) are dropped
128
+ * entirely. Open items are posted; closed/merged ones only when they closed
129
+ * after `closedCutoff` ('' = all of them); older closed ones go to `archive`.
130
+ */
131
+ export function selectItems(all, o) {
132
+ const sel = { items: [], archive: [], tooOld: [], excluded: [], closedCutoff: o.closedCutoff, seedCutoff: o.seedCutoff ?? '' };
133
+ for (const i of all) {
134
+ const at = i.closed_at || i.updated_at;
135
+ if (isExcluded(i, o.exclude))
136
+ sel.excluded.push(i);
137
+ else if (i.state === 'open' || !o.closedCutoff || at >= o.closedCutoff)
138
+ sel.items.push(i);
139
+ else if (!o.seedCutoff || at >= o.seedCutoff)
140
+ sel.archive.push(i);
141
+ else
142
+ sel.tooOld.push(i);
143
+ }
144
+ return sel;
145
+ }
146
+ /** --exclude a,b / --no-exclude -> the exclusion terms. */
147
+ export function excludeTerms(values) {
148
+ if (values['no-exclude'])
149
+ return [];
150
+ const v = sv(values.exclude);
151
+ if (v === undefined)
152
+ return DEFAULT_EXCLUDE;
153
+ return v.split(',').map((x) => x.trim()).filter(Boolean);
154
+ }
155
+ /** --seed-all / --seed-months N -> the Memory seed cutoff ('' = everything). */
156
+ export function seedCutoff(values, now) {
157
+ if (values['seed-all'])
158
+ return '';
159
+ const raw = sv(values['seed-months']);
160
+ const n = raw === undefined ? DEFAULT_SEED_MONTHS : Number(raw);
161
+ if (!Number.isInteger(n) || n < 1 || n > 240)
162
+ throw new ImportError(`--seed-months: expected a whole number of months (1-240), got "${raw}"`);
163
+ const d = new Date(now);
164
+ d.setUTCMonth(d.getUTCMonth() - n);
165
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
166
+ }
167
+ /** The two import ranges as the hub stores them (wizard-ready API fields). */
168
+ export function importRanges(values) {
169
+ const out = {};
170
+ const cd = sv(values['post-closed-days']) ?? sv(values['closed-days']);
171
+ out.post_closed_days = values.all ? 3650 : cd === undefined ? DEFAULT_CLOSED_DAYS : Number(cd);
172
+ if (values['seed-all'])
173
+ out.seed = 'all';
174
+ else
175
+ out.seed_months = sv(values['seed-months']) === undefined ? DEFAULT_SEED_MONTHS : Number(sv(values['seed-months']));
176
+ return out;
177
+ }
178
+ /** --all / --closed-days N (alias --post-closed-days) -> the closed-item cutoff ('' = no cutoff). */
179
+ export function closedCutoff(values, now) {
180
+ if (values.all)
181
+ return '';
182
+ const raw = sv(values['post-closed-days']) ?? sv(values['closed-days']);
183
+ const n = raw === undefined ? DEFAULT_CLOSED_DAYS : Number(raw);
184
+ if (!Number.isInteger(n) || n < 0 || n > 3650)
185
+ throw new ImportError(`--closed-days: expected a whole number of days, got "${raw}"`);
186
+ return new Date(now - n * 86400_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
187
+ }
188
+ /** Issues and PRs of one repo updated since `since`. */
189
+ export async function fetchItems(exec, full, since, log) {
190
+ const q = `updated:>=${since.slice(0, 10)}`;
191
+ const iss = (await ghJson(exec, ['issue', 'list', '-R', full, '--state', 'all', '--limit', '1000', '--search', q, '--json', ISSUE_FIELDS], `issue list ${full}`, true)) ?? [];
192
+ const prs = (await ghJson(exec, ['pr', 'list', '-R', full, '--state', 'all', '--limit', '1000', '--search', q, '--json', PR_FIELDS], `pr list ${full}`, true)) ?? [];
193
+ const keep = (x) => !x.updatedAt || x.updatedAt >= since;
194
+ log?.(` ${full}: ${iss.length} issues, ${prs.length} PRs`);
195
+ return { issues: iss.filter(keep).map((x) => toItem('github_issue', full, x)), prs: prs.filter(keep).map((x) => toItem('github_pr', full, x)) };
196
+ }
197
+ /** Releases of one repo published since `since` (read-only; drafts skipped). Missing access -> none. */
198
+ export async function fetchReleases(exec, full, since) {
199
+ const list = (await ghJson(exec, ['release', 'list', '-R', full, '--limit', '30', '--json', 'tagName,name,publishedAt,isDraft'], `release list ${full}`, true)) ?? [];
200
+ return (Array.isArray(list) ? list : [])
201
+ .filter((r) => r && !r.isDraft && r.tagName && String(r.publishedAt ?? '') >= since)
202
+ .map((r) => ({
203
+ repo: full,
204
+ tag: String(r.tagName),
205
+ name: String(r.name ?? ''),
206
+ url: `https://github.com/${full}/releases/tag/${encodeURIComponent(String(r.tagName))}`,
207
+ published_at: String(r.publishedAt ?? ''),
208
+ }));
209
+ }
210
+ /** Read the org from GitHub: active repos, commits, issues, PRs, people. Read-only. */
211
+ export async function collectGithub(exec, org, since, o = {}) {
212
+ const me = await ghJson(exec, ['api', 'user'], 'api user');
213
+ const self = String(me?.login ?? '');
214
+ let targets;
215
+ if (o.repos) {
216
+ targets = o.repos.map((full) => ({ full, name: full.split('/')[1] ?? full, description: '', pushedAt: '' }));
217
+ }
218
+ else {
219
+ const list = (await ghJson(exec, ['repo', 'list', org, '--limit', '1000', '--json', 'name,description,isArchived,pushedAt,updatedAt'], `repo list ${org}`)) ?? [];
220
+ targets = list
221
+ .filter((r) => !r.isArchived && ((r.pushedAt ?? '') >= since || (r.updatedAt ?? '') >= since))
222
+ .map((r) => ({ full: `${org}/${r.name}`, name: String(r.name), description: String(r.description ?? ''), pushedAt: String(r.pushedAt ?? '') }));
223
+ }
224
+ o.log?.(`reading ${targets.length} repo(s) of ${org} since ${since.slice(0, 10)} with your gh login (${self || 'unknown'})`);
225
+ const repos = [];
226
+ for (const t of targets) {
227
+ const items = await fetchItems(exec, t.full, since, o.log);
228
+ let commits = [];
229
+ if (o.commits !== false) {
230
+ const cs = (await ghJson(exec, ['api', `repos/${t.full}/commits?since=${encodeURIComponent(since)}&per_page=100`], `commits ${t.full}`, true)) ?? [];
231
+ commits = (Array.isArray(cs) ? cs : []).map((c) => ({
232
+ login: String(c?.author?.login ?? ''),
233
+ name: String(c?.commit?.author?.name ?? ''),
234
+ bot: c?.author?.type === 'Bot',
235
+ }));
236
+ }
237
+ repos.push({ ...t, commits, ...items });
238
+ }
239
+ // display names / public emails for the people we saw (bots skipped)
240
+ const logins = new Set();
241
+ for (const r of repos) {
242
+ for (const c of r.commits)
243
+ if (c.login && !isBot(c.login, c.bot))
244
+ logins.add(c.login);
245
+ for (const i of [...r.issues, ...r.prs]) {
246
+ if (i.author && !isBot(i.author, i.author_bot))
247
+ logins.add(i.author);
248
+ for (const a of i.assignees)
249
+ if (!isBot(a))
250
+ logins.add(a);
251
+ }
252
+ }
253
+ const users = {};
254
+ for (const l of logins) {
255
+ const u = await ghJson(exec, ['api', `users/${l}`], `user ${l}`, true);
256
+ users[l] = { name: String(u?.name ?? '') || l, email: String(u?.email ?? ''), bot: u?.type === 'Bot' };
257
+ }
258
+ return { org, since, self, repos, users };
259
+ }
260
+ // ------------------------------------------------------------------ plan heuristic
261
+ const activity = (r) => r.commits.length + r.issues.length + r.prs.length;
262
+ /**
263
+ * Default channel mapping: repos grouped by name prefix (`myshow-room`,
264
+ * `myshow-video` -> #myshow); a repo that dominates a big group, or an active
265
+ * singleton, gets its own channel; quiet singletons go to #general; at most
266
+ * `maxChannels` channels besides #general. The plan is a starting point: edit it.
267
+ */
268
+ export function defaultChannels(repos, maxChannels = 8) {
269
+ const groups = new Map();
270
+ for (const r of repos) {
271
+ const key = r.name.toLowerCase().split(/[-_.]/)[0] || r.name.toLowerCase();
272
+ groups.set(key, [...(groups.get(key) ?? []), r]);
273
+ }
274
+ const chans = [];
275
+ const general = [];
276
+ const total = repos.reduce((s, r) => s + activity(r), 0) || 1;
277
+ for (const [key, rs] of groups) {
278
+ const act = rs.reduce((s, r) => s + activity(r), 0);
279
+ if (rs.length === 1) {
280
+ // an active singleton (≥ 5% of the org's activity, and > 10 events) is its own channel
281
+ if (act > 10 && act / total >= 0.05)
282
+ chans.push({ name: slug(rs[0].name), repos: rs });
283
+ else
284
+ general.push(...rs);
285
+ continue;
286
+ }
287
+ const sorted = [...rs].sort((a, b) => activity(b) - activity(a));
288
+ const rest = [...sorted];
289
+ // a repo carrying ≥ 40% of a group of 4+ gets its own channel
290
+ if (rs.length >= 4 && activity(sorted[0]) / (act || 1) >= 0.4 && activity(sorted[0]) > 10) {
291
+ chans.push({ name: slug(sorted[0].name), repos: [sorted[0]] });
292
+ rest.shift();
293
+ }
294
+ if (act === 0)
295
+ general.push(...rest);
296
+ else
297
+ chans.push({ name: slug(key), repos: rest });
298
+ }
299
+ chans.sort((a, b) => b.repos.reduce((s, r) => s + activity(r), 0) - a.repos.reduce((s, r) => s + activity(r), 0));
300
+ const kept = chans.slice(0, maxChannels);
301
+ for (const c of chans.slice(maxChannels))
302
+ general.push(...c.repos);
303
+ // names must be unique (a group key may equal a split-out repo name)
304
+ const used = new Set(['general']);
305
+ const out = [];
306
+ for (const c of kept) {
307
+ let name = c.name;
308
+ for (let i = 2; used.has(name); i++)
309
+ name = `${c.name}-${i}`.slice(0, 40);
310
+ used.add(name);
311
+ out.push(chanOut(name, c.repos));
312
+ }
313
+ out.push(chanOut('general', general, 'Team-wide discussion and tasks'));
314
+ return out;
315
+ }
316
+ function chanOut(name, rs, desc) {
317
+ const d = desc ?? (rs.length === 1 && rs[0].description ? rs[0].description : `GitHub: ${rs.map((r) => r.name).join(', ')}`);
318
+ return {
319
+ name,
320
+ description: d.slice(0, 2000),
321
+ repos: rs.map((r) => r.full),
322
+ stats: {
323
+ issues: rs.reduce((s, r) => s + r.issues.length, 0),
324
+ prs: rs.reduce((s, r) => s + r.prs.length, 0),
325
+ commits: rs.reduce((s, r) => s + r.commits.length, 0),
326
+ },
327
+ };
328
+ }
329
+ /** People from commits, issues and PRs; roles inferred; bots excluded. */
330
+ export function inferPeople(c, channels) {
331
+ const chanOf = new Map();
332
+ for (const ch of channels)
333
+ for (const r of ch.repos)
334
+ chanOf.set(r.toLowerCase(), ch.name);
335
+ const acc = new Map();
336
+ const bots = new Set();
337
+ const unlinked = new Set();
338
+ const get = (login) => {
339
+ const k = login.toLowerCase();
340
+ if (!acc.has(k))
341
+ acc.set(k, { login, commits: 0, byRepo: {}, opened: 0, prs: 0, assigned: 0, chans: new Map() });
342
+ return acc.get(k);
343
+ };
344
+ const touch = (a, repo, w) => {
345
+ const ch = chanOf.get(repo.toLowerCase());
346
+ if (ch)
347
+ a.chans.set(ch, (a.chans.get(ch) ?? 0) + w);
348
+ };
349
+ const botUser = (l) => !!c.users[l]?.bot;
350
+ for (const r of c.repos) {
351
+ for (const cm of r.commits) {
352
+ if (!cm.login) {
353
+ if (cm.name && !isBot(cm.name))
354
+ unlinked.add(cm.name);
355
+ else if (cm.name)
356
+ bots.add(cm.name);
357
+ continue;
358
+ }
359
+ if (isBot(cm.login, cm.bot || botUser(cm.login))) {
360
+ bots.add(cm.login);
361
+ continue;
362
+ }
363
+ const a = get(cm.login);
364
+ a.commits++;
365
+ a.byRepo[r.name] = (a.byRepo[r.name] ?? 0) + 1;
366
+ touch(a, r.full, 1);
367
+ }
368
+ for (const it of [...r.issues, ...r.prs]) {
369
+ if (it.author) {
370
+ if (isBot(it.author, it.author_bot || botUser(it.author)))
371
+ bots.add(it.author);
372
+ else {
373
+ const a = get(it.author);
374
+ if (it.kind === 'github_pr')
375
+ a.prs++;
376
+ else
377
+ a.opened++;
378
+ touch(a, r.full, 1);
379
+ }
380
+ }
381
+ for (const as of it.assignees) {
382
+ if (isBot(as, botUser(as))) {
383
+ bots.add(as);
384
+ continue;
385
+ }
386
+ const a = get(as);
387
+ a.assigned++;
388
+ touch(a, r.full, 2);
389
+ }
390
+ }
391
+ }
392
+ const people = [];
393
+ for (const a of acc.values()) {
394
+ const total = a.commits + a.opened + a.prs + a.assigned;
395
+ const self = !!c.self && a.login.toLowerCase() === c.self.toLowerCase();
396
+ const top = Object.entries(a.byRepo).sort((x, y) => y[1] - x[1]).slice(0, 3);
397
+ const parts = [];
398
+ if (top.length)
399
+ parts.push(`commits: ${top.map(([n, k]) => `${n} ${k}`).join(', ')}`);
400
+ if (a.opened)
401
+ parts.push(`opened ${a.opened} issue(s)`);
402
+ if (a.prs)
403
+ parts.push(`opened ${a.prs} PR(s)`);
404
+ if (a.assigned)
405
+ parts.push(`assigned ${a.assigned}`);
406
+ const label = self ? 'admin' : a.commits >= 10 ? 'maintainer' : a.opened >= 5 && a.commits < 3 ? 'reporter / tester' : 'contributor';
407
+ const invite = self || total >= 3 || a.assigned > 0;
408
+ const u = c.users[a.login];
409
+ people.push({
410
+ github_login: a.login,
411
+ name: u?.name || a.login,
412
+ ...(u?.email ? { email: u.email } : {}),
413
+ is_admin: self,
414
+ ...(self ? { self: true } : {}),
415
+ invite,
416
+ role: `${label}${parts.length ? ` (${parts.join('; ')})` : ''}`,
417
+ ...(invite ? {} : { note: 'occasional contributor: confirm who this is before inviting' }),
418
+ channels: [...a.chans.entries()].sort((x, y) => y[1] - x[1]).map(([n]) => n),
419
+ stats: { commits: a.commits, issues_opened: a.opened, prs_opened: a.prs, assigned: a.assigned, commits_by_repo: a.byRepo },
420
+ });
421
+ }
422
+ people.sort((x, y) => Number(!!y.self) - Number(!!x.self) || Number(y.invite) - Number(x.invite) || score(y) - score(x));
423
+ return { people, bots: [...bots].sort(), unlinked: [...unlinked].sort() };
424
+ }
425
+ const score = (p) => (p.stats ? p.stats.commits + p.stats.issues_opened + p.stats.prs_opened + p.stats.assigned : 0);
426
+ export function buildPlan(c, now = new Date()) {
427
+ const channels = defaultChannels(c.repos);
428
+ const { people, bots, unlinked } = inferPeople(c, channels);
429
+ return {
430
+ version: 1,
431
+ source: { kind: 'github', org: c.org },
432
+ since: c.since,
433
+ generated_at: now.toISOString(),
434
+ channels,
435
+ people,
436
+ bots,
437
+ unlinked_commit_authors: unlinked,
438
+ counts: {
439
+ repos: c.repos.length,
440
+ issues: c.repos.reduce((s, r) => s + r.issues.length, 0),
441
+ prs: c.repos.reduce((s, r) => s + r.prs.length, 0),
442
+ commits: c.repos.reduce((s, r) => s + r.commits.length, 0),
443
+ },
444
+ };
445
+ }
446
+ export function readPlan(file) {
447
+ let p;
448
+ try {
449
+ p = JSON.parse(readFileSync(file, 'utf8'));
450
+ }
451
+ catch (e) {
452
+ throw new ImportError(`--plan ${file}: ${e?.message ?? e}`);
453
+ }
454
+ if (!p || !Array.isArray(p.channels) || !Array.isArray(p.people))
455
+ throw new ImportError(`--plan ${file}: expected {channels: [...], people: [...]}`);
456
+ for (const ch of p.channels)
457
+ if (!ch || typeof ch.name !== 'string' || !Array.isArray(ch.repos))
458
+ throw new ImportError(`--plan ${file}: every channel needs name and repos`);
459
+ for (const pp of p.people)
460
+ if (!pp || typeof pp.github_login !== 'string')
461
+ throw new ImportError(`--plan ${file}: every person needs github_login`);
462
+ return p;
463
+ }
464
+ /** {channel: {login: {commits}}} from the plan's per-person commits by repo (bots excluded). */
465
+ export function channelStats(plan) {
466
+ const chanOf = new Map();
467
+ for (const ch of plan.channels)
468
+ for (const r of ch.repos)
469
+ chanOf.set((r.split('/')[1] ?? r).toLowerCase(), ch.name);
470
+ const out = {};
471
+ for (const p of plan.people) {
472
+ if (isBot(p.github_login))
473
+ continue;
474
+ for (const [repo, n] of Object.entries(p.stats?.commits_by_repo ?? {})) {
475
+ const ch = chanOf.get(repo.toLowerCase());
476
+ if (!ch)
477
+ continue;
478
+ out[ch] ??= {};
479
+ out[ch][p.github_login] = { commits: (out[ch][p.github_login]?.commits ?? 0) + n };
480
+ }
481
+ }
482
+ return out;
483
+ }
484
+ function hubItem(i) {
485
+ return {
486
+ kind: i.kind, repo: i.repo, number: i.number, url: i.url, state: i.state, title: i.title,
487
+ author: i.author, assignees: i.assignees, created_at: i.created_at, updated_at: i.updated_at, excerpt: i.excerpt, labels: i.labels ?? [],
488
+ };
489
+ }
490
+ /** An older closed item: a link for the wiki seed, never a post (no excerpt). */
491
+ function archiveItem(i) {
492
+ return {
493
+ kind: i.kind, repo: i.repo, number: i.number, url: i.url, title: i.title, state: i.state,
494
+ author: i.author, updated_at: i.updated_at, closed_at: i.closed_at,
495
+ };
496
+ }
497
+ const CHUNK = 200;
498
+ function addCounts(a, b) {
499
+ for (const [k, v] of Object.entries(b ?? {}))
500
+ a[k] = (a[k] ?? 0) + v;
501
+ }
502
+ /** Push plan + items to the hub in chunks; the last request records the sync time. */
503
+ export async function pushToHub(admin, source, o) {
504
+ const counts = {};
505
+ let people = [];
506
+ let skipped = [];
507
+ let channels = [];
508
+ const items = o.items.map(hubItem);
509
+ const archive = (o.archive ?? []).map(archiveItem);
510
+ const n = Math.max(1, Math.ceil(items.length / CHUNK), Math.ceil(archive.length / CHUNK));
511
+ for (let i = 0; i < n; i++) {
512
+ const first = i === 0;
513
+ const last = i === n - 1;
514
+ const body = { source, items: items.slice(i * CHUNK, (i + 1) * CHUNK) };
515
+ const arch = archive.slice(i * CHUNK, (i + 1) * CHUNK);
516
+ if (arch.length)
517
+ body.archive = arch;
518
+ if (first && o.plan) {
519
+ body.plan = {
520
+ channels: o.plan.channels.map((c) => ({
521
+ name: c.name, title: c.title, description: c.description, repos: c.repos,
522
+ ...(c.visibility ? { visibility: c.visibility } : {}),
523
+ })),
524
+ people: o.plan.people.map((p) => ({
525
+ github_login: p.github_login, name: p.name, email: p.email, is_admin: !!p.is_admin, self: !!p.self, invite: p.invite !== false,
526
+ ...(p.role ? { role: p.role } : {}),
527
+ ...(Array.isArray(p.channels) ? { channels: p.channels } : {}),
528
+ ...(p.feishu_open_id ? { feishu_open_id: p.feishu_open_id } : {}),
529
+ })),
530
+ };
531
+ }
532
+ if (first && o.stats)
533
+ body.stats = o.stats;
534
+ if (first && o.ranges)
535
+ body.ranges = o.ranges;
536
+ if (last)
537
+ body.sync = { at: o.syncAt, since: o.since };
538
+ if (last && o.releases?.length)
539
+ body.releases = o.releases;
540
+ const r = await admin.request('POST', '/api/ach/admin/import/apply', body);
541
+ addCounts(counts, r?.counts);
542
+ if (r?.people)
543
+ people = r.people;
544
+ if (r?.skipped_people)
545
+ skipped = r.skipped_people;
546
+ if (r?.channels)
547
+ channels = r.channels;
548
+ }
549
+ return { counts, people, skipped, channels };
550
+ }
551
+ // ------------------------------------------------------------------ output
552
+ function pad(s, n) {
553
+ return s.length >= n ? s + ' ' : s + ' '.repeat(n - s.length);
554
+ }
555
+ /** One set of numbers for the dry-run header and the "Would import" line. */
556
+ export function selectionCounts(sel) {
557
+ const all = [...sel.items, ...sel.archive, ...(sel.tooOld ?? []), ...sel.excluded];
558
+ return {
559
+ read: all.length,
560
+ issues: all.filter((i) => i.kind === 'github_issue').length,
561
+ prs: all.filter((i) => i.kind === 'github_pr').length,
562
+ items: sel.items.length,
563
+ open: sel.items.filter((i) => i.state === 'open').length,
564
+ recently_closed: sel.items.filter((i) => i.state !== 'open').length,
565
+ archived: sel.archive.length,
566
+ too_old: (sel.tooOld ?? []).length,
567
+ excluded: sel.excluded.length,
568
+ };
569
+ }
570
+ export function selectionLine(sel, channels) {
571
+ const c = selectionCounts(sel);
572
+ const closed = sel.closedCutoff ? `closed/merged since ${sel.closedCutoff.slice(0, 10)}` : 'closed/merged (--all)';
573
+ const parts = [`Would import ${c.items} issue(s)/PR(s) into ${channels} channel(s) (${c.open} open + ${c.recently_closed} ${closed})`];
574
+ if (c.archived)
575
+ parts.push(`${c.archived} older closed -> Memory seed links only (${sel.seedCutoff ? `closed since ${sel.seedCutoff.slice(0, 10)}` : 'all, --seed-all'}), not posted`);
576
+ if (c.too_old)
577
+ parts.push(`${c.too_old} closed before the seed range, not sent (--seed-months N or --seed-all to include)`);
578
+ if (c.excluded)
579
+ parts.push(`${c.excluded} excluded as sensitive, never sent`);
580
+ return parts.join('; ') + '.';
581
+ }
582
+ export function planSummary(plan, file, sel) {
583
+ const L = [];
584
+ const sc = sel ? selectionCounts(sel) : undefined;
585
+ const iss = sc ? sc.issues : plan.counts.issues;
586
+ const prs = sc ? sc.prs : plan.counts.prs;
587
+ L.push(`GitHub org ${plan.source.org}, since ${plan.since.slice(0, 10)}: ${plan.counts.repos} active repo(s), ${iss} issue(s), ${prs} PR(s), ${plan.counts.commits} commit(s)`);
588
+ if (sc)
589
+ L.push(` of these ${sc.read}: ${sc.items} to post, ${sc.archived} older closed (Memory seed links), ${sc.too_old} older than the seed range, ${sc.excluded} excluded as sensitive`);
590
+ L.push('');
591
+ L.push(`Channels (${plan.channels.length}):`);
592
+ for (const c of plan.channels) {
593
+ const s = c.stats ? `${c.stats.issues} issues · ${c.stats.prs} PRs · ${c.stats.commits} commits` : '';
594
+ L.push(` ${pad('#' + c.name + (c.visibility === 'private' ? ' [private]' : ''), 18)}${pad(s, 36)}${c.repos.map((r) => r.split('/')[1] ?? r).join(', ') || '-'}`);
595
+ }
596
+ L.push('');
597
+ L.push('People:');
598
+ for (const p of plan.people.filter((x) => x.invite !== false)) {
599
+ L.push(` ${pad(p.github_login, 18)}${pad(p.name, 18)}${p.is_admin ? '[admin] ' : ''}${p.role ?? ''}${p.channels?.length ? ` -> ${p.channels.map((c) => '#' + c).join(' ')}` : ''}`);
600
+ }
601
+ const later = plan.people.filter((x) => x.invite === false);
602
+ if (later.length)
603
+ L.push(` not invited (occasional; set "invite": true in the plan to include): ${later.map((p) => p.github_login).join(', ')}`);
604
+ if (plan.bots.length)
605
+ L.push(`Bots (never invited): ${plan.bots.join(', ')}`);
606
+ if (plan.unlinked_commit_authors?.length)
607
+ L.push(`Commit authors without a GitHub login (map by hand if they matter): ${plan.unlinked_commit_authors.join(', ')}`);
608
+ if (file) {
609
+ L.push('');
610
+ L.push(`Plan written to ${file}. Nothing was changed on GitHub or the hub.`);
611
+ L.push(`Edit it (channel names, which repos go where, "visibility": "private" for members-only channels,`);
612
+ L.push(`people's "channels" (their membership) and "role", names, emails, invite true/false), then:`);
613
+ L.push(`Note: the hub widens access compared with GitHub: every hub member can read a "team" channel even if`);
614
+ L.push(`they cannot see that repo on GitHub. Mark channels of private repos "private" and list their members.`);
615
+ L.push(` ach admin import github --org ${plan.source.org} --plan ${file}`);
616
+ }
617
+ return L.join('\n');
618
+ }
619
+ function sv(v) {
620
+ return typeof v === 'string' && v.length ? v : undefined;
621
+ }
622
+ /** `ach admin import github` */
623
+ export async function importGithub(values, positionals, io, admin) {
624
+ if (positionals[0] !== 'github')
625
+ throw new ImportError('usage: ach admin import github --org <org> [--since 60d] [--plan plan.json] [--out plan.json] [--dry-run] [--closed-days 7 | --all] [--exclude a,b | --no-exclude]');
626
+ const exec = io.exec ?? makeExec(io.env);
627
+ const now = io.now?.() ?? Date.now();
628
+ const planFile = sv(values.plan);
629
+ let plan = planFile ? readPlan(resolve(io.cwd, planFile)) : undefined;
630
+ const org = sv(values.org) ?? plan?.source?.org;
631
+ if (!org || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(org))
632
+ throw new ImportError('import github: --org <org> is required');
633
+ // read far enough back for the Memory seed range (default 6 months; --seed-all: everything)
634
+ const seedCut = seedCutoff(values, now);
635
+ const defaultSince = seedCut === '' ? '2008-01-01T00:00:00Z' : [parseSince('60d', now), seedCut].sort()[0];
636
+ const since = sv(values.since) ? parseSince(sv(values.since), now) : plan?.since ? parseSince(plan.since, now) : defaultSince;
637
+ const syncAt = new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z');
638
+ const log = (s) => io.err(s);
639
+ let items;
640
+ if (plan) {
641
+ const repos = [...new Set(plan.channels.flatMap((c) => c.repos))];
642
+ log(`reading ${repos.length} repo(s) from the plan since ${since.slice(0, 10)} with your gh login`);
643
+ items = [];
644
+ for (const full of repos) {
645
+ const r = await fetchItems(exec, full, since, log);
646
+ items.push(...r.issues, ...r.prs);
647
+ }
648
+ }
649
+ else {
650
+ const c = await collectGithub(exec, org, since, { log });
651
+ plan = buildPlan(c, new Date(now));
652
+ items = c.repos.flatMap((r) => [...r.issues, ...r.prs]);
653
+ }
654
+ const sel = selectItems(items, { exclude: excludeTerms(values), closedCutoff: closedCutoff(values, now), seedCutoff: seedCut });
655
+ const counts = selectionCounts(sel);
656
+ if (values['dry-run']) {
657
+ const file = resolve(io.cwd, sv(values.out) ?? `ach-import-${org}.json`);
658
+ if (!planFile || sv(values.out))
659
+ writeFileSync(file, JSON.stringify(plan, null, 2) + '\n');
660
+ if (values.json)
661
+ io.out(JSON.stringify({ plan, items: counts.items, counts, plan_file: file }, null, 2));
662
+ else {
663
+ io.out(planSummary(plan, file, sel));
664
+ io.out(selectionLine(sel, plan.channels.length));
665
+ }
666
+ return 0;
667
+ }
668
+ if (sel.excluded.length)
669
+ io.err(`${sel.excluded.length} item(s) excluded as sensitive (labels/title match: ${excludeTerms(values).join(', ')}); they are never sent. --exclude a,b or --no-exclude to change.`);
670
+ const r = await pushToHub(admin(), `github:${org}`, { plan, items: sel.items, archive: sel.archive, stats: channelStats(plan), syncAt, since, ranges: importRanges(values) });
671
+ r.excluded = sel.excluded.length;
672
+ if (values.json) {
673
+ io.out(JSON.stringify(r, null, 2));
674
+ return 0;
675
+ }
676
+ const c = r.counts;
677
+ io.out(`channels: ${c.channels_created ?? 0} created, ${c.channels_updated ?? 0} updated`);
678
+ io.out(`people: ${c.people_created ?? 0} created, ${c.people_matched ?? 0} matched${r.skipped.length ? `; not invited: ${r.skipped.join(', ')}` : ''}`);
679
+ io.out(`issues/PRs: ${c.items_created ?? 0} new, ${c.items_updated ?? 0} updated, ${c.items_unchanged ?? 0} unchanged${c.items_unmapped ? ` (${c.items_unmapped} from unmapped repos went to #general)` : ''}`);
680
+ io.out(`older closed: ${sel.archive.length} kept as Memory seed links (not posted)${sel.seedCutoff ? ` (seed range: since ${sel.seedCutoff.slice(0, 10)})` : ' (seed range: all)'}; older than the seed range: ${(sel.tooOld ?? []).length}; excluded as sensitive: ${sel.excluded.length}`);
681
+ io.out('Access: everyone on the hub can read "team" channels, even repos they cannot see on GitHub; use "visibility": "private" + people "channels" in the plan for members-only channels.');
682
+ io.out('Next: ach admin invite --feishu (preview; add --yes to send) or ach admin invite --people <a,b> --yes (print one-time links)');
683
+ io.out(`Keep it current: ach admin sync github --org ${org} --every 5m (or every 5 minutes from cron)`);
684
+ return 0;
685
+ }
686
+ /** --every 5m / 30s / 1h -> milliseconds (at least 1 minute unless ACH_SYNC_MIN_MS allows less, for tests). */
687
+ export function parseEvery(v, env = {}) {
688
+ const m = /^(\d{1,5})(s|m|h)$/.exec(v.trim());
689
+ if (!m)
690
+ throw new ImportError(`--every: expected 30s, 5m or 1h, got "${v}"`);
691
+ const ms = Number(m[1]) * (m[2] === 's' ? 1000 : m[2] === 'm' ? 60_000 : 3_600_000);
692
+ const min = Number(env.ACH_SYNC_MIN_MS ?? 60_000);
693
+ if (ms < min)
694
+ throw new ImportError(`--every: at least ${Math.round(min / 1000)}s`);
695
+ return ms;
696
+ }
697
+ /**
698
+ * `ach admin sync github [--every 5m [--runs N]]`: one sync, or a loop (a failed
699
+ * run is reported and the loop goes on). The hub dedupes events, so a repeated
700
+ * sync never notifies twice.
701
+ */
702
+ export async function syncGithub(values, positionals, io, admin) {
703
+ const every = sv(values.every);
704
+ if (!every)
705
+ return syncGithubOnce(values, positionals, io, admin);
706
+ if (positionals[0] !== 'github')
707
+ throw new ImportError('usage: ach admin sync github [--org <org>] [--every 5m]');
708
+ const ms = parseEvery(every, io.env);
709
+ const runs = sv(values.runs) ? Number(sv(values.runs)) : Infinity;
710
+ if (!(runs >= 1))
711
+ throw new ImportError('--runs: expected a positive number');
712
+ for (let i = 1;; i++) {
713
+ try {
714
+ await syncGithubOnce(values, positionals, io, admin);
715
+ }
716
+ catch (e) {
717
+ io.err(`sync github failed (will retry in ${every}): ${e?.message ?? e}`);
718
+ }
719
+ if (i >= runs)
720
+ return 0;
721
+ await new Promise((r) => setTimeout(r, ms));
722
+ }
723
+ }
724
+ async function syncGithubOnce(values, positionals, io, admin) {
725
+ if (positionals[0] !== 'github')
726
+ throw new ImportError('usage: ach admin sync github [--org <org>]');
727
+ const exec = io.exec ?? makeExec(io.env);
728
+ const now = io.now?.() ?? Date.now();
729
+ const a = admin();
730
+ const st = await a.request('GET', '/api/ach/admin/import/state');
731
+ const sources = Object.keys(st?.sources ?? {}).filter((k) => k.startsWith('github:'));
732
+ let org = sv(values.org);
733
+ if (!org) {
734
+ if (sources.length !== 1)
735
+ throw new ImportError(sources.length ? `sync github: --org is required (imported: ${sources.map((s) => s.slice(7)).join(', ')})` : 'sync github: nothing imported yet; run `ach admin import github --org <org>` first');
736
+ org = sources[0].slice(7);
737
+ }
738
+ const prev = st.sources?.[`github:${org}`];
739
+ if (!prev?.last_sync)
740
+ throw new ImportError(`sync github: ${org} was never imported; run \`ach admin import github --org ${org}\` first`);
741
+ // 1 h overlap: an item updated during the last run is read again (idempotent)
742
+ const since = sv(values.since) ? parseSince(sv(values.since), now) : new Date(Date.parse(prev.last_sync) - 3600_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
743
+ const repos = [...new Set((st.channels ?? []).filter((c) => !c.archived).flatMap((c) => c.repos ?? []))].filter((r) => r.toLowerCase().startsWith(org.toLowerCase() + '/'));
744
+ if (!repos.length)
745
+ throw new ImportError(`sync github: no channel has ${org} repos mapped`);
746
+ const items = [];
747
+ const releases = [];
748
+ for (const full of repos) {
749
+ const r = await fetchItems(exec, full, since);
750
+ items.push(...r.issues, ...r.prs);
751
+ releases.push(...(await fetchReleases(exec, full, since)));
752
+ }
753
+ // an item that closed since the last sync must still update its post (it
754
+ // was open there): the cutoff is the earlier of the last sync and N days ago
755
+ // the ranges saved by the import (or a wizard) apply unless flags override them
756
+ const rv = { ...values };
757
+ const saved = st?.ranges;
758
+ if (saved) {
759
+ if (rv['closed-days'] === undefined && rv['post-closed-days'] === undefined && !rv.all)
760
+ rv['closed-days'] = String(saved.post_closed_days);
761
+ if (rv['seed-months'] === undefined && !rv['seed-all']) {
762
+ if (saved.seed_all)
763
+ rv['seed-all'] = true;
764
+ else
765
+ rv['seed-months'] = String(saved.seed_months);
766
+ }
767
+ }
768
+ const cut = closedCutoff(rv, now);
769
+ const sel = selectItems(items, { exclude: excludeTerms(rv), closedCutoff: cut && since < cut ? since : cut, seedCutoff: seedCutoff(rv, now) });
770
+ const syncAt = new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z');
771
+ const r = await pushToHub(a, `github:${org}`, { items: sel.items, archive: sel.archive, syncAt, since: prev.since || since, releases });
772
+ const c = r.counts;
773
+ if (values.json)
774
+ io.out(JSON.stringify({ org, since, repos: repos.length, counts: c, archived: sel.archive.length, excluded: sel.excluded.length }, null, values.every ? 0 : 2));
775
+ else
776
+ io.out(`synced ${org} since ${since}: ${repos.length} repo(s); ${c.items_created ?? 0} new, ${c.items_updated ?? 0} updated, ${c.items_unchanged ?? 0} unchanged; ${c.releases ?? 0} release(s); ${sel.archive.length} older closed (Memory seed links only), ${sel.excluded.length} excluded as sensitive`);
777
+ return 0;
778
+ }
779
+ // ------------------------------------------------------------------ invites
780
+ export function inviteMessage(name, link, workspace, assigned) {
781
+ // "AgentChannel (AgentChannel)" reads wrong: a workspace named like the product is left out
782
+ const w = String(workspace ?? '').trim();
783
+ const ws = w && !/agent\s*channel/i.test(w) ? ` (${w})` : '';
784
+ // nothing assigned to this person on GitHub: do not promise "your items"
785
+ const none = assigned === 0;
786
+ return [
787
+ none
788
+ ? `${name},你好:团队在用 AgentChannel${ws} 协作,你的频道已经在里面了(目前没有分给你的 GitHub 事项)。`
789
+ : `${name},你好:团队在用 AgentChannel${ws} 协作,你的频道和分给你的 GitHub 事项已经在里面了。`,
790
+ `用下面这个一次性链接登录(24 小时内有效,只能用一次,之后浏览器会保持登录):`,
791
+ link,
792
+ '',
793
+ none
794
+ ? `Hi ${name}: the team works in AgentChannel${ws}. Your channels are already there (nothing on GitHub is assigned to you yet).`
795
+ : `Hi ${name}: the team works in AgentChannel${ws}. Your channels and the GitHub items assigned to you are already there.`,
796
+ `Log in with this one-time link (valid 24 h, works once; your browser stays logged in afterwards).`,
797
+ ].join('\n');
798
+ }
799
+ async function larkAvailable(exec) {
800
+ const r = await exec('lark-cli', ['--version']);
801
+ return r.code === 0;
802
+ }
803
+ const LARK_SEARCH_SCOPE = 'contact:user:search';
804
+ /** The exact login that grants missing user scopes (lark-cli's own syntax; scopes add up across logins). */
805
+ export function larkScopeHint(scopes = [LARK_SEARCH_SCOPE]) {
806
+ return `lark-cli auth login --scope "${scopes.join(' ')}"`;
807
+ }
808
+ /** Missing scopes named in a lark-cli error (JSON `missing_scopes`, or the scope named in the text). */
809
+ export function larkMissingScopes(out) {
810
+ const found = new Set();
811
+ for (const chunk of [out, ...out.split('\n')]) {
812
+ try {
813
+ const j = JSON.parse(chunk);
814
+ const walk = (v) => {
815
+ if (!v || typeof v !== 'object')
816
+ return;
817
+ for (const [k, x] of Object.entries(v)) {
818
+ if (k === 'missing_scopes' && Array.isArray(x)) {
819
+ for (const sc of x)
820
+ if (typeof sc === 'string')
821
+ found.add(sc);
822
+ }
823
+ else
824
+ walk(x);
825
+ }
826
+ };
827
+ walk(j);
828
+ }
829
+ catch {
830
+ /* not JSON */
831
+ }
832
+ }
833
+ if (!found.size && /scope|permission|99991679|99991672|unauthori[sz]ed/i.test(out)) {
834
+ for (const m of out.matchAll(/\b([a-z_]+:[a-z_]+(?::[a-z_]+)+)\b/g))
835
+ found.add(m[1]);
836
+ if (!found.size && /scope/i.test(out))
837
+ found.add(LARK_SEARCH_SCOPE);
838
+ }
839
+ return [...found];
840
+ }
841
+ async function larkSearch(exec, query) {
842
+ const r = await exec('lark-cli', ['contact', '+search-user', '--query', query, '--as', 'user', '--exclude-external-users', '--format', 'json']);
843
+ if (r.code !== 0) {
844
+ const text = `${r.stdout}\n${r.stderr}`;
845
+ return { error: (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}`, scopes: larkMissingScopes(text) };
846
+ }
847
+ try {
848
+ const j = JSON.parse(r.stdout);
849
+ const users = j?.data?.users ?? j?.users ?? [];
850
+ return {
851
+ hits: (Array.isArray(users) ? users : []).map((u) => ({
852
+ open_id: String(u.open_id ?? ''),
853
+ name: String(u.localized_name ?? u.name ?? ''),
854
+ email: String(u.enterprise_email || u.email || ''),
855
+ })).filter((u) => u.open_id),
856
+ };
857
+ }
858
+ catch {
859
+ return { error: 'unexpected lark-cli output', scopes: larkMissingScopes(r.stdout) };
860
+ }
861
+ }
862
+ function searchFailed(x) {
863
+ if (x.scopes.length)
864
+ return { why: `lark-cli is missing scope ${x.scopes.join(', ')}`, scopes: x.scopes };
865
+ return { why: `lark-cli search failed (${x.error}); check \`lark-cli auth status\`` };
866
+ }
867
+ /** github person -> Feishu open_id: stored mapping, else email, else exact name match. */
868
+ async function resolveFeishu(exec, p) {
869
+ if (p.feishu_open_id)
870
+ return { open_id: p.feishu_open_id, why: 'mapped' };
871
+ const email = String(p.email ?? '').toLowerCase();
872
+ if (email) {
873
+ const r = await larkSearch(exec, email);
874
+ if ('error' in r)
875
+ return searchFailed(r);
876
+ const exact = r.hits.filter((h) => h.email.toLowerCase() === email);
877
+ if (exact.length === 1)
878
+ return { open_id: exact[0].open_id, why: 'email' };
879
+ }
880
+ for (const q of [p.name, p.github_login].filter(Boolean)) {
881
+ const r = await larkSearch(exec, q);
882
+ if ('error' in r)
883
+ return searchFailed(r);
884
+ const hits = r.hits;
885
+ const exact = hits.filter((h) => h.name.toLowerCase() === String(q).toLowerCase());
886
+ if (exact.length === 1)
887
+ return { open_id: exact[0].open_id, why: 'name' };
888
+ if (hits.length > 1 || exact.length > 1)
889
+ return { why: `ambiguous "${q}"`, candidates: hits.slice(0, 5) };
890
+ }
891
+ return { why: 'no Feishu match' };
892
+ }
893
+ /**
894
+ * `ach admin invite --feishu [--people a,b] [--map login=ou_x ...] [--yes]`
895
+ * `ach admin invite --people a,b --yes` (no Feishu: print the links to copy)
896
+ * Without --yes nothing is minted or sent: the messages are previewed.
897
+ */
898
+ export async function invitePeople(values, io, admin) {
899
+ const exec = io.exec ?? makeExec(io.env);
900
+ const a = admin();
901
+ const st = await a.request('GET', '/api/ach/admin/import/state');
902
+ const meRes = await a.request('GET', '/api/ach/me').catch(() => null);
903
+ const settings = await a.request('GET', '/api/ach/settings').catch(() => null);
904
+ const workspace = settings?.workspace_name || settings?.workspace || undefined;
905
+ const want = (sv(values.people) ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
906
+ let people = (st?.people ?? []).filter((p) => !p.deactivated && p.id !== meRes?.id);
907
+ if (want.length) {
908
+ people = people.filter((p) => want.includes(String(p.github_login).toLowerCase()) || want.includes(String(p.name).toLowerCase()) || want.includes(String(p.email).toLowerCase()));
909
+ const found = new Set(people.flatMap((p) => [String(p.github_login).toLowerCase(), String(p.name).toLowerCase(), String(p.email).toLowerCase()]));
910
+ const missing = want.filter((w) => !found.has(w));
911
+ if (missing.length)
912
+ io.err(`not on the hub (import them first or check the spelling): ${missing.join(', ')}`);
913
+ }
914
+ else {
915
+ people = people.filter((p) => p.github_login);
916
+ }
917
+ if (!people.length)
918
+ throw new ImportError('invite: nobody to invite (import people first, or pass --people <login,...>)');
919
+ const maps = {};
920
+ for (const m of (Array.isArray(values.map) ? values.map : [])) {
921
+ const [k, v] = m.split('=');
922
+ if (!k || !/^ou_[A-Za-z0-9_-]+$/.test(v ?? ''))
923
+ throw new ImportError(`--map ${m}: expected <github_login>=ou_...`);
924
+ maps[k.toLowerCase()] = v;
925
+ }
926
+ for (const p of people)
927
+ if (maps[String(p.github_login).toLowerCase()])
928
+ p.feishu_open_id = maps[String(p.github_login).toLowerCase()];
929
+ const yes = !!values.yes;
930
+ const feishu = !!values.feishu;
931
+ const placeholder = '<one-time login link: minted when you run again with --yes>';
932
+ let lark = false;
933
+ if (feishu) {
934
+ lark = await larkAvailable(exec);
935
+ if (!lark)
936
+ io.err('lark-cli is not installed or not on PATH: the links will be printed for you to send by hand.');
937
+ }
938
+ const plan = [];
939
+ let missingScopes;
940
+ for (const p of people) {
941
+ if (feishu && lark && !missingScopes) {
942
+ const x = await resolveFeishu(exec, p);
943
+ if (x.scopes)
944
+ missingScopes = x.scopes;
945
+ plan.push({ p, ...x });
946
+ }
947
+ else if (missingScopes)
948
+ plan.push({ p, open_id: p.feishu_open_id || undefined, why: p.feishu_open_id ? 'mapped' : `lark-cli is missing scope ${missingScopes.join(', ')}` });
949
+ else
950
+ plan.push({ p, why: feishu ? 'no lark-cli' : 'manual' });
951
+ }
952
+ if (missingScopes) {
953
+ io.err(`lark-cli cannot search Feishu contacts: your lark-cli login lacks the scope ${missingScopes.join(', ')}. Grant it, then run this again:`);
954
+ io.err(` ${larkScopeHint(missingScopes)}`);
955
+ }
956
+ const unresolved = plan.filter((x) => feishu && lark && !x.open_id);
957
+ if (!yes) {
958
+ io.out(feishu ? 'Preview (nothing minted or sent). Feishu DMs that would be sent:' : 'Preview (nothing minted). Links that would be printed:');
959
+ for (const x of plan) {
960
+ io.out('');
961
+ io.out(`--- ${x.p.name} (${x.p.github_login || x.p.email || x.p.id})${x.open_id ? ` -> Feishu ${x.open_id} [${x.why}]` : feishu && lark ? ` -> NOT RESOLVED: ${x.why}` : ''}`);
962
+ io.out(inviteMessage(x.p.name, placeholder, workspace, typeof x.p.assigned_open === 'number' ? x.p.assigned_open : undefined));
963
+ }
964
+ if (unresolved.length) {
965
+ io.out('');
966
+ io.out('Unresolved (add --map <github_login>=ou_xxx, or they get a printed link):');
967
+ for (const x of unresolved)
968
+ io.out(` ${x.p.github_login}: ${x.why}${x.candidates?.length ? `; candidates: ${x.candidates.map((c) => `${c.name} ${c.email} ${c.open_id}`).join(' | ')}` : ''}`);
969
+ }
970
+ io.out('');
971
+ io.out('Run again with --yes to mint the links' + (feishu ? ' and send the DMs.' : ' and print them.'));
972
+ return 0;
973
+ }
974
+ const results = [];
975
+ const newMaps = [];
976
+ for (const x of plan) {
977
+ const link = await a.adminUserLoginLink(x.p.id);
978
+ const url = link.url.startsWith('http') ? link.url : `${a.hub}${link.path}`;
979
+ const msg = inviteMessage(x.p.name, url, workspace, typeof x.p.assigned_open === 'number' ? x.p.assigned_open : undefined);
980
+ if (feishu && lark && x.open_id) {
981
+ const r = await exec('lark-cli', ['im', '+messages-send', '--user-id', x.open_id, '--text', msg, '--idempotency-key', `ach-inv-${x.p.id}-${link.expires_at.slice(0, 13)}`.slice(0, 50)]);
982
+ if (r.code === 0) {
983
+ results.push({ person: x.p.name, sent: 'feishu', open_id: x.open_id });
984
+ io.out(`sent Feishu DM to ${x.p.name}`);
985
+ if (x.p.github_login && x.why !== 'mapped')
986
+ newMaps.push({ github_login: x.p.github_login, feishu_open_id: x.open_id, match_only: true });
987
+ else if (x.p.github_login && maps[String(x.p.github_login).toLowerCase()])
988
+ newMaps.push({ github_login: x.p.github_login, feishu_open_id: x.open_id, match_only: true });
989
+ continue;
990
+ }
991
+ io.err(`Feishu DM to ${x.p.name} failed (${r.stderr.trim().split('\n')[0] || r.code}); send the link by hand:`);
992
+ }
993
+ results.push({ person: x.p.name, sent: 'manual', url });
994
+ io.out(`${x.p.name}: ${url}`);
995
+ }
996
+ // avatars: Feishu pictures of the people just messaged (fetched here, cached on the hub)
997
+ const dmd = plan.filter((x) => results.some((r) => r.sent === 'feishu' && r.person === x.p.name));
998
+ if (dmd.length) {
999
+ const { syncFeishuAvatars } = await import('./avatars.js');
1000
+ await syncFeishuAvatars(a, dmd.map((x) => ({ id: x.p.id, name: x.p.name, email: x.p.email, feishu_open_id: x.open_id })), { exec, fetch: io.fetch, log: io.err });
1001
+ }
1002
+ if (newMaps.length)
1003
+ await a.request('POST', '/api/ach/admin/import/apply', { source: `github:${Object.keys(st?.sources ?? {})[0]?.slice(7) || 'manual'}`, plan: { people: newMaps } }).catch(() => undefined);
1004
+ if (values.json)
1005
+ io.out(JSON.stringify(results, null, 2));
1006
+ io.err('Each link works once and expires in 24 h; a new invite replaces the older link.');
1007
+ return 0;
1008
+ }