loreli 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,1558 @@
1
+ import { Octokit } from '@octokit/rest';
2
+ import { BaseHub } from './base.js';
3
+ import { logger } from 'loreli/log';
4
+ import { mark, has, parse } from 'loreli/marker';
5
+
6
+ const log = logger('hub');
7
+
8
+ /**
9
+ * Default threshold (fraction) below which a rate limit warning is emitted.
10
+ * @type {number}
11
+ */
12
+ const RATE_WARN_THRESHOLD = 0.2;
13
+
14
+ /**
15
+ * Maximum retry attempts for rate-limited (429) responses.
16
+ * @type {number}
17
+ */
18
+ const MAX_RETRIES = 3;
19
+
20
+ /**
21
+ * Calculate exponential backoff with jitter.
22
+ *
23
+ * @param {number} attempt - Zero-based retry attempt number.
24
+ * @returns {number} Milliseconds to wait.
25
+ */
26
+ function backoff(attempt) {
27
+ const base = Math.pow(2, attempt) * 1000;
28
+ const jitter = Math.random() * 500;
29
+ return base + jitter;
30
+ }
31
+
32
+ /**
33
+ * Parse the retry-after header from a 429 response.
34
+ * GitHub may send `retry-after` as seconds to wait.
35
+ *
36
+ * @param {object} [headers] - Response headers.
37
+ * @returns {number|null} Milliseconds to wait, or null if no header found.
38
+ */
39
+ function parseRetryAfter(headers) {
40
+ const value = headers?.['retry-after'];
41
+ if (value == null) return null;
42
+ const seconds = parseInt(value, 10);
43
+ return Number.isNaN(seconds) ? null : seconds * 1000;
44
+ }
45
+
46
+ // ── Self-Review Fallback ────────────────────────────────
47
+
48
+ /**
49
+ * Maps GitHub review event names to their resulting state values.
50
+ * Used when reconstructing the intended state from a COMMENT fallback.
51
+ *
52
+ * @type {Record<string, string>}
53
+ */
54
+ const EVENT_TO_STATE = {
55
+ APPROVE: 'APPROVED',
56
+ REQUEST_CHANGES: 'CHANGES_REQUESTED'
57
+ };
58
+
59
+ /**
60
+ * Detect whether an Octokit error is a GitHub self-review rejection.
61
+ * GitHub returns 422 when a user tries to APPROVE or REQUEST_CHANGES
62
+ * on their own pull request.
63
+ *
64
+ * @param {Error} err - Error from Octokit.
65
+ * @param {string} event - Review event that was attempted.
66
+ * @returns {boolean} True if this is a self-review error.
67
+ */
68
+ function isSelfReview(err, event) {
69
+ if (err.status !== 422) return false;
70
+ if (event !== 'APPROVE' && event !== 'REQUEST_CHANGES') return false;
71
+ const msg = err.message?.toLowerCase() ?? '';
72
+ return msg.includes('can not approve your own') || msg.includes('can not request changes on your own');
73
+ }
74
+
75
+ /**
76
+ * Post-process a normalized review to detect Loreli self-review markers.
77
+ * If the review is COMMENTED and contains a marker, patches the state
78
+ * to the intended verdict so downstream consumers see the correct state.
79
+ *
80
+ * @param {object} review - Normalized review object.
81
+ * @returns {object} Review with potentially patched state.
82
+ */
83
+ function patchMarker(review) {
84
+ if (review.state !== 'COMMENTED') return review;
85
+
86
+ const data = parse(review.body, 'review-event');
87
+ if (data?.event) {
88
+ review.state = EVENT_TO_STATE[data.event] ?? review.state;
89
+ }
90
+
91
+ return review;
92
+ }
93
+
94
+
95
+ /**
96
+ * Normalization functions that convert GitHub API responses into
97
+ * provider-agnostic shapes. Exported as a static property on GitHubHub
98
+ * so tests can verify the normalization logic without making API calls.
99
+ */
100
+ const normalize = {
101
+ /**
102
+ * Normalize a GitHub issue object.
103
+ *
104
+ * @param {object} raw - Raw GitHub issue API response.
105
+ * @returns {{id: number, number: number, title: string, body: string, state: string, author: string, url: string, labels: string[], created: string, updated: string}}
106
+ */
107
+ issue(raw) {
108
+ return {
109
+ id: raw.id,
110
+ number: raw.number,
111
+ title: raw.title,
112
+ body: raw.body ?? '',
113
+ state: raw.state,
114
+ author: raw.user?.login ?? '',
115
+ url: raw.html_url,
116
+ labels: (raw.labels ?? []).map(function extractName(l) {
117
+ return typeof l === 'string' ? l : l.name;
118
+ }),
119
+ created: raw.created_at,
120
+ updated: raw.updated_at
121
+ };
122
+ },
123
+
124
+ /**
125
+ * Normalize a GitHub pull request object.
126
+ *
127
+ * @param {object} raw - Raw GitHub PR API response.
128
+ * @returns {{number: number, title: string, body: string, state: string, head: string, headSha: string, base: string, author: string, url: string, labels: string[], merged: boolean, created: string, updated: string}}
129
+ */
130
+ pull(raw) {
131
+ return {
132
+ number: raw.number,
133
+ title: raw.title,
134
+ body: raw.body ?? '',
135
+ state: raw.state,
136
+ head: raw.head?.ref ?? '',
137
+ headSha: raw.head?.sha ?? '',
138
+ base: raw.base?.ref ?? '',
139
+ author: raw.user?.login ?? '',
140
+ url: raw.html_url,
141
+ labels: (raw.labels ?? []).map(function extractName(l) {
142
+ return typeof l === 'string' ? l : l.name;
143
+ }),
144
+ merged: raw.merged ?? false,
145
+ created: raw.created_at,
146
+ updated: raw.updated_at
147
+ };
148
+ },
149
+
150
+ /**
151
+ * Normalize a GitHub comment object.
152
+ *
153
+ * @param {object} raw - Raw GitHub comment API response.
154
+ * @returns {{id: number, body: string, author: string, created: string}}
155
+ */
156
+ comment(raw) {
157
+ return {
158
+ id: raw.id,
159
+ body: raw.body ?? '',
160
+ author: raw.user?.login ?? '',
161
+ created: raw.created_at
162
+ };
163
+ },
164
+
165
+ /**
166
+ * Normalize a GitHub review object.
167
+ *
168
+ * @param {object} raw - Raw GitHub review API response.
169
+ * @returns {{id: number, body: string, state: string, author: string, submitted: string, commitId: string|null}}
170
+ */
171
+ review(raw) {
172
+ return {
173
+ id: raw.id,
174
+ body: raw.body ?? '',
175
+ state: raw.state,
176
+ author: raw.user?.login ?? '',
177
+ submitted: raw.submitted_at,
178
+ commitId: raw.commit_id ?? null
179
+ };
180
+ }
181
+ };
182
+
183
+ /**
184
+ * GitHub implementation of the BaseHub interface.
185
+ * Uses @octokit/rest for all API interactions.
186
+ *
187
+ * @example
188
+ * ```js
189
+ * const h = new GitHubHub({ token: process.env.GITHUB_TOKEN });
190
+ * const issues = await h.issues('owner/repo', { state: 'open' });
191
+ * ```
192
+ */
193
+ export class GitHubHub extends BaseHub {
194
+ /**
195
+ * Normalization functions for converting raw GitHub responses.
196
+ * Exposed as a static property so tests can verify shapes.
197
+ */
198
+ static normalize = normalize;
199
+
200
+ /**
201
+ * @param {object} opts
202
+ * @param {string} opts.token - GitHub personal access token.
203
+ */
204
+ constructor({ token }) {
205
+ super();
206
+
207
+ /**
208
+ * Current rate limit state, updated after every REST API call.
209
+ * GraphQL calls update via separate x-ratelimit headers.
210
+ *
211
+ * @type {{remaining: number|null, limit: number|null, reset: number|null, used: number|null}}
212
+ */
213
+ this.rateLimit = { remaining: null, limit: null, reset: null, used: null };
214
+
215
+ this.client = new Octokit({ auth: token });
216
+
217
+ const self = this;
218
+
219
+ /**
220
+ * Wrap every request to extract rate limit headers and retry on 429.
221
+ * Uses Octokit's hook.wrap API (before-after-hook).
222
+ */
223
+ this.client.hook.wrap('request', async function rateLimitHook(request, options) {
224
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
225
+ let response;
226
+ try {
227
+ response = await request(options);
228
+ } catch (err) {
229
+ // Octokit throws on non-2xx; check for 429 (secondary rate limit)
230
+ if (err.status === 429 && attempt < MAX_RETRIES) {
231
+ const wait = parseRetryAfter(err.response?.headers) ?? backoff(attempt);
232
+ await new Promise(function delay(r) { setTimeout(r, wait); });
233
+ continue;
234
+ }
235
+ // Still extract rate limit info from error responses
236
+ if (err.response?.headers) {
237
+ self._updateRateLimit(err.response.headers);
238
+ }
239
+ throw err;
240
+ }
241
+
242
+ // Successful response — extract rate limit headers
243
+ if (response.headers) {
244
+ self._updateRateLimit(response.headers);
245
+ }
246
+
247
+ return response;
248
+ }
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Parse rate limit headers from a GitHub API response and update internal state.
254
+ * Emits a warning when remaining requests drop below the configured threshold.
255
+ *
256
+ * @param {object} headers - Response headers object.
257
+ */
258
+ _updateRateLimit(headers) {
259
+ const remaining = parseInt(headers['x-ratelimit-remaining'], 10);
260
+ const limit = parseInt(headers['x-ratelimit-limit'], 10);
261
+ const reset = parseInt(headers['x-ratelimit-reset'], 10);
262
+ const used = parseInt(headers['x-ratelimit-used'], 10);
263
+
264
+ if (!Number.isNaN(remaining)) this.rateLimit.remaining = remaining;
265
+ if (!Number.isNaN(limit)) this.rateLimit.limit = limit;
266
+ if (!Number.isNaN(reset)) this.rateLimit.reset = reset;
267
+ if (!Number.isNaN(used)) this.rateLimit.used = used;
268
+
269
+ // Warn when below threshold — use callback if set, else log directly
270
+ if (this.rateLimit.remaining !== null && this.rateLimit.limit !== null && this.rateLimit.limit > 0) {
271
+ const ratio = this.rateLimit.remaining / this.rateLimit.limit;
272
+ if (ratio < RATE_WARN_THRESHOLD) {
273
+ const resetDate = this.rateLimit.reset ? new Date(this.rateLimit.reset * 1000).toISOString() : 'unknown';
274
+ if (this._onRateLimitWarning) {
275
+ this._onRateLimitWarning({
276
+ remaining: this.rateLimit.remaining,
277
+ limit: this.rateLimit.limit,
278
+ reset: resetDate,
279
+ ratio
280
+ });
281
+ } else {
282
+ log.warn(`rate limit low: ${this.rateLimit.remaining}/${this.rateLimit.limit} (${Math.round(ratio * 100)}%), resets ${resetDate}`);
283
+ }
284
+ // Early return to avoid the duplicate call below
285
+ return;
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Wait for a mutation to settle in GitHub's eventually-consistent
292
+ * indices. After a write (issue create, file write, PR create, etc.)
293
+ * the corresponding list/get endpoint may not reflect the change
294
+ * immediately. This method retries a verification function until it
295
+ * returns truthy, using short exponential backoff.
296
+ *
297
+ * Applied automatically inside mutation methods so callers never
298
+ * need to add manual delays.
299
+ *
300
+ * @param {function} verify - Async function that returns truthy when settled.
301
+ * @param {string} label - Human-readable label for debug logging.
302
+ * @param {object} [opts] - Options.
303
+ * @param {number} [opts.retries=5] - Maximum retry attempts.
304
+ * @param {number} [opts.base=300] - Base delay in ms (doubles each attempt).
305
+ * @param {number} [opts.cap=5000] - Maximum delay per attempt in ms.
306
+ * @returns {Promise<void>}
307
+ */
308
+ async _settle(verify, label, { retries = 5, base = 300, cap = 5000 } = {}) {
309
+ for (let attempt = 0; attempt < retries; attempt++) {
310
+ try {
311
+ if (await verify()) return;
312
+ } catch { /* verification may throw before the resource is visible */ }
313
+
314
+ const wait = Math.min(base * Math.pow(2, attempt), cap);
315
+ log.debug(`settle(${label}): attempt ${attempt + 1}/${retries}, waiting ${wait}ms`);
316
+ await new Promise(function delay(r) { setTimeout(r, wait); });
317
+ }
318
+
319
+ // Final attempt — if this fails, log warning but don't throw.
320
+ // The resource was created; the index is just slow.
321
+ log.warn(`settle(${label}): gave up after ${retries} attempts — index may be stale`);
322
+ }
323
+
324
+ /**
325
+ * Get current rate limit information.
326
+ * Returns the last-observed rate limit state from GitHub API response headers.
327
+ *
328
+ * @returns {{remaining: number|null, limit: number|null, reset: string|null, used: number|null, ratio: number|null}}
329
+ */
330
+ rates() {
331
+ const { remaining, limit, reset, used } = this.rateLimit;
332
+ return {
333
+ remaining,
334
+ limit,
335
+ used,
336
+ reset: reset ? new Date(reset * 1000).toISOString() : null,
337
+ ratio: (remaining !== null && limit !== null && limit > 0)
338
+ ? Math.round((remaining / limit) * 100) / 100
339
+ : null
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Register a callback invoked when rate limit drops below 20%.
345
+ * The callback receives `{ remaining, limit, reset, ratio }`.
346
+ *
347
+ * @param {function} fn - Warning handler.
348
+ */
349
+ set onRateLimitWarning(fn) {
350
+ this._onRateLimitWarning = fn;
351
+ }
352
+
353
+ /**
354
+ * Parse an "owner/repo" string into [owner, repo].
355
+ *
356
+ * @param {string} repo - Repository in "owner/name" format.
357
+ * @returns {[string, string]} Tuple of [owner, repo].
358
+ */
359
+ parse(repo) {
360
+ const [owner, name] = repo.split('/');
361
+ return [owner, name];
362
+ }
363
+
364
+ /**
365
+ * Return a scoped hub that auto-appends the agent's signature to
366
+ * every content-producing GitHub call (comments, issues, PRs, drafts).
367
+ * Uses prototype delegation so no state leaks to the parent instance.
368
+ *
369
+ * @param {object} identity - Agent Identity instance with signature().
370
+ * @param {string} role - Agent role for the signature block.
371
+ * @returns {GitHubHub} Scoped hub.
372
+ */
373
+ as(identity, role) {
374
+ const scoped = Object.create(this);
375
+ scoped._identity = identity;
376
+ scoped._role = role;
377
+ return scoped;
378
+ }
379
+
380
+ /**
381
+ * Throw if a content-producing method is called on an unscoped hub.
382
+ * Every automated interaction must carry an identity for traceability.
383
+ *
384
+ * @param {string} method - Method name for the error message.
385
+ * @throws {Error} When the hub has not been scoped via as().
386
+ */
387
+ _guard(method) {
388
+ if (!this._identity) {
389
+ throw new Error(`hub.${method}() requires scoping — call hub.as(identity, role) first`);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Append the active agent signature to a body string.
395
+ * Injects a machine-readable agent marker before the visible signature
396
+ * so downstream tools can detect agent-authored content without
397
+ * parsing the visible markdown table.
398
+ *
399
+ * @param {string} body - Original content.
400
+ * @returns {string} Body with agent marker and signature appended.
401
+ */
402
+ _stamp(body) {
403
+ const sig = this._identity?.signature?.(this._role) ?? '';
404
+ if (!sig) return body;
405
+ const agentMarker = mark('agent', { name: this._identity.name, role: this._role, provider: this._identity.provider });
406
+ return `${body}\n${agentMarker}${sig}`;
407
+ }
408
+
409
+ /**
410
+ * Merge identity labels with explicitly-passed labels.
411
+ * When scoped, auto-includes the agent's identity labels (loreli,
412
+ * provider, model, role). Deduplicates the merged set.
413
+ *
414
+ * @param {string[]} [explicit] - Caller-supplied labels.
415
+ * @returns {string[]|undefined} Merged labels, or undefined when empty.
416
+ */
417
+ _labels(explicit) {
418
+ const auto = this._identity?.labels?.(this._role) ?? [];
419
+ if (!auto.length) return explicit ?? undefined;
420
+ return [...new Set([...auto, ...(explicit ?? [])])];
421
+ }
422
+
423
+ // --- Issues ---
424
+
425
+ /**
426
+ * List issues for a repository, excluding pull requests.
427
+ *
428
+ * @param {string} repo - "owner/name" repository.
429
+ * @param {object} [opts] - Filter options.
430
+ * @param {string} [opts.state='open'] - Issue state filter ('open', 'closed', 'all').
431
+ * @param {string[]} [opts.labels] - Filter by label names.
432
+ * @returns {Promise<Array<{number: number, title: string, body: string, state: string, author: string, url: string, labels: string[], created: string, updated: string}>>}
433
+ */
434
+ async issues(repo, opts = {}) {
435
+ log.debug(`issues: ${repo} state=${opts.state ?? 'open'}`);
436
+ const [owner, name] = this.parse(repo);
437
+ const data = await this.client.paginate(this.client.issues.listForRepo, {
438
+ owner, repo: name,
439
+ state: opts.state ?? 'open',
440
+ labels: opts.labels?.join(','),
441
+ per_page: 100
442
+ });
443
+ return data.filter(function notPR(i) { return !i.pull_request; }).map(normalize.issue);
444
+ }
445
+
446
+ /**
447
+ * Get a single issue by number.
448
+ *
449
+ * @param {string} repo - "owner/name" repository.
450
+ * @param {number} number - Issue number.
451
+ * @returns {Promise<{number: number, title: string, body: string, state: string, author: string, url: string, labels: string[], created: string, updated: string}>}
452
+ */
453
+ async issue(repo, number) {
454
+ const [owner, name] = this.parse(repo);
455
+ const { data } = await this.client.issues.get({ owner, repo: name, issue_number: number });
456
+ return normalize.issue(data);
457
+ }
458
+
459
+ /**
460
+ * Create a new issue. Requires scoping via as(identity, role).
461
+ * Auto-appends signature and auto-applies identity labels.
462
+ * Waits for the issue to be indexed by GitHub before returning.
463
+ *
464
+ * @param {string} repo - "owner/name" repository.
465
+ * @param {object} opts - Issue options.
466
+ * @param {string} opts.title - Issue title.
467
+ * @param {string} [opts.body] - Issue body.
468
+ * @param {string[]} [opts.labels] - Additional label names to apply.
469
+ * @returns {Promise<{number: number, title: string, body: string, state: string, author: string, url: string, labels: string[], created: string, updated: string}>}
470
+ * @throws {Error} When hub is not scoped via as().
471
+ */
472
+ async open(repo, opts) {
473
+ this._guard('open');
474
+ log.debug(`open issue: ${repo} title="${opts.title}"`);
475
+ const [owner, name] = this.parse(repo);
476
+ const { data } = await this.client.issues.create({
477
+ owner, repo: name,
478
+ title: opts.title,
479
+ body: this._stamp(opts.body ?? ''),
480
+ labels: this._labels(opts.labels)
481
+ });
482
+
483
+ const result = normalize.issue(data);
484
+ const client = this.client;
485
+
486
+ // Verify the issue is indexed by the list endpoint before returning
487
+ await this._settle(async function visible() {
488
+ const { data: list } = await client.issues.listForRepo({
489
+ owner, repo: name, state: 'open', per_page: 100
490
+ });
491
+ return list.some(function match(i) { return i.number === result.number; });
492
+ }, `issue #${result.number}`);
493
+
494
+ return result;
495
+ }
496
+
497
+ /**
498
+ * Update an issue's fields (state, title, labels, etc.).
499
+ * Does NOT require scoping — this is an administrative operation
500
+ * used by circuit breakers and abandon logic.
501
+ *
502
+ * @param {string} repo - "owner/name" repository.
503
+ * @param {number} number - Issue number.
504
+ * @param {object} fields - Fields to update (state, title, body, labels).
505
+ * @returns {Promise<{number: number, title: string, body: string, state: string, author: string, url: string, labels: string[], created: string, updated: string}>}
506
+ */
507
+ async update(repo, number, fields) {
508
+ log.debug(`update issue: ${repo}#${number}`);
509
+ const [owner, name] = this.parse(repo);
510
+ const { data } = await this.client.issues.update({
511
+ owner, repo: name, issue_number: number, ...fields
512
+ });
513
+ return normalize.issue(data);
514
+ }
515
+
516
+ /**
517
+ * Post a comment on an issue or PR. Requires scoping via as(identity, role).
518
+ * Auto-appends agent signature.
519
+ * Waits for the comment to be visible before returning.
520
+ *
521
+ * @param {string} repo - "owner/name" repository.
522
+ * @param {number} number - Issue or PR number.
523
+ * @param {string} body - Comment body text.
524
+ * @returns {Promise<{id: number, body: string, author: string, created: string}>}
525
+ * @throws {Error} When hub is not scoped via as().
526
+ */
527
+ async comment(repo, number, body) {
528
+ this._guard('comment');
529
+ const [owner, name] = this.parse(repo);
530
+ const { data } = await this.client.issues.createComment({
531
+ owner, repo: name, issue_number: number, body: this._stamp(body)
532
+ });
533
+
534
+ const result = normalize.comment(data);
535
+ const client = this.client;
536
+
537
+ // Verify the comment is visible in the comments list
538
+ await this._settle(async function visible() {
539
+ const { data: list } = await client.issues.listComments({
540
+ owner, repo: name, issue_number: number
541
+ });
542
+ return list.some(function match(c) { return c.id === data.id; });
543
+ }, `comment #${data.id} on issue #${number}`);
544
+
545
+ return result;
546
+ }
547
+
548
+ /**
549
+ * List all comments on an issue or PR.
550
+ *
551
+ * @param {string} repo - "owner/name" repository.
552
+ * @param {number} number - Issue or PR number.
553
+ * @returns {Promise<Array<{id: number, body: string, author: string, created: string}>>}
554
+ */
555
+ async comments(repo, number) {
556
+ const [owner, name] = this.parse(repo);
557
+ const data = await this.client.paginate(this.client.issues.listComments, {
558
+ owner, repo: name, issue_number: number, per_page: 100
559
+ });
560
+ return data.map(normalize.comment);
561
+ }
562
+
563
+ // --- Pull Requests ---
564
+
565
+ /**
566
+ * List pull requests for a repository.
567
+ *
568
+ * @param {string} repo - "owner/name" repository.
569
+ * @param {object} [opts] - Filter options.
570
+ * @param {string} [opts.state='open'] - PR state filter ('open', 'closed', 'all').
571
+ * @returns {Promise<Array<{number: number, title: string, body: string, state: string, head: string, base: string, author: string, url: string, labels: string[], merged: boolean, created: string, updated: string}>>}
572
+ */
573
+ async pulls(repo, opts = {}) {
574
+ const [owner, name] = this.parse(repo);
575
+ const data = await this.client.paginate(this.client.pulls.list, {
576
+ owner, repo: name,
577
+ state: opts.state ?? 'open',
578
+ per_page: 100
579
+ });
580
+ return data.map(normalize.pull);
581
+ }
582
+
583
+ /**
584
+ * Get a single pull request by number.
585
+ *
586
+ * @param {string} repo - "owner/name" repository.
587
+ * @param {number} number - Pull request number.
588
+ * @returns {Promise<{number: number, title: string, body: string, state: string, head: string, base: string, author: string, url: string, labels: string[], merged: boolean, created: string, updated: string}>}
589
+ */
590
+ async pull(repo, number) {
591
+ const [owner, name] = this.parse(repo);
592
+ const { data } = await this.client.pulls.get({ owner, repo: name, pull_number: number });
593
+ return normalize.pull(data);
594
+ }
595
+
596
+ /**
597
+ * Create a new pull request. Requires scoping via as(identity, role).
598
+ * Auto-appends signature and auto-applies identity labels.
599
+ * Waits for the PR to be indexed before returning.
600
+ *
601
+ * @param {string} repo - "owner/name" repository.
602
+ * @param {object} opts - PR options.
603
+ * @param {string} opts.title - PR title.
604
+ * @param {string} [opts.body] - PR description.
605
+ * @param {string} opts.head - Source branch containing changes.
606
+ * @param {string} opts.base - Target branch to merge into (resolved by caller from merge.base config).
607
+ * @param {string[]} [opts.labels] - Additional label names to apply.
608
+ * @returns {Promise<{number: number, title: string, body: string, state: string, head: string, base: string, author: string, url: string, labels: string[], merged: boolean, created: string, updated: string}>}
609
+ * @throws {Error} When hub is not scoped via as().
610
+ */
611
+ async propose(repo, opts) {
612
+ this._guard('propose');
613
+ log.debug(`propose PR: ${repo} head=${opts.head} base=${opts.base}`);
614
+ const [owner, name] = this.parse(repo);
615
+ const { data } = await this.client.pulls.create({
616
+ owner, repo: name,
617
+ title: opts.title,
618
+ body: this._stamp(opts.body ?? ''),
619
+ head: opts.head,
620
+ base: opts.base
621
+ });
622
+
623
+ // Apply identity + explicit labels to the PR (PRs are issues in GitHub)
624
+ const merged = this._labels(opts.labels);
625
+ if (merged?.length) {
626
+ await this.label(repo, data.number, merged);
627
+ }
628
+
629
+ const result = normalize.pull(data);
630
+ const client = this.client;
631
+
632
+ // Verify the PR is indexed by the list endpoint before returning
633
+ await this._settle(async function visible() {
634
+ const { data: list } = await client.pulls.list({
635
+ owner, repo: name, state: 'open', per_page: 100
636
+ });
637
+ return list.some(function match(p) { return p.number === result.number; });
638
+ }, `PR #${result.number}`);
639
+
640
+ return result;
641
+ }
642
+
643
+ /**
644
+ * Merge a pull request.
645
+ *
646
+ * @param {string} repo - "owner/name" repository.
647
+ * @param {number} number - Pull request number.
648
+ * @param {object} [opts] - Merge options.
649
+ * @param {string} [opts.method='squash'] - Merge method ('merge', 'squash', 'rebase').
650
+ * @returns {Promise<void>}
651
+ */
652
+ async merge(repo, number, opts = {}) {
653
+ const [owner, name] = this.parse(repo);
654
+ await this.client.pulls.merge({
655
+ owner, repo: name, pull_number: number,
656
+ merge_method: opts.method ?? 'squash'
657
+ });
658
+ }
659
+
660
+ /**
661
+ * Close a pull request without merging.
662
+ *
663
+ * @param {string} repo - "owner/name" repository.
664
+ * @param {number} number - Pull request number.
665
+ * @returns {Promise<void>}
666
+ */
667
+ async closePull(repo, number) {
668
+ const [owner, name] = this.parse(repo);
669
+ await this.client.pulls.update({ owner, repo: name, pull_number: number, state: 'closed' });
670
+ }
671
+
672
+ /**
673
+ * List files changed in a pull request.
674
+ *
675
+ * @param {string} repo - "owner/name" repository.
676
+ * @param {number} number - Pull request number.
677
+ * @returns {Promise<Array<{filename: string, status: string, additions: number, deletions: number}>>}
678
+ */
679
+ async files(repo, number) {
680
+ const [owner, name] = this.parse(repo);
681
+ const data = await this.client.paginate(this.client.pulls.listFiles, {
682
+ owner, repo: name, pull_number: number, per_page: 100
683
+ });
684
+ return data.map(function normalizeFile(f) {
685
+ return { filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, patch: f.patch };
686
+ });
687
+ }
688
+
689
+ /**
690
+ * Fetch the unified diff for a pull request.
691
+ * Uses GitHub's content negotiation to return the diff as a string.
692
+ * Truncates to maxBytes to avoid blowing up LLM context windows.
693
+ *
694
+ * @param {string} repo - Repository in "owner/name" format.
695
+ * @param {number} number - Pull request number.
696
+ * @param {number} [maxBytes=102400] - Maximum diff size in bytes (default 100KB).
697
+ * @returns {Promise<string>} Unified diff content.
698
+ */
699
+ async diff(repo, number, maxBytes = 102400) {
700
+ const [owner, name] = this.parse(repo);
701
+ const { data } = await this.client.pulls.get({
702
+ owner, repo: name, pull_number: number,
703
+ mediaType: { format: 'diff' }
704
+ });
705
+ if (typeof data === 'string' && data.length > maxBytes) {
706
+ return data.slice(0, maxBytes) + '\n\n... [diff truncated at ' + maxBytes + ' bytes]';
707
+ }
708
+ return data;
709
+ }
710
+
711
+ // --- Reviews ---
712
+
713
+ /**
714
+ * Create a review on a pull request. Requires scoping via as(identity, role).
715
+ * Auto-appends agent signature to the review body.
716
+ *
717
+ * When the reviewer and PR author share the same token, GitHub rejects
718
+ * APPROVE and REQUEST_CHANGES with a 422. This method catches that
719
+ * error and falls back to a COMMENT review with a hidden HTML marker
720
+ * encoding the intended event. The marker is parsed by reviews() so
721
+ * downstream consumers (land, forward) see the correct state.
722
+ *
723
+ * @param {string} repo - "owner/name" repository.
724
+ * @param {number} number - Pull request number.
725
+ * @param {object} opts - Review options.
726
+ * @param {string} [opts.body] - Review body text.
727
+ * @param {string} [opts.event='COMMENT'] - Review event ('APPROVE', 'REQUEST_CHANGES', 'COMMENT').
728
+ * @param {Array<{path: string, body: string, line: number}>} [opts.comments] - Inline review comments.
729
+ * @returns {Promise<{id: number, body: string, state: string, author: string, submitted: string}>}
730
+ * @throws {Error} When hub is not scoped via as().
731
+ */
732
+ async review(repo, number, opts) {
733
+ this._guard('review');
734
+ const [owner, name] = this.parse(repo);
735
+ const event = opts.event ?? 'COMMENT';
736
+ const body = this._stamp(opts.body ?? '');
737
+
738
+ try {
739
+ const { data } = await this.client.pulls.createReview({
740
+ owner, repo: name, pull_number: number,
741
+ body, event, comments: opts.comments
742
+ });
743
+ return normalize.review(data);
744
+ } catch (err) {
745
+ if (!isSelfReview(err, event)) throw err;
746
+
747
+ // GitHub blocks formal verdicts on your own PR. Fall back to
748
+ // COMMENT with a machine-readable marker so reviews() can
749
+ // reconstruct the intended state for downstream consumers.
750
+ log.info(`review: self-review detected on PR #${number}, falling back to COMMENT with marker`);
751
+ const marker = mark('review-event', { event });
752
+ const { data } = await this.client.pulls.createReview({
753
+ owner, repo: name, pull_number: number,
754
+ body: `${marker}\n${body}`,
755
+ event: 'COMMENT',
756
+ comments: opts.comments
757
+ });
758
+ const result = normalize.review(data);
759
+ result.state = EVENT_TO_STATE[event] ?? result.state;
760
+ return result;
761
+ }
762
+ }
763
+
764
+ /**
765
+ * List all reviews on a pull request. Post-processes COMMENTED
766
+ * reviews that contain the Loreli self-review marker, patching
767
+ * their state to the intended verdict.
768
+ *
769
+ * @param {string} repo - "owner/name" repository.
770
+ * @param {number} number - Pull request number.
771
+ * @returns {Promise<Array<{id: number, body: string, state: string, author: string, submitted: string}>>}
772
+ */
773
+ async reviews(repo, number) {
774
+ const [owner, name] = this.parse(repo);
775
+ const data = await this.client.paginate(this.client.pulls.listReviews, {
776
+ owner, repo: name, pull_number: number, per_page: 100
777
+ });
778
+ return data.map(normalize.review).map(patchMarker);
779
+ }
780
+
781
+ /**
782
+ * Post an inline review comment on a specific file line in a pull request.
783
+ *
784
+ * **Reserved for future line-level review support.** Currently unused in
785
+ * production — the review workflow sends full-PR reviews via `review()`.
786
+ * Will be wired when the review workflow adds inline annotation support.
787
+ *
788
+ * @param {string} repo - "owner/name" repository.
789
+ * @param {number} number - Pull request number.
790
+ * @param {object} opts - Annotation options.
791
+ * @param {string} opts.body - Comment text.
792
+ * @param {string} opts.path - Relative file path.
793
+ * @param {number} opts.line - Line number in the diff.
794
+ * @param {string} opts.commit - Commit SHA to attach the comment to.
795
+ * @returns {Promise<{id: number, body: string, author: string, created: string}>}
796
+ */
797
+ async annotate(repo, number, opts) {
798
+ const [owner, name] = this.parse(repo);
799
+ const { data } = await this.client.pulls.createReviewComment({
800
+ owner, repo: name, pull_number: number,
801
+ body: opts.body,
802
+ path: opts.path,
803
+ line: opts.line,
804
+ commit_id: opts.commit
805
+ });
806
+ return normalize.comment(data);
807
+ }
808
+
809
+ // --- Contents ---
810
+
811
+ /**
812
+ * Read a file or directory from a repository.
813
+ * Returns a single file object with decoded content, or an array
814
+ * of directory entries when the path is a directory.
815
+ *
816
+ * @param {string} repo - "owner/name" repository.
817
+ * @param {string} path - File or directory path within the repo.
818
+ * @param {object} [opts] - Read options.
819
+ * @param {string} [opts.ref] - Git ref (branch, tag, or SHA) to read from.
820
+ * @returns {Promise<{name: string, path: string, type: string, sha: string, content: string}|Array<{name: string, path: string, type: string, sha: string}>>}
821
+ */
822
+ async read(repo, path, opts = {}) {
823
+ const [owner, name] = this.parse(repo);
824
+ const { data } = await this.client.repos.getContent({
825
+ owner, repo: name, path, ref: opts.ref
826
+ });
827
+
828
+ if (Array.isArray(data)) {
829
+ return data.map(function normalizeEntry(e) {
830
+ return { name: e.name, path: e.path, type: e.type, sha: e.sha };
831
+ });
832
+ }
833
+
834
+ // Single file: decode content from base64
835
+ const content = data.content ? Buffer.from(data.content, 'base64').toString('utf8') : '';
836
+ return { name: data.name, path: data.path, type: data.type, sha: data.sha, content };
837
+ }
838
+
839
+ /**
840
+ * Create or update a file in a repository.
841
+ * Automatically detects whether the file exists (for SHA-based updates).
842
+ * Waits for the write to be readable before returning.
843
+ *
844
+ * @param {string} repo - "owner/name" repository.
845
+ * @param {string} path - File path within the repo.
846
+ * @param {object} opts - Write options.
847
+ * @param {string} opts.content - File content (UTF-8 string, base64-encoded internally).
848
+ * @param {string} [opts.message] - Commit message (defaults to "Update {path}").
849
+ * @param {string} [opts.branch] - Target branch.
850
+ * @returns {Promise<{path: string, sha: string}>}
851
+ */
852
+ async write(repo, path, opts) {
853
+ const [owner, name] = this.parse(repo);
854
+ const encoded = Buffer.from(opts.content).toString('base64');
855
+
856
+ // Check if file exists to get its SHA for updates
857
+ let sha;
858
+ try {
859
+ const existing = await this.read(repo, path, { ref: opts.branch });
860
+ sha = existing.sha;
861
+ } catch { /* file doesn't exist yet */ }
862
+
863
+ const { data } = await this.client.repos.createOrUpdateFileContents({
864
+ owner, repo: name, path,
865
+ message: opts.message ?? `Update ${path}`,
866
+ content: encoded,
867
+ branch: opts.branch,
868
+ sha
869
+ });
870
+
871
+ const result = { path: data.content.path, sha: data.content.sha };
872
+ const self = this;
873
+
874
+ // Verify the file is readable at its new SHA before returning
875
+ await this._settle(async function readable() {
876
+ const file = await self.read(repo, path, { ref: opts.branch });
877
+ return file.sha === result.sha;
878
+ }, `file ${path}`);
879
+
880
+ return result;
881
+ }
882
+
883
+ /**
884
+ * List contents of a directory in a repository.
885
+ *
886
+ * @param {string} repo - "owner/name" repository.
887
+ * @param {object} [opts] - Tree options.
888
+ * @param {string} [opts.path=''] - Directory path within the repo.
889
+ * @returns {Promise<Array<{name: string, path: string, type: string, sha: string}>>}
890
+ */
891
+ async tree(repo, opts = {}) {
892
+ const [owner, name] = this.parse(repo);
893
+ const { data } = await this.client.repos.getContent({
894
+ owner, repo: name, path: opts.path ?? ''
895
+ });
896
+ return (Array.isArray(data) ? data : [data]).map(function normalizeEntry(e) {
897
+ return { name: e.name, path: e.path, type: e.type, sha: e.sha };
898
+ });
899
+ }
900
+
901
+ // --- Repository ---
902
+
903
+ /**
904
+ * Get repository metadata.
905
+ *
906
+ * @param {string} repo - "owner/name" repository.
907
+ * @returns {Promise<{name: string, description: string, default_branch: string, private: boolean, url: string}>}
908
+ */
909
+ async repo(repo) {
910
+ const [owner, name] = this.parse(repo);
911
+ const { data } = await this.client.repos.get({ owner, repo: name });
912
+ return {
913
+ name: data.full_name,
914
+ description: data.description ?? '',
915
+ default_branch: data.default_branch,
916
+ private: data.private,
917
+ url: data.html_url
918
+ };
919
+ }
920
+
921
+ /**
922
+ * Get branch metadata.
923
+ *
924
+ * @param {string} repo - "owner/name" repository.
925
+ * @param {string} name - Branch name.
926
+ * @returns {Promise<{name: string, sha: string, protected: boolean}>}
927
+ */
928
+ async branch(repo, name) {
929
+ const [owner, repoName] = this.parse(repo);
930
+ const { data } = await this.client.repos.getBranch({ owner, repo: repoName, branch: name });
931
+ return { name: data.name, sha: data.commit.sha, protected: data.protected };
932
+ }
933
+
934
+ /**
935
+ * Create a new branch from an existing branch or the default branch.
936
+ * Waits for the branch to be visible via the API before returning.
937
+ *
938
+ * @param {string} repo - "owner/name" repository.
939
+ * @param {object} opts - Fork options.
940
+ * @param {string} opts.name - New branch name.
941
+ * @param {string} [opts.from] - Source branch name (defaults to repo's default branch).
942
+ * @returns {Promise<{name: string, sha: string}>}
943
+ */
944
+ async fork(repo, opts) {
945
+ const [owner, name] = this.parse(repo);
946
+ const sha = opts.from
947
+ ? (await this.branch(repo, opts.from)).sha
948
+ : (await this.branch(repo, (await this.repo(repo)).default_branch)).sha;
949
+
950
+ await this.client.git.createRef({
951
+ owner, repo: name,
952
+ ref: `refs/heads/${opts.name}`,
953
+ sha
954
+ });
955
+
956
+ const self = this;
957
+ const branchName = opts.name;
958
+
959
+ // Verify the branch is visible via the branches endpoint
960
+ await this._settle(async function visible() {
961
+ return self.branch(repo, branchName);
962
+ }, `branch ${branchName}`);
963
+
964
+ return { name: opts.name, sha };
965
+ }
966
+
967
+ // --- Labels ---
968
+
969
+ /**
970
+ * Add labels to an issue or pull request.
971
+ *
972
+ * @param {string} repo - "owner/name" repository.
973
+ * @param {number} number - Issue or PR number.
974
+ * @param {string[]} labels - Label names to add.
975
+ * @returns {Promise<void>}
976
+ */
977
+ async label(repo, number, labels) {
978
+ const [owner, name] = this.parse(repo);
979
+ await this.client.issues.addLabels({
980
+ owner, repo: name, issue_number: number, labels
981
+ });
982
+ }
983
+
984
+ /**
985
+ * Remove a label from an issue or pull request.
986
+ * Silently succeeds if the label is not present (404 is swallowed).
987
+ *
988
+ * @param {string} repo - "owner/name" repository.
989
+ * @param {number} number - Issue or PR number.
990
+ * @param {string} name - Label name to remove.
991
+ * @returns {Promise<void>}
992
+ */
993
+ async unlabel(repo, number, name) {
994
+ const [owner, repoName] = this.parse(repo);
995
+ try {
996
+ await this.client.issues.removeLabel({
997
+ owner, repo: repoName, issue_number: number, name
998
+ });
999
+ } catch (err) {
1000
+ // 404 = label not present — idempotent removal
1001
+ if (err.status !== 404) throw err;
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * List all labels in a repository.
1007
+ *
1008
+ * @param {string} repo - "owner/name" repository.
1009
+ * @returns {Promise<Array<{name: string, color: string, description: string}>>}
1010
+ */
1011
+ async labels(repo) {
1012
+ const [owner, name] = this.parse(repo);
1013
+ const { data } = await this.client.issues.listLabelsForRepo({
1014
+ owner, repo: name, per_page: 100
1015
+ });
1016
+ return data.map(function norm(l) {
1017
+ return { name: l.name, color: l.color, description: l.description ?? '' };
1018
+ });
1019
+ }
1020
+
1021
+ /**
1022
+ * Ensure a set of labels exist in a repository, creating any that are missing.
1023
+ * Idempotent — safe to call repeatedly with the same labels.
1024
+ *
1025
+ * @param {string} repo - "owner/name" repository.
1026
+ * @param {Array<{name: string, color: string, description?: string}>} required - Labels to ensure.
1027
+ * @returns {Promise<string[]>} Names of labels that were created.
1028
+ */
1029
+ async ensure(repo, required) {
1030
+ log.debug(`ensure labels: ${repo} (${required.length} required)`);
1031
+ const existing = await this.labels(repo);
1032
+ const names = new Set(existing.map(function name(l) { return l.name; }));
1033
+ const [owner, repoName] = this.parse(repo);
1034
+ const created = [];
1035
+
1036
+ for (const entry of required) {
1037
+ if (!names.has(entry.name)) {
1038
+ try {
1039
+ await this.client.issues.createLabel({
1040
+ owner, repo: repoName,
1041
+ name: entry.name,
1042
+ color: (entry.color ?? 'ededed').replace('#', ''),
1043
+ description: entry.description ?? ''
1044
+ });
1045
+ created.push(entry.name);
1046
+ } catch (err) {
1047
+ // Race condition: label was created between list and create calls
1048
+ const isConflict = err.status === 422 &&
1049
+ JSON.stringify(err.response?.data).includes('already_exists');
1050
+ if (!isConflict) throw err;
1051
+ log.debug(`ensure: label "${entry.name}" already exists (race)`);
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ return created;
1057
+ }
1058
+
1059
+ // --- Discussions (GraphQL) ---
1060
+
1061
+ /**
1062
+ * Find a discussion category by name in a repository.
1063
+ *
1064
+ * @param {string} repo - "owner/name" repository.
1065
+ * @param {string} name - Category name (e.g. 'Loreli').
1066
+ * @returns {Promise<{id: string, name: string, isAnswerable: boolean}>}
1067
+ * @throws {Error} When the category is not found.
1068
+ */
1069
+ async category(repo, name) {
1070
+ const [owner, repoName] = this.parse(repo);
1071
+ const query = `query($owner: String!, $name: String!) {
1072
+ repository(owner: $owner, name: $name) {
1073
+ id
1074
+ discussionCategories(first: 25) {
1075
+ nodes { id name isAnswerable }
1076
+ }
1077
+ }
1078
+ }`;
1079
+
1080
+ const result = await this.client.graphql(query, { owner, name: repoName });
1081
+ const cats = result.repository?.discussionCategories?.nodes ?? [];
1082
+ const match = cats.find(function byName(c) { return c.name === name; });
1083
+ if (!match) {
1084
+ throw new Error(
1085
+ `Discussion category "${name}" not found in ${repo}. ` +
1086
+ 'Enable GitHub Discussions and create the category in repo settings.'
1087
+ );
1088
+ }
1089
+ return { id: match.id, name: match.name, isAnswerable: match.isAnswerable, repositoryId: result.repository.id };
1090
+ }
1091
+
1092
+ /**
1093
+ * Create a discussion in a repository. Requires scoping via as(identity, role).
1094
+ * Auto-appends agent signature to the body.
1095
+ *
1096
+ * @param {string} repo - "owner/name" repository.
1097
+ * @param {object} opts - Discussion options.
1098
+ * @param {string} opts.title - Discussion title.
1099
+ * @param {string} [opts.body] - Discussion body.
1100
+ * @param {string} opts.categoryId - Discussion category node ID.
1101
+ * @param {string} opts.repositoryId - Repository node ID.
1102
+ * @param {string[]} [opts.labels] - Label names to apply.
1103
+ * @returns {Promise<{id: string, number: number, title: string, url: string}>}
1104
+ * @throws {Error} When hub is not scoped via as().
1105
+ */
1106
+ async discuss(repo, opts) {
1107
+ this._guard('discuss');
1108
+ log.debug(`discuss: ${repo} title="${opts.title}"`);
1109
+
1110
+ const mutation = `mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
1111
+ createDiscussion(input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }) {
1112
+ discussion { id number title url }
1113
+ }
1114
+ }`;
1115
+
1116
+ const result = await this.client.graphql(mutation, {
1117
+ repositoryId: opts.repositoryId,
1118
+ categoryId: opts.categoryId,
1119
+ title: opts.title,
1120
+ body: this._stamp(opts.body ?? '')
1121
+ });
1122
+
1123
+ const d = result.createDiscussion.discussion;
1124
+ const created = { id: d.id, number: d.number, title: d.title, url: d.url };
1125
+
1126
+ // Apply labels if provided
1127
+ if (opts.labels?.length) {
1128
+ await this.applyDiscussionLabels(repo, created.id, opts.labels);
1129
+ }
1130
+
1131
+ // Verify the discussion is visible via direct node lookup before returning.
1132
+ // Uses node() instead of the list endpoint — list queries have severe
1133
+ // eventual consistency under high API load.
1134
+ const client = this.client;
1135
+ await this._settle(async function visible() {
1136
+ const check = await client.graphql(`query($id: ID!) { node(id: $id) { id } }`, { id: created.id });
1137
+ return Boolean(check.node);
1138
+ }, `discussion #${created.number}`);
1139
+
1140
+ return created;
1141
+ }
1142
+
1143
+ /**
1144
+ * List open discussions in a category.
1145
+ * Includes labels for each discussion.
1146
+ *
1147
+ * @param {string} repo - "owner/name" repository.
1148
+ * @param {string} categoryId - Discussion category node ID.
1149
+ * @returns {Promise<Array<{id: string, number: number, title: string, body: string, author: string, url: string, labels: string[], closed: boolean}>>}
1150
+ */
1151
+ async discussions(repo, categoryId) {
1152
+ const [owner, repoName] = this.parse(repo);
1153
+ const query = `query($owner: String!, $name: String!, $categoryId: ID!) {
1154
+ repository(owner: $owner, name: $name) {
1155
+ discussions(first: 100, categoryId: $categoryId) {
1156
+ nodes {
1157
+ id number title body url closed
1158
+ author { login }
1159
+ labels(first: 20) { nodes { name } }
1160
+ }
1161
+ }
1162
+ }
1163
+ }`;
1164
+
1165
+ const result = await this.client.graphql(query, { owner, name: repoName, categoryId });
1166
+ const nodes = result.repository?.discussions?.nodes ?? [];
1167
+ return nodes.map(function norm(d) {
1168
+ return {
1169
+ id: d.id,
1170
+ number: d.number,
1171
+ title: d.title,
1172
+ body: d.body ?? '',
1173
+ author: d.author?.login ?? '',
1174
+ url: d.url,
1175
+ labels: (d.labels?.nodes ?? []).map(function n(l) { return l.name; }),
1176
+ closed: d.closed ?? false
1177
+ };
1178
+ });
1179
+ }
1180
+
1181
+ /**
1182
+ * Get a single discussion by number, including comments and labels.
1183
+ *
1184
+ * @param {string} repo - "owner/name" repository.
1185
+ * @param {number} number - Discussion number.
1186
+ * @returns {Promise<{id: string, number: number, title: string, body: string, author: string, url: string, labels: string[], closed: boolean, comments: Array<{id: string, body: string, author: string, created: string}>}>}
1187
+ */
1188
+ async discussion(repo, number) {
1189
+ const [owner, repoName] = this.parse(repo);
1190
+ const query = `query($owner: String!, $name: String!, $number: Int!) {
1191
+ repository(owner: $owner, name: $name) {
1192
+ discussion(number: $number) {
1193
+ id number title body url closed
1194
+ author { login }
1195
+ labels(first: 20) { nodes { name } }
1196
+ comments(first: 100) {
1197
+ nodes {
1198
+ id body createdAt
1199
+ author { login }
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+ }`;
1205
+
1206
+ const result = await this.client.graphql(query, { owner, name: repoName, number });
1207
+ const d = result.repository?.discussion;
1208
+ if (!d) throw new Error(`Discussion #${number} not found in ${repo}`);
1209
+ return {
1210
+ id: d.id,
1211
+ number: d.number,
1212
+ title: d.title,
1213
+ body: d.body ?? '',
1214
+ author: d.author?.login ?? '',
1215
+ url: d.url,
1216
+ labels: (d.labels?.nodes ?? []).map(function n(l) { return l.name; }),
1217
+ closed: d.closed ?? false,
1218
+ comments: (d.comments?.nodes ?? []).map(function normComment(c) {
1219
+ return { id: c.id, body: c.body ?? '', author: c.author?.login ?? '', created: c.createdAt };
1220
+ })
1221
+ };
1222
+ }
1223
+
1224
+ /**
1225
+ * List comments on a discussion.
1226
+ *
1227
+ * @param {string} discussionId - Discussion node ID.
1228
+ * @returns {Promise<Array<{id: string, body: string, author: string, created: string}>>}
1229
+ */
1230
+ async discussionComments(discussionId) {
1231
+ const query = `query($id: ID!) {
1232
+ node(id: $id) {
1233
+ ... on Discussion {
1234
+ comments(first: 100) {
1235
+ nodes {
1236
+ id body createdAt
1237
+ author { login }
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ }`;
1243
+
1244
+ const result = await this.client.graphql(query, { id: discussionId });
1245
+ const nodes = result.node?.comments?.nodes ?? [];
1246
+ return nodes.map(function norm(c) {
1247
+ return { id: c.id, body: c.body ?? '', author: c.author?.login ?? '', created: c.createdAt };
1248
+ });
1249
+ }
1250
+
1251
+ /**
1252
+ * Post a comment on a discussion. Requires scoping via as(identity, role).
1253
+ * Auto-appends agent signature to the body.
1254
+ *
1255
+ * @param {string} discussionId - Discussion node ID.
1256
+ * @param {string} body - Comment body text.
1257
+ * @returns {Promise<{id: string, body: string}>}
1258
+ * @throws {Error} When hub is not scoped via as().
1259
+ */
1260
+ async discussionComment(discussionId, body) {
1261
+ this._guard('discussionComment');
1262
+ const mutation = `mutation($discussionId: ID!, $body: String!) {
1263
+ addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
1264
+ comment { id body }
1265
+ }
1266
+ }`;
1267
+
1268
+ const result = await this.client.graphql(mutation, {
1269
+ discussionId,
1270
+ body: this._stamp(body)
1271
+ });
1272
+
1273
+ return {
1274
+ id: result.addDiscussionComment.comment.id,
1275
+ body: result.addDiscussionComment.comment.body
1276
+ };
1277
+ }
1278
+
1279
+ /**
1280
+ * Update a discussion's title and/or body.
1281
+ * Requires scoping via as(identity, role) when updating the body —
1282
+ * auto-appends agent signature.
1283
+ *
1284
+ * @param {string} discussionId - Discussion node ID.
1285
+ * @param {object} opts - Update options.
1286
+ * @param {string} [opts.title] - New title.
1287
+ * @param {string} [opts.body] - New body (will be stamped).
1288
+ * @returns {Promise<{id: string, title: string}>}
1289
+ */
1290
+ async updateDiscussion(discussionId, opts) {
1291
+ if (opts.body !== undefined) this._guard('updateDiscussion');
1292
+
1293
+ const input = { discussionId };
1294
+ if (opts.title !== undefined) input.title = opts.title;
1295
+ if (opts.body !== undefined) input.body = this._stamp(opts.body);
1296
+
1297
+ const mutation = `mutation($discussionId: ID!, $title: String, $body: String) {
1298
+ updateDiscussion(input: { discussionId: $discussionId, title: $title, body: $body }) {
1299
+ discussion { id title }
1300
+ }
1301
+ }`;
1302
+
1303
+ const result = await this.client.graphql(mutation, input);
1304
+ return {
1305
+ id: result.updateDiscussion.discussion.id,
1306
+ title: result.updateDiscussion.discussion.title
1307
+ };
1308
+ }
1309
+
1310
+ /**
1311
+ * Close and lock a discussion. Posts an optional closing comment first.
1312
+ *
1313
+ * @param {string} discussionId - Discussion node ID.
1314
+ * @returns {Promise<void>}
1315
+ */
1316
+ async closeDiscussion(discussionId) {
1317
+ const closeMutation = `mutation($id: ID!) {
1318
+ closeDiscussion(input: { discussionId: $id }) {
1319
+ discussion { id }
1320
+ }
1321
+ }`;
1322
+ await this.client.graphql(closeMutation, { id: discussionId });
1323
+
1324
+ const lockMutation = `mutation($id: ID!) {
1325
+ lockLockable(input: { lockableId: $id }) {
1326
+ lockedRecord { locked }
1327
+ }
1328
+ }`;
1329
+ await this.client.graphql(lockMutation, { id: discussionId });
1330
+ }
1331
+
1332
+ /**
1333
+ * Delete a discussion entirely.
1334
+ *
1335
+ * @param {string} discussionId - Discussion node ID.
1336
+ * @returns {Promise<void>}
1337
+ */
1338
+ async deleteDiscussion(discussionId) {
1339
+ const mutation = `mutation($id: ID!) {
1340
+ deleteDiscussion(input: { id: $id }) {
1341
+ discussion { id }
1342
+ }
1343
+ }`;
1344
+ const result = await this.client.graphql(mutation, { id: discussionId });
1345
+ const deletedId = result.deleteDiscussion?.discussion?.id;
1346
+
1347
+ // Verify the discussion is no longer fetchable by node ID
1348
+ const self = this;
1349
+ await this._settle(async function gone() {
1350
+ try {
1351
+ const check = await self.client.graphql(`query($id: ID!) { node(id: $id) { id } }`, { id: deletedId ?? discussionId });
1352
+ return !check.node;
1353
+ } catch {
1354
+ // GraphQL error means the node is gone
1355
+ return true;
1356
+ }
1357
+ }, `deleteDiscussion(${discussionId})`);
1358
+ }
1359
+
1360
+ /**
1361
+ * Apply labels to a discussion using the Labelable interface.
1362
+ * Auto-creates any labels that do not yet exist in the repository
1363
+ * via the REST API before applying them via GraphQL.
1364
+ *
1365
+ * @param {string} repo - "owner/name" repository.
1366
+ * @param {string} discussionId - Discussion node ID (Labelable).
1367
+ * @param {string[]} labelNames - Label names to apply.
1368
+ * @returns {Promise<void>}
1369
+ */
1370
+ async applyDiscussionLabels(repo, discussionId, labelNames) {
1371
+ if (!labelNames?.length) return;
1372
+
1373
+ const [owner, repoName] = this.parse(repo);
1374
+
1375
+ // Auto-create missing labels so the resolve never silently drops them
1376
+ await this.ensure(repo, labelNames.map(function def(n) {
1377
+ return { name: n, color: 'ededed' };
1378
+ }));
1379
+
1380
+ // Resolve label names to node IDs via REST — REST reads are immediately
1381
+ // consistent after REST writes, unlike GraphQL which may lag.
1382
+ const { data: restLabels } = await this.client.issues.listLabelsForRepo({
1383
+ owner, repo: repoName, per_page: 100
1384
+ });
1385
+
1386
+ const labelIds = labelNames
1387
+ .map(function resolve(n) { return restLabels.find(function m(l) { return l.name === n; }); })
1388
+ .filter(Boolean)
1389
+ .map(function id(l) { return l.node_id; });
1390
+
1391
+ if (!labelIds.length) {
1392
+ log.warn(`applyDiscussionLabels: none of [${labelNames}] resolved after ensure — skipping`);
1393
+ return;
1394
+ }
1395
+
1396
+ const mutation = `mutation($labelableId: ID!, $labelIds: [ID!]!) {
1397
+ addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) {
1398
+ labelable { ... on Discussion { id } }
1399
+ }
1400
+ }`;
1401
+ await this.client.graphql(mutation, { labelableId: discussionId, labelIds });
1402
+ }
1403
+
1404
+ /**
1405
+ * Remove labels from a discussion using the Labelable interface.
1406
+ *
1407
+ * @param {string} repo - "owner/name" repository.
1408
+ * @param {string} discussionId - Discussion node ID (Labelable).
1409
+ * @param {string[]} labelNames - Label names to remove.
1410
+ * @returns {Promise<void>}
1411
+ */
1412
+ async removeDiscussionLabels(repo, discussionId, labelNames) {
1413
+ if (!labelNames?.length) return;
1414
+
1415
+ const [owner, repoName] = this.parse(repo);
1416
+
1417
+ // Resolve via REST for immediate consistency with prior writes
1418
+ const { data: restLabels } = await this.client.issues.listLabelsForRepo({
1419
+ owner, repo: repoName, per_page: 100
1420
+ });
1421
+
1422
+ const labelIds = labelNames
1423
+ .map(function resolve(n) { return restLabels.find(function m(l) { return l.name === n; }); })
1424
+ .filter(Boolean)
1425
+ .map(function id(l) { return l.node_id; });
1426
+
1427
+ if (!labelIds.length) return;
1428
+
1429
+ const mutation = `mutation($labelableId: ID!, $labelIds: [ID!]!) {
1430
+ removeLabelsFromLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) {
1431
+ labelable { ... on Discussion { id } }
1432
+ }
1433
+ }`;
1434
+ await this.client.graphql(mutation, { labelableId: discussionId, labelIds });
1435
+ }
1436
+
1437
+ // --- Search & Commits ---
1438
+
1439
+ /**
1440
+ * List pull requests associated with a commit SHA.
1441
+ *
1442
+ * @param {string} repo - "owner/name" repository.
1443
+ * @param {string} sha - Full commit SHA.
1444
+ * @returns {Promise<Array<{number: number, title: string, state: string}>>}
1445
+ * @see https://docs.github.com/en/rest/commits/commits#list-pull-requests-associated-with-a-commit
1446
+ */
1447
+ async associatedPulls(repo, sha) {
1448
+ const [owner, name] = this.parse(repo);
1449
+ log.debug(`associatedPulls: ${sha.slice(0, 7)} in ${repo}`);
1450
+ const { data } = await this.client.repos.listPullRequestsAssociatedWithCommit({
1451
+ owner, repo: name, commit_sha: sha, per_page: 10
1452
+ });
1453
+ return data.map(function brief(pr) {
1454
+ return { number: pr.number, title: pr.title, state: pr.state };
1455
+ });
1456
+ }
1457
+
1458
+ /**
1459
+ * Search issues and pull requests in a repository.
1460
+ * Uses GitHub Search API scoped to the repo.
1461
+ *
1462
+ * @param {string} repo - "owner/name" repository.
1463
+ * @param {string} query - Search keywords.
1464
+ * @returns {Promise<Array<{number: number, title: string, type: string, url: string}>>}
1465
+ * @see https://docs.github.com/en/rest/search/search#search-issues-and-pull-requests
1466
+ */
1467
+ async searchIssues(repo, query) {
1468
+ const [owner, name] = this.parse(repo);
1469
+ const q = `${query} repo:${owner}/${name}`;
1470
+ log.debug(`searchIssues: "${query}" in ${repo}`);
1471
+ const { data } = await this.client.request('GET /search/issues', {
1472
+ q, per_page: 20, sort: 'updated', order: 'desc'
1473
+ });
1474
+ return data.items.map(function brief(item) {
1475
+ return {
1476
+ number: item.number,
1477
+ title: item.title,
1478
+ type: item.pull_request ? 'pr' : 'issue',
1479
+ url: item.html_url
1480
+ };
1481
+ });
1482
+ }
1483
+
1484
+ // --- Sub-Issues ---
1485
+
1486
+ /**
1487
+ * Add an existing issue as a sub-issue of a parent issue.
1488
+ * Uses the GitHub sub-issues REST API.
1489
+ *
1490
+ * @param {string} repo - "owner/name" repository.
1491
+ * @param {number} parent - Parent issue number.
1492
+ * @param {number} childId - Database ID (`id`, not `number`) of the child issue.
1493
+ * @returns {Promise<{id: number, number: number, title: string}>} The linked sub-issue.
1494
+ * @see https://docs.github.com/en/rest/issues/sub-issues
1495
+ */
1496
+ async sub(repo, parent, childId) {
1497
+ const [owner, name] = this.parse(repo);
1498
+ log.debug(`sub: linking child id=${childId} to parent #${parent} in ${repo}`);
1499
+ const { data } = await this.client.request(
1500
+ 'POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
1501
+ { owner, repo: name, issue_number: parent, sub_issue_id: childId }
1502
+ );
1503
+ return { id: data.id, number: data.number, title: data.title };
1504
+ }
1505
+
1506
+ /**
1507
+ * List sub-issues of a parent issue.
1508
+ * Uses the GitHub sub-issues REST API.
1509
+ *
1510
+ * @param {string} repo - "owner/name" repository.
1511
+ * @param {number} number - Parent issue number.
1512
+ * @returns {Promise<Array<{id: number, number: number, title: string, state: string}>>}
1513
+ * @see https://docs.github.com/en/rest/issues/sub-issues
1514
+ */
1515
+ async subs(repo, number) {
1516
+ const [owner, name] = this.parse(repo);
1517
+ const { data } = await this.client.request(
1518
+ 'GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
1519
+ { owner, repo: name, issue_number: number, per_page: 100 }
1520
+ );
1521
+ return data.map(function brief(i) {
1522
+ return { id: i.id, number: i.number, title: i.title, state: i.state };
1523
+ });
1524
+ }
1525
+
1526
+ // --- Human In The Loop (HITL) ---
1527
+
1528
+ /**
1529
+ * Assign users to an issue or PR.
1530
+ *
1531
+ * @param {string} repo - "owner/name" repository.
1532
+ * @param {number} number - Issue or PR number.
1533
+ * @param {string[]} usernames - GitHub usernames to assign.
1534
+ * @returns {Promise<void>}
1535
+ */
1536
+ async assign(repo, number, usernames) {
1537
+ const [owner, name] = this.parse(repo);
1538
+ await this.client.issues.addAssignees({
1539
+ owner, repo: name, issue_number: number, assignees: usernames
1540
+ });
1541
+ }
1542
+
1543
+ /**
1544
+ * Request reviews from users on a PR.
1545
+ *
1546
+ * @param {string} repo - "owner/name" repository.
1547
+ * @param {number} number - PR number.
1548
+ * @param {string[]} usernames - GitHub usernames to request review from.
1549
+ * @returns {Promise<void>}
1550
+ */
1551
+ async request(repo, number, usernames) {
1552
+ const [owner, name] = this.parse(repo);
1553
+ await this.client.pulls.requestReviewers({
1554
+ owner, repo: name, pull_number: number, reviewers: usernames
1555
+ });
1556
+ }
1557
+
1558
+ }