@generacy-ai/orchestrator 0.7.0 → 0.9.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 (145) hide show
  1. package/dist/config/loader.d.ts.map +1 -1
  2. package/dist/config/loader.js +47 -1
  3. package/dist/config/loader.js.map +1 -1
  4. package/dist/config/schema.d.ts +459 -0
  5. package/dist/config/schema.d.ts.map +1 -1
  6. package/dist/config/schema.js +13 -1
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/launcher/agent-launcher.d.ts +15 -16
  13. package/dist/launcher/agent-launcher.d.ts.map +1 -1
  14. package/dist/launcher/agent-launcher.js +54 -23
  15. package/dist/launcher/agent-launcher.js.map +1 -1
  16. package/dist/launcher/constants.d.ts +5 -0
  17. package/dist/launcher/constants.d.ts.map +1 -0
  18. package/dist/launcher/constants.js +5 -0
  19. package/dist/launcher/constants.js.map +1 -0
  20. package/dist/launcher/errors.d.ts +15 -0
  21. package/dist/launcher/errors.d.ts.map +1 -0
  22. package/dist/launcher/errors.js +25 -0
  23. package/dist/launcher/errors.js.map +1 -0
  24. package/dist/launcher/generic-subprocess-plugin.d.ts +1 -0
  25. package/dist/launcher/generic-subprocess-plugin.d.ts.map +1 -1
  26. package/dist/launcher/generic-subprocess-plugin.js +2 -0
  27. package/dist/launcher/generic-subprocess-plugin.js.map +1 -1
  28. package/dist/launcher/index.d.ts +2 -1
  29. package/dist/launcher/index.d.ts.map +1 -1
  30. package/dist/launcher/index.js +1 -0
  31. package/dist/launcher/index.js.map +1 -1
  32. package/dist/launcher/types.d.ts +81 -4
  33. package/dist/launcher/types.d.ts.map +1 -1
  34. package/dist/routes/health.d.ts +7 -0
  35. package/dist/routes/health.d.ts.map +1 -1
  36. package/dist/routes/health.js +5 -0
  37. package/dist/routes/health.js.map +1 -1
  38. package/dist/routes/internal-relay-events.d.ts +2 -2
  39. package/dist/routes/internal-relay-events.d.ts.map +1 -1
  40. package/dist/routes/internal-relay-events.js +12 -0
  41. package/dist/routes/internal-relay-events.js.map +1 -1
  42. package/dist/routes/retained-tunnel-event.d.ts +20 -0
  43. package/dist/routes/retained-tunnel-event.d.ts.map +1 -0
  44. package/dist/routes/retained-tunnel-event.js +67 -0
  45. package/dist/routes/retained-tunnel-event.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +168 -27
  48. package/dist/server.js.map +1 -1
  49. package/dist/services/adaptive-poll-controller.d.ts +75 -0
  50. package/dist/services/adaptive-poll-controller.d.ts.map +1 -0
  51. package/dist/services/adaptive-poll-controller.js +96 -0
  52. package/dist/services/adaptive-poll-controller.js.map +1 -0
  53. package/dist/services/clarification-answer-monitor-service.d.ts +71 -0
  54. package/dist/services/clarification-answer-monitor-service.d.ts.map +1 -0
  55. package/dist/services/clarification-answer-monitor-service.js +366 -0
  56. package/dist/services/clarification-answer-monitor-service.js.map +1 -0
  57. package/dist/services/failure-fingerprint-tracker.d.ts +28 -0
  58. package/dist/services/failure-fingerprint-tracker.d.ts.map +1 -0
  59. package/dist/services/failure-fingerprint-tracker.js +38 -0
  60. package/dist/services/failure-fingerprint-tracker.js.map +1 -0
  61. package/dist/services/index.d.ts +1 -0
  62. package/dist/services/index.d.ts.map +1 -1
  63. package/dist/services/index.js +3 -0
  64. package/dist/services/index.js.map +1 -1
  65. package/dist/services/label-monitor-service.d.ts +2 -2
  66. package/dist/services/label-monitor-service.d.ts.map +1 -1
  67. package/dist/services/label-monitor-service.js +33 -16
  68. package/dist/services/label-monitor-service.js.map +1 -1
  69. package/dist/services/merge-conflict-monitor-service.d.ts +1 -1
  70. package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -1
  71. package/dist/services/merge-conflict-monitor-service.js +33 -13
  72. package/dist/services/merge-conflict-monitor-service.js.map +1 -1
  73. package/dist/services/post-activation-retry.d.ts +3 -0
  74. package/dist/services/post-activation-retry.d.ts.map +1 -1
  75. package/dist/services/post-activation-retry.js +34 -2
  76. package/dist/services/post-activation-retry.js.map +1 -1
  77. package/dist/services/pr-feedback-monitor-service.d.ts +1 -1
  78. package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
  79. package/dist/services/pr-feedback-monitor-service.js +33 -16
  80. package/dist/services/pr-feedback-monitor-service.js.map +1 -1
  81. package/dist/services/relay-bridge.d.ts +1 -0
  82. package/dist/services/relay-bridge.d.ts.map +1 -1
  83. package/dist/services/relay-bridge.js +21 -0
  84. package/dist/services/relay-bridge.js.map +1 -1
  85. package/dist/services/smee-channel-resolver.d.ts +60 -0
  86. package/dist/services/smee-channel-resolver.d.ts.map +1 -0
  87. package/dist/services/smee-channel-resolver.js +177 -0
  88. package/dist/services/smee-channel-resolver.js.map +1 -0
  89. package/dist/services/webhook-setup-service.d.ts +115 -111
  90. package/dist/services/webhook-setup-service.d.ts.map +1 -1
  91. package/dist/services/webhook-setup-service.js +377 -209
  92. package/dist/services/webhook-setup-service.js.map +1 -1
  93. package/dist/types/api.d.ts +3 -0
  94. package/dist/types/api.d.ts.map +1 -1
  95. package/dist/types/api.js +1 -0
  96. package/dist/types/api.js.map +1 -1
  97. package/dist/types/monitor.d.ts +15 -2
  98. package/dist/types/monitor.d.ts.map +1 -1
  99. package/dist/worker/clarification-markers.d.ts +62 -0
  100. package/dist/worker/clarification-markers.d.ts.map +1 -1
  101. package/dist/worker/clarification-markers.js +101 -0
  102. package/dist/worker/clarification-markers.js.map +1 -1
  103. package/dist/worker/clarification-poster.d.ts +102 -6
  104. package/dist/worker/clarification-poster.d.ts.map +1 -1
  105. package/dist/worker/clarification-poster.js +299 -89
  106. package/dist/worker/clarification-poster.js.map +1 -1
  107. package/dist/worker/claude-cli-worker.d.ts +7 -0
  108. package/dist/worker/claude-cli-worker.d.ts.map +1 -1
  109. package/dist/worker/claude-cli-worker.js +7 -3
  110. package/dist/worker/claude-cli-worker.js.map +1 -1
  111. package/dist/worker/cli-spawner.d.ts.map +1 -1
  112. package/dist/worker/cli-spawner.js +2 -0
  113. package/dist/worker/cli-spawner.js.map +1 -1
  114. package/dist/worker/config.d.ts +396 -0
  115. package/dist/worker/config.d.ts.map +1 -1
  116. package/dist/worker/config.js +142 -0
  117. package/dist/worker/config.js.map +1 -1
  118. package/dist/worker/failure-fingerprint.d.ts +41 -0
  119. package/dist/worker/failure-fingerprint.d.ts.map +1 -0
  120. package/dist/worker/failure-fingerprint.js +75 -0
  121. package/dist/worker/failure-fingerprint.js.map +1 -0
  122. package/dist/worker/label-manager.d.ts +42 -0
  123. package/dist/worker/label-manager.d.ts.map +1 -1
  124. package/dist/worker/label-manager.js +108 -4
  125. package/dist/worker/label-manager.js.map +1 -1
  126. package/dist/worker/phase-loop.d.ts +30 -0
  127. package/dist/worker/phase-loop.d.ts.map +1 -1
  128. package/dist/worker/phase-loop.js +158 -27
  129. package/dist/worker/phase-loop.js.map +1 -1
  130. package/dist/worker/pr-feedback-handler.d.ts +13 -0
  131. package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
  132. package/dist/worker/pr-feedback-handler.js +58 -3
  133. package/dist/worker/pr-feedback-handler.js.map +1 -1
  134. package/dist/worker/stage-comment-manager.d.ts +5 -0
  135. package/dist/worker/stage-comment-manager.d.ts.map +1 -1
  136. package/dist/worker/stage-comment-manager.js +8 -1
  137. package/dist/worker/stage-comment-manager.js.map +1 -1
  138. package/dist/worker/terminal-label-op-error.d.ts +1 -1
  139. package/dist/worker/terminal-label-op-error.d.ts.map +1 -1
  140. package/dist/worker/terminal-label-op-error.js.map +1 -1
  141. package/dist/worker/types.d.ts +51 -0
  142. package/dist/worker/types.d.ts.map +1 -1
  143. package/dist/worker/types.js +11 -0
  144. package/dist/worker/types.js.map +1 -1
  145. package/package.json +11 -7
