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