@generacy-ai/orchestrator 0.8.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.
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +15 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +30 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +13 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/health.d.ts +7 -0
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +5 -0
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/internal-relay-events.d.ts +2 -2
- package/dist/routes/internal-relay-events.d.ts.map +1 -1
- package/dist/routes/internal-relay-events.js +12 -0
- package/dist/routes/internal-relay-events.js.map +1 -1
- package/dist/routes/retained-tunnel-event.d.ts +20 -0
- package/dist/routes/retained-tunnel-event.d.ts.map +1 -0
- package/dist/routes/retained-tunnel-event.js +67 -0
- package/dist/routes/retained-tunnel-event.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +168 -27
- package/dist/server.js.map +1 -1
- package/dist/services/adaptive-poll-controller.d.ts +75 -0
- package/dist/services/adaptive-poll-controller.d.ts.map +1 -0
- package/dist/services/adaptive-poll-controller.js +96 -0
- package/dist/services/adaptive-poll-controller.js.map +1 -0
- package/dist/services/clarification-answer-monitor-service.d.ts +71 -0
- package/dist/services/clarification-answer-monitor-service.d.ts.map +1 -0
- package/dist/services/clarification-answer-monitor-service.js +366 -0
- package/dist/services/clarification-answer-monitor-service.js.map +1 -0
- package/dist/services/failure-fingerprint-tracker.d.ts +28 -0
- package/dist/services/failure-fingerprint-tracker.d.ts.map +1 -0
- package/dist/services/failure-fingerprint-tracker.js +38 -0
- package/dist/services/failure-fingerprint-tracker.js.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +3 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/label-monitor-service.d.ts +2 -2
- package/dist/services/label-monitor-service.d.ts.map +1 -1
- package/dist/services/label-monitor-service.js +33 -16
- package/dist/services/label-monitor-service.js.map +1 -1
- package/dist/services/merge-conflict-monitor-service.d.ts +1 -1
- package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -1
- package/dist/services/merge-conflict-monitor-service.js +33 -13
- package/dist/services/merge-conflict-monitor-service.js.map +1 -1
- package/dist/services/pr-feedback-monitor-service.d.ts +1 -1
- package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
- package/dist/services/pr-feedback-monitor-service.js +33 -16
- package/dist/services/pr-feedback-monitor-service.js.map +1 -1
- package/dist/services/relay-bridge.d.ts +1 -0
- package/dist/services/relay-bridge.d.ts.map +1 -1
- package/dist/services/relay-bridge.js +21 -0
- package/dist/services/relay-bridge.js.map +1 -1
- package/dist/services/smee-channel-resolver.d.ts +60 -0
- package/dist/services/smee-channel-resolver.d.ts.map +1 -0
- package/dist/services/smee-channel-resolver.js +177 -0
- package/dist/services/smee-channel-resolver.js.map +1 -0
- package/dist/services/webhook-setup-service.d.ts +115 -111
- package/dist/services/webhook-setup-service.d.ts.map +1 -1
- package/dist/services/webhook-setup-service.js +377 -209
- package/dist/services/webhook-setup-service.js.map +1 -1
- package/dist/types/api.d.ts +3 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js +1 -0
- package/dist/types/api.js.map +1 -1
- package/dist/types/monitor.d.ts +15 -2
- package/dist/types/monitor.d.ts.map +1 -1
- package/dist/worker/clarification-markers.d.ts +62 -0
- package/dist/worker/clarification-markers.d.ts.map +1 -1
- package/dist/worker/clarification-markers.js +101 -0
- package/dist/worker/clarification-markers.js.map +1 -1
- package/dist/worker/clarification-poster.d.ts +102 -6
- package/dist/worker/clarification-poster.d.ts.map +1 -1
- package/dist/worker/clarification-poster.js +299 -89
- package/dist/worker/clarification-poster.js.map +1 -1
- package/dist/worker/claude-cli-worker.d.ts +7 -0
- package/dist/worker/claude-cli-worker.d.ts.map +1 -1
- package/dist/worker/claude-cli-worker.js +3 -0
- package/dist/worker/claude-cli-worker.js.map +1 -1
- package/dist/worker/failure-fingerprint.d.ts +41 -0
- package/dist/worker/failure-fingerprint.d.ts.map +1 -0
- package/dist/worker/failure-fingerprint.js +75 -0
- package/dist/worker/failure-fingerprint.js.map +1 -0
- package/dist/worker/label-manager.d.ts +42 -0
- package/dist/worker/label-manager.d.ts.map +1 -1
- package/dist/worker/label-manager.js +108 -4
- package/dist/worker/label-manager.js.map +1 -1
- package/dist/worker/phase-loop.d.ts +30 -0
- package/dist/worker/phase-loop.d.ts.map +1 -1
- package/dist/worker/phase-loop.js +124 -26
- package/dist/worker/phase-loop.js.map +1 -1
- package/dist/worker/pr-feedback-handler.d.ts +13 -0
- package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
- package/dist/worker/pr-feedback-handler.js +48 -0
- package/dist/worker/pr-feedback-handler.js.map +1 -1
- package/dist/worker/stage-comment-manager.d.ts +5 -0
- package/dist/worker/stage-comment-manager.d.ts.map +1 -1
- package/dist/worker/stage-comment-manager.js +8 -1
- package/dist/worker/stage-comment-manager.js.map +1 -1
- package/dist/worker/terminal-label-op-error.d.ts +1 -1
- package/dist/worker/terminal-label-op-error.d.ts.map +1 -1
- package/dist/worker/terminal-label-op-error.js.map +1 -1
- package/dist/worker/types.d.ts +31 -0
- package/dist/worker/types.d.ts.map +1 -1
- package/dist/worker/types.js +11 -0
- package/dist/worker/types.js.map +1 -1
- package/package.json +5 -5
|
@@ -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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
156
|
+
webhookId: hook.id,
|
|
157
|
+
currentEvents: hook.events,
|
|
158
|
+
expectedEvents: ['issues'],
|
|
159
|
+
}, 'Existing webhook has event mismatch - events not updated');
|
|
175
160
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
*
|
|
275
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
318
|
-
*
|
|
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
|
|
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
|
-
]
|
|
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
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
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
|
-
|
|
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
|
}
|