@@ -8,40 +8,54 @@
8
8
  * This eliminates manual webhook configuration and ensures near-instant label
9
9
  * detection via Smee instead of relying on 15-minute polling fallback.
10
10
  */
11
+ import { readFile } from 'node:fs/promises';
11
12
  import { executeCommand } from '@generacy-ai/workflow-engine';
12
13
  import { JitTokenError } from '@generacy-ai/control-plane';
14
+ /** Locked event set on all Generacy-created/updated webhooks (FR-001). */
15
+ const LOCKED_EVENTS = ['issues', 'pull_request', 'check_run', 'check_suite'];
16
+ /** Default location of the persisted smee channel URL (written by SmeeChannelResolver). */
17
+ const DEFAULT_CHANNEL_FILE_PATH = '/var/lib/generacy/smee-channel';
13
18
  /**
14
19
  * Service for auto-configuring GitHub webhooks for monitored repositories.
15
20
  *
16
21
  * When SMEE_CHANNEL_URL is configured, this service verifies and creates
17
22
  * GitHub webhooks for all monitored repositories on orchestrator startup,
18
23
  * ensuring near-instant label detection via Smee instead of 15-minute polling.
19
- *
20
- * Features:
21
- * - Creates missing webhooks pointing to Smee channel URL
22
- * - Skips existing active webhooks (idempotent)
23
- * - Reactivates inactive webhooks and merges events
24
- * - Graceful error handling (per-repo failures don't block startup)
25
- * - Warns on event mismatches without modifying
26
- * - Validates Smee URL format (warns on non-smee.io URLs)
27
- *
28
- * Error Handling:
29
- * - 403/404 errors: Logged as warnings, system falls back to polling
30
- * - Network/API errors: Logged and continue with next repo
31
- * - All errors are non-fatal and don't block orchestrator startup
32
24
  */
