@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 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
+ }
@@ -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,4 @@
1
+ import { Server } from "@ericsanchezok/synergy-server/server/server";
2
+ export function registerHttp() {
3
+ Server.registerContributions({ features: ["github-agenda"] }, "github-agenda");
4
+ }
@@ -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 = {}));
@@ -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
@@ -0,0 +1,6 @@
1
+ import { version } from "../package.json" with { type: "json" };
2
+ import { githubAgenda } from "./component.js";
3
+ export const metadata = { id: "github-agenda", version, apiVersion: 1 };
4
+ export function registerWorker() {
5
+ githubAgenda().register();
6
+ }