@ericsanchezok/synergy-github-agenda 0.0.0-stage → 3.1.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/package.json CHANGED
@@ -1,6 +1,109 @@
1
1
  {
2
2
  "name": "@ericsanchezok/synergy-github-agenda",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "exports": {
7
+ "./component": {
8
+ "types": "./src/component.ts",
9
+ "bun": "./dist/component.js",
10
+ "import": "./dist/component.js"
11
+ },
12
+ "./github-trigger": {
13
+ "types": "./src/github-trigger.ts",
14
+ "bun": "./dist/github-trigger.js",
15
+ "import": "./dist/github-trigger.js"
16
+ },
17
+ "./http": {
18
+ "types": "./src/http.ts",
19
+ "bun": "./dist/http.js",
20
+ "import": "./dist/http.js"
21
+ },
22
+ "./preflight": {
23
+ "types": "./src/preflight.ts",
24
+ "bun": "./dist/preflight.js",
25
+ "import": "./dist/preflight.js"
26
+ },
27
+ "./register": {
28
+ "types": "./src/register.ts",
29
+ "bun": "./dist/register.js",
30
+ "import": "./dist/register.js"
31
+ },
32
+ "./schedule-description.txt": {
33
+ "types": "./src/schedule-description.txt",
34
+ "bun": "./dist/schedule-description.txt",
35
+ "import": "./dist/schedule-description.txt"
36
+ },
37
+ "./watch-description.txt": {
38
+ "types": "./src/watch-description.txt",
39
+ "bun": "./dist/watch-description.txt",
40
+ "import": "./dist/watch-description.txt"
41
+ },
42
+ "./worker": {
43
+ "types": "./src/worker.ts",
44
+ "bun": "./dist/worker.js",
45
+ "import": "./dist/worker.js"
46
+ },
47
+ "./features": {
48
+ "types": "./src/features.ts",
49
+ "bun": "./dist/features.js",
50
+ "import": "./dist/features.js"
51
+ }
52
+ },
53
+ "dependencies": {
54
+ "@ericsanchezok/synergy-agenda": "3.1.0",
55
+ "@ericsanchezok/synergy-github": "3.1.0",
56
+ "@ericsanchezok/synergy-harness": "3.1.0"
57
+ },
58
+ "synergy": {
59
+ "formatVersion": 1,
60
+ "kind": "component",
61
+ "id": "github-agenda",
62
+ "version": "3.1.0",
63
+ "compatibility": {
64
+ "synergy": "3.1.0"
65
+ },
66
+ "apiVersion": 1,
67
+ "entry": "./dist/component.js",
68
+ "export": "githubAgenda",
69
+ "requires": {
70
+ "github": "3.1.0",
71
+ "agenda": "3.1.0"
72
+ },
73
+ "packages": {
74
+ "@ericsanchezok/synergy-github": "3.1.0",
75
+ "@ericsanchezok/synergy-agenda": "3.1.0"
76
+ },
77
+ "features": {
78
+ "kind": "bridge",
79
+ "definitions": [
80
+ {
81
+ "id": "github-agenda",
82
+ "owner": "github-agenda",
83
+ "maturity": "stable",
84
+ "activation": "restart",
85
+ "name": "Github Agenda",
86
+ "description": "Provide github agenda capabilities.",
87
+ "component": "github-agenda"
88
+ }
89
+ ]
90
+ }
91
+ },
92
+ "peerDependencies": {
93
+ "@ericsanchezok/synergy-server": "3.1.0"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "@ericsanchezok/synergy-server": {
97
+ "optional": true
98
+ }
99
+ },
100
+ "files": [
101
+ "src",
102
+ "dist",
103
+ "README.md",
104
+ "AGENTS.md"
105
+ ],
106
+ "engines": {
107
+ "bun": ">=1.4.3"
108
+ }
109
+ }
@@ -0,0 +1,18 @@
1
+ import { componentFeatures } from "./features"
2
+ import type { RuntimeComponent } from "@ericsanchezok/synergy-harness/lifecycle"
3
+ import { version } from "../package.json" with { type: "json" }
4
+ import { registerBridge } from "./register"
5
+ export function githubAgenda(): RuntimeComponent {
6
+ return {
7
+ id: "github-agenda",
8
+ features: componentFeatures,
9
+ apiVersion: 1,
10
+ version,
11
+ requires: { github: version, agenda: version },
12
+ adapters: { http: new URL("./http.ts", import.meta.url) },
13
+ workers: { agent: new URL("./worker.ts", import.meta.url) },
14
+ register() {
15
+ registerBridge()
16
+ },
17
+ }
18
+ }
@@ -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
+ } as const
@@ -0,0 +1,539 @@
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 { AgendaTypes } from "@ericsanchezok/synergy-agenda/agenda/types"
6
+ import { Log } from "@ericsanchezok/synergy-harness/util/log"
7
+ import { GitHubProvider } from "@ericsanchezok/synergy-harness/provider/github"
8
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
9
+
10
+ /**
11
+ * GitHub agenda trigger — polls the GitHub REST API on a per-trigger interval
12
+ * and fires agenda items when a watched PR / issue / workflow / check run
13
+ * changes state.
14
+ *
15
+ * Mirrors the other agenda trigger sources: registrations are keyed by item,
16
+ * each entry runs its own polling loop, and matches are forwarded to the
17
+ * shared Agenda handler as a FiredSignal whose payload carries the resource
18
+ * snapshot (number, state, URL, and resource-specific fields).
19
+ *
20
+ * - Zero-cost when idle: entries with no resolved GitHub credential make no
21
+ * API call, and the whole source goes quiet when `github.watch.enabled` is
22
+ * false in config (default: enabled).
23
+ * - Triggers without an explicit `interval` fall back to the configured
24
+ * `github.watch.defaultIntervalMs` (default 5 minutes, floor 30 seconds).
25
+ * - Multiple transitions observed in one poll are dispatched sequentially so
26
+ * the Agenda inflight guard cannot drop later transitions.
27
+ * - Repeated poll failures (unreachable repo, invalid token) auto-pause the
28
+ * item instead of retrying forever, and a credential that stays missing
29
+ * across consecutive polls (account disconnected after creation) pauses
30
+ * the item too instead of holding a wait silently.
31
+ */
32
+ export namespace AgendaGithubTrigger {
33
+ const log = Log.create({ service: "agenda.github-trigger" })
34
+
35
+ const DEFAULT_INTERVAL_MS = 5 * 60_000
36
+ const MIN_INTERVAL_MS = 30_000
37
+ const MAX_CONSECUTIVE_FAILURES = 5
38
+ /** Bounded recent baseline: repository-wide watches must not accumulate
39
+ * every observed resource ID for the lifetime of the trigger. */
40
+ const MAX_BASELINE_ENTRIES = 256
41
+
42
+ type Handler = (signal: AgendaTypes.FiredSignal, scopeID: string) => Promise<void>
43
+
44
+ export interface Entry {
45
+ itemID: string
46
+ scopeID: string
47
+ resource: "pr" | "issue" | "workflow" | "check"
48
+ repository: string
49
+ number?: number
50
+ ref?: string
51
+ /** Explicit trigger interval; undefined falls back to the configured default. */
52
+ intervalMs?: number
53
+ states?: string[]
54
+ timer?: Timer
55
+ /** resource key → last observed state */
56
+ lastStates: Map<string, string>
57
+ /**
58
+ * Whether first observations of a targeted state may fire. True for
59
+ * newly created items; false during the first restore poll of a
60
+ * previously-run item (so a restart re-baselines silently), then flipped
61
+ * true after that baseline so genuinely new resources are reported again.
62
+ */
63
+ allowInitialMatch: boolean
64
+ consecutiveFailures: number
65
+ /** Consecutive polls that found no resolvable GitHub credential. */
66
+ missingCredentialPolls: number
67
+ /** Serializes handler dispatch so concurrent transitions run in order
68
+ * instead of racing the Agenda inflight guard. */
69
+ dispatch: Promise<void>
70
+ }
71
+
72
+ const runtimeState = RuntimeContext.state(() => ({
73
+ entries: new Map<string, Entry[]>(),
74
+ handler: null as Handler | null,
75
+ started: false,
76
+ }))
77
+
78
+ export function start(onFire: Handler, items: AgendaTypes.Item[]): void {
79
+ const instanceState = runtimeState()
80
+
81
+ instanceState.handler = onFire
82
+ // Arm before registering: register() only schedules while started, and
83
+ // items restored from storage at startup must begin polling immediately.
84
+ instanceState.started = true
85
+ for (const item of items) {
86
+ register(item.id, item.origin.scope.id, item.triggers, { hasRun: item.state.runCount > 0 })
87
+ }
88
+ log.info("started", { entries: countEntries() })
89
+ }
90
+
91
+ export function stop(): void {
92
+ const instanceState = runtimeState()
93
+
94
+ for (const list of instanceState.entries.values()) {
95
+ for (const entry of list) cancelTimer(entry)
96
+ }
97
+ instanceState.entries.clear()
98
+ instanceState.started = false
99
+ instanceState.handler = null
100
+ }
101
+
102
+ export function register(
103
+ itemID: string,
104
+ scopeID: string,
105
+ triggers: AgendaTypes.Trigger[],
106
+ opts: { hasRun?: boolean } = {},
107
+ ): void {
108
+ const instanceState = runtimeState()
109
+
110
+ unregister(itemID)
111
+ const created: Entry[] = []
112
+ for (const trigger of triggers) {
113
+ if (trigger.type !== "github") continue
114
+ created.push({
115
+ itemID,
116
+ scopeID,
117
+ resource: trigger.resource,
118
+ repository: trigger.repository,
119
+ number: trigger.number,
120
+ ref: trigger.ref,
121
+ intervalMs: trigger.interval ? AgendaStore.parseDuration(trigger.interval) : undefined,
122
+ states: trigger.states,
123
+ lastStates: new Map(),
124
+ // Only items that have never run may report an already-satisfied
125
+ // target state on first observation; restored items re-baseline
126
+ // silently so restarts do not re-notify.
127
+ allowInitialMatch: opts.hasRun !== true,
128
+ consecutiveFailures: 0,
129
+ missingCredentialPolls: 0,
130
+ dispatch: Promise.resolve(),
131
+ })
132
+ }
133
+ if (created.length > 0) {
134
+ instanceState.entries.set(itemID, created)
135
+ log.info("github trigger registered", {
136
+ itemID,
137
+ entries: created.length,
138
+ resource: created[0]?.resource,
139
+ repository: created[0]?.repository,
140
+ started: instanceState.started,
141
+ })
142
+ if (instanceState.started) for (const entry of created) schedule(entry, 0)
143
+ }
144
+ }
145
+
146
+ export function unregister(itemID: string): void {
147
+ const instanceState = runtimeState()
148
+
149
+ const list = instanceState.entries.get(itemID)
150
+ if (!list) return
151
+ for (const entry of list) cancelTimer(entry)
152
+ instanceState.entries.delete(itemID)
153
+ }
154
+
155
+ export function active(): { items: number; entries: number } {
156
+ const instanceState = runtimeState()
157
+
158
+ return { items: instanceState.entries.size, entries: countEntries() }
159
+ }
160
+
161
+ /** Test/inspection hook: the live poll entries registered for an item. */
162
+ export function entriesFor(itemID: string): Entry[] {
163
+ const instanceState = runtimeState()
164
+
165
+ return instanceState.entries.get(itemID) ?? []
166
+ }
167
+
168
+ function countEntries(): number {
169
+ const instanceState = runtimeState()
170
+
171
+ let n = 0
172
+ for (const list of instanceState.entries.values()) n += list.length
173
+ return n
174
+ }
175
+
176
+ function cancelTimer(entry: Entry) {
177
+ if (entry.timer) {
178
+ clearTimeout(entry.timer)
179
+ entry.timer = undefined
180
+ }
181
+ }
182
+
183
+ function schedule(entry: Entry, delayMs: number) {
184
+ cancelTimer(entry)
185
+ entry.timer = setTimeout(() => void poll(entry), delayMs)
186
+ }
187
+
188
+ function isCurrent(instanceState: ReturnType<typeof runtimeState>, entry: Entry): boolean {
189
+ return instanceState.entries.get(entry.itemID)?.includes(entry) === true
190
+ }
191
+
192
+ async function watchConfig(): Promise<{ enabled: boolean; defaultIntervalMs?: number }> {
193
+ return GithubWatchPolicy.read()
194
+ }
195
+
196
+ export async function poll(entry: Entry) {
197
+ const instanceState = runtimeState()
198
+ if (!isCurrent(instanceState, entry)) return
199
+ entry.timer = undefined
200
+ let configuredDefaultMs: number | undefined
201
+ let abandoned = false
202
+ try {
203
+ const watch = await watchConfig()
204
+ if (!isCurrent(instanceState, entry)) return
205
+ configuredDefaultMs = watch.defaultIntervalMs
206
+ if (!watch.enabled) {
207
+ // Polling was switched off after this item was created: pause it now
208
+ // (releasing any continuation holding it) instead of idling forever.
209
+ abandoned = true
210
+ await pauseEntry(entry, "github.watch.enabled=false")
211
+ return
212
+ }
213
+ const resolved = await GitHubProvider.resolveToken()
214
+ if (!isCurrent(instanceState, entry)) return
215
+ if (!resolved?.token) {
216
+ // Credential vanished after creation (account disconnected, env
217
+ // removed): poll silently a few times, then pause the item — the
218
+ // same release path as the other pauses — so a waiting session is
219
+ // not held forever by a watch that can never fire.
220
+ entry.missingCredentialPolls++
221
+ if (entry.missingCredentialPolls >= MAX_CONSECUTIVE_FAILURES) {
222
+ abandoned = true
223
+ await pauseEntry(entry, "no GitHub credential resolves")
224
+ }
225
+ return
226
+ }
227
+ entry.missingCredentialPolls = 0
228
+ const snapshots = await fetchSnapshots(entry, resolved.token)
229
+ if (!isCurrent(instanceState, entry)) return
230
+ const changes = collectChanges(entry, snapshots)
231
+ // The first poll after a restart is the restore baseline: it must not
232
+ // fire for already-satisfied states, but after it completes, first
233
+ // observations of genuinely new resources may fire again.
234
+ entry.allowInitialMatch = true
235
+ for (const change of changes) {
236
+ entry.dispatch = entry.dispatch.then(() => fire(instanceState, entry, change.snapshot, change.previous))
237
+ }
238
+ entry.consecutiveFailures = 0
239
+ } catch (error) {
240
+ if (!isCurrent(instanceState, entry)) return
241
+ entry.consecutiveFailures++
242
+ log.warn("github poll failed", {
243
+ itemID: entry.itemID,
244
+ repository: entry.repository,
245
+ consecutiveFailures: entry.consecutiveFailures,
246
+ error: error instanceof Error ? error.message : String(error),
247
+ })
248
+ if (entry.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
249
+ abandoned = true
250
+ await pauseEntry(entry, "repeated poll failures")
251
+ }
252
+ } finally {
253
+ // Never reschedule a detached entry: unregister()/stop() may have run
254
+ // while the poll request was in flight.
255
+ if (!abandoned && instanceState.started && isCurrent(instanceState, entry)) {
256
+ const intervalMs = entry.intervalMs ?? configuredDefaultMs ?? DEFAULT_INTERVAL_MS
257
+ schedule(entry, Math.max(MIN_INTERVAL_MS, intervalMs))
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Pause an item and release any continuation it holds (Light Loop /
264
+ * BlueprintLoop) — the same release hook Agenda pause/cancel use — or the
265
+ * session stays stopped forever.
266
+ */
267
+ async function pauseEntry(entry: Entry, reason: string): Promise<void> {
268
+ const instanceState = runtimeState()
269
+
270
+ try {
271
+ const transition = await Storage.transaction(async () => {
272
+ const instanceState = runtimeState()
273
+
274
+ if (!instanceState.entries.get(entry.itemID)?.includes(entry)) return
275
+ const before = await AgendaStore.get(entry.scopeID, entry.itemID)
276
+ if (before.status !== "active" || !instanceState.entries.get(entry.itemID)?.includes(entry)) return
277
+ const after = await AgendaStore.update(entry.scopeID, entry.itemID, { status: "paused" })
278
+ return { before, after }
279
+ })
280
+ if (!transition) return
281
+ if (instanceState.entries.get(entry.itemID)?.includes(entry)) unregister(entry.itemID)
282
+ await AgendaSessionWakeup.resumeIfReleased(transition)
283
+ } catch (err) {
284
+ log.error("failed to pause github trigger", {
285
+ itemID: entry.itemID,
286
+ reason,
287
+ error: err instanceof Error ? err : new Error(String(err)),
288
+ })
289
+ return
290
+ }
291
+ log.warn(`github trigger paused: ${reason}`, {
292
+ itemID: entry.itemID,
293
+ repository: entry.repository,
294
+ })
295
+ }
296
+
297
+ function fire(
298
+ instanceState: ReturnType<typeof runtimeState>,
299
+ entry: Entry,
300
+ snapshot: ResourceSnapshot,
301
+ previous: string | undefined,
302
+ ): Promise<void> {
303
+ const handler = instanceState.handler
304
+ if (!handler || !isCurrent(instanceState, entry)) return Promise.resolve()
305
+ const signal: AgendaTypes.FiredSignal = {
306
+ type: "github",
307
+ source: entry.itemID,
308
+ payload: {
309
+ resource: entry.resource,
310
+ repository: entry.repository,
311
+ number: snapshot.number,
312
+ title: snapshot.title,
313
+ state: snapshot.state,
314
+ previousState: previous,
315
+ url: snapshot.url,
316
+ ...(snapshot.conclusion !== undefined ? { conclusion: snapshot.conclusion } : {}),
317
+ ...(snapshot.draft !== undefined ? { draft: snapshot.draft } : {}),
318
+ ...(snapshot.mergeable !== undefined ? { mergeable: snapshot.mergeable } : {}),
319
+ ...(snapshot.updatedAt !== undefined ? { updatedAt: snapshot.updatedAt } : {}),
320
+ },
321
+ timestamp: Date.now(),
322
+ }
323
+ return handler(signal, entry.scopeID).catch((err) => {
324
+ log.error("github trigger handler failed", {
325
+ itemID: entry.itemID,
326
+ error: err instanceof Error ? err : new Error(String(err)),
327
+ })
328
+ })
329
+ }
330
+
331
+ /**
332
+ * Diff snapshots against the entry baseline and return the transitions that
333
+ * should fire, in observation order. The baseline always advances (even for
334
+ * filtered-out transitions) so `previousState` reflects the last seen state.
335
+ *
336
+ * The baseline key is `state` for PR/issue watches and `state:conclusion`
337
+ * for workflow/check watches, so a run finishing (`completed` →
338
+ * `completed:failure`) counts as a transition and the states filter can
339
+ * match both the run status ("completed") and the conclusion ("success",
340
+ * "failure") as documented.
341
+ *
342
+ * First observation of a resource (`previous === undefined`): a
343
+ * state-targeted watch reports immediately when the resource already sits
344
+ * in a targeted state — covering watches created for an already-satisfied
345
+ * condition and races where the state changed between item creation and the
346
+ * first poll. Watches without a states filter stay silent on first
347
+ * observation so repository-wide watchers do not fire on creation.
348
+ * `allowInitialMatch` gates this to items that have never run, so a
349
+ * restart re-baselines silently instead of re-notifying.
350
+ */
351
+ export function collectChanges(
352
+ entry: Pick<Entry, "lastStates" | "allowInitialMatch" | "states">,
353
+ snapshots: ResourceSnapshot[],
354
+ ): Array<{ snapshot: ResourceSnapshot; previous: string | undefined }> {
355
+ const pending: Array<{ snapshot: ResourceSnapshot; previous: string | undefined }> = []
356
+ for (const snapshot of snapshots) {
357
+ const key = `${snapshot.resource ?? ""}:${snapshot.number}`
358
+ const filterKey = snapshotKey(snapshot)
359
+ const previous = entry.lastStates.get(key)
360
+ // Refresh insertion order on every observation (Map.set keeps the
361
+ // original position for existing keys) so eviction approximates LRU:
362
+ // resources still inside the observation window are never evicted,
363
+ // only ones that left it long ago. Without the refresh, a busy
364
+ // repository could evict an actively observed resource and re-fire it
365
+ // as a duplicate first observation.
366
+ entry.lastStates.delete(key)
367
+ entry.lastStates.set(key, filterKey)
368
+ // Bound the baseline: repository-wide watches observe new resource IDs
369
+ // every poll; drop the least recently observed entry once the cap is
370
+ // exceeded so long-running servers do not accumulate unbounded memory.
371
+ if (entry.lastStates.size > MAX_BASELINE_ENTRIES) {
372
+ const oldest = entry.lastStates.keys().next().value
373
+ if (oldest !== undefined) entry.lastStates.delete(oldest)
374
+ }
375
+ if (previous === undefined) {
376
+ // First observation: only a state-targeted watch may report an
377
+ // already-satisfied condition; unfiltered watches baseline silently.
378
+ if (entry.allowInitialMatch && entry.states !== undefined && matchesStates(entry.states, snapshot)) {
379
+ pending.push({ snapshot, previous: undefined })
380
+ }
381
+ continue
382
+ }
383
+ if (previous === filterKey) continue
384
+ if (!matchesStates(entry.states, snapshot)) continue
385
+ pending.push({ snapshot, previous })
386
+ }
387
+ return pending
388
+ }
389
+
390
+ /** Baseline key combining run status and conclusion for workflow/check runs. */
391
+ function snapshotKey(snapshot: ResourceSnapshot): string {
392
+ return snapshot.conclusion !== undefined ? `${snapshot.state}:${snapshot.conclusion}` : snapshot.state
393
+ }
394
+
395
+ /** Whether a transition matches the configured states filter (status or conclusion). */
396
+ function matchesStates(states: string[] | undefined, snapshot: ResourceSnapshot): boolean {
397
+ if (!states) return true
398
+ return (
399
+ states.includes(snapshot.state) || (snapshot.conclusion !== undefined && states.includes(snapshot.conclusion))
400
+ )
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // GitHub REST polling
404
+ // ---------------------------------------------------------------------------
405
+
406
+ export interface ResourceSnapshot {
407
+ resource?: "pr" | "issue" | "workflow" | "check"
408
+ number: number
409
+ title?: string
410
+ state: string
411
+ url?: string
412
+ conclusion?: string
413
+ draft?: boolean
414
+ mergeable?: boolean
415
+ updatedAt?: string
416
+ }
417
+
418
+ async function api(path: string, token: string): Promise<any> {
419
+ const response = await fetch(`https://api.github.com${path}`, {
420
+ headers: {
421
+ Authorization: `Bearer ${token}`,
422
+ Accept: "application/vnd.github+json",
423
+ "X-GitHub-Api-Version": "2022-11-28",
424
+ "User-Agent": GitHubProvider.USER_AGENT,
425
+ },
426
+ signal: AbortSignal.timeout(15_000),
427
+ })
428
+ if (!response.ok) {
429
+ throw new Error(`GitHub API ${path} failed with status ${response.status}`)
430
+ }
431
+ return response.json()
432
+ }
433
+
434
+ async function fetchSnapshots(entry: Entry, token: string): Promise<ResourceSnapshot[]> {
435
+ const [owner, repo] = entry.repository.split("/")
436
+ switch (entry.resource) {
437
+ case "pr": {
438
+ if (entry.number !== undefined) {
439
+ const pr = await api(`/repos/${owner}/${repo}/pulls/${entry.number}`, token)
440
+ return [prSnapshot(pr)]
441
+ }
442
+ const list = await api(`/repos/${owner}/${repo}/pulls?state=all&per_page=10&sort=updated&direction=desc`, token)
443
+ return (Array.isArray(list) ? list : []).map(prSnapshot)
444
+ }
445
+ case "issue": {
446
+ if (entry.number !== undefined) {
447
+ const issue = await api(`/repos/${owner}/${repo}/issues/${entry.number}`, token)
448
+ return [issueSnapshot(issue)]
449
+ }
450
+ // The issues list mixes PRs into the newest page; fetch several pages
451
+ // so filtering PRs out still leaves the intended issue window even in
452
+ // PR-heavy repositories.
453
+ const pages = await Promise.all(
454
+ [1, 2, 3].map((page) =>
455
+ api(`/repos/${owner}/${repo}/issues?state=all&per_page=30&sort=updated&direction=desc&page=${page}`, token),
456
+ ),
457
+ )
458
+ return pages
459
+ .flatMap((page) => (Array.isArray(page) ? page : []))
460
+ .filter((i: any) => i.pull_request === undefined)
461
+ .slice(0, 10)
462
+ .map(issueSnapshot)
463
+ }
464
+ case "workflow": {
465
+ if (entry.number !== undefined) {
466
+ const run = await api(`/repos/${owner}/${repo}/actions/runs/${entry.number}`, token)
467
+ const payload = run.workflow_run ?? run
468
+ return [workflowSnapshot(payload)]
469
+ }
470
+ const branch = entry.ref ? `&branch=${encodeURIComponent(entry.ref)}` : ""
471
+ const list = await api(`/repos/${owner}/${repo}/actions/runs?per_page=10${branch}`, token)
472
+ return (Array.isArray(list.workflow_runs) ? list.workflow_runs : []).map(workflowSnapshot)
473
+ }
474
+ case "check": {
475
+ const ref = encodeURIComponent(entry.ref ?? "HEAD")
476
+ const list = await api(`/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=20`, token)
477
+ return (Array.isArray(list.check_runs) ? list.check_runs : []).map(checkSnapshot)
478
+ }
479
+ }
480
+ }
481
+
482
+ export function prSnapshot(pr: any): ResourceSnapshot {
483
+ // List responses expose merge completion via merged_at; the single-PR
484
+ // response additionally carries a merged boolean. A draft PR closed
485
+ // without being marked ready still reports draft:true alongside
486
+ // state:"closed" — the terminal closed state must win over draft.
487
+ const merged = pr.merged === true || typeof pr.merged_at === "string"
488
+ const state = merged ? "merged" : pr.state === "closed" ? "closed" : pr.draft ? "draft" : pr.state
489
+ return {
490
+ resource: "pr",
491
+ number: pr.number,
492
+ title: pr.title,
493
+ state,
494
+ url: pr.html_url,
495
+ draft: pr.draft === true,
496
+ mergeable: pr.mergeable === null ? undefined : pr.mergeable,
497
+ updatedAt: pr.updated_at,
498
+ }
499
+ }
500
+
501
+ export function issueSnapshot(issue: any): ResourceSnapshot {
502
+ return {
503
+ resource: "issue",
504
+ number: issue.number,
505
+ title: issue.title,
506
+ state: issue.state,
507
+ url: issue.html_url,
508
+ updatedAt: issue.updated_at,
509
+ }
510
+ }
511
+
512
+ /** Workflow state is the run status; the conclusion is carried separately. */
513
+ export function workflowSnapshot(run: any): ResourceSnapshot {
514
+ return {
515
+ resource: "workflow",
516
+ number: run.id,
517
+ title: run.name,
518
+ state: run.status,
519
+ url: run.html_url,
520
+ // GitHub returns null for in-progress runs; normalize to undefined so
521
+ // baseline keys and payloads treat "no conclusion yet" consistently.
522
+ conclusion: run.conclusion ?? undefined,
523
+ updatedAt: run.updated_at,
524
+ }
525
+ }
526
+
527
+ /** Check state is the check status; the conclusion is carried separately. */
528
+ export function checkSnapshot(check: any): ResourceSnapshot {
529
+ return {
530
+ resource: "check",
531
+ number: check.id,
532
+ title: check.name,
533
+ state: check.status,
534
+ url: check.html_url,
535
+ conclusion: check.conclusion ?? undefined,
536
+ updatedAt: check.started_at,
537
+ }
538
+ }
539
+ }
package/src/http.ts 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
+ }