@ericsanchezok/synergy-github-agenda 0.0.0-stage → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -0
- package/dist/component.js +17 -0
- package/dist/features.js +14 -0
- package/dist/github-trigger.js +476 -0
- package/dist/http.js +4 -0
- package/dist/preflight.js +48 -0
- package/dist/register.js +18 -0
- package/dist/schedule-description.txt +3 -0
- package/dist/watch-description.txt +5 -0
- package/dist/worker.js +6 -0
- package/package.json +107 -4
- package/src/component.ts +18 -0
- package/src/features.ts +14 -0
- package/src/github-trigger.ts +539 -0
- package/src/http.ts +4 -0
- package/src/preflight.ts +53 -0
- package/src/register.ts +18 -0
- package/src/schedule-description.txt +3 -0
- package/src/watch-description.txt +5 -0
- package/src/worker.ts +6 -0
- package/README.md +0 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# github-agenda Package
|
|
2
|
+
|
|
3
|
+
GitHub Agenda polling, watch preflight and selected trigger guidance. Required components: GitHub and Agenda. See [owning architecture](../../docs/architecture/workflows.md).
|
|
4
|
+
|
|
5
|
+
Register the named Agenda source explicitly. Only selected bridges add GitHub trigger inputs and instructions; native Agenda scheduling remains independent.
|
|
6
|
+
|
|
7
|
+
Expose selection through `./component`; imports are inert. Keep factory requirements, published `synergy` metadata, worker roles and production dependencies aligned. Register owner schemas, events, tools and HTTP features before Runtime composition closes.
|
|
8
|
+
|
|
9
|
+
Tests under `test/` select this owner and its real requirements. Run `bun run typecheck` and focused `bun test test/<file>.test.ts` with the package preload; then run the root dependency and installed-composition checks.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { componentFeatures } from "./features.js";
|
|
2
|
+
import { version } from "../package.json" with { type: "json" };
|
|
3
|
+
import { registerBridge } from "./register.js";
|
|
4
|
+
export function githubAgenda() {
|
|
5
|
+
return {
|
|
6
|
+
id: "github-agenda",
|
|
7
|
+
features: componentFeatures,
|
|
8
|
+
apiVersion: 1,
|
|
9
|
+
version,
|
|
10
|
+
requires: { github: version, agenda: version },
|
|
11
|
+
adapters: { http: new URL("./http.js", import.meta.url) },
|
|
12
|
+
workers: { agent: new URL("./worker.js", import.meta.url) },
|
|
13
|
+
register() {
|
|
14
|
+
registerBridge();
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/features.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const componentFeatures = {
|
|
2
|
+
kind: "bridge",
|
|
3
|
+
definitions: [
|
|
4
|
+
{
|
|
5
|
+
id: "github-agenda",
|
|
6
|
+
owner: "github-agenda",
|
|
7
|
+
maturity: "stable",
|
|
8
|
+
activation: "restart",
|
|
9
|
+
name: "Github Agenda",
|
|
10
|
+
description: "Provide github agenda capabilities.",
|
|
11
|
+
component: "github-agenda",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
};
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context";
|
|
2
|
+
import { GithubWatchPolicy } from "@ericsanchezok/synergy-agenda/agenda/github-watch-policy";
|
|
3
|
+
import { AgendaSessionWakeup } from "@ericsanchezok/synergy-agenda/agenda/session-wakeup";
|
|
4
|
+
import { AgendaStore } from "@ericsanchezok/synergy-agenda/agenda/store";
|
|
5
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log";
|
|
6
|
+
import { GitHubProvider } from "@ericsanchezok/synergy-harness/provider/github";
|
|
7
|
+
import { Storage } from "@ericsanchezok/synergy-harness/storage/storage";
|
|
8
|
+
/**
|
|
9
|
+
* GitHub agenda trigger — polls the GitHub REST API on a per-trigger interval
|
|
10
|
+
* and fires agenda items when a watched PR / issue / workflow / check run
|
|
11
|
+
* changes state.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the other agenda trigger sources: registrations are keyed by item,
|
|
14
|
+
* each entry runs its own polling loop, and matches are forwarded to the
|
|
15
|
+
* shared Agenda handler as a FiredSignal whose payload carries the resource
|
|
16
|
+
* snapshot (number, state, URL, and resource-specific fields).
|
|
17
|
+
*
|
|
18
|
+
* - Zero-cost when idle: entries with no resolved GitHub credential make no
|
|
19
|
+
* API call, and the whole source goes quiet when `github.watch.enabled` is
|
|
20
|
+
* false in config (default: enabled).
|
|
21
|
+
* - Triggers without an explicit `interval` fall back to the configured
|
|
22
|
+
* `github.watch.defaultIntervalMs` (default 5 minutes, floor 30 seconds).
|
|
23
|
+
* - Multiple transitions observed in one poll are dispatched sequentially so
|
|
24
|
+
* the Agenda inflight guard cannot drop later transitions.
|
|
25
|
+
* - Repeated poll failures (unreachable repo, invalid token) auto-pause the
|
|
26
|
+
* item instead of retrying forever, and a credential that stays missing
|
|
27
|
+
* across consecutive polls (account disconnected after creation) pauses
|
|
28
|
+
* the item too instead of holding a wait silently.
|
|
29
|
+
*/
|
|
30
|
+
export var AgendaGithubTrigger;
|
|
31
|
+
(function (AgendaGithubTrigger) {
|
|
32
|
+
const log = Log.create({ service: "agenda.github-trigger" });
|
|
33
|
+
const DEFAULT_INTERVAL_MS = 5 * 60_000;
|
|
34
|
+
const MIN_INTERVAL_MS = 30_000;
|
|
35
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
36
|
+
/** Bounded recent baseline: repository-wide watches must not accumulate
|
|
37
|
+
* every observed resource ID for the lifetime of the trigger. */
|
|
38
|
+
const MAX_BASELINE_ENTRIES = 256;
|
|
39
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
40
|
+
entries: new Map(),
|
|
41
|
+
handler: null,
|
|
42
|
+
started: false,
|
|
43
|
+
}));
|
|
44
|
+
function start(onFire, items) {
|
|
45
|
+
const instanceState = runtimeState();
|
|
46
|
+
instanceState.handler = onFire;
|
|
47
|
+
// Arm before registering: register() only schedules while started, and
|
|
48
|
+
// items restored from storage at startup must begin polling immediately.
|
|
49
|
+
instanceState.started = true;
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
register(item.id, item.origin.scope.id, item.triggers, { hasRun: item.state.runCount > 0 });
|
|
52
|
+
}
|
|
53
|
+
log.info("started", { entries: countEntries() });
|
|
54
|
+
}
|
|
55
|
+
AgendaGithubTrigger.start = start;
|
|
56
|
+
function stop() {
|
|
57
|
+
const instanceState = runtimeState();
|
|
58
|
+
for (const list of instanceState.entries.values()) {
|
|
59
|
+
for (const entry of list)
|
|
60
|
+
cancelTimer(entry);
|
|
61
|
+
}
|
|
62
|
+
instanceState.entries.clear();
|
|
63
|
+
instanceState.started = false;
|
|
64
|
+
instanceState.handler = null;
|
|
65
|
+
}
|
|
66
|
+
AgendaGithubTrigger.stop = stop;
|
|
67
|
+
function register(itemID, scopeID, triggers, opts = {}) {
|
|
68
|
+
const instanceState = runtimeState();
|
|
69
|
+
unregister(itemID);
|
|
70
|
+
const created = [];
|
|
71
|
+
for (const trigger of triggers) {
|
|
72
|
+
if (trigger.type !== "github")
|
|
73
|
+
continue;
|
|
74
|
+
created.push({
|
|
75
|
+
itemID,
|
|
76
|
+
scopeID,
|
|
77
|
+
resource: trigger.resource,
|
|
78
|
+
repository: trigger.repository,
|
|
79
|
+
number: trigger.number,
|
|
80
|
+
ref: trigger.ref,
|
|
81
|
+
intervalMs: trigger.interval ? AgendaStore.parseDuration(trigger.interval) : undefined,
|
|
82
|
+
states: trigger.states,
|
|
83
|
+
lastStates: new Map(),
|
|
84
|
+
// Only items that have never run may report an already-satisfied
|
|
85
|
+
// target state on first observation; restored items re-baseline
|
|
86
|
+
// silently so restarts do not re-notify.
|
|
87
|
+
allowInitialMatch: opts.hasRun !== true,
|
|
88
|
+
consecutiveFailures: 0,
|
|
89
|
+
missingCredentialPolls: 0,
|
|
90
|
+
dispatch: Promise.resolve(),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (created.length > 0) {
|
|
94
|
+
instanceState.entries.set(itemID, created);
|
|
95
|
+
log.info("github trigger registered", {
|
|
96
|
+
itemID,
|
|
97
|
+
entries: created.length,
|
|
98
|
+
resource: created[0]?.resource,
|
|
99
|
+
repository: created[0]?.repository,
|
|
100
|
+
started: instanceState.started,
|
|
101
|
+
});
|
|
102
|
+
if (instanceState.started)
|
|
103
|
+
for (const entry of created)
|
|
104
|
+
schedule(entry, 0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
AgendaGithubTrigger.register = register;
|
|
108
|
+
function unregister(itemID) {
|
|
109
|
+
const instanceState = runtimeState();
|
|
110
|
+
const list = instanceState.entries.get(itemID);
|
|
111
|
+
if (!list)
|
|
112
|
+
return;
|
|
113
|
+
for (const entry of list)
|
|
114
|
+
cancelTimer(entry);
|
|
115
|
+
instanceState.entries.delete(itemID);
|
|
116
|
+
}
|
|
117
|
+
AgendaGithubTrigger.unregister = unregister;
|
|
118
|
+
function active() {
|
|
119
|
+
const instanceState = runtimeState();
|
|
120
|
+
return { items: instanceState.entries.size, entries: countEntries() };
|
|
121
|
+
}
|
|
122
|
+
AgendaGithubTrigger.active = active;
|
|
123
|
+
/** Test/inspection hook: the live poll entries registered for an item. */
|
|
124
|
+
function entriesFor(itemID) {
|
|
125
|
+
const instanceState = runtimeState();
|
|
126
|
+
return instanceState.entries.get(itemID) ?? [];
|
|
127
|
+
}
|
|
128
|
+
AgendaGithubTrigger.entriesFor = entriesFor;
|
|
129
|
+
function countEntries() {
|
|
130
|
+
const instanceState = runtimeState();
|
|
131
|
+
let n = 0;
|
|
132
|
+
for (const list of instanceState.entries.values())
|
|
133
|
+
n += list.length;
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
function cancelTimer(entry) {
|
|
137
|
+
if (entry.timer) {
|
|
138
|
+
clearTimeout(entry.timer);
|
|
139
|
+
entry.timer = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function schedule(entry, delayMs) {
|
|
143
|
+
cancelTimer(entry);
|
|
144
|
+
entry.timer = setTimeout(() => void poll(entry), delayMs);
|
|
145
|
+
}
|
|
146
|
+
function isCurrent(instanceState, entry) {
|
|
147
|
+
return instanceState.entries.get(entry.itemID)?.includes(entry) === true;
|
|
148
|
+
}
|
|
149
|
+
async function watchConfig() {
|
|
150
|
+
return GithubWatchPolicy.read();
|
|
151
|
+
}
|
|
152
|
+
async function poll(entry) {
|
|
153
|
+
const instanceState = runtimeState();
|
|
154
|
+
if (!isCurrent(instanceState, entry))
|
|
155
|
+
return;
|
|
156
|
+
entry.timer = undefined;
|
|
157
|
+
let configuredDefaultMs;
|
|
158
|
+
let abandoned = false;
|
|
159
|
+
try {
|
|
160
|
+
const watch = await watchConfig();
|
|
161
|
+
if (!isCurrent(instanceState, entry))
|
|
162
|
+
return;
|
|
163
|
+
configuredDefaultMs = watch.defaultIntervalMs;
|
|
164
|
+
if (!watch.enabled) {
|
|
165
|
+
// Polling was switched off after this item was created: pause it now
|
|
166
|
+
// (releasing any continuation holding it) instead of idling forever.
|
|
167
|
+
abandoned = true;
|
|
168
|
+
await pauseEntry(entry, "github.watch.enabled=false");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const resolved = await GitHubProvider.resolveToken();
|
|
172
|
+
if (!isCurrent(instanceState, entry))
|
|
173
|
+
return;
|
|
174
|
+
if (!resolved?.token) {
|
|
175
|
+
// Credential vanished after creation (account disconnected, env
|
|
176
|
+
// removed): poll silently a few times, then pause the item — the
|
|
177
|
+
// same release path as the other pauses — so a waiting session is
|
|
178
|
+
// not held forever by a watch that can never fire.
|
|
179
|
+
entry.missingCredentialPolls++;
|
|
180
|
+
if (entry.missingCredentialPolls >= MAX_CONSECUTIVE_FAILURES) {
|
|
181
|
+
abandoned = true;
|
|
182
|
+
await pauseEntry(entry, "no GitHub credential resolves");
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
entry.missingCredentialPolls = 0;
|
|
187
|
+
const snapshots = await fetchSnapshots(entry, resolved.token);
|
|
188
|
+
if (!isCurrent(instanceState, entry))
|
|
189
|
+
return;
|
|
190
|
+
const changes = collectChanges(entry, snapshots);
|
|
191
|
+
// The first poll after a restart is the restore baseline: it must not
|
|
192
|
+
// fire for already-satisfied states, but after it completes, first
|
|
193
|
+
// observations of genuinely new resources may fire again.
|
|
194
|
+
entry.allowInitialMatch = true;
|
|
195
|
+
for (const change of changes) {
|
|
196
|
+
entry.dispatch = entry.dispatch.then(() => fire(instanceState, entry, change.snapshot, change.previous));
|
|
197
|
+
}
|
|
198
|
+
entry.consecutiveFailures = 0;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (!isCurrent(instanceState, entry))
|
|
202
|
+
return;
|
|
203
|
+
entry.consecutiveFailures++;
|
|
204
|
+
log.warn("github poll failed", {
|
|
205
|
+
itemID: entry.itemID,
|
|
206
|
+
repository: entry.repository,
|
|
207
|
+
consecutiveFailures: entry.consecutiveFailures,
|
|
208
|
+
error: error instanceof Error ? error.message : String(error),
|
|
209
|
+
});
|
|
210
|
+
if (entry.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
211
|
+
abandoned = true;
|
|
212
|
+
await pauseEntry(entry, "repeated poll failures");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
// Never reschedule a detached entry: unregister()/stop() may have run
|
|
217
|
+
// while the poll request was in flight.
|
|
218
|
+
if (!abandoned && instanceState.started && isCurrent(instanceState, entry)) {
|
|
219
|
+
const intervalMs = entry.intervalMs ?? configuredDefaultMs ?? DEFAULT_INTERVAL_MS;
|
|
220
|
+
schedule(entry, Math.max(MIN_INTERVAL_MS, intervalMs));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
AgendaGithubTrigger.poll = poll;
|
|
225
|
+
/**
|
|
226
|
+
* Pause an item and release any continuation it holds (Light Loop /
|
|
227
|
+
* BlueprintLoop) — the same release hook Agenda pause/cancel use — or the
|
|
228
|
+
* session stays stopped forever.
|
|
229
|
+
*/
|
|
230
|
+
async function pauseEntry(entry, reason) {
|
|
231
|
+
const instanceState = runtimeState();
|
|
232
|
+
try {
|
|
233
|
+
const transition = await Storage.transaction(async () => {
|
|
234
|
+
const instanceState = runtimeState();
|
|
235
|
+
if (!instanceState.entries.get(entry.itemID)?.includes(entry))
|
|
236
|
+
return;
|
|
237
|
+
const before = await AgendaStore.get(entry.scopeID, entry.itemID);
|
|
238
|
+
if (before.status !== "active" || !instanceState.entries.get(entry.itemID)?.includes(entry))
|
|
239
|
+
return;
|
|
240
|
+
const after = await AgendaStore.update(entry.scopeID, entry.itemID, { status: "paused" });
|
|
241
|
+
return { before, after };
|
|
242
|
+
});
|
|
243
|
+
if (!transition)
|
|
244
|
+
return;
|
|
245
|
+
if (instanceState.entries.get(entry.itemID)?.includes(entry))
|
|
246
|
+
unregister(entry.itemID);
|
|
247
|
+
await AgendaSessionWakeup.resumeIfReleased(transition);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
log.error("failed to pause github trigger", {
|
|
251
|
+
itemID: entry.itemID,
|
|
252
|
+
reason,
|
|
253
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
log.warn(`github trigger paused: ${reason}`, {
|
|
258
|
+
itemID: entry.itemID,
|
|
259
|
+
repository: entry.repository,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function fire(instanceState, entry, snapshot, previous) {
|
|
263
|
+
const handler = instanceState.handler;
|
|
264
|
+
if (!handler || !isCurrent(instanceState, entry))
|
|
265
|
+
return Promise.resolve();
|
|
266
|
+
const signal = {
|
|
267
|
+
type: "github",
|
|
268
|
+
source: entry.itemID,
|
|
269
|
+
payload: {
|
|
270
|
+
resource: entry.resource,
|
|
271
|
+
repository: entry.repository,
|
|
272
|
+
number: snapshot.number,
|
|
273
|
+
title: snapshot.title,
|
|
274
|
+
state: snapshot.state,
|
|
275
|
+
previousState: previous,
|
|
276
|
+
url: snapshot.url,
|
|
277
|
+
...(snapshot.conclusion !== undefined ? { conclusion: snapshot.conclusion } : {}),
|
|
278
|
+
...(snapshot.draft !== undefined ? { draft: snapshot.draft } : {}),
|
|
279
|
+
...(snapshot.mergeable !== undefined ? { mergeable: snapshot.mergeable } : {}),
|
|
280
|
+
...(snapshot.updatedAt !== undefined ? { updatedAt: snapshot.updatedAt } : {}),
|
|
281
|
+
},
|
|
282
|
+
timestamp: Date.now(),
|
|
283
|
+
};
|
|
284
|
+
return handler(signal, entry.scopeID).catch((err) => {
|
|
285
|
+
log.error("github trigger handler failed", {
|
|
286
|
+
itemID: entry.itemID,
|
|
287
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Diff snapshots against the entry baseline and return the transitions that
|
|
293
|
+
* should fire, in observation order. The baseline always advances (even for
|
|
294
|
+
* filtered-out transitions) so `previousState` reflects the last seen state.
|
|
295
|
+
*
|
|
296
|
+
* The baseline key is `state` for PR/issue watches and `state:conclusion`
|
|
297
|
+
* for workflow/check watches, so a run finishing (`completed` →
|
|
298
|
+
* `completed:failure`) counts as a transition and the states filter can
|
|
299
|
+
* match both the run status ("completed") and the conclusion ("success",
|
|
300
|
+
* "failure") as documented.
|
|
301
|
+
*
|
|
302
|
+
* First observation of a resource (`previous === undefined`): a
|
|
303
|
+
* state-targeted watch reports immediately when the resource already sits
|
|
304
|
+
* in a targeted state — covering watches created for an already-satisfied
|
|
305
|
+
* condition and races where the state changed between item creation and the
|
|
306
|
+
* first poll. Watches without a states filter stay silent on first
|
|
307
|
+
* observation so repository-wide watchers do not fire on creation.
|
|
308
|
+
* `allowInitialMatch` gates this to items that have never run, so a
|
|
309
|
+
* restart re-baselines silently instead of re-notifying.
|
|
310
|
+
*/
|
|
311
|
+
function collectChanges(entry, snapshots) {
|
|
312
|
+
const pending = [];
|
|
313
|
+
for (const snapshot of snapshots) {
|
|
314
|
+
const key = `${snapshot.resource ?? ""}:${snapshot.number}`;
|
|
315
|
+
const filterKey = snapshotKey(snapshot);
|
|
316
|
+
const previous = entry.lastStates.get(key);
|
|
317
|
+
// Refresh insertion order on every observation (Map.set keeps the
|
|
318
|
+
// original position for existing keys) so eviction approximates LRU:
|
|
319
|
+
// resources still inside the observation window are never evicted,
|
|
320
|
+
// only ones that left it long ago. Without the refresh, a busy
|
|
321
|
+
// repository could evict an actively observed resource and re-fire it
|
|
322
|
+
// as a duplicate first observation.
|
|
323
|
+
entry.lastStates.delete(key);
|
|
324
|
+
entry.lastStates.set(key, filterKey);
|
|
325
|
+
// Bound the baseline: repository-wide watches observe new resource IDs
|
|
326
|
+
// every poll; drop the least recently observed entry once the cap is
|
|
327
|
+
// exceeded so long-running servers do not accumulate unbounded memory.
|
|
328
|
+
if (entry.lastStates.size > MAX_BASELINE_ENTRIES) {
|
|
329
|
+
const oldest = entry.lastStates.keys().next().value;
|
|
330
|
+
if (oldest !== undefined)
|
|
331
|
+
entry.lastStates.delete(oldest);
|
|
332
|
+
}
|
|
333
|
+
if (previous === undefined) {
|
|
334
|
+
// First observation: only a state-targeted watch may report an
|
|
335
|
+
// already-satisfied condition; unfiltered watches baseline silently.
|
|
336
|
+
if (entry.allowInitialMatch && entry.states !== undefined && matchesStates(entry.states, snapshot)) {
|
|
337
|
+
pending.push({ snapshot, previous: undefined });
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (previous === filterKey)
|
|
342
|
+
continue;
|
|
343
|
+
if (!matchesStates(entry.states, snapshot))
|
|
344
|
+
continue;
|
|
345
|
+
pending.push({ snapshot, previous });
|
|
346
|
+
}
|
|
347
|
+
return pending;
|
|
348
|
+
}
|
|
349
|
+
AgendaGithubTrigger.collectChanges = collectChanges;
|
|
350
|
+
/** Baseline key combining run status and conclusion for workflow/check runs. */
|
|
351
|
+
function snapshotKey(snapshot) {
|
|
352
|
+
return snapshot.conclusion !== undefined ? `${snapshot.state}:${snapshot.conclusion}` : snapshot.state;
|
|
353
|
+
}
|
|
354
|
+
/** Whether a transition matches the configured states filter (status or conclusion). */
|
|
355
|
+
function matchesStates(states, snapshot) {
|
|
356
|
+
if (!states)
|
|
357
|
+
return true;
|
|
358
|
+
return (states.includes(snapshot.state) || (snapshot.conclusion !== undefined && states.includes(snapshot.conclusion)));
|
|
359
|
+
}
|
|
360
|
+
async function api(path, token) {
|
|
361
|
+
const response = await fetch(`https://api.github.com${path}`, {
|
|
362
|
+
headers: {
|
|
363
|
+
Authorization: `Bearer ${token}`,
|
|
364
|
+
Accept: "application/vnd.github+json",
|
|
365
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
366
|
+
"User-Agent": GitHubProvider.USER_AGENT,
|
|
367
|
+
},
|
|
368
|
+
signal: AbortSignal.timeout(15_000),
|
|
369
|
+
});
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
throw new Error(`GitHub API ${path} failed with status ${response.status}`);
|
|
372
|
+
}
|
|
373
|
+
return response.json();
|
|
374
|
+
}
|
|
375
|
+
async function fetchSnapshots(entry, token) {
|
|
376
|
+
const [owner, repo] = entry.repository.split("/");
|
|
377
|
+
switch (entry.resource) {
|
|
378
|
+
case "pr": {
|
|
379
|
+
if (entry.number !== undefined) {
|
|
380
|
+
const pr = await api(`/repos/${owner}/${repo}/pulls/${entry.number}`, token);
|
|
381
|
+
return [prSnapshot(pr)];
|
|
382
|
+
}
|
|
383
|
+
const list = await api(`/repos/${owner}/${repo}/pulls?state=all&per_page=10&sort=updated&direction=desc`, token);
|
|
384
|
+
return (Array.isArray(list) ? list : []).map(prSnapshot);
|
|
385
|
+
}
|
|
386
|
+
case "issue": {
|
|
387
|
+
if (entry.number !== undefined) {
|
|
388
|
+
const issue = await api(`/repos/${owner}/${repo}/issues/${entry.number}`, token);
|
|
389
|
+
return [issueSnapshot(issue)];
|
|
390
|
+
}
|
|
391
|
+
// The issues list mixes PRs into the newest page; fetch several pages
|
|
392
|
+
// so filtering PRs out still leaves the intended issue window even in
|
|
393
|
+
// PR-heavy repositories.
|
|
394
|
+
const pages = await Promise.all([1, 2, 3].map((page) => api(`/repos/${owner}/${repo}/issues?state=all&per_page=30&sort=updated&direction=desc&page=${page}`, token)));
|
|
395
|
+
return pages
|
|
396
|
+
.flatMap((page) => (Array.isArray(page) ? page : []))
|
|
397
|
+
.filter((i) => i.pull_request === undefined)
|
|
398
|
+
.slice(0, 10)
|
|
399
|
+
.map(issueSnapshot);
|
|
400
|
+
}
|
|
401
|
+
case "workflow": {
|
|
402
|
+
if (entry.number !== undefined) {
|
|
403
|
+
const run = await api(`/repos/${owner}/${repo}/actions/runs/${entry.number}`, token);
|
|
404
|
+
const payload = run.workflow_run ?? run;
|
|
405
|
+
return [workflowSnapshot(payload)];
|
|
406
|
+
}
|
|
407
|
+
const branch = entry.ref ? `&branch=${encodeURIComponent(entry.ref)}` : "";
|
|
408
|
+
const list = await api(`/repos/${owner}/${repo}/actions/runs?per_page=10${branch}`, token);
|
|
409
|
+
return (Array.isArray(list.workflow_runs) ? list.workflow_runs : []).map(workflowSnapshot);
|
|
410
|
+
}
|
|
411
|
+
case "check": {
|
|
412
|
+
const ref = encodeURIComponent(entry.ref ?? "HEAD");
|
|
413
|
+
const list = await api(`/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=20`, token);
|
|
414
|
+
return (Array.isArray(list.check_runs) ? list.check_runs : []).map(checkSnapshot);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function prSnapshot(pr) {
|
|
419
|
+
// List responses expose merge completion via merged_at; the single-PR
|
|
420
|
+
// response additionally carries a merged boolean. A draft PR closed
|
|
421
|
+
// without being marked ready still reports draft:true alongside
|
|
422
|
+
// state:"closed" — the terminal closed state must win over draft.
|
|
423
|
+
const merged = pr.merged === true || typeof pr.merged_at === "string";
|
|
424
|
+
const state = merged ? "merged" : pr.state === "closed" ? "closed" : pr.draft ? "draft" : pr.state;
|
|
425
|
+
return {
|
|
426
|
+
resource: "pr",
|
|
427
|
+
number: pr.number,
|
|
428
|
+
title: pr.title,
|
|
429
|
+
state,
|
|
430
|
+
url: pr.html_url,
|
|
431
|
+
draft: pr.draft === true,
|
|
432
|
+
mergeable: pr.mergeable === null ? undefined : pr.mergeable,
|
|
433
|
+
updatedAt: pr.updated_at,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
AgendaGithubTrigger.prSnapshot = prSnapshot;
|
|
437
|
+
function issueSnapshot(issue) {
|
|
438
|
+
return {
|
|
439
|
+
resource: "issue",
|
|
440
|
+
number: issue.number,
|
|
441
|
+
title: issue.title,
|
|
442
|
+
state: issue.state,
|
|
443
|
+
url: issue.html_url,
|
|
444
|
+
updatedAt: issue.updated_at,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
AgendaGithubTrigger.issueSnapshot = issueSnapshot;
|
|
448
|
+
/** Workflow state is the run status; the conclusion is carried separately. */
|
|
449
|
+
function workflowSnapshot(run) {
|
|
450
|
+
return {
|
|
451
|
+
resource: "workflow",
|
|
452
|
+
number: run.id,
|
|
453
|
+
title: run.name,
|
|
454
|
+
state: run.status,
|
|
455
|
+
url: run.html_url,
|
|
456
|
+
// GitHub returns null for in-progress runs; normalize to undefined so
|
|
457
|
+
// baseline keys and payloads treat "no conclusion yet" consistently.
|
|
458
|
+
conclusion: run.conclusion ?? undefined,
|
|
459
|
+
updatedAt: run.updated_at,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
AgendaGithubTrigger.workflowSnapshot = workflowSnapshot;
|
|
463
|
+
/** Check state is the check status; the conclusion is carried separately. */
|
|
464
|
+
function checkSnapshot(check) {
|
|
465
|
+
return {
|
|
466
|
+
resource: "check",
|
|
467
|
+
number: check.id,
|
|
468
|
+
title: check.name,
|
|
469
|
+
state: check.status,
|
|
470
|
+
url: check.html_url,
|
|
471
|
+
conclusion: check.conclusion ?? undefined,
|
|
472
|
+
updatedAt: check.started_at,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
AgendaGithubTrigger.checkSnapshot = checkSnapshot;
|
|
476
|
+
})(AgendaGithubTrigger || (AgendaGithubTrigger = {}));
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { GitHubProvider } from "@ericsanchezok/synergy-harness/provider/github";
|
|
2
|
+
import { GithubWatchPolicy } from "@ericsanchezok/synergy-agenda/agenda/github-watch-policy";
|
|
3
|
+
/**
|
|
4
|
+
* Creation-time gate for agenda items with GitHub triggers.
|
|
5
|
+
*
|
|
6
|
+
* A GitHub watch without a resolvable credential polls silently forever and
|
|
7
|
+
* never fires — and an `agenda_watch` with `onGithub` is a continuation
|
|
8
|
+
* blocker, so the origin session would wait indefinitely with no feedback.
|
|
9
|
+
* Reject at creation with concrete connection steps instead of persisting an
|
|
10
|
+
* item that can never fire. Token validity is deliberately not checked here
|
|
11
|
+
* (that needs a live API call); an invalid token surfaces through the existing
|
|
12
|
+
* consecutive-failure auto-pause in the poll loop.
|
|
13
|
+
*/
|
|
14
|
+
export var GithubWatchPreflight;
|
|
15
|
+
(function (GithubWatchPreflight) {
|
|
16
|
+
async function check(toolName) {
|
|
17
|
+
const watch = await GithubWatchPolicy.read();
|
|
18
|
+
if (watch?.enabled === false) {
|
|
19
|
+
return {
|
|
20
|
+
title: `${toolName} rejected`,
|
|
21
|
+
output: [
|
|
22
|
+
`GitHub triggers are disabled (github.watch.enabled=false in config).`,
|
|
23
|
+
``,
|
|
24
|
+
`Ask the user to enable them in Settings → GitHub → "Allow GitHub agenda triggers", or set github.watch.enabled=true in 115-github.jsonc.`,
|
|
25
|
+
].join("\n"),
|
|
26
|
+
metadata: { blocked: true, reason: "github_watch_disabled" },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const resolved = await GitHubProvider.resolveToken();
|
|
30
|
+
if (!resolved?.token) {
|
|
31
|
+
return {
|
|
32
|
+
title: `${toolName} rejected`,
|
|
33
|
+
output: [
|
|
34
|
+
`No connected GitHub credential — this GitHub watch would poll silently forever and never fire.`,
|
|
35
|
+
``,
|
|
36
|
+
`Connect one first, then retry this exact call:`,
|
|
37
|
+
`1. Ask the user to connect GitHub in Settings → GitHub (device login, import gh auth token, or paste a token).`,
|
|
38
|
+
`2. Or ensure GH_TOKEN / GITHUB_TOKEN is exported in the Synergy server environment.`,
|
|
39
|
+
``,
|
|
40
|
+
`Do not substitute a timed delay loop that shell-checks GitHub — that is the approximation this connection replaces. If the user declines to connect, tell them GitHub watches need a credential.`,
|
|
41
|
+
].join("\n"),
|
|
42
|
+
metadata: { blocked: true, reason: "github_credential_missing" },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
GithubWatchPreflight.check = check;
|
|
48
|
+
})(GithubWatchPreflight || (GithubWatchPreflight = {}));
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import watchDescription from "./watch-description.txt";
|
|
2
|
+
import scheduleDescription from "./schedule-description.txt";
|
|
3
|
+
import { AgendaGithubSource } from "@ericsanchezok/synergy-agenda/agenda/github-source";
|
|
4
|
+
import { AgendaGithubTrigger } from "./github-trigger.js";
|
|
5
|
+
import { GithubWatchPreflight } from "./preflight.js";
|
|
6
|
+
import { GithubWatchPolicy } from "@ericsanchezok/synergy-agenda/agenda/github-watch-policy";
|
|
7
|
+
import { readConfig } from "@ericsanchezok/synergy-github/config-schema";
|
|
8
|
+
export function registerBridge() {
|
|
9
|
+
AgendaGithubSource.registerSource({
|
|
10
|
+
...AgendaGithubTrigger,
|
|
11
|
+
descriptions: { watch: watchDescription, schedule: scheduleDescription },
|
|
12
|
+
check: GithubWatchPreflight.check,
|
|
13
|
+
});
|
|
14
|
+
GithubWatchPolicy.register(async () => {
|
|
15
|
+
const watch = (await readConfig()).github?.watch;
|
|
16
|
+
return { enabled: watch?.enabled !== false, defaultIntervalMs: watch?.defaultIntervalMs };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
- `{type:"github", resource:"pr"|"issue"|"workflow"|"check", repository:"owner/repo", number?, ref?, interval?, states?}` — fires when the watched GitHub resource changes state, polled on an interval (default 5m). `number` targets one PR/issue or workflow run id; `ref` targets a branch/tag/commit for workflow/check watches; `states` filters transitions (e.g. ["merged"], ["failure"]). Requires a connected GitHub credential — creation is rejected with connection steps when none resolves; do not substitute a generic `every` interval that shell-checks GitHub.
|
|
2
|
+
|
|
3
|
+
For "wait until a GitHub resource reaches a state" requests, the github trigger IS the right schedule — prefer it over a generic `every` interval plus manual shell checks.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Pass exactly one of `delay`, `onSessionEnd`, or `onGithub`.
|
|
2
|
+
|
|
3
|
+
**Prefer managed GitHub watches over repeated shell polling.** When the condition you are waiting for is a GitHub event (PR merged/closed, issue closed, workflow or check run finishing), pass `onGithub: {resource, repository, number?, ref?, states?}` instead of a `delay` plus repeated shell checks. The trigger polls the GitHub REST API with the connected credential on an interval (default 5 minutes, minimum 30 seconds) and wakes you when it next observes a matching state. Intermediate transitions between polls may be missed. `resource` is "pr" | "issue" | "workflow" | "check"; `repository` is owner/repo; `number` targets one PR/issue or workflow run id (omit for the repository's recent ites); `ref` targets a branch/tag/commit for workflow/check watches; `states` filters which new state should wake you (e.g. ["merged"], ["failure"], ["completed"]). Requires a connected GitHub credential — without one the watch is rejected with connection steps; ask the user to connect GitHub in Settings → GitHub rather than substituting timed shell polling.
|
|
4
|
+
|
|
5
|
+
Repo-wide watches (no `number`) fire for transitions of the 10 most recently updated items. A states-filtered watch reports new items first observed already in a targeted state; an unfiltered watch baselines first observations silently, so an item created and merged entirely between two polls is not reported. Point watches (`number`) always see transitions.
|
package/dist/worker.js
ADDED