@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/package.json
CHANGED
|
@@ -1,6 +1,109 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericsanchezok/synergy-github-agenda",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.1.1",
|
|
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.1",
|
|
55
|
+
"@ericsanchezok/synergy-github": "3.1.1",
|
|
56
|
+
"@ericsanchezok/synergy-harness": "3.1.1"
|
|
57
|
+
},
|
|
58
|
+
"synergy": {
|
|
59
|
+
"formatVersion": 1,
|
|
60
|
+
"kind": "component",
|
|
61
|
+
"id": "github-agenda",
|
|
62
|
+
"version": "3.1.1",
|
|
63
|
+
"compatibility": {
|
|
64
|
+
"synergy": "3.1.1"
|
|
65
|
+
},
|
|
66
|
+
"apiVersion": 1,
|
|
67
|
+
"entry": "./dist/component.js",
|
|
68
|
+
"export": "githubAgenda",
|
|
69
|
+
"requires": {
|
|
70
|
+
"github": "3.1.1",
|
|
71
|
+
"agenda": "3.1.1"
|
|
72
|
+
},
|
|
73
|
+
"packages": {
|
|
74
|
+
"@ericsanchezok/synergy-github": "3.1.1",
|
|
75
|
+
"@ericsanchezok/synergy-agenda": "3.1.1"
|
|
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.1"
|
|
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
|
+
}
|
package/src/component.ts
ADDED
|
@@ -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
|
+
}
|
package/src/features.ts
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
|
+
} 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