diff --git a/packages/loopover-miner/lib/ams-notifications.ts b/packages/loopover-miner/lib/ams-notifications.ts new file mode 100644 index 0000000000..95142c6580 --- /dev/null +++ b/packages/loopover-miner/lib/ams-notifications.ts @@ -0,0 +1,213 @@ +// AMS → hosted badge notifications (#7657). Builds DetectedNotificationEvent-shaped AMS kinds and POSTs them +// to the contributor ams-notifications ingest, which evaluates through evaluateNotificationEvent → +// notify-deliver (same handoff as src/queue/job-dispatch.ts). Fail-soft: a missing session or network blip +// never breaks the miner's real work. No parallel local notification store. + +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; + +export type AmsNotificationEventPayload = { + eventType: "ams_attempt_started" | "ams_attempt_failed" | "ams_governor_paused" | "ams_pr_outcome"; + recipientLogin: string; + repoFullName: string; + pullNumber: number; + dedupKey: string; + deeplink: string; + actorLogin: string; + detectedAt: string; +}; + +export type AmsNotificationPublishResult = { sent: number; error?: string }; + +export type AmsNotificationFetch = ( + url: string, + init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, +) => Promise; + +export type PublishAmsNotificationEventsOptions = { + env?: Record; + fetchFn?: AmsNotificationFetch; + timeoutMs?: number; + /** Test/self-host inject: mirrors job-dispatch evaluate → notify-deliver without HTTP. */ + dispatch?: (events: AmsNotificationEventPayload[]) => Promise; +}; + +export const DEFAULT_AMS_NOTIFICATION_TIMEOUT_MS = 10_000; + +function normalizeLogin(login: string): string { + return login.trim().toLowerCase(); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function githubIssueDeeplink(repoFullName: string, issueNumber: number): string { + return `https://github.com/${repoFullName}/issues/${issueNumber}`; +} + +function githubPullDeeplink(repoFullName: string, pullNumber: number): string { + return `https://github.com/${repoFullName}/pull/${pullNumber}`; +} + +export function buildAmsAttemptStartedPayload(input: { + recipientLogin: string; + repoFullName: string; + issueNumber: number; + attemptId: string; + detectedAt?: string; +}): AmsNotificationEventPayload { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + return { + eventType: "ams_attempt_started", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.issueNumber, + dedupKey: `ams_attempt_started:${input.repoFullName}#${input.issueNumber}:${input.attemptId}`, + deeplink: githubIssueDeeplink(input.repoFullName, input.issueNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +export function buildAmsAttemptFailedPayload(input: { + recipientLogin: string; + repoFullName: string; + issueNumber: number; + attemptId: string; + reason?: string | null; + detectedAt?: string; +}): AmsNotificationEventPayload { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const reasonKey = input.reason?.trim() ? `:${input.reason.trim().slice(0, 80)}` : ""; + return { + eventType: "ams_attempt_failed", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.issueNumber, + dedupKey: `ams_attempt_failed:${input.repoFullName}#${input.issueNumber}:${input.attemptId}${reasonKey}`, + deeplink: githubIssueDeeplink(input.repoFullName, input.issueNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +export function buildAmsGovernorPausedPayload(input: { + recipientLogin: string; + reason?: string | null; + pausedAt?: string; + detectedAt?: string; +}): AmsNotificationEventPayload { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const pausedAt = input.pausedAt ?? detectedAt; + const reasonKey = input.reason?.trim() ? `:${input.reason.trim().slice(0, 80)}` : ""; + return { + eventType: "ams_governor_paused", + recipientLogin, + repoFullName: "ams/governor", + pullNumber: 0, + dedupKey: `ams_governor_paused:${recipientLogin}:${pausedAt}${reasonKey}`, + deeplink: "https://github.com/JSONbored/loopover", + actorLogin: recipientLogin, + detectedAt, + }; +} + +export function buildAmsPrOutcomePayload(input: { + recipientLogin: string; + repoFullName: string; + pullNumber: number; + decision: "merged" | "closed"; + closedAt?: string | null; + detectedAt?: string; +}): AmsNotificationEventPayload { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const closedAt = input.closedAt?.trim() || detectedAt; + return { + eventType: "ams_pr_outcome", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.pullNumber, + dedupKey: `ams_pr_outcome:${input.repoFullName}#${input.pullNumber}:${input.decision}:${closedAt}`, + deeplink: githubPullDeeplink(input.repoFullName, input.pullNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +/** + * Publish AMS notification events through the hosted evaluate → notify-deliver path. Prefer an injected + * `dispatch` (tests / in-process self-host). Otherwise POST to `/v1/contributors/:login/ams-notifications` + * when a loopover-mcp session is on disk. Never throws. + */ +export async function publishAmsNotificationEvents( + events: AmsNotificationEventPayload[], + options: PublishAmsNotificationEventsOptions = {}, +): Promise { + if (!Array.isArray(events) || events.length === 0) return { sent: 0 }; + if (options.dispatch) { + try { + await options.dispatch(events); + return { sent: events.length }; + } catch (error) { + return { sent: 0, error: error instanceof Error ? error.message.slice(0, 160) : "dispatch_failed" }; + } + } + + const env = options.env ?? process.env; + const session = resolveLoopoverBackendSession(env as NodeJS.ProcessEnv); + if (!session) return { sent: 0, error: "no_session" }; + + const recipientLogin = normalizeLogin(events[0]!.recipientLogin); + if (!recipientLogin) return { sent: 0, error: "missing_recipient" }; + if (events.some((event) => normalizeLogin(event.recipientLogin) !== recipientLogin)) { + return { sent: 0, error: "mixed_recipients" }; + } + + const fetchFn = options.fetchFn ?? (fetch as AmsNotificationFetch); + const timeoutMs = options.timeoutMs ?? DEFAULT_AMS_NOTIFICATION_TIMEOUT_MS; + const url = `${session.apiUrl}/v1/contributors/${encodeURIComponent(recipientLogin)}/ams-notifications`; + const body = JSON.stringify({ + events: events.map(({ eventType, repoFullName, pullNumber, dedupKey, deeplink, actorLogin, detectedAt }) => ({ + eventType, + repoFullName, + pullNumber, + dedupKey, + deeplink, + actorLogin, + detectedAt, + })), + }); + + try { + const response = await fetchFn(url, { + method: "POST", + headers: { + authorization: `Bearer ${session.sessionToken}`, + "content-type": "application/json", + accept: "application/json", + }, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + return { sent: 0, error: `http_${response.status}` }; + } + return { sent: events.length }; + } catch (error) { + return { sent: 0, error: error instanceof Error ? error.message.slice(0, 160) : "network_failed" }; + } +} + +/** Fire-and-forget wrapper for sync call sites (never awaits into the caller's critical path). */ +export function scheduleAmsNotificationEvents( + events: AmsNotificationEventPayload[], + options: PublishAmsNotificationEventsOptions = {}, +): void { + void publishAmsNotificationEvents(events, options).catch(() => { + // publishAmsNotificationEvents is already fail-soft; this only guards a rejected promise from an inject. + }); +} diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts index 2367774f46..fe8e7be86e 100644 --- a/packages/loopover-miner/lib/attempt-cli.ts +++ b/packages/loopover-miner/lib/attempt-cli.ts @@ -49,6 +49,12 @@ import { buildCodingTaskSpec } from "./coding-task-spec.js"; import type { buildCodingTaskSpec as BuildCodingTaskSpecFn } from "./coding-task-spec.js"; import { resolveAmsPolicy } from "./ams-policy.js"; import type { resolveAmsPolicy as ResolveAmsPolicyFn } from "./ams-policy.js"; +import { + buildAmsAttemptFailedPayload, + buildAmsAttemptStartedPayload, + scheduleAmsNotificationEvents, + type PublishAmsNotificationEventsOptions, +} from "./ams-notifications.js"; import { checkMinerKillSwitch, recordMinerKillSwitchTransition } from "./governor-kill-switch.js"; import type { checkMinerKillSwitch as CheckMinerKillSwitchFn } from "./governor-kill-switch.js"; import { captureMinerError } from "./sentry.js"; @@ -141,6 +147,11 @@ export type RunAttemptOptions = { /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to * discovery-index-client.js's own submitSoftClaim. */ submitSoftClaim?: typeof SubmitSoftClaimFn; + /** AMS badge notifications (#7657). Defaults to scheduleAmsNotificationEvents (session POST / inject). */ + scheduleAmsNotifications?: ( + events: Parameters[0], + options?: PublishAmsNotificationEventsOptions, + ) => void; /** Invoked with the real structured result at every return point, in addition to (never instead of) the * plain exit-code return -- the loop orchestrator's real hook into what actually happened. */ onResult?: (result: AttemptCliResult) => void; @@ -666,6 +677,19 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} } const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + const scheduleAmsNotifications = options.scheduleAmsNotifications ?? scheduleAmsNotificationEvents; + // AMS badge notify (#7657): attempt start — fire-and-forget through the hosted evaluate → deliver path. + scheduleAmsNotifications( + [ + buildAmsAttemptStartedPayload({ + recipientLogin: parsed.minerLogin, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + attemptId, + }), + ], + { env: env as NodeJS.ProcessEnv }, + ); let result; try { result = await runAttemptPipeline( @@ -691,10 +715,36 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. worktreeResult.attemptOk = false; + scheduleAmsNotifications( + [ + buildAmsAttemptFailedPayload({ + recipientLogin: parsed.minerLogin, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + attemptId, + reason: "attempt_crashed", + }), + ], + { env: env as NodeJS.ProcessEnv }, + ); throw error; } worktreeResult.attemptOk = result.outcome === "submitted"; + if (result.outcome !== "submitted") { + scheduleAmsNotifications( + [ + buildAmsAttemptFailedPayload({ + recipientLogin: parsed.minerLogin, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + attemptId, + reason: result.outcome, + }), + ], + { env: env as NodeJS.ProcessEnv }, + ); + } // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the diff --git a/packages/loopover-miner/lib/governor-pause-cli.ts b/packages/loopover-miner/lib/governor-pause-cli.ts index 2f28fc6a14..a4b8d11610 100644 --- a/packages/loopover-miner/lib/governor-pause-cli.ts +++ b/packages/loopover-miner/lib/governor-pause-cli.ts @@ -9,6 +9,12 @@ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { openGovernorState } from "./governor-state.js"; import type { GovernorPauseState, GovernorState } from "./governor-state.js"; +import { + buildAmsGovernorPausedPayload, + publishAmsNotificationEvents, + type PublishAmsNotificationEventsOptions, +} from "./ams-notifications.js"; +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; const GOVERNOR_PAUSE_USAGE = "Usage: loopover-miner governor pause [--reason ] [--dry-run] [--json]"; const GOVERNOR_RESUME_USAGE = "Usage: loopover-miner governor resume [--dry-run] [--json]"; @@ -24,6 +30,10 @@ export type ParsedGovernorNoArgsSubcommand = { json: boolean } | { error: string export type GovernorPauseCliOptions = { openGovernorState?: () => GovernorState; + env?: Record; + /** Override AMS badge notify (#7657). Defaults to publishAmsNotificationEvents. */ + publishAmsNotifications?: typeof publishAmsNotificationEvents; + fetchSessionLogin?: (session: { apiUrl: string; sessionToken: string }) => Promise; }; export function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs { @@ -101,6 +111,50 @@ function renderPauseState(pauseState: GovernorPauseState): string { return `governor is PAUSED since ${pauseState.pausedAt}${reason}`; } +async function resolveSessionLogin(env: NodeJS.ProcessEnv): Promise { + const session = resolveLoopoverBackendSession(env); + if (!session) return null; + try { + const response = await fetch(`${session.apiUrl}/v1/auth/session`, { + headers: { authorization: `Bearer ${session.sessionToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) return null; + const payload = (await response.json().catch(() => null)) as { login?: unknown } | null; + return typeof payload?.login === "string" && payload.login.trim() ? payload.login.trim() : null; + } catch { + return null; + } +} + +async function notifyGovernorPaused( + pauseState: GovernorPauseState, + options: GovernorPauseCliOptions, +): Promise { + const env = options.env ?? process.env; + // Injected fetchSessionLogin (tests) may resolve a login without a disk session; only require a real + // session when falling back to GET /v1/auth/session. + const processEnv = env as NodeJS.ProcessEnv; + const login = options.fetchSessionLogin + ? await options.fetchSessionLogin( + resolveLoopoverBackendSession(processEnv) ?? { apiUrl: "https://api.loopover.ai", sessionToken: "" }, + ) + : await resolveSessionLogin(processEnv); + if (!login) return; + const publish = options.publishAmsNotifications ?? publishAmsNotificationEvents; + const publishOptions: PublishAmsNotificationEventsOptions = { env }; + await publish( + [ + buildAmsGovernorPausedPayload({ + recipientLogin: login, + reason: pauseState.reason, + ...(pauseState.pausedAt ? { pausedAt: pauseState.pausedAt } : {}), + }), + ], + publishOptions, + ); +} + export async function runGovernorPause(args: string[], options: GovernorPauseCliOptions = {}): Promise { const parsed = parseGovernorPauseArgs(args); if ("error" in parsed) { @@ -119,15 +173,17 @@ export async function runGovernorPause(args: string[], options: GovernorPauseCli } try { - return await withGovernorState(options, (governorState) => { - const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); - if (parsed.json) { - console.log(JSON.stringify(pauseState)); - } else { - console.log(renderPauseState(pauseState)); - } - return 0; - }); + const pauseState = await withGovernorState(options, (governorState) => + governorState.savePauseState({ paused: true, reason: parsed.reason }), + ); + // AMS badge notify (#7657): best-effort; a notify miss must not fail the pause itself. + await notifyGovernorPaused(pauseState, options).catch(() => undefined); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; } catch (error) { return reportCliFailure(parsed.json, describeCliError(error)); } diff --git a/packages/loopover-miner/lib/loop-cli.ts b/packages/loopover-miner/lib/loop-cli.ts index 676362879a..7b8037047e 100644 --- a/packages/loopover-miner/lib/loop-cli.ts +++ b/packages/loopover-miner/lib/loop-cli.ts @@ -567,7 +567,7 @@ export async function runLoop(args: string[], options: RunLoopOptions = {}): Pro decision: prDisposition.merged ? "merged" : "closed", closedAt: prDisposition.closedAt, }, - { eventLedger }, + { eventLedger, recipientLogin: parsed.minerLogin }, ); // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; diff --git a/packages/loopover-miner/lib/pr-outcome.ts b/packages/loopover-miner/lib/pr-outcome.ts index 274df98712..ad78ddaf32 100644 --- a/packages/loopover-miner/lib/pr-outcome.ts +++ b/packages/loopover-miner/lib/pr-outcome.ts @@ -11,6 +11,11 @@ import { REJECTION_REASONS } from "./rejection-templates.js"; import type { AppendEventInput, LedgerEntry } from "./event-ledger.js"; +import { + buildAmsPrOutcomePayload, + scheduleAmsNotificationEvents, + type PublishAmsNotificationEventsOptions, +} from "./ams-notifications.js"; /** Event-ledger vocabulary for a miner-local PR outcome. */ export const MINER_PR_OUTCOME_EVENT = "pr_outcome" as const; @@ -40,6 +45,13 @@ export type RecordPrOutcomeOptions = { * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. Reuses the * real EventLedger#appendEvent signature so a genuine EventLedger (not just a same-shaped stub) type-checks. */ eventLedger?: { appendEvent(event: AppendEventInput): LedgerEntry }; + /** Recipient for AMS badge notify (#7657). When absent, notification is skipped (ledger write still happens). */ + recipientLogin?: string; + env?: Record; + scheduleAmsNotifications?: ( + events: Parameters[0], + options?: PublishAmsNotificationEventsOptions, + ) => void; }; export type PrOutcomeLedgerReader = { @@ -97,7 +109,25 @@ export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options: RecordPr reason: input.reason, }); if (!payload) return null; - return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); + const entry = eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); + // AMS badge notify (#7657): fire-and-forget when a recipient login is known (loop-cli passes minerLogin). + const recipientLogin = typeof options.recipientLogin === "string" ? options.recipientLogin.trim() : ""; + if (recipientLogin) { + const schedule = options.scheduleAmsNotifications ?? scheduleAmsNotificationEvents; + schedule( + [ + buildAmsPrOutcomePayload({ + recipientLogin, + repoFullName, + pullNumber: payload.prNumber, + decision: payload.decision, + closedAt: payload.closedAt, + }), + ], + { env: options.env ?? process.env }, + ); + } + return entry; } /** diff --git a/src/api/routes.ts b/src/api/routes.ts index 8055f95372..ccc0b3821a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -284,7 +284,8 @@ import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, bu import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes"; import { buildReviewRiskExplanation } from "../signals/review-risk"; -import { buildNotificationFeed } from "../notifications/service"; +import { buildNotificationFeed, evaluateAndEnqueueNotificationDeliveries } from "../notifications/service"; +import { normalizeAmsNotificationEventInput } from "../notifications/ams-events"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildIssueSlopAssessment } from "../signals/issue-slop"; @@ -469,6 +470,24 @@ const markNotificationsReadBodySchema = z.object({ ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), }); +// #7657: AMS miner posts DetectedNotificationEvent-shaped AMS kinds; recipient is forced to the path login. +const amsNotificationsBodySchema = z.object({ + events: z + .array( + z.object({ + eventType: z.enum(["ams_attempt_started", "ams_attempt_failed", "ams_governor_paused", "ams_pr_outcome"]), + repoFullName: z.string().min(1).max(200), + pullNumber: z.number().int().min(0), + dedupKey: z.string().min(1).max(500), + deeplink: z.string().min(1).max(2000), + actorLogin: z.string().min(1).max(100), + detectedAt: z.string().min(1).max(64), + }), + ) + .min(1) + .max(20), +}); + // #6746: body of POST/DELETE /v1/contributors/:login/watches. Mirrors watchIssuesShape (src/mcp/server.ts) minus // `login` (path param) and `action` (the HTTP verb). `labels` is POST-only (a DELETE ignores it). const watchSubscriptionBodySchema = z.object({ @@ -3594,6 +3613,27 @@ export function createApp() { return c.json({ login: login.toLowerCase(), marked }); }); + // #7657: AMS miner (or any self-scoped session) posts AMS-relevant notification events. Events are forced onto + // the path login and evaluated through evaluateAndEnqueueNotificationDeliveries — the same + // evaluateNotificationEvent → notify-deliver handoff job-dispatch.ts uses for webhook kinds. + app.post("/v1/contributors/:login/ams-notifications", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const parsed = amsNotificationsBodySchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "invalid_ams_notifications", issues: parsed.error.issues }, 400); + const events = parsed.data.events + .map((raw) => normalizeAmsNotificationEventInput(raw, login)) + .filter((event): event is NonNullable => event !== null); + if (events.length === 0) return c.json({ error: "invalid_ams_notifications", detail: "no_valid_events" }, 400); + const deliveries = await evaluateAndEnqueueNotificationDeliveries(c.env, events); + return c.json({ + login: login.toLowerCase(), + accepted: events.length, + enqueued: deliveries.length, + }); + }); + // #6746: REST mirror of the `loopover_watch_issues` MCP tool (LoopoverMcp.watchIssues) — manage a contributor's // own issue-watch subscriptions. The MCP tool's `action` enum splits across the HTTP verbs: GET=list, POST=watch, // DELETE=unwatch. Every verb is self-scoped via requireContributorAccess (a session may only touch its own diff --git a/src/db/schema.ts b/src/db/schema.ts index 901c7da958..c77a91c1bb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1133,6 +1133,9 @@ export const notificationSubscriptions = sqliteTable( }), ); +// event_type is free-text: webhook kinds (pull_request_changes_requested / pull_request_merged / +// issue_watch_match) plus AMS kinds (#7657: ams_attempt_started / ams_attempt_failed / +// ams_governor_paused / ams_pr_outcome). Subscriptions stay channel-scoped; kind filtering is at evaluate time. export const notificationDeliveries = sqliteTable( "notification_deliveries", { diff --git a/src/notifications/ams-events.ts b/src/notifications/ams-events.ts new file mode 100644 index 0000000000..594465577f --- /dev/null +++ b/src/notifications/ams-events.ts @@ -0,0 +1,157 @@ +// AMS → badge notification bridge (#7657). Pure builders for DetectedNotificationEvent rows that the miner +// (and the session-authenticated ingest route) feed into evaluateNotificationEvent → notify-deliver — the same +// path job-dispatch.ts uses for webhook-detected kinds. No parallel delivery store. + +import type { DetectedNotificationEvent, NotificationEventType } from "../types"; +import { nowIso } from "../utils/json"; + +export const AMS_NOTIFICATION_EVENT_TYPES = [ + "ams_attempt_started", + "ams_attempt_failed", + "ams_governor_paused", + "ams_pr_outcome", +] as const satisfies readonly NotificationEventType[]; + +export type AmsNotificationEventType = (typeof AMS_NOTIFICATION_EVENT_TYPES)[number]; + +const AMS_EVENT_TYPE_SET = new Set(AMS_NOTIFICATION_EVENT_TYPES); + +export function isAmsNotificationEventType(value: unknown): value is AmsNotificationEventType { + return typeof value === "string" && AMS_EVENT_TYPE_SET.has(value); +} + +function normalizeLogin(login: string): string { + return login.trim().toLowerCase(); +} + +function githubIssueDeeplink(repoFullName: string, issueNumber: number): string { + return `https://github.com/${repoFullName}/issues/${issueNumber}`; +} + +function githubPullDeeplink(repoFullName: string, pullNumber: number): string { + return `https://github.com/${repoFullName}/pull/${pullNumber}`; +} + +/** Attempt start — `pullNumber` carries the ISSUE number (same overload issue_watch_match uses). */ +export function buildAmsAttemptStartedEvent(input: { + recipientLogin: string; + repoFullName: string; + issueNumber: number; + attemptId: string; + detectedAt?: string; +}): DetectedNotificationEvent { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + return { + eventType: "ams_attempt_started", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.issueNumber, + dedupKey: `ams_attempt_started:${input.repoFullName}#${input.issueNumber}:${input.attemptId}`, + deeplink: githubIssueDeeplink(input.repoFullName, input.issueNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +/** Attempt fail — same issue-number overload as start. */ +export function buildAmsAttemptFailedEvent(input: { + recipientLogin: string; + repoFullName: string; + issueNumber: number; + attemptId: string; + reason?: string | null; + detectedAt?: string; +}): DetectedNotificationEvent { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const reasonKey = input.reason?.trim() ? `:${input.reason.trim().slice(0, 80)}` : ""; + return { + eventType: "ams_attempt_failed", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.issueNumber, + dedupKey: `ams_attempt_failed:${input.repoFullName}#${input.issueNumber}:${input.attemptId}${reasonKey}`, + deeplink: githubIssueDeeplink(input.repoFullName, input.issueNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +/** + * Governor pause — not PR-scoped. `repoFullName` is a stable synthetic scope (`ams/governor`); `pullNumber` is 0. + */ +export function buildAmsGovernorPausedEvent(input: { + recipientLogin: string; + reason?: string | null; + pausedAt?: string; + detectedAt?: string; +}): DetectedNotificationEvent { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const pausedAt = input.pausedAt ?? detectedAt; + const reasonKey = input.reason?.trim() ? `:${input.reason.trim().slice(0, 80)}` : ""; + return { + eventType: "ams_governor_paused", + recipientLogin, + repoFullName: "ams/governor", + pullNumber: 0, + dedupKey: `ams_governor_paused:${recipientLogin}:${pausedAt}${reasonKey}`, + deeplink: "https://github.com/JSONbored/loopover", + actorLogin: recipientLogin, + detectedAt, + }; +} + +/** Miner-local PR outcome change (merged or closed). */ +export function buildAmsPrOutcomeEvent(input: { + recipientLogin: string; + repoFullName: string; + pullNumber: number; + decision: "merged" | "closed"; + closedAt?: string | null; + detectedAt?: string; +}): DetectedNotificationEvent { + const recipientLogin = normalizeLogin(input.recipientLogin); + const detectedAt = input.detectedAt ?? nowIso(); + const closedAt = input.closedAt?.trim() || detectedAt; + return { + eventType: "ams_pr_outcome", + recipientLogin, + repoFullName: input.repoFullName, + pullNumber: input.pullNumber, + dedupKey: `ams_pr_outcome:${input.repoFullName}#${input.pullNumber}:${input.decision}:${closedAt}`, + deeplink: githubPullDeeplink(input.repoFullName, input.pullNumber), + actorLogin: recipientLogin, + detectedAt, + }; +} + +/** + * Validate a miner-posted AMS event payload and stamp the authenticated recipient. Rejects non-AMS kinds so + * this ingest cannot forge webhook notification types. + */ +export function normalizeAmsNotificationEventInput( + raw: unknown, + recipientLogin: string, +): DetectedNotificationEvent | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + if (!isAmsNotificationEventType(record.eventType)) return null; + if (typeof record.repoFullName !== "string" || !record.repoFullName.trim()) return null; + if (typeof record.dedupKey !== "string" || !record.dedupKey.trim()) return null; + if (typeof record.deeplink !== "string" || !record.deeplink.trim()) return null; + if (typeof record.actorLogin !== "string" || !record.actorLogin.trim()) return null; + if (typeof record.detectedAt !== "string" || !record.detectedAt.trim()) return null; + if (!Number.isInteger(record.pullNumber) || (record.pullNumber as number) < 0) return null; + return { + eventType: record.eventType, + recipientLogin: normalizeLogin(recipientLogin), + repoFullName: record.repoFullName.trim(), + pullNumber: record.pullNumber as number, + dedupKey: record.dedupKey.trim(), + deeplink: record.deeplink.trim(), + actorLogin: record.actorLogin.trim(), + detectedAt: record.detectedAt.trim(), + }; +} diff --git a/src/notifications/service.ts b/src/notifications/service.ts index 826e2de800..3f0a60e433 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -10,7 +10,13 @@ import { } from "../db/repositories"; import { isGrabbableHighMultiplierIssue } from "../signals/engine"; import { canLoginAccessRepo } from "../services/control-panel-roles"; -import type { DetectedNotificationEvent, IssueRecord, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types"; +import type { + DetectedNotificationEvent, + IssueRecord, + NotificationChannel, + NotificationDeliveryRecord, + NotificationSubscriptionRecord, +} from "../types"; import { nowIso } from "../utils/json"; // Per-recipient, per-channel safety cap. The killer event (changes_requested) delivers immediately, but a @@ -54,6 +60,52 @@ export function buildIssueWatchNotification(event: DetectedNotificationEvent): { }; } +// AMS (#7657): attempt lifecycle — `pullNumber` carries the ISSUE number. Public-safe; no reward/trust figures. +export function buildAmsAttemptStartedNotification(event: DetectedNotificationEvent): { title: string; body: string } { + const ref = `${event.repoFullName}#${event.pullNumber}`; + return { + title: sanitizePublicComment(`Attempt started on ${ref}`), + body: sanitizePublicComment(`Your AMS miner started an attempt on ${ref}. Watch the attempt log for progress and the next decision.`), + }; +} + +export function buildAmsAttemptFailedNotification(event: DetectedNotificationEvent): { title: string; body: string } { + const ref = `${event.repoFullName}#${event.pullNumber}`; + return { + title: sanitizePublicComment(`Attempt failed on ${ref}`), + body: sanitizePublicComment(`Your AMS miner attempt on ${ref} did not complete successfully. Check the attempt log, then reclaim or pick the next high-fit issue.`), + }; +} + +export function buildAmsGovernorPausedNotification(_event: DetectedNotificationEvent): { title: string; body: string } { + return { + title: sanitizePublicComment("AMS governor paused"), + body: sanitizePublicComment( + "Your AMS governor is paused. New attempt cycles will wait until you resume with `loopover-miner governor resume`.", + ), + }; +} + +export function buildAmsPrOutcomeNotification(event: DetectedNotificationEvent): { title: string; body: string } { + const ref = `${event.repoFullName}#${event.pullNumber}`; + // buildAmsPrOutcomeEvent embeds `:merged:` or `:closed:` in the dedupKey after the PR number. + const merged = event.dedupKey.includes(":merged:"); + if (merged) { + return { + title: sanitizePublicComment(`AMS recorded merge: ${ref}`), + body: sanitizePublicComment( + `Your AMS miner recorded that ${ref} merged. Merged work strengthens your standing on ${event.repoFullName} — check your decision pack for the next high-fit issue.`, + ), + }; + } + return { + title: sanitizePublicComment(`AMS recorded close: ${ref}`), + body: sanitizePublicComment( + `Your AMS miner recorded that ${ref} closed without merge. Review the feedback, then pick the next high-fit issue on ${event.repoFullName}.`, + ), + }; +} + // Maps a detected event to its public-safe notification content. export function buildNotificationContent(event: DetectedNotificationEvent): { title: string; body: string } { switch (event.eventType) { @@ -61,7 +113,15 @@ export function buildNotificationContent(event: DetectedNotificationEvent): { ti return buildMergedOutcomeNotification(event); case "issue_watch_match": return buildIssueWatchNotification(event); - default: + case "ams_attempt_started": + return buildAmsAttemptStartedNotification(event); + case "ams_attempt_failed": + return buildAmsAttemptFailedNotification(event); + case "ams_governor_paused": + return buildAmsGovernorPausedNotification(event); + case "ams_pr_outcome": + return buildAmsPrOutcomeNotification(event); + case "pull_request_changes_requested": return buildChangesRequestedNotification(event); } } @@ -146,6 +206,31 @@ export async function evaluateNotificationEvent(env: Env, event: DetectedNotific return pending; } +/** + * Mirrors `job-dispatch.ts`'s notify-evaluate → notify-deliver handoff: evaluate each event, then enqueue one + * `notify-deliver` job per freshly-created pending delivery. Used by the AMS ingest route (#7657) and kept + * identical in shape to the queue processor so AMS kinds never take a parallel path. + */ +export async function evaluateAndEnqueueNotificationDeliveries( + env: Env, + events: DetectedNotificationEvent[], +): Promise { + const pending: NotificationDeliveryRecord[] = []; + for (const event of events) { + pending.push(...(await evaluateNotificationEvent(env, event))); + } + await Promise.all( + pending.map((delivery) => + env.JOBS.send({ + type: "notify-deliver", + requestedBy: "notify-evaluate", + deliveryId: delivery.id, + }), + ), + ); + return pending; +} + export type NotificationFeedItem = { id: string; eventType: string; diff --git a/src/types.ts b/src/types.ts index bd8e3cc5fb..58aa9366a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2429,7 +2429,16 @@ export type DigestSubscriptionRecord = { // unless a row is `paused`). export type NotificationChannel = "badge" | "email"; export type NotificationDeliveryStatus = "pending" | "delivered" | "read" | "suppressed"; -export type NotificationEventType = "pull_request_changes_requested" | "pull_request_merged" | "issue_watch_match"; +export type NotificationEventType = + | "pull_request_changes_requested" + | "pull_request_merged" + | "issue_watch_match" + // AMS-relevant kinds (#7657): attempt lifecycle, governor pause, and the miner's own PR-outcome change. + // Delivered through the same evaluateNotificationEvent → notify-deliver path as the webhook kinds above. + | "ams_attempt_started" + | "ams_attempt_failed" + | "ams_governor_paused" + | "ams_pr_outcome"; /** #699 path B: a miner's standing watch on a repo for new grabbable issues. `labels` ([]=any) filters * which issues notify. The `pullNumber` field of the resulting notification event carries the ISSUE number. */ diff --git a/test/unit/miner-ams-notifications.test.ts b/test/unit/miner-ams-notifications.test.ts new file mode 100644 index 0000000000..4985aafaf8 --- /dev/null +++ b/test/unit/miner-ams-notifications.test.ts @@ -0,0 +1,243 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildAmsAttemptFailedPayload, + buildAmsAttemptStartedPayload, + buildAmsGovernorPausedPayload, + buildAmsPrOutcomePayload, + publishAmsNotificationEvents, + scheduleAmsNotificationEvents, +} from "../../packages/loopover-miner/lib/ams-notifications.js"; + +const roots: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function writeSessionConfig(loginToken = "session-token"): { env: Record; root: string } { + const root = mkdtempSync(join(tmpdir(), "ams-notifications-config-")); + roots.push(root); + const configPath = join(root, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + activeProfile: "default", + profiles: { + default: { + apiUrl: "https://api.example.test", + session: { token: loginToken }, + }, + }, + }), + ); + return { env: { LOOPOVER_CONFIG_PATH: configPath }, root }; +} + +describe("ams-notifications (#7657)", () => { + it("builds AMS payloads mirroring hosted DetectedNotificationEvent shape", () => { + expect( + buildAmsAttemptStartedPayload({ + recipientLogin: "Miner", + repoFullName: "acme/widgets", + issueNumber: 2, + attemptId: "a1", + detectedAt: "2026-07-21T00:00:00.000Z", + }), + ).toMatchObject({ + eventType: "ams_attempt_started", + recipientLogin: "miner", + pullNumber: 2, + }); + expect( + buildAmsAttemptFailedPayload({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + issueNumber: 2, + attemptId: "a1", + reason: "abandon", + }).dedupKey, + ).toContain(":abandon"); + expect(buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" }).pullNumber).toBe(0); + expect( + buildAmsPrOutcomePayload({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + pullNumber: 9, + decision: "closed", + closedAt: "t", + }).eventType, + ).toBe("ams_pr_outcome"); + }); + + it("defaults detectedAt/reason when omitted (branch coverage)", () => { + expect( + buildAmsAttemptStartedPayload({ recipientLogin: "miner", repoFullName: "acme/widgets", issueNumber: 1, attemptId: "a1" }) + .detectedAt, + ).toEqual(expect.any(String)); + expect( + buildAmsAttemptFailedPayload({ recipientLogin: "miner", repoFullName: "acme/widgets", issueNumber: 1, attemptId: "a1" }) + .dedupKey, + ).toBe("ams_attempt_failed:acme/widgets#1:a1"); + const paused = buildAmsGovernorPausedPayload({ recipientLogin: "miner" }); + expect(paused.dedupKey).toBe(`ams_governor_paused:miner:${paused.detectedAt}`); + expect( + buildAmsPrOutcomePayload({ recipientLogin: "miner", repoFullName: "acme/widgets", pullNumber: 1, decision: "merged" }) + .detectedAt, + ).toEqual(expect.any(String)); + }); + + it("uses an injected dispatch (job-dispatch evaluate→deliver shape) when provided", async () => { + const dispatch = vi.fn(async () => undefined); + const event = buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" }); + await expect(publishAmsNotificationEvents([event], { dispatch })).resolves.toEqual({ sent: 1 }); + expect(dispatch).toHaveBeenCalledWith([event]); + }); + + it("returns no_session without a loopover backend session", async () => { + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + env: { LOOPOVER_CONFIG_PATH: join(tmpdir(), "missing-loopover-config.json") }, + }), + ).resolves.toEqual({ sent: 0, error: "no_session" }); + }); + + it("falls back to process.env when no env option is provided", async () => { + const original = process.env.LOOPOVER_CONFIG_PATH; + process.env.LOOPOVER_CONFIG_PATH = join(tmpdir(), "missing-loopover-config.json"); + try { + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })]), + ).resolves.toEqual({ sent: 0, error: "no_session" }); + } finally { + if (original === undefined) delete process.env.LOOPOVER_CONFIG_PATH; + else process.env.LOOPOVER_CONFIG_PATH = original; + } + }); + + it("includes an optional pause reason in the dedupKey", () => { + expect(buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t", reason: "ops incident" }).dedupKey).toContain( + ":ops incident", + ); + }); + + it("returns missing_recipient when the recipient login is blank", async () => { + const { env } = writeSessionConfig(); + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: " ", pausedAt: "t" })], { env }), + ).resolves.toEqual({ sent: 0, error: "missing_recipient" }); + }); + + it("POSTs to the contributor ams-notifications ingest when a session is present", async () => { + const { env } = writeSessionConfig(); + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ accepted: 1 }), { status: 200 })); + const event = buildAmsAttemptStartedPayload({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + issueNumber: 1, + attemptId: "a1", + detectedAt: "2026-07-21T00:00:00.000Z", + }); + await expect(publishAmsNotificationEvents([event], { env, fetchFn })).resolves.toEqual({ sent: 1 }); + expect(fetchFn).toHaveBeenCalledWith( + "https://api.example.test/v1/contributors/miner/ams-notifications", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ authorization: "Bearer session-token" }), + }), + ); + }); + + it("reports http errors and mixed recipients on the HTTP path", async () => { + const { env } = writeSessionConfig(); + const fetchFn = vi.fn(async () => new Response("nope", { status: 500 })); + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + env, + fetchFn, + }), + ).resolves.toEqual({ sent: 0, error: "http_500" }); + + await expect( + publishAmsNotificationEvents( + [ + buildAmsGovernorPausedPayload({ recipientLogin: "a", pausedAt: "t" }), + buildAmsGovernorPausedPayload({ recipientLogin: "b", pausedAt: "t" }), + ], + { env, fetchFn }, + ), + ).resolves.toEqual({ sent: 0, error: "mixed_recipients" }); + }); + + it("reports network failures on the HTTP path without throwing", async () => { + const { env } = writeSessionConfig(); + const fetchFn = vi.fn(async () => { + throw new Error("network down"); + }); + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + env, + fetchFn, + }), + ).resolves.toEqual({ sent: 0, error: "network down" }); + }); + + it("scheduleAmsNotificationEvents is fire-and-forget", async () => { + const dispatch = vi.fn(async () => undefined); + scheduleAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + dispatch, + }); + await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()); + }); + + it("reports dispatch failures without throwing", async () => { + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + dispatch: async () => { + throw new Error("boom"); + }, + }), + ).resolves.toEqual({ sent: 0, error: "boom" }); + }); + + it("falls back to a generic error string when a non-Error is thrown (dispatch and HTTP paths)", async () => { + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + dispatch: async () => { + throw "not an Error instance"; + }, + }), + ).resolves.toEqual({ sent: 0, error: "dispatch_failed" }); + + const { env } = writeSessionConfig(); + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { + env, + fetchFn: async () => { + throw "not an Error instance"; + }, + }), + ).resolves.toEqual({ sent: 0, error: "network_failed" }); + }); + + it("uses the ambient global fetch when fetchFn is not injected", async () => { + const { env } = writeSessionConfig(); + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ accepted: 1 }), { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + try { + await expect( + publishAmsNotificationEvents([buildAmsGovernorPausedPayload({ recipientLogin: "miner", pausedAt: "t" })], { env }), + ).resolves.toEqual({ sent: 1 }); + expect(fetchSpy).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("returns sent 0 for an empty event list", async () => { + await expect(publishAmsNotificationEvents([])).resolves.toEqual({ sent: 0 }); + }); +}); diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index ca3f725b24..d758abaced 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -529,6 +529,99 @@ describe("runAttempt (#5132)", () => { }); }); + it("schedules an AMS attempt-started notification before the attempt runs, and no failure notification on submit (#7657)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const worktreeResult = fakeWorktreeResult(); + const runMinerAttemptSpy = vi.fn().mockResolvedValue({ + outcome: "submitted", + spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, + execResult: { code: 0 }, + loopResult: fakeLoopResult(), + }); + const scheduleAmsNotifications = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + scheduleAmsNotifications, + ...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }), + }); + + expect(exitCode).toBe(0); + expect(scheduleAmsNotifications).toHaveBeenCalledOnce(); + const [events, notifyOptions] = scheduleAmsNotifications.mock.calls[0]!; + expect(events).toEqual([ + expect.objectContaining({ + eventType: "ams_attempt_started", + recipientLogin: "alice", + repoFullName: "acme/widgets", + pullNumber: 7, + dedupKey: "ams_attempt_started:acme/widgets#7:fixed-attempt-id", + }), + ]); + expect(notifyOptions).toMatchObject({ env: { MINER_CODING_AGENT_PROVIDER: "noop" } }); + }); + + it("schedules an AMS attempt-failed notification when the attempt does not submit (#7657)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runMinerAttemptSpy = vi.fn().mockResolvedValue({ outcome: "abandon", loopResult: fakeLoopResult({ outcome: "abandon" }) }); + const scheduleAmsNotifications = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + scheduleAmsNotifications, + ...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }), + }); + + expect(exitCode).toBe(7); // "abandon" outcome + // Once for attempt-started, once for attempt-failed (reason: the real outcome, "abandon"). + expect(scheduleAmsNotifications).toHaveBeenCalledTimes(2); + expect(scheduleAmsNotifications.mock.calls[1]![0][0]).toMatchObject({ + eventType: "ams_attempt_failed", + recipientLogin: "alice", + }); + expect(scheduleAmsNotifications.mock.calls[1]![0][0].dedupKey).toContain(":abandon"); + }); + + it("schedules an AMS attempt-failed(attempt_crashed) notification when runMinerAttempt throws (#7657)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const captureSpy = vi.spyOn(minerSentryModule, "captureMinerError").mockImplementation(() => undefined); + const runMinerAttemptSpy = vi.fn().mockRejectedValue(new Error("driver exploded")); + const scheduleAmsNotifications = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + scheduleAmsNotifications, + ...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }), + }); + + expect(exitCode).toBe(2); // caught by runAttempt's own outer catch -> reportCliFailure's default exit code + expect(scheduleAmsNotifications).toHaveBeenCalledTimes(2); + expect(scheduleAmsNotifications.mock.calls[1]![0][0]).toMatchObject({ eventType: "ams_attempt_failed" }); + expect(scheduleAmsNotifications.mock.calls[1]![0][0].dedupKey).toContain(":attempt_crashed"); + captureSpy.mockRestore(); + }); + it("REGRESSION (#6011): an attempt_outcome_summary ledger-append failure never fails an otherwise-successful attempt, but is captured instead of silently swallowed", async () => { const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/test/unit/miner-governor-pause-cli.test.ts b/test/unit/miner-governor-pause-cli.test.ts index d481780282..58e9fa6927 100644 --- a/test/unit/miner-governor-pause-cli.test.ts +++ b/test/unit/miner-governor-pause-cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -11,6 +11,14 @@ import { runGovernorStatus, } from "../../packages/loopover-miner/lib/governor-pause-cli.js"; +function writeSessionConfig(dir: string, loginToken = "session-token"): void { + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ activeProfile: "default", profiles: { default: { apiUrl: "https://api.example.test", session: { token: loginToken } } } }), + { mode: 0o600 }, + ); +} + const roots: string[] = []; const states: Array<{ close(): void }> = []; @@ -92,6 +100,180 @@ describe("loopover-miner governor pause/resume/status CLI (#4851)", () => { expect(governorState.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); }); + it("publishes an AMS governor-paused notification when a session login is available (#7657)", async () => { + const governorState = tempGovernorState(); + const published: Array<{ eventType: string; recipientLogin: string }> = []; + vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + await runGovernorPause(["--reason", "ops", "--json"], { + openGovernorState: () => governorState, + publishAmsNotifications: async (events) => { + published.push(...(events as Array<{ eventType: string; recipientLogin: string }>)); + return { sent: events.length }; + }, + fetchSessionLogin: async () => "miner", + }), + ).toBe(0); + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject({ + eventType: "ams_governor_paused", + recipientLogin: "miner", + }); + }); + + it("skips the AMS notify entirely when fetchSessionLogin resolves no login", async () => { + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const publishAmsNotifications = vi.fn(async () => ({ sent: 0 })); + + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + publishAmsNotifications, + fetchSessionLogin: async () => null, + }), + ).toBe(0); + expect(publishAmsNotifications).not.toHaveBeenCalled(); + }); + + it("never fails the pause itself when the AMS notify throws (#7657)", async () => { + const governorState = tempGovernorState(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + fetchSessionLogin: async () => { + throw new Error("notify boom"); + }, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ paused: true }); + }); + + it("resolves the session login via the real GET /v1/auth/session round-trip when fetchSessionLogin is not injected (#7657)", async () => { + const dir = mkdtempSync(join(tmpdir(), "loopover-miner-governor-pause-session-")); + try { + writeSessionConfig(dir); + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchSpy = vi.fn(async (url: string) => { + expect(url).toBe("https://api.example.test/v1/auth/session"); + return new Response(JSON.stringify({ login: "session-miner" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchSpy); + const publishAmsNotifications = vi.fn(async () => ({ sent: 1 })); + + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + env: { LOOPOVER_CONFIG_DIR: dir }, + publishAmsNotifications, + }), + ).toBe(0); + expect(fetchSpy).toHaveBeenCalled(); + expect(publishAmsNotifications).toHaveBeenCalledWith( + [expect.objectContaining({ recipientLogin: "session-miner" })], + { env: { LOOPOVER_CONFIG_DIR: dir } }, + ); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves no login (skips notify) when there is no session on disk, the session GET fails, or the payload is malformed", async () => { + // No session at all. + { + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const publishAmsNotifications = vi.fn(async () => ({ sent: 0 })); + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + env: { LOOPOVER_CONFIG_DIR: mkdtempSync(join(tmpdir(), "loopover-miner-governor-pause-nosession-")) }, + publishAmsNotifications, + }), + ).toBe(0); + expect(publishAmsNotifications).not.toHaveBeenCalled(); + } + + // Session present, but the GET /v1/auth/session responds non-OK. + { + const dir = mkdtempSync(join(tmpdir(), "loopover-miner-governor-pause-badresponse-")); + try { + writeSessionConfig(dir); + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 }))); + const publishAmsNotifications = vi.fn(async () => ({ sent: 0 })); + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + env: { LOOPOVER_CONFIG_DIR: dir }, + publishAmsNotifications, + }), + ).toBe(0); + expect(publishAmsNotifications).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + } + + // Session present, fetch throws. + { + const dir = mkdtempSync(join(tmpdir(), "loopover-miner-governor-pause-fetchthrow-")); + try { + writeSessionConfig(dir); + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + const publishAmsNotifications = vi.fn(async () => ({ sent: 0 })); + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + env: { LOOPOVER_CONFIG_DIR: dir }, + publishAmsNotifications, + }), + ).toBe(0); + expect(publishAmsNotifications).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + } + + // Session present, GET succeeds but the payload has no usable login field. + { + const dir = mkdtempSync(join(tmpdir(), "loopover-miner-governor-pause-badpayload-")); + try { + writeSessionConfig(dir); + const governorState = tempGovernorState(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ login: " " }), { status: 200 }))); + const publishAmsNotifications = vi.fn(async () => ({ sent: 0 })); + expect( + await runGovernorPause(["--json"], { + openGovernorState: () => governorState, + env: { LOOPOVER_CONFIG_DIR: dir }, + publishAmsNotifications, + }), + ).toBe(0); + expect(publishAmsNotifications).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + } + }); + it("pauses with no reason and renders the plain-text form", async () => { const governorState = tempGovernorState(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/test/unit/miner-pr-outcome.test.ts b/test/unit/miner-pr-outcome.test.ts index 5ae4cf293d..fde991aa07 100644 --- a/test/unit/miner-pr-outcome.test.ts +++ b/test/unit/miner-pr-outcome.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MINER_PR_OUTCOME_DECISIONS, MINER_PR_OUTCOME_EVENT, @@ -79,6 +79,46 @@ describe("recordPrOutcomeSnapshot (#4274)", () => { expect(entry.payload).toEqual({ prNumber: 12, decision: "closed", closedAt: "t", reason: "superseded_by_duplicate" }); expect(MINER_PR_OUTCOME_DECISIONS).toEqual(["merged", "closed"]); }); + + it("schedules an AMS pr-outcome notification when recipientLogin is provided (#7657)", () => { + const schedule = vi.fn(); + const ledger = mockLedger(); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 4, decision: "merged", closedAt: "2026-07-21T00:00:00.000Z" }, + { eventLedger: ledger, recipientLogin: "miner", scheduleAmsNotifications: schedule }, + ); + expect(schedule).toHaveBeenCalledOnce(); + expect(schedule.mock.calls[0]![0][0]).toMatchObject({ + eventType: "ams_pr_outcome", + recipientLogin: "miner", + pullNumber: 4, + }); + }); + + it("skips the AMS notify when recipientLogin is absent or blank (branch coverage, #7657)", () => { + const schedule = vi.fn(); + const ledger = mockLedger(); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 5, decision: "closed", closedAt: "t" }, + { eventLedger: ledger, scheduleAmsNotifications: schedule }, + ); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 6, decision: "closed", closedAt: "t" }, + { eventLedger: ledger, recipientLogin: " ", scheduleAmsNotifications: schedule }, + ); + expect(schedule).not.toHaveBeenCalled(); + }); + + it("defaults to scheduleAmsNotificationEvents and process.env when not overridden (branch coverage, #7657)", () => { + const ledger = mockLedger(); + const entry = recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 7, decision: "merged", closedAt: "t" }, + { eventLedger: ledger, recipientLogin: "miner" }, + ) as Record; + // No session on disk in this test environment, so the real scheduleAmsNotificationEvents fire-and-forgets + // and fails soft -- this only asserts the ledger write itself still succeeds when no override is given. + expect(entry.type).toBe(MINER_PR_OUTCOME_EVENT); + }); }); describe("readPrOutcomes (#4274)", () => { diff --git a/test/unit/notifications-ams-events.test.ts b/test/unit/notifications-ams-events.test.ts new file mode 100644 index 0000000000..14edf5f328 --- /dev/null +++ b/test/unit/notifications-ams-events.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + buildAmsAttemptFailedEvent, + buildAmsAttemptStartedEvent, + buildAmsGovernorPausedEvent, + buildAmsPrOutcomeEvent, + isAmsNotificationEventType, + normalizeAmsNotificationEventInput, +} from "../../src/notifications/ams-events"; + +describe("AMS notification event builders (#7657)", () => { + it("builds attempt start/fail events with issue-number pullNumber overload", () => { + const started = buildAmsAttemptStartedEvent({ + recipientLogin: "Miner", + repoFullName: "acme/widgets", + issueNumber: 42, + attemptId: "a1", + detectedAt: "2026-07-21T00:00:00.000Z", + }); + expect(started).toMatchObject({ + eventType: "ams_attempt_started", + recipientLogin: "miner", + pullNumber: 42, + dedupKey: "ams_attempt_started:acme/widgets#42:a1", + deeplink: "https://github.com/acme/widgets/issues/42", + }); + + const failed = buildAmsAttemptFailedEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + issueNumber: 42, + attemptId: "a1", + reason: "abandon", + detectedAt: "2026-07-21T00:00:00.000Z", + }); + expect(failed.eventType).toBe("ams_attempt_failed"); + expect(failed.dedupKey).toContain(":abandon"); + }); + + it("defaults detectedAt/reason/pausedAt when omitted (branch coverage)", () => { + const started = buildAmsAttemptStartedEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + issueNumber: 1, + attemptId: "a1", + }); + expect(started.detectedAt).toEqual(expect.any(String)); + + const failedNoReason = buildAmsAttemptFailedEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + issueNumber: 1, + attemptId: "a1", + }); + expect(failedNoReason.dedupKey).toBe("ams_attempt_failed:acme/widgets#1:a1"); + expect(failedNoReason.detectedAt).toEqual(expect.any(String)); + + const pausedNoReasonOrPausedAt = buildAmsGovernorPausedEvent({ recipientLogin: "miner" }); + expect(pausedNoReasonOrPausedAt.dedupKey).toBe(`ams_governor_paused:miner:${pausedNoReasonOrPausedAt.detectedAt}`); + + const outcomeNoDetectedAt = buildAmsPrOutcomeEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + pullNumber: 9, + decision: "merged", + }); + expect(outcomeNoDetectedAt.detectedAt).toEqual(expect.any(String)); + }); + + it("builds governor pause and pr-outcome events", () => { + const paused = buildAmsGovernorPausedEvent({ + recipientLogin: "miner", + reason: "ops", + pausedAt: "2026-07-21T00:00:00.000Z", + }); + expect(paused).toMatchObject({ + eventType: "ams_governor_paused", + repoFullName: "ams/governor", + pullNumber: 0, + }); + + const merged = buildAmsPrOutcomeEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + pullNumber: 9, + decision: "merged", + closedAt: "2026-07-21T01:00:00.000Z", + }); + expect(merged.dedupKey).toContain(":merged:"); + expect(merged.deeplink).toContain("/pull/9"); + + const closed = buildAmsPrOutcomeEvent({ + recipientLogin: "miner", + repoFullName: "acme/widgets", + pullNumber: 9, + decision: "closed", + closedAt: null, + detectedAt: "2026-07-21T02:00:00.000Z", + }); + expect(closed.dedupKey).toContain(":closed:"); + }); + + it("normalizes ingest payloads and rejects non-AMS kinds", () => { + expect(isAmsNotificationEventType("ams_attempt_started")).toBe(true); + expect(isAmsNotificationEventType("pull_request_merged")).toBe(false); + + const ok = normalizeAmsNotificationEventInput( + { + eventType: "ams_attempt_started", + repoFullName: "acme/widgets", + pullNumber: 1, + dedupKey: "k", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + "Miner", + ); + expect(ok?.recipientLogin).toBe("miner"); + + expect(normalizeAmsNotificationEventInput(null, "miner")).toBeNull(); + expect( + normalizeAmsNotificationEventInput( + { + eventType: "pull_request_merged", + repoFullName: "acme/widgets", + pullNumber: 1, + dedupKey: "k", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + "miner", + ), + ).toBeNull(); + expect( + normalizeAmsNotificationEventInput( + { + eventType: "ams_attempt_started", + repoFullName: "", + pullNumber: 1, + dedupKey: "k", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + "miner", + ), + ).toBeNull(); + expect( + normalizeAmsNotificationEventInput( + { + eventType: "ams_attempt_started", + repoFullName: "acme/widgets", + pullNumber: -1, + dedupKey: "k", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + "miner", + ), + ).toBeNull(); + }); + + it("rejects each remaining malformed field independently (branch coverage)", () => { + const valid = { + eventType: "ams_attempt_started" as const, + repoFullName: "acme/widgets", + pullNumber: 1, + dedupKey: "k", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }; + expect(normalizeAmsNotificationEventInput([], "miner")).toBeNull(); // array, not an object + expect(normalizeAmsNotificationEventInput({ ...valid, dedupKey: "" }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, dedupKey: 1 }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, deeplink: "" }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, deeplink: 1 }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, actorLogin: "" }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, actorLogin: 1 }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, detectedAt: "" }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, detectedAt: 1 }, "miner")).toBeNull(); + expect(normalizeAmsNotificationEventInput({ ...valid, pullNumber: 1.5 }, "miner")).toBeNull(); // not an integer + expect(normalizeAmsNotificationEventInput({ ...valid, repoFullName: 1 }, "miner")).toBeNull(); // not a string + }); +}); diff --git a/test/unit/notifications-service.test.ts b/test/unit/notifications-service.test.ts index 9ac0ef9522..24e48318fe 100644 --- a/test/unit/notifications-service.test.ts +++ b/test/unit/notifications-service.test.ts @@ -4,6 +4,7 @@ import { buildNotificationContent, buildNotificationFeed, deliverNotification, + evaluateAndEnqueueNotificationDeliveries, evaluateNotificationEvent, NOTIFICATION_RATE_LIMIT, resolveNotificationChannels, @@ -123,6 +124,80 @@ describe("merged-PR outcome attribution (#702)", () => { }); }); +describe("AMS notification event kinds (#7657)", () => { + it("builds public-safe copy for every AMS event kind (both pr-outcome branches)", () => { + const started = buildNotificationContent(event({ eventType: "ams_attempt_started", pullNumber: 12 })); + expect(started.title.toLowerCase()).toContain("attempt started"); + expect(started.body).toContain("owner/repo#12"); + + const failed = buildNotificationContent(event({ eventType: "ams_attempt_failed", pullNumber: 12 })); + expect(failed.title.toLowerCase()).toContain("attempt failed"); + + const paused = buildNotificationContent( + event({ eventType: "ams_governor_paused", repoFullName: "ams/governor", pullNumber: 0, dedupKey: "ams_governor_paused:miner:t" }), + ); + expect(paused.title.toLowerCase()).toContain("governor paused"); + expect(paused.body.toLowerCase()).toContain("resume"); + + const merged = buildNotificationContent( + event({ + eventType: "ams_pr_outcome", + dedupKey: "ams_pr_outcome:owner/repo#7:merged:2026-05-28T12:00:00.000Z", + }), + ); + expect(merged.title.toLowerCase()).toContain("merge"); + expect(JSON.stringify(merged)).not.toMatch(/reward|payout|trust score|wallet|\$/i); + + const closed = buildNotificationContent( + event({ + eventType: "ams_pr_outcome", + dedupKey: "ams_pr_outcome:owner/repo#7:closed:2026-05-28T12:00:00.000Z", + }), + ); + expect(closed.title.toLowerCase()).toContain("close"); + expect(closed.body.toLowerCase()).toContain("without merge"); + }); + + it("evaluates AMS events and enqueues notify-deliver jobs (job-dispatch handoff shape)", async () => { + const sent: Array<{ type: string; deliveryId?: string; requestedBy?: string }> = []; + const env = createTestEnv({ + JOBS: { + async send(message: { type: string; deliveryId?: string; requestedBy?: string }) { + sent.push(message); + }, + } as unknown as Queue, + }); + const deliveries = await evaluateAndEnqueueNotificationDeliveries(env, [ + event({ + eventType: "ams_attempt_started", + dedupKey: "ams_attempt_started:owner/repo#7:a1", + pullNumber: 7, + }), + event({ + eventType: "ams_governor_paused", + repoFullName: "ams/governor", + pullNumber: 0, + dedupKey: "ams_governor_paused:miner:t1", + }), + ]); + expect(deliveries).toHaveLength(2); + expect(sent).toEqual([ + { type: "notify-deliver", requestedBy: "notify-evaluate", deliveryId: deliveries[0]!.id }, + { type: "notify-deliver", requestedBy: "notify-evaluate", deliveryId: deliveries[1]!.id }, + ]); + }); + + it("returns no deliveries when the badge channel is paused", async () => { + const env = createTestEnv(); + await upsertNotificationSubscription(env, { login: "miner", channel: "badge", status: "paused", source: "test" }); + expect( + await evaluateAndEnqueueNotificationDeliveries(env, [ + event({ eventType: "ams_attempt_failed", dedupKey: "ams_attempt_failed:owner/repo#7:a1" }), + ]), + ).toEqual([]); + }); +}); + describe("evaluateNotificationEvent", () => { it("creates exactly one badge delivery and is idempotent on a duplicate event", async () => { const env = createTestEnv(); diff --git a/test/unit/routes-ams-notifications.test.ts b/test/unit/routes-ams-notifications.test.ts new file mode 100644 index 0000000000..0eaeb9f4ec --- /dev/null +++ b/test/unit/routes-ams-notifications.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { listNotificationDeliveriesForRecipient } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +// Operator API token (same posture as routes-notifications.test.ts) — requireContributorAccess allows the +// static `api` identity to post AMS events for any login; session callers need ADMIN_GITHUB_LOGINS first. +const jsonHeaders = (env: Env) => ({ + authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, + "content-type": "application/json", +}); + +describe("POST /v1/contributors/:login/ams-notifications (#7657)", () => { + it("evaluates AMS events through the existing notify-deliver path", async () => { + const sent: Array<{ type: string; deliveryId?: string; requestedBy?: string }> = []; + const env = createTestEnv({ + JOBS: { + async send(message: { type: string; deliveryId?: string; requestedBy?: string }) { + sent.push(message); + }, + } as unknown as Queue, + }); + const app = createApp(); + + const response = await app.request( + "/v1/contributors/miner/ams-notifications", + { + method: "POST", + headers: jsonHeaders(env), + body: JSON.stringify({ + events: [ + { + eventType: "ams_attempt_started", + repoFullName: "acme/widgets", + pullNumber: 3, + dedupKey: "ams_attempt_started:acme/widgets#3:a1", + deeplink: "https://github.com/acme/widgets/issues/3", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + ], + }), + }, + env, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { accepted: number; enqueued: number; login: string }; + expect(body).toEqual({ login: "miner", accepted: 1, enqueued: 1 }); + expect(sent).toEqual([{ type: "notify-deliver", requestedBy: "notify-evaluate", deliveryId: expect.any(String) }]); + + const deliveries = await listNotificationDeliveriesForRecipient(env, "miner", { eventType: "ams_attempt_started" }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ eventType: "ams_attempt_started", pullNumber: 3, status: "pending" }); + }); + + it("rejects forged webhook event kinds and invalid bodies", async () => { + const env = createTestEnv(); + const app = createApp(); + + const forged = await app.request( + "/v1/contributors/miner/ams-notifications", + { + method: "POST", + headers: jsonHeaders(env), + body: JSON.stringify({ + events: [ + { + eventType: "pull_request_merged", + repoFullName: "acme/widgets", + pullNumber: 1, + dedupKey: "x", + deeplink: "https://example.com", + actorLogin: "miner", + detectedAt: "2026-07-21T00:00:00.000Z", + }, + ], + }), + }, + env, + ); + expect(forged.status).toBe(400); + + const empty = await app.request( + "/v1/contributors/miner/ams-notifications", + { + method: "POST", + headers: jsonHeaders(env), + body: JSON.stringify({ events: [] }), + }, + env, + ); + expect(empty.status).toBe(400); + }); + + it("rejects an unauthenticated request and a malformed JSON body", async () => { + const env = createTestEnv(); + const app = createApp(); + + const unauthenticated = await app.request( + "/v1/contributors/miner/ams-notifications", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ events: [] }) }, + env, + ); + expect(unauthenticated.status).toBeGreaterThanOrEqual(401); + + const malformed = await app.request( + "/v1/contributors/miner/ams-notifications", + { method: "POST", headers: jsonHeaders(env), body: "not-json" }, + env, + ); + expect(malformed.status).toBe(400); + }); +});