33
25
  export class WebhookSetupService {
34
26
  _logger;
35
27
  _tokenProvider;
28
+ _sendRelayEvent;
29
+ _statusReporter;
30
+ _channelFilePath;
31
+ _installationIdProvider;
32
+ /**
33
+ * Set of `owner/repo` values for which the 403 fail-loud triple has already
34
+ * fired within this orchestrator boot. Bounds cloud-side banner noise to
35
+ * at most N events per boot for a cluster with N configured repos
36
+ * (see `contracts/webhook-registration-forbidden-event.md` §"Emission rate").
37
+ */
38
+ _forbiddenFiredForRepo = new Set();
39
+ /**
40
+ * Cached github-app installation id resolved once at startup via
41
+ * `installationIdProvider`. `undefined` means "not yet resolved this boot";
42
+ * `null` means "resolved, but no id available"; `number` means resolved.
43
+ */
44
+ _installationIdCache;
36
45
  /**
37
46
  * Creates a new WebhookSetupService instance.
38
47
  *
39
48
  * @param logger - Logger instance for structured logging (Pino/Fastify compatible)
40
49
  * @param tokenProvider - Optional async function returning a GH_TOKEN for auth
50
+ * @param options - Optional DI hooks for #972 fail-loud triple + FR-004 persisted-URL healing
41
51
  */
42
- constructor(logger, tokenProvider) {
52
+ constructor(logger, tokenProvider, options = {}) {
43
53
  this._logger = logger;
44
54
  this._tokenProvider = tokenProvider;
55
+ this._sendRelayEvent = options.sendRelayEvent;
56
+ this._statusReporter = options.statusReporter;
57
+ this._channelFilePath = options.channelFilePath ?? DEFAULT_CHANNEL_FILE_PATH;
58
+ this._installationIdProvider = options.installationIdProvider;
45
59
  }
46
60
  /**
47
61
  * Invariant: when a tokenProvider is configured, the env override ALWAYS
@@ -60,38 +74,13 @@ export class WebhookSetupService {
60
74
  /**
61
75
  * Ensure webhooks exist for all repositories.
62
76
  *
63
- * This is the main entry point for webhook setup. For each repository:
64
- * 1. Lists existing webhooks via GitHub API
65
- * 2. Checks if a webhook already exists for the Smee channel URL
66
- * 3. Creates missing webhooks, reactivates inactive ones, or skips active ones
67
- * 4. Logs results and continues on errors (graceful degradation)
68
- *
69
- * Per-repository errors are logged but don't block the overall process,
70
- * allowing partial success when some repositories fail (e.g., due to
71
- * insufficient permissions).
77
+ * For each repository, evaluates the decision matrix documented at
78
+ * `specs/972-summary-snappoll-preview/contracts/ensure-webhooks-behavior.md`.
79
+ * Per-repository errors are logged but don't block the overall process.
72
80
  *
73
81
  * @param smeeChannelUrl - The Smee channel URL to configure webhooks for
74
- * (e.g., 'https://smee.io/abc123'). URL is validated
75
- * and a warning is logged if it doesn't point to smee.io.
76
- * @param repositories - List of repositories to ensure webhooks for. Each
77
- * repository is processed sequentially to avoid rate limits.
78
- * @returns Promise resolving to a summary of webhook setup results, including
79
- * aggregate counts and detailed per-repository results.
80
- *
81
- * @example
82
- * ```typescript
83
- * const service = new WebhookSetupService(logger);
84
- * const summary = await service.ensureWebhooks(
85
- * 'https://smee.io/abc123',
86
- * [
87
- * { owner: 'myorg', repo: 'myrepo' },
88
- * { owner: 'myorg', repo: 'another-repo' }
89
- * ]
90
- * );
91
- * console.log(`Created: ${summary.created}, Skipped: ${summary.skipped}, Failed: ${summary.failed}`);
92
- * ```
93
- *
94
- * @see {@link WebhookSetupSummary} for the structure of the returned summary
82
+ * @param repositories - List of repositories to ensure webhooks for
83
+ * @returns Promise resolving to a summary of webhook setup results
95
84
  */
96
85
  async ensureWebhooks(smeeChannelUrl, repositories) {
97
86
  // Validate Smee URL (warn if not smee.io)
@@ -99,6 +88,10 @@ export class WebhookSetupService {
99
88
  if (!normalizedUrl.startsWith('https://smee.io/')) {
100
89
  this._logger.warn({ smeeChannelUrl }, 'SMEE_CHANNEL_URL does not point to smee.io — ensure this URL is correct');
101
90
  }
91
+ // Read the persisted channel URL once per ensureWebhooks() invocation.
92
+ // Used to detect the FR-004 stale-channel-rotation case (row 6 of the
93
+ // decision matrix).
94
+ const previouslyPersistedUrl = await this._readPersistedChannelUrl();
102
95
  // Initialize counters
103
96
  const results = [];
104
97
  let created = 0;
@@ -107,7 +100,7 @@ export class WebhookSetupService {
107
100
  let failed = 0;
108
101
  // Process each repository
109
102
  for (const repo of repositories) {
110
- const result = await this._ensureWebhookForRepo(repo.owner, repo.repo, smeeChannelUrl);
103
+ const result = await this._ensureWebhookForRepo(repo.owner, repo.repo, smeeChannelUrl, previouslyPersistedUrl);
111
104
  results.push(result);
112
105
  // Update counters based on action
113
106
  switch (result.action) {
@@ -138,248 +131,400 @@ export class WebhookSetupService {
138
131
  /**
139
132
  * Ensure webhook exists for a single repository.
140
133
  *
141
- * Internal method that handles webhook verification and setup for one repository.
142
- * All errors are caught and converted to a 'failed' result to allow graceful
143
- * degradation.
144
- *
145
- * Logic flow:
146
- * 1. List existing webhooks
147
- * 2. Find webhook matching the Smee URL (case-insensitive)
148
- * 3. If not found: Create new webhook → 'created'
149
- * 4. If found and active: Skip → 'skipped' (warn on event mismatch)
150
- * 5. If found but inactive: Reactivate and merge events → 'reactivated'
151
- *
152
- * @param owner - Repository owner (organization or user)
153
- * @param repo - Repository name
154
- * @param smeeChannelUrl - The Smee channel URL to configure
155
- * @returns Promise resolving to the result of webhook setup for this repository
156
- *
157
- * @throws Never throws - all errors are caught and returned as 'failed' results
134
+ * Implements rows 1-11 of the per-repo decision matrix in
135
+ * `contracts/ensure-webhooks-behavior.md`.
158
136
  */
159
- async _ensureWebhookForRepo(owner, repo, smeeChannelUrl) {
137
+ async _ensureWebhookForRepo(owner, repo, smeeChannelUrl, previouslyPersistedUrl) {
138
+ // List existing webhooks. When list fails with 403, emit the fail-loud
139
+ // triple; other list errors return a `failed` result with the raw stderr.
140
+ let existingWebhooks;
160
141
  try {
161
- // List existing webhooks
162
- const existingWebhooks = await this._listRepoWebhooks(owner, repo);
163
- // Check for matching webhook
164
- const matchingWebhook = this._findMatchingWebhook(existingWebhooks, smeeChannelUrl);
165
- // No matching webhook - create new one
166
- if (!matchingWebhook) {
167
- const webhookId = await this._createRepoWebhook(owner, repo, smeeChannelUrl);
168
- this._logger.info({ owner, repo, webhookId, action: 'created' }, 'Created new webhook for repository');
169
- return {
142
+ existingWebhooks = await this._listRepoWebhooks(owner, repo);
143
+ }
144
+ catch (error) {
145
+ return this._handleGhFailure(owner, repo, error, 'list', 'Insufficient permissions to manage webhooks (admin:repo_hook required)');
146
+ }
147
+ // FR-004 decision matrix: find whether to skip, reactivate, PATCH, log-and-skip, or create.
148
+ const selection = this._selectExistingHookForUpdate(existingWebhooks, smeeChannelUrl, previouslyPersistedUrl);
149
+ if (selection.kind === 'skip-active') {
150
+ const hook = selection.hook;
151
+ const missingEvents = LOCKED_EVENTS.filter((e) => !hook.events.includes(e));
152
+ if (missingEvents.length > 0) {
153
+ this._logger.warn({
170
154
  owner,
171
155
  repo,
172
- action: 'created',
173
- webhookId,
174
- };
156
+ webhookId: hook.id,
157
+ currentEvents: hook.events,
158
+ expectedEvents: ['issues'],
159
+ }, 'Existing webhook has event mismatch - events not updated');
175
160
  }
176
- // Matching webhook exists and is active
177
- if (matchingWebhook.active) {
178
- // Check for event mismatch
179
- const hasIssuesEvent = matchingWebhook.events.includes('issues');
180
- if (!hasIssuesEvent) {
181
- this._logger.warn({
182
- owner,
183
- repo,
184
- webhookId: matchingWebhook.id,
185
- currentEvents: matchingWebhook.events,
186
- expectedEvents: ['issues'],
187
- }, 'Existing webhook has event mismatch - events not updated');
188
- }
189
- this._logger.info({ owner, repo, webhookId: matchingWebhook.id, action: 'skipped' }, 'Webhook already exists and is active');
190
- return {
191
- owner,
192
- repo,
193
- action: 'skipped',
194
- webhookId: matchingWebhook.id,
195
- };
161
+ this._logger.info({ owner, repo, webhookId: hook.id, action: 'skipped' }, 'Webhook already exists and is active');
162
+ return { owner, repo, action: 'skipped', webhookId: hook.id };
163
+ }
164
+ if (selection.kind === 'reactivate') {
165
+ const hook = selection.hook;
166
+ const mergedEvents = [...new Set([...hook.events, 'issues'])];
167
+ try {
168
+ await this._updateRepoWebhook(owner, repo, hook.id, {
169
+ active: true,
170
+ events: mergedEvents,
171
+ });
172
+ }
173
+ catch (error) {
174
+ return this._handleGhFailure(owner, repo, error, 'patch', 'Failed to manage webhook for repository');
196
175
  }
197
- // Matching webhook exists but is inactive - reactivate and merge events
198
- const mergedEvents = [
199
- ...new Set([...matchingWebhook.events, 'issues']),
200
- ];
201
- await this._updateRepoWebhook(owner, repo, matchingWebhook.id, {
202
- active: true,
203
- events: mergedEvents,
204
- });
205
176
  this._logger.info({
206
177
  owner,
207
178
  repo,
208
- webhookId: matchingWebhook.id,
179
+ webhookId: hook.id,
209
180
  action: 'reactivated',
210
181
  events: mergedEvents,
211
182
  }, 'Reactivated inactive webhook');
212
- return {
183
+ return { owner, repo, action: 'reactivated', webhookId: hook.id };
184
+ }
185
+ if (selection.kind === 'update-url') {
186
+ const hook = selection.hook;
187
+ try {
188
+ await this._updateRepoWebhookConfig(owner, repo, hook.id, {
189
+ url: smeeChannelUrl,
190
+ active: true,
191
+ events: [...LOCKED_EVENTS],
192
+ });
193
+ }
194
+ catch (error) {
195
+ return this._handleGhFailure(owner, repo, error, 'patch', 'Failed to manage webhook for repository');
196
+ }
197
+ this._logger.info({
213
198
  owner,
214
199
  repo,
200
+ webhookId: hook.id,
215
201
  action: 'reactivated',
216
- webhookId: matchingWebhook.id,
217
- };
202
+ oldUrl: hook.config?.url,
203
+ newUrl: smeeChannelUrl,
204
+ events: [...LOCKED_EVENTS],
205
+ }, 'Updated Generacy webhook to current channel URL');
206
+ return { owner, repo, action: 'reactivated', webhookId: hook.id };
207
+ }
208
+ if (selection.kind === 'foreign') {
209
+ const hook = selection.hook;
210
+ const truncatedUrl = (hook.config?.url ?? '').slice(0, 80);
211
+ this._logger.warn({ owner, repo, webhookId: hook.id, foreignUrl: truncatedUrl }, 'Foreign webhook present; not modifying');
212
+ return { owner, repo, action: 'skipped', webhookId: hook.id };
213
+ }
214
+ // selection.kind === 'create' — no existing hook match, POST a new one.
215
+ let webhookId;
216
+ try {
217
+ webhookId = await this._createRepoWebhook(owner, repo, smeeChannelUrl);
218
218
  }
219
219
  catch (error) {
220
- // Handle per-repo errors gracefully
221
- const errorMessage = String(error);
222
- // Determine appropriate log level and message based on error type
223
- if (error instanceof JitTokenError) {
224
- this._logger.warn({ owner, repo, code: error.code, message: error.message }, 'JIT GitHub token refresh failed — skipping webhook setup for repository');
225
- }
226
- else if (errorMessage.includes('403') || errorMessage.includes('404')) {
227
- this._logger.warn({ owner, repo, error: errorMessage }, 'Insufficient permissions to manage webhooks (admin:repo_hook required)');
228
- }
229
- else if (errorMessage.includes('500')) {
230
- this._logger.warn({ owner, repo, error: errorMessage }, 'GitHub API error while managing webhooks');
231
- }
232
- else {
233
- this._logger.warn({ owner, repo, error: errorMessage }, 'Failed to manage webhook for repository');
220
+ return this._handleGhFailure(owner, repo, error, 'create', 'Failed to manage webhook for repository');
221
+ }
222
+ this._logger.info({ owner, repo, webhookId, action: 'created' }, 'Created new webhook for repository');
223
+ return { owner, repo, action: 'created', webhookId };
224
+ }
225
+ /**
226
+ * Read the previously-persisted smee channel URL from disk.
227
+ *
228
+ * Returns `null` on ENOENT, on empty content, or on any read/parse error.
229
+ * Written only by `SmeeChannelResolver`; read-only here.
230
+ */
231
+ async _readPersistedChannelUrl() {
232
+ try {
233
+ const raw = await readFile(this._channelFilePath, 'utf8');
234
+ const trimmed = raw.trim();
235
+ return trimmed.length > 0 ? trimmed : null;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ /**
242
+ * Decision matrix for choosing what to do with the existing hook set.
243
+ *
244
+ * Implements rows 4-9 of `contracts/ensure-webhooks-behavior.md`. First
245
+ * match wins. `matches(A, B)` is case-insensitive string equality per
246
+ * `data-model.md` §"Field-Level Validation Rules Summary".
247
+ */
248
+ _selectExistingHookForUpdate(hooks, currentUrl, persistedUrl) {
249
+ const currentNormalized = currentUrl.toLowerCase();
250
+ const persistedNormalized = persistedUrl?.toLowerCase() ?? null;
251
+ // Row 4/5: hook whose config.url matches the CURRENT channel URL.
252
+ const currentMatch = hooks.find((h) => (h.config?.url?.toLowerCase() ?? '') === currentNormalized);
253
+ if (currentMatch) {
254
+ return currentMatch.active
255
+ ? { kind: 'skip-active', hook: currentMatch }
256
+ : { kind: 'reactivate', hook: currentMatch };
257
+ }
258
+ // Row 6: hook whose config.url matches the PERSISTED (rotated) channel URL.
259
+ if (persistedNormalized !== null &&
260
+ persistedNormalized !== currentNormalized) {
261
+ const persistedMatch = hooks.find((h) => (h.config?.url?.toLowerCase() ?? '') === persistedNormalized);
262
+ if (persistedMatch) {
263
+ return { kind: 'update-url', hook: persistedMatch };
234
264
  }
265
+ }
266
+ // Row 8: existing foreign smee hook — do not touch, log-and-skip.
267
+ // A hook is "foreign" when its URL is neither the current nor the persisted
268
+ // Generacy channel URL. We only flag smee.io URLs to keep the signal
269
+ // relevant (a random non-smee webhook is almost certainly the operator's).
270
+ const foreign = hooks.find((h) => {
271
+ const url = (h.config?.url ?? '').toLowerCase();
272
+ if (!url)
273
+ return false;
274
+ if (url === currentNormalized)
275
+ return false;
276
+ if (persistedNormalized !== null && url === persistedNormalized)
277
+ return false;
278
+ return url.startsWith('https://smee.io/');
279
+ });
280
+ if (foreign) {
281
+ return { kind: 'foreign', hook: foreign };
282
+ }
283
+ // Row 9: no match — POST a new hook.
284
+ return { kind: 'create' };
285
+ }
286
+ /**
287
+ * Detect whether a `gh api` failure is the 403 fail-loud case.
288
+ *
289
+ * Matches the two error patterns GitHub can return for the same underlying
290
+ * failure mode: `HTTP 403` (canonical) and `Resource not accessible by
291
+ * integration` (typical stderr wording). Both patterns fire for the same
292
+ * missing-scope case; matching either is sufficient (case-insensitive
293
+ * substring). See `contracts/webhook-registration-forbidden-event.md`
294
+ * §"Trigger conditions".
295
+ */
296
+ _isForbiddenError(stderr) {
297
+ const lower = stderr.toLowerCase();
298
+ return (lower.includes('http 403') ||
299
+ lower.includes('resource not accessible by integration'));
300
+ }
301
+ /**
302
+ * Handle any `gh` failure (list, create, or patch) from within
303
+ * `_ensureWebhookForRepo`. On 403 → emit the fail-loud triple; on 404/500
304
+ * → warn-only; on all other errors → generic warn.
305
+ *
306
+ * Guarantees the log line is the audit floor (always fires); relay event +
307
+ * status transition are best-effort per the contracts.
308
+ */
309
+ _handleGhFailure(owner, repo, error, site, genericMessage) {
310
+ const errorMessage = String(error);
311
+ if (error instanceof JitTokenError) {
312
+ this._logger.warn({ owner, repo, code: error.code, message: error.message, site }, 'JIT GitHub token refresh failed — skipping webhook setup for repository');
313
+ return { owner, repo, action: 'failed', error: errorMessage };
314
+ }
315
+ if (this._isForbiddenError(errorMessage)) {
316
+ this._fireForbiddenTriple(owner, repo, errorMessage);
235
317
  return {
236
318
  owner,
237
319
  repo,
238
320
  action: 'failed',
239
- error: errorMessage,
321
+ error: 'webhook-registration-forbidden',
240
322
  };
241
323
  }
324
+ if (errorMessage.includes('403') || errorMessage.includes('404')) {
325
+ this._logger.warn({ owner, repo, error: errorMessage, site }, 'Insufficient permissions to manage webhooks (admin:repo_hook required)');
326
+ }
327
+ else if (errorMessage.includes('500')) {
328
+ this._logger.warn({ owner, repo, error: errorMessage, site }, 'GitHub API error while managing webhooks');
329
+ }
330
+ else {
331
+ this._logger.warn({ owner, repo, error: errorMessage, site }, genericMessage);
332
+ }
333
+ return { owner, repo, action: 'failed', error: errorMessage };
242
334
  }
243
335
  /**
244
- * Find a webhook matching the given URL.
336
+ * Emit the fail-loud triple (warn log + relay event + degraded status) for
337
+ * a webhook-registration 403. Bounded to at most once per `(repo, boot)`
338
+ * per `contracts/webhook-registration-forbidden-event.md` §"Emission rate".
245
339
  *
246
- * Uses case-insensitive URL comparison. No URL normalization is performed -
247
- * simple string matching after lowercasing both URLs. This means that
248
- * 'https://smee.io/ABC' and 'https://smee.io/abc' are considered equal,
249
- * but trailing slashes or different protocols would not match.
250
- *
251
- * @param webhooks - Array of webhooks to search through
252
- * @param targetUrl - The URL to match (typically the Smee channel URL)
253
- * @returns The first matching webhook, or undefined if none found
254
- *
255
- * @example
256
- * ```typescript
257
- * const webhooks = [
258
- * { id: 1, active: true, config: { url: 'https://smee.io/ABC' }, events: ['issues'] }
259
- * ];
260
- * const match = this._findMatchingWebhook(webhooks, 'https://smee.io/abc');
261
- * // Returns the webhook with id: 1
262
- * ```
340
+ * The log line is synchronous and always succeeds — this is the audit
341
+ * floor. Relay event and status transition are fire-and-forget.
263
342
  */
264
- _findMatchingWebhook(webhooks, targetUrl) {
265
- const normalizedTargetUrl = targetUrl.toLowerCase();
266
- return webhooks.find((webhook) => {
267
- const webhookUrl = webhook.config?.url?.toLowerCase() ?? '';
268
- return webhookUrl === normalizedTargetUrl;
343
+ _fireForbiddenTriple(owner, repo, ghStderr) {
344
+ const repoKey = `${owner}/${repo}`;
345
+ if (this._forbiddenFiredForRepo.has(repoKey)) {
346
+ // Already fired for this repo this boot — silence to bound cloud noise.
347
+ // The individual `gh` failure is still surfaced via the caller's return.
348
+ return;
349
+ }
350
+ this._forbiddenFiredForRepo.add(repoKey);
351
+ // Resolve installation id best-effort. `undefined` means "not resolved
352
+ // yet"; anything else (null or number) means "resolved" for this boot.
353
+ this._resolveInstallationId()
354
+ .then((installationId) => {
355
+ // 1. Structured warn log — synchronous, audit-floor guarantee.
356
+ this._logger.warn({
357
+ owner,
358
+ repo,
359
+ installationId,
360
+ missingScope: 'admin:repo_hook',
361
+ reason: 'webhook-registration-forbidden',
362
+ ghStderr,
363
+ }, 'Webhook registration forbidden: missing admin:repo_hook scope');
364
+ // 2. `cluster.bootstrap` relay event.
365
+ if (this._sendRelayEvent) {
366
+ try {
367
+ this._sendRelayEvent('cluster.bootstrap', {
368
+ status: 'failed',
369
+ reason: 'webhook-registration-forbidden',
370
+ repo: repoKey,
371
+ installationId,
372
+ missingScope: 'admin:repo_hook',
373
+ });
374
+ }
375
+ catch {
376
+ // fire-and-forget
377
+ }
378
+ }
379
+ // 3. Cluster status → degraded.
380
+ if (this._statusReporter) {
381
+ this._statusReporter
382
+ .pushStatus('degraded', 'webhook-registration-forbidden')
383
+ .catch(() => {
384
+ // fire-and-forget
385
+ });
386
+ }
387
+ })
388
+ .catch(() => {
389
+ // Provider failure must not swallow the audit-floor log line — emit
390
+ // with null installationId so operators still see the 403 signal.
391
+ this._logger.warn({
392
+ owner,
393
+ repo,
394
+ installationId: null,
395
+ missingScope: 'admin:repo_hook',
396
+ reason: 'webhook-registration-forbidden',
397
+ ghStderr,
398
+ }, 'Webhook registration forbidden: missing admin:repo_hook scope');
399
+ if (this._sendRelayEvent) {
400
+ try {
401
+ this._sendRelayEvent('cluster.bootstrap', {
402
+ status: 'failed',
403
+ reason: 'webhook-registration-forbidden',
404
+ repo: repoKey,
405
+ installationId: null,
406
+ missingScope: 'admin:repo_hook',
407
+ });
408
+ }
409
+ catch {
410
+ // fire-and-forget
411
+ }
412
+ }
413
+ if (this._statusReporter) {
414
+ this._statusReporter
415
+ .pushStatus('degraded', 'webhook-registration-forbidden')
416
+ .catch(() => {
417
+ // fire-and-forget
418
+ });
419
+ }
269
420
  });
270
421
  }
422
+ /**
423
+ * Resolve the github-app installation id, caching the result for the process
424
+ * lifetime. Returns `null` when no provider is configured or the provider
425
+ * cannot resolve an id (per data-model.md, `null` is a valid emit value).
426
+ */
427
+ async _resolveInstallationId() {
428
+ if (this._installationIdCache !== undefined)
429
+ return this._installationIdCache;
430
+ if (!this._installationIdProvider) {
431
+ this._installationIdCache = null;
432
+ return null;
433
+ }
434
+ try {
435
+ const resolved = await this._installationIdProvider();
436
+ this._installationIdCache = resolved ?? null;
437
+ }
438
+ catch {
439
+ this._installationIdCache = null;
440
+ }
441
+ return this._installationIdCache;
442
+ }
271
443
  /**
272
444
  * List webhooks for a repository via GitHub API.
273
445
  *
274
- * Uses the `gh` CLI to call `GET /repos/{owner}/{repo}/hooks`.
275
- * Errors are logged and an empty array is returned for graceful degradation.
276
- *
277
- * @param owner - Repository owner (organization or user)
278
- * @param repo - Repository name
279
- * @returns Promise resolving to an array of webhooks (empty array on error)
280
- *
281
- * @see {@link https://docs.github.com/en/rest/webhooks/repos#list-repository-webhooks}
446
+ * Errors are thrown so the per-repo catch branch can classify them as 403
447
+ * (fail-loud) vs 404/500 (warn-only). Returns an empty array when the API
448
+ * returns a well-formed non-array response.
282
449
  */
283
450
  async _listRepoWebhooks(owner, repo) {
451
+ const env = await this.resolveTokenEnv();
452
+ const result = await executeCommand('gh', [
453
+ 'api',
454
+ `/repos/${owner}/${repo}/hooks`,
455
+ ], { env });
456
+ if (result.exitCode !== 0) {
457
+ // Surface stderr as the thrown error so the catch branch can classify
458
+ // the failure (403 → fail-loud; else → warn). Preserves the pre-#972
459
+ // warn log line for the 500/404/other cases via `_handleGhFailure`.
460
+ const errorMsg = result.stderr.trim() || 'Unknown error';
461
+ this._logger.warn({ owner, repo, stderr: result.stderr }, 'Failed to list webhooks for repository');
462
+ throw new Error(errorMsg);
463
+ }
464
+ // Parse JSON response
465
+ let parsed;
284
466
  try {
285
- const env = await this.resolveTokenEnv();
286
- const result = await executeCommand('gh', [
287
- 'api',
288
- `/repos/${owner}/${repo}/hooks`,
289
- ], { env });
290
- if (result.exitCode !== 0) {
291
- this._logger.warn({ owner, repo, stderr: result.stderr }, 'Failed to list webhooks for repository');
292
- return [];
293
- }
294
- // Parse JSON response
295
- const parsed = JSON.parse(result.stdout);
296
- // Validate the response is an array
297
- if (!Array.isArray(parsed)) {
298
- this._logger.warn({ owner, repo, response: result.stdout }, 'Unexpected response format when listing webhooks (expected array)');
299
- return [];
300
- }
301
- // Return as GitHubWebhook array (minimal validation - trust GitHub API)
302
- return parsed;
467
+ parsed = JSON.parse(result.stdout);
303
468
  }
304
469
  catch (error) {
305
- if (error instanceof JitTokenError) {
306
- this._logger.warn({ owner, repo, code: error.code, message: error.message }, 'JIT GitHub token refresh failed — skipping webhook listing for repository');
307
- return [];
308
- }
309
- // Handle parse errors and other exceptions gracefully
310
470
  this._logger.warn({ owner, repo, error: String(error) }, 'Error listing webhooks for repository');
311
471
  return [];
312
472
  }
473
+ if (!Array.isArray(parsed)) {
474
+ this._logger.warn({ owner, repo, response: result.stdout }, 'Unexpected response format when listing webhooks (expected array)');
475
+ return [];
476
+ }
477
+ return parsed;
313
478
  }
314
479
  /**
315
480
  * Create a new webhook for a repository via GitHub API.
316
481
  *
317
- * Uses the `gh` CLI to call `POST /repos/{owner}/{repo}/hooks` with:
318
- * - `config.url`: The Smee channel URL
319
- * - `config.content_type`: 'json'
320
- * - `events`: ['issues']
321
- * - `active`: true
322
- *
323
- * @param owner - Repository owner (organization or user)
324
- * @param repo - Repository name
325
- * @param smeeChannelUrl - The Smee channel URL to configure
326
- * @returns Promise resolving to the webhook ID on success
327
- * @throws Error if webhook creation fails (caught and logged by caller)
482
+ * FR-001: locks the create-time event list to `issues`, `pull_request`,
483
+ * `check_run`, `check_suite`. `content_type` is `json`; `active` is `true`.
328
484
  *
329
485
  * @see {@link https://docs.github.com/en/rest/webhooks/repos#create-a-repository-webhook}
330
486
  */
331
487
  async _createRepoWebhook(owner, repo, smeeChannelUrl) {
332
488
  const env = await this.resolveTokenEnv();
333
- const result = await executeCommand('gh', [
489
+ const args = [
334
490
  'api',
335
491
  '-X', 'POST',
336
492
  `/repos/${owner}/${repo}/hooks`,
337
493
  '-f', `config[url]=${smeeChannelUrl}`,
338
494
  '-f', 'config[content_type]=json',
339
- '-F', 'events[]=issues',
340
495
  '-F', 'active=true',
341
- ], { env });
496
+ ];
497
+ for (const event of LOCKED_EVENTS) {
498
+ args.push('-F', `events[]=${event}`);
499
+ }
500
+ const result = await executeCommand('gh', args, { env });
342
501
  if (result.exitCode !== 0) {
343
- // Extract error message from stderr for better debugging
344
502
  const errorMsg = result.stderr.trim() || 'Unknown error';
345
503
  throw new Error(`Failed to create webhook: ${errorMsg}`);
346
504
  }
347
- // Parse response to get webhook ID
348
505
  const response = JSON.parse(result.stdout);
349
506
  return response.id;
350
507
  }
351
508
  /**
352
509
  * Update an existing webhook (reactivate and/or merge events) via GitHub API.
353
510
  *
354
- * Uses the `gh` CLI to call `PATCH /repos/{owner}/{repo}/hooks/{webhookId}`.
355
- * When reactivating an inactive webhook, this method also merges the 'issues'
356
- * event with any existing events to preserve the webhook's original configuration.
357
- *
358
- * @param owner - Repository owner (organization or user)
359
- * @param repo - Repository name
360
- * @param webhookId - Webhook ID to update
361
- * @param updates - Updates to apply
362
- * @param updates.active - Set webhook active status (true to reactivate)
363
- * @param updates.events - Event types to subscribe to (merged with existing)
364
- * @returns Promise resolving to true on success
365
- * @throws Error if webhook update fails (caught and logged by caller)
511
+ * Used for the `reactivate` decision-matrix row (inactive hook matching
512
+ * current URL). Preserves pre-#972 argv ordering so the existing test
513
+ * assertions (`build correct PATCH request with active flag and merged events`)
514
+ * continue to pass.
366
515
  *
367
516
  * @see {@link https://docs.github.com/en/rest/webhooks/repos#update-a-repository-webhook}
368
517
  */
369
518
  async _updateRepoWebhook(owner, repo, webhookId, updates) {
370
- // Build gh api command arguments
371
519
  const args = [
372
520
  'api',
373
521
  '-X', 'PATCH',
374
522
  `/repos/${owner}/${repo}/hooks/${webhookId}`,
375
523
  ];
376
- // Add active flag if specified
377
524
  if (updates.active !== undefined) {
378
525
  args.push('-F', `active=${updates.active}`);
379
526
  }
380
- // Add events if specified (merge with existing)
381
527
  if (updates.events && updates.events.length > 0) {
382
- // GitHub expects events as an array, so we need to add each event
383
528
  updates.events.forEach((event) => {
384
529
  args.push('-F', `events[]=${event}`);
385
530
  });
@@ -387,7 +532,30 @@ export class WebhookSetupService {
387
532
  const env = await this.resolveTokenEnv();
388
533
  const result = await executeCommand('gh', args, { env });
389
534
  if (result.exitCode !== 0) {
390
- // Extract error message from stderr for better debugging
535
+ const errorMsg = result.stderr.trim() || 'Unknown error';
536
+ throw new Error(`Failed to update webhook: ${errorMsg}`);
537
+ }
538
+ return true;
539
+ }
540
+ /**
541
+ * Update an existing webhook's `config.url` to the current channel (FR-004
542
+ * stale-channel heal). Also enforces the locked events set and active flag.
543
+ */
544
+ async _updateRepoWebhookConfig(owner, repo, webhookId, updates) {
545
+ const args = [
546
+ 'api',
547
+ '-X', 'PATCH',
548
+ `/repos/${owner}/${repo}/hooks/${webhookId}`,
549
+ '-f', `config[url]=${updates.url}`,
550
+ '-f', 'config[content_type]=json',
551
+ '-F', `active=${updates.active}`,
552
+ ];
553
+ for (const event of updates.events) {
554
+ args.push('-F', `events[]=${event}`);
555
+ }
556
+ const env = await this.resolveTokenEnv();
557
+ const result = await executeCommand('gh', args, { env });
558
+ if (result.exitCode !== 0) {
391
559
  const errorMsg = result.stderr.trim() || 'Unknown error';
392
560
  throw new Error(`Failed to update webhook: ${errorMsg}`);
393
561
  }