diff --git a/apps/worker/src/coordinator.ts b/apps/worker/src/coordinator.ts index 0298cb7..d4238f7 100644 --- a/apps/worker/src/coordinator.ts +++ b/apps/worker/src/coordinator.ts @@ -324,12 +324,14 @@ export class ClusterCoordinator extends DurableObject { ); if (!installationId.ok) return installationId; if (!installationId.data) return Ok(undefined); + const privateKey = this.env.GITHUB_APP_PRIVATE_KEY; + if (!privateKey) return Ok(undefined); const github = new GitHubAdapter({ token: installationTokenProvider({ kv: this.env.INSTALL_TOKENS, - privateKeyPem: this.env.GITHUB_APP_PRIVATE_KEY!, - clientId: this.env.GITHUB_APP_CLIENT_ID!, + privateKeyPem: privateKey, + clientId: this.env.GITHUB_APP_CLIENT_ID, installationId: installationId.data, }), botAccounts: ctx.config.botAccounts, @@ -384,15 +386,16 @@ export const debounceMs = (event: RawEvent): number => { export function githubTypeHints( thread: Pick, ): Map { - const text = [thread.body ?? "", ...thread.timeline.map((e) => String(e.data.body ?? ""))].join( - "\n", - ); + const eventBody = (e: Thread["timeline"][number]): string => + typeof e.data.body === "string" ? e.data.body : ""; + const text = [thread.body ?? "", ...thread.timeline.map(eventBody)].join("\n"); const matches = [ ...text.matchAll(/\bhttps:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)\b/g), ]; const entries = matches.map((match) => { - const key = `${match[1]}/${match[2]}#${match[4]}`; - const type: ThreadType = match[3] === "issues" ? "issue" : "pr"; + const [, owner = "", repo = "", kind = "", number = ""] = match; + const key = `${owner}/${repo}#${number}`; + const type: ThreadType = kind === "issues" ? "issue" : "pr"; return [key, type] as const; }); return new Map(entries); diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 094e017..ef81539 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -12,7 +12,8 @@ export interface Env { INGEST_QUEUE: Queue; CLUSTER_COORDINATOR: DurableObjectNamespace; MERGE_REGISTRY: DurableObjectNamespace; - AI: Ai; + /** Optional: absent in environments (e.g. local/test) without the AI binding configured. */ + AI?: Ai; // vars SHADOW_GLOBAL: string; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index a7ca744..f3b44be 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -161,7 +161,7 @@ export default { function parseSweepRepos(raw: string | undefined): Array { if (!raw) return []; - const parsed = Result.fromSync(() => JSON.parse(raw)); + const parsed = Result.fromSync(() => JSON.parse(raw) as unknown); if (!parsed.ok) return []; return Array.isArray(parsed.data) ? (parsed.data as Array) : []; } diff --git a/apps/worker/src/routes/github.ts b/apps/worker/src/routes/github.ts index e2ff37e..13c0ce3 100644 --- a/apps/worker/src/routes/github.ts +++ b/apps/worker/src/routes/github.ts @@ -39,7 +39,7 @@ githubRoutes.post("/", async (c) => { // Carry the discriminators the engine needs: event name (header — the only // reliable classifier), action, delivery id, and installation id (for token). - const parsed = Result.fromSync(() => JSON.parse(raw.data)); + const parsed = Result.fromSync(() => JSON.parse(raw.data) as unknown); if (!parsed.ok) return c.json({ error: "invalid json" }, 400); if (!isGithubWebhookBody(parsed.data)) return c.json({ error: "invalid payload" }, 400); const body = parsed.data; diff --git a/apps/worker/src/routes/slack.ts b/apps/worker/src/routes/slack.ts index 6504f2a..6a68037 100644 --- a/apps/worker/src/routes/slack.ts +++ b/apps/worker/src/routes/slack.ts @@ -24,7 +24,7 @@ slackRoutes.post("/", async (c) => { if (!verified.ok) throw verified.error; if (!verified.data) return c.json({ error: "bad signature" }, 401); - const parsed = Result.fromSync(() => JSON.parse(raw.data)); + const parsed = Result.fromSync(() => JSON.parse(raw.data) as unknown); if (!parsed.ok) return c.json({ error: "invalid json" }, 400); if (!isSlackEnvelope(parsed.data)) return c.json({ error: "invalid payload" }, 400); const body = parsed.data; @@ -32,9 +32,10 @@ slackRoutes.post("/", async (c) => { if (body.type === "url_verification") return c.json({ challenge: body.challenge }); // Event dedupe — Slack retries deliveries (DESIGN §6 delivery-id dedupe). + const eventId = body.event_id; const dedupe = await (async () => { - if (!body.event_id) return Ok(null); - return Result.from(() => c.env.DELIVERY_DEDUPE.get(`sl:${body.event_id}`)); + if (!eventId) return Ok(null); + return Result.from(() => c.env.DELIVERY_DEDUPE.get(`sl:${eventId}`)); })(); if (!dedupe.ok) throw dedupe.error; if (dedupe.data) { diff --git a/eslint.config.mjs b/eslint.config.mjs index 05ec19c..7c1868c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ export default defineConfig( "**/.turbo/**", "**/node_modules/**", "**/*.test.ts", + "**/vitest.config.ts", + "apps/worker/test/**", "site/**", "**/generated/**", "**/worker-configuration.d.ts", @@ -20,9 +22,13 @@ export default defineConfig( ], }, eslint.configs.recommended, - ...tseslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, { languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, globals: { ...globals.node, ...globals.browser, diff --git a/packages/adapter-github/src/adapter.ts b/packages/adapter-github/src/adapter.ts index 3586758..6e92c91 100644 --- a/packages/adapter-github/src/adapter.ts +++ b/packages/adapter-github/src/adapter.ts @@ -7,6 +7,7 @@ import { type NormalizedRef, type Platform, type PostTarget, + type Link, type RawEvent, type Thread, type ThreadType, @@ -138,12 +139,12 @@ export class GitHubAdapter implements Platform { }); } - async discoverLinks(thread: Thread) { + discoverLinks(thread: Thread): Promise, Error>> { const raw = this.rawByNativeId.get(thread.nativeId); const nativeLinks = raw ? discoverLinksFromGraphql(thread.nativeId, raw) : []; - if (!this.config.regexLinkFallback) return Ok(nativeLinks); + if (!this.config.regexLinkFallback) return Promise.resolve(Ok(nativeLinks)); const parsedNativeId = parseNativeId(thread.nativeId); - if (!parsedNativeId.ok) return parsedNativeId; + if (!parsedNativeId.ok) return Promise.resolve(parsedNativeId); const { owner, repo } = parsedNativeId.data; const text = `${thread.title ?? ""}\n${thread.body ?? ""}`; const seen = new Set(nativeLinks.map((l) => `${l.to}:${l.kind}`)); @@ -151,7 +152,7 @@ export class GitHubAdapter implements Platform { const extraLinks = textLinks.flatMap((l) => { return seen.has(`${l.to}:${l.kind}`) ? [] : [l]; }); - return Ok([...nativeLinks, ...extraLinks]); + return Promise.resolve(Ok([...nativeLinks, ...extraLinks])); } // --- outbound --- @@ -169,7 +170,7 @@ export class GitHubAdapter implements Platform { const response = await ghRest( token.data, "POST", - `/repos/${owner}/${repo}/issues/${number}/comments`, + `/repos/${owner}/${repo}/issues/${String(number)}/comments`, { body }, z.object({ url: z.string() }), this.restOpts(), @@ -206,7 +207,7 @@ export class GitHubAdapter implements Platform { const commentsResult = await ghRest( token.data, "GET", - `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${COMMENTS_PAGE_SIZE}&page=${page}`, + `/repos/${owner}/${repo}/issues/${String(number)}/comments?per_page=${String(COMMENTS_PAGE_SIZE)}&page=${String(page)}`, undefined, z.array(z.object({ url: z.string(), body: z.string().optional() })), this.restOpts(), @@ -237,9 +238,9 @@ export class GitHubAdapter implements Platform { return Ok(undefined); } - async notifyPerson(_identity: Identity, _body: string) { + notifyPerson(_identity: Identity, _body: string): Promise> { // GitHub has no DM; nudges go out via Slack (phase-3). - return Err(new Error("TODO(phase-3): GitHub has no DM channel")); + return Promise.resolve(Err(new Error("TODO(phase-3): GitHub has no DM channel"))); } // --- internals --- @@ -315,7 +316,7 @@ const shallowThread = ( state: string, ): Thread => ({ platform: "github", - nativeId: `${repoFull}#${number}`, + nativeId: `${repoFull}#${String(number)}`, type, state, participants: [], diff --git a/packages/adapter-github/src/auth.ts b/packages/adapter-github/src/auth.ts index c816e1a..0d2c54b 100644 --- a/packages/adapter-github/src/auth.ts +++ b/packages/adapter-github/src/auth.ts @@ -122,7 +122,7 @@ export async function resolveRepoInstallationId( ); if (!fetched.ok) return fetched; const res = fetched.data; - if (!res.ok) return Err(new Error(`installation lookup HTTP ${res.status}`)); + if (!res.ok) return Err(new Error(`installation lookup HTTP ${String(res.status)}`)); const parsed = await Result.from(() => res.json()); if (!parsed.ok) return parsed; const validated = installationSchema.safeParse(parsed.data); @@ -137,14 +137,14 @@ export async function mintInstallationToken( ): Promise> { const base = opts.apiBaseUrl ?? DEFAULT_BASE; const fetched = await Result.from(() => - (opts.fetchImpl ?? fetch)(`${base}/app/installations/${installationId}/access_tokens`, { + (opts.fetchImpl ?? fetch)(`${base}/app/installations/${String(installationId)}/access_tokens`, { method: "POST", headers: ghHeaders(appJwt), }), ); if (!fetched.ok) return fetched; const res = fetched.data; - if (!res.ok) return Err(new Error(`installation token HTTP ${res.status}`)); + if (!res.ok) return Err(new Error(`installation token HTTP ${String(res.status)}`)); const parsed = await Result.from(() => res.json()); if (!parsed.ok) return parsed; const validated = installationTokenSchema.safeParse(parsed.data); @@ -163,7 +163,7 @@ export function installationTokenProvider( config: InstallationTokenProviderConfig, ): () => Promise> { const now = config.now ?? Date.now; - const key = `inst:${config.installationId}`; + const key = `inst:${String(config.installationId)}`; return async () => { const cachedResult = await Result.from(() => config.kv.get(key)); @@ -172,7 +172,7 @@ export function installationTokenProvider( // Treat a corrupt/unparseable/invalid entry as a miss rather than wedging forever. const validCachedToken = (() => { if (!cached) return null; - const json = Result.fromSync(() => JSON.parse(cached)); + const json = Result.fromSync(() => JSON.parse(cached) as unknown); if (!json.ok) return null; const parsed = cachedTokenSchema.safeParse(json.data); if (!parsed.success) return null; diff --git a/packages/adapter-github/src/discover-links.ts b/packages/adapter-github/src/discover-links.ts index 3e6e71d..027fddd 100644 --- a/packages/adapter-github/src/discover-links.ts +++ b/packages/adapter-github/src/discover-links.ts @@ -26,14 +26,22 @@ export function discoverLinksFromGraphql(fromNativeId: string, node: GraphqlNode }; // --- live connections (authoritative) --- - connNodes(node.closingIssuesReferences).forEach((r) => add(fromNativeId, ref(r), "closes")); - connNodes(node.closedByPullRequestsReferences).forEach((r) => - add(ref(r), fromNativeId, "closes"), - ); + connNodes(node.closingIssuesReferences).forEach((r) => { + add(fromNativeId, ref(r), "closes"); + }); + connNodes(node.closedByPullRequestsReferences).forEach((r) => { + add(ref(r), fromNativeId, "closes"); + }); add(fromNativeId, ref(node.parent), "sub_issue"); - connNodes(node.subIssues).forEach((r) => add(ref(r), fromNativeId, "sub_issue")); - connNodes(node.blockedBy).forEach((r) => add(fromNativeId, ref(r), "blocked_by")); - connNodes(node.blocking).forEach((r) => add(ref(r), fromNativeId, "blocked_by")); + connNodes(node.subIssues).forEach((r) => { + add(ref(r), fromNativeId, "sub_issue"); + }); + connNodes(node.blockedBy).forEach((r) => { + add(fromNativeId, ref(r), "blocked_by"); + }); + connNodes(node.blocking).forEach((r) => { + add(ref(r), fromNativeId, "blocked_by"); + }); // --- timeline (event-only kinds + Connected/Disconnected negation) --- connNodes(node.timelineItems).forEach((e) => { @@ -57,7 +65,7 @@ export function discoverLinksFromGraphql(fromNativeId: string, node: GraphqlNode } export function linkNativeId(repoNameWithOwner: string, number: number): string { - return `${repoNameWithOwner}#${number}`; + return `${repoNameWithOwner}#${String(number)}`; } const connNodes = (conn: { nodes?: Array | null } | null | undefined): Array => { @@ -71,5 +79,5 @@ const ref = (n: Ref): string | undefined => { if (!n.number) return undefined; const owner = n.repository?.nameWithOwner; if (!owner) return undefined; - return `${owner}#${n.number}`; + return `${owner}#${String(n.number)}`; }; diff --git a/packages/adapter-github/src/graphql.ts b/packages/adapter-github/src/graphql.ts index 942061d..c77d581 100644 --- a/packages/adapter-github/src/graphql.ts +++ b/packages/adapter-github/src/graphql.ts @@ -49,7 +49,7 @@ export async function ghGraphQL( if (!res.ok) { const bodyText = await Result.from(() => res.text()); const detail = bodyText.ok ? bodyText.data : ""; - return Err(new Error(`GitHub GraphQL HTTP ${res.status}: ${detail}`)); + return Err(new Error(`GitHub GraphQL HTTP ${String(res.status)}: ${detail}`)); } const parsed = await Result.from(() => res.json()); diff --git a/packages/adapter-github/src/normalize.ts b/packages/adapter-github/src/normalize.ts index d847735..9165c7a 100644 --- a/packages/adapter-github/src/normalize.ts +++ b/packages/adapter-github/src/normalize.ts @@ -20,8 +20,11 @@ export interface ParsedNativeId { export function parseNativeId(nativeId: string): Result { const m = /^([^/]+)\/([^#]+)#(\d+)$/.exec(nativeId); - if (!m) return Err(new Error(`unparseable GitHub nativeId: ${nativeId}`)); - return Ok({ owner: m[1]!, repo: m[2]!, number: Number(m[3]) }); + const [, owner, repo, numberText] = m ?? []; + if (!owner || !repo || !numberText) { + return Err(new Error(`unparseable GitHub nativeId: ${nativeId}`)); + } + return Ok({ owner, repo, number: Number(numberText) }); } export function isBotLogin(login: string | undefined, botAccounts: Array = []): boolean { @@ -78,7 +81,7 @@ export function normalizeWebhookEvent(raw: RawEvent): NormalizedRef | undefined if (!full) return undefined; const mk = (number: number | undefined, type: ThreadType): NormalizedRef | undefined => - number ? { nativeId: `${full}#${number}`, type } : undefined; + number ? { nativeId: `${full}#${String(number)}`, type } : undefined; switch (raw.event) { case "issues": @@ -130,12 +133,22 @@ export function collectParticipantLogins( }; add(loginOf(node.author)); - nodesOf(node.assignees).forEach((a) => add(loginOf(a))); - nodesOf(node.timelineItems).forEach((n) => add(loginOf(n.actor) ?? loginOf(n.author))); - nodesOf(node.reviews).forEach((r) => add(loginOf(r.author))); - nodesOf(node.reviewRequests).forEach((r) => add(loginOf(r.requestedReviewer))); + nodesOf(node.assignees).forEach((a) => { + add(loginOf(a)); + }); + nodesOf(node.timelineItems).forEach((n) => { + add(loginOf(n.actor) ?? loginOf(n.author)); + }); + nodesOf(node.reviews).forEach((r) => { + add(loginOf(r.author)); + }); + nodesOf(node.reviewRequests).forEach((r) => { + add(loginOf(r.requestedReviewer)); + }); nodesOf(node.reviewThreads).forEach((t) => { - nodesOf(t.comments).forEach((c) => add(loginOf(c.author))); + nodesOf(t.comments).forEach((c) => { + add(loginOf(c.author)); + }); }); return [...out]; } @@ -238,7 +251,7 @@ export function normalizeIssueGraphql( ): Thread { return { platform: "github", - nativeId: `${repoFullName}#${node.number}`, + nativeId: `${repoFullName}#${String(node.number ?? "")}`, type: "issue", title: node.title ?? undefined, body: node.body ?? undefined, @@ -261,7 +274,7 @@ export function normalizePrGraphql( ): Thread { return { platform: "github", - nativeId: `${repoFullName}#${node.number}`, + nativeId: `${repoFullName}#${String(node.number ?? "")}`, type: "pr", title: node.title ?? undefined, body: node.body ?? undefined, diff --git a/packages/adapter-github/src/rest.ts b/packages/adapter-github/src/rest.ts index 5f5324e..855fd65 100644 --- a/packages/adapter-github/src/rest.ts +++ b/packages/adapter-github/src/rest.ts @@ -39,7 +39,7 @@ export async function ghRest( if (!fetched.ok) return fetched; const res = fetched.data; if (!res.ok) { - const msg = `GitHub REST ${method} ${url} HTTP ${res.status}: ${await res.text().catch(() => "")}`; + const msg = `GitHub REST ${method} ${url} HTTP ${String(res.status)}: ${await res.text().catch(() => "")}`; // Attach status structurally so callers can branch (e.g. 404 deleted comment) // without importing this module's types. return Err(Object.assign(new Error(msg), { status: res.status })); diff --git a/packages/adapter-github/src/webhook.ts b/packages/adapter-github/src/webhook.ts index 6569b8c..d4c7692 100644 --- a/packages/adapter-github/src/webhook.ts +++ b/packages/adapter-github/src/webhook.ts @@ -32,6 +32,9 @@ export async function verifyWebhook( function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; - const mismatch = [...a].reduce((acc, ch, i) => acc | (ch.charCodeAt(0) ^ b.charCodeAt(i)), 0); + const mismatch = Array.from(a).reduce( + (acc, ch, i) => acc | (ch.charCodeAt(0) ^ b.charCodeAt(i)), + 0, + ); return mismatch === 0; } diff --git a/packages/adapter-llm/src/index.ts b/packages/adapter-llm/src/index.ts index b93819e..c213d52 100644 --- a/packages/adapter-llm/src/index.ts +++ b/packages/adapter-llm/src/index.ts @@ -48,7 +48,9 @@ export class WorkersAiLlmAdapter implements LlmAdapter { ); const timedOut = Symbol("llm-timeout"); const timer = new Promise((resolve) => - setTimeout(() => resolve(timedOut), this.config.requestTimeoutMs), + setTimeout(() => { + resolve(timedOut); + }, this.config.requestTimeoutMs), ); const res = await Promise.race([completion, timer]); if (res === timedOut) return Ok(""); @@ -111,8 +113,8 @@ export function extractText(res: unknown): Result { /** A deterministic stub for tests / shadow runs without an AI binding. */ export class EchoLlmAdapter implements LlmAdapter { - async complete(prompt: string) { - return Ok(prompt); + complete(prompt: string): Promise> { + return Promise.resolve(Ok(prompt)); } } @@ -132,7 +134,7 @@ export class LlmBudgetExceededError extends Error { readonly window: BudgetWindow, readonly limit: number, ) { - super(`LLM budget exceeded: ${window} limit of ${limit} reached`); + super(`LLM budget exceeded: ${window} limit of ${String(limit)} reached`); this.name = "LlmBudgetExceededError"; } } @@ -216,7 +218,7 @@ export class BudgetedLlmAdapter implements LlmAdapter { const pad = (n: number) => String(n).padStart(2, "0"); function dayKey(d: Date): string { - return `${KEY_PREFIX}${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; + return `${KEY_PREFIX}${String(d.getUTCFullYear())}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; } function minuteKey(d: Date): string { return `${dayKey(d)}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; diff --git a/packages/adapter-slack/src/adapter.ts b/packages/adapter-slack/src/adapter.ts index bafa9af..b52f9e5 100644 --- a/packages/adapter-slack/src/adapter.ts +++ b/packages/adapter-slack/src/adapter.ts @@ -62,8 +62,8 @@ export class SlackAdapter implements Platform { return { nativeId: `${raw.payload.channel}/${raw.payload.threadTs}`, type: "slack_thread" }; } - async listThreads(_query: Record): Promise, Error>> { - return Err(new Error("TODO: Slack thread sweeps")); + listThreads(_query: Record): Promise, Error>> { + return Promise.resolve(Err(new Error("TODO: Slack thread sweeps"))); } /** Fetch a Slack thread's replies and normalize to a Thread. */ @@ -80,7 +80,7 @@ export class SlackAdapter implements Platform { const messages = res.data.messages ?? []; const root = messages[0]; const participants = [ - ...new Set(messages.filter((m) => m.user && !m.bot_id).map((m) => m.user!)), + ...new Set(messages.flatMap((m) => (m.user && !m.bot_id ? [m.user] : []))), ]; return Ok({ platform: "slack", @@ -107,10 +107,10 @@ export class SlackAdapter implements Platform { } /** Cluster a Slack thread with referenced GitHub issues/PRs (DESIGN §8). */ - async discoverLinks(thread: Thread): Promise, Error>> { - const text = [thread.body ?? "", ...thread.timeline.map((e) => String(e.data.body ?? ""))].join( - "\n", - ); + discoverLinks(thread: Thread): Promise, Error>> { + const eventBody = (e: TimelineEvent): string => + typeof e.data.body === "string" ? e.data.body : ""; + const text = [thread.body ?? "", ...thread.timeline.map(eventBody)].join("\n"); const seen = new Set(); const links = githubRefs(text).flatMap((to) => { if (!to || seen.has(to)) return []; @@ -118,7 +118,7 @@ export class SlackAdapter implements Platform { const link: Link = { from: thread.nativeId, to, kind: "cross_ref" }; return [link]; }); - return Ok(links); + return Promise.resolve(Ok(links)); } /** Post into a channel (`meta.channelId`) or thread (`threadNativeId` = `${channel}/${threadTs}`). */ @@ -132,7 +132,7 @@ export class SlackAdapter implements Platform { chatPostMessageSchema, ); if (!posted.ok) return posted; - return Ok({ id: `${channelId}/${posted.data.ts}` }); + return Ok({ id: `${channelId}/${posted.data.ts ?? ""}` }); } const threadNativeId = target.threadNativeId; if (!threadNativeId) { @@ -147,7 +147,7 @@ export class SlackAdapter implements Platform { chatPostMessageSchema, ); if (!res.ok) return res; - return Ok({ id: `${channel}/${res.data.ts}` }); + return Ok({ id: `${channel}/${res.data.ts ?? ""}` }); } /** Edit a posted message (`messageId` = `${channel}/${messageTs}`). */ @@ -234,7 +234,7 @@ export class SlackAdapter implements Platform { ); if (!fetched.ok) return fetched; const res = fetched.data; - if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${res.status}`)); + if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${String(res.status)}`)); const parsed = await Result.from(() => res.json()); if (!parsed.ok) return parsed; return parseSlackResponse(method, parsed.data, schema); @@ -254,7 +254,7 @@ export class SlackAdapter implements Platform { ); if (!fetched.ok) return fetched; const res = fetched.data; - if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${res.status}`)); + if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${String(res.status)}`)); const parsed = await Result.from(() => res.json()); if (!parsed.ok) return parsed; return parseSlackResponse(method, parsed.data, schema); @@ -291,12 +291,16 @@ function parseSlackNativeId(nativeId: string): Result<{ channel: string; ts: str const slackTsToIso = (ts: string): string => new Date(Number.parseFloat(ts) * 1000).toISOString(); function githubRefs(text: string): Array { - const shorthandRefs = [...text.matchAll(/\b([\w.-]+\/[\w.-]+)#(\d+)\b/g)].map( - (m) => `${m[1]}#${m[2]}`, - ); + const shorthandRefs = [...text.matchAll(/\b([\w.-]+\/[\w.-]+)#(\d+)\b/g)].map((m) => { + const [, repo = "", number = ""] = m; + return `${repo}#${number}`; + }); const urlRefs = [ ...text.matchAll(/\bhttps:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+)\b/g), - ].map((m) => `${m[1]}/${m[2]}#${m[3]}`); + ].map((m) => { + const [, owner = "", repo = "", number = ""] = m; + return `${owner}/${repo}#${number}`; + }); return [...shorthandRefs, ...urlRefs]; } diff --git a/packages/adapter-slack/src/identity.ts b/packages/adapter-slack/src/identity.ts index 431f155..13dcd99 100644 --- a/packages/adapter-slack/src/identity.ts +++ b/packages/adapter-slack/src/identity.ts @@ -130,7 +130,7 @@ async function call( } const httpFailed = !res.ok; if (httpFailed) { - const httpError = new Error(`Slack ${method} HTTP ${res.status}`); + const httpError = new Error(`Slack ${method} HTTP ${String(res.status)}`); return { kind: "STOP" as const, value: Err(httpError) }; } const parsed = await Result.from(() => res.json()); diff --git a/packages/adapter-slack/src/verify.ts b/packages/adapter-slack/src/verify.ts index 19cd75d..97d817e 100644 --- a/packages/adapter-slack/src/verify.ts +++ b/packages/adapter-slack/src/verify.ts @@ -34,7 +34,7 @@ export async function verifySlackRequest( const hex = [...new Uint8Array(sig.data)].map((b) => b.toString(16).padStart(2, "0")).join(""); const actual = `v0=${hex}`; if (actual.length !== signature.length) return Ok(false); - const mismatch = [...actual].reduce( + const mismatch = Array.from(actual).reduce( (acc, ch, i) => acc | (ch.charCodeAt(0) ^ signature.charCodeAt(i)), 0, ); diff --git a/packages/core/src/cluster.ts b/packages/core/src/cluster.ts index 5b09626..75b8392 100644 --- a/packages/core/src/cluster.ts +++ b/packages/core/src/cluster.ts @@ -23,7 +23,9 @@ export function computeClusters( ensure(b); parent.set(find(a), find(b)); }; - links.forEach((l) => union(l.from, l.to)); + links.forEach((l) => { + union(l.from, l.to); + }); const grouped = groupBy([...parent.keys()], (id) => find(id)); const components = Object.values(grouped).flatMap((c) => (c ? [c] : [])); diff --git a/packages/core/src/clusters.test.ts b/packages/core/src/clusters.test.ts index a61738d..aa4c5dd 100644 --- a/packages/core/src/clusters.test.ts +++ b/packages/core/src/clusters.test.ts @@ -61,8 +61,24 @@ const ctx = ( describe("synthesizeCluster", () => { const cluster: Cluster = { id: "cluster:o/r#1", threadIds: ["o/r#1", "o/r#2"] }; const threads = { - "o/r#1": { state: "open" } as Thread, - "o/r#2": { state: "merged" } as Thread, + "o/r#1": { + platform: "github", + nativeId: "o/r#1", + type: "issue", + state: "open", + participants: [], + meta: {}, + timeline: [], + } as Thread, + "o/r#2": { + platform: "github", + nativeId: "o/r#2", + type: "issue", + state: "merged", + participants: [], + meta: {}, + timeline: [], + } as Thread, }; it("writes a concise cluster note without a raw member listing", async () => { diff --git a/packages/core/src/clusters.ts b/packages/core/src/clusters.ts index 0e2a81e..2cfea67 100644 --- a/packages/core/src/clusters.ts +++ b/packages/core/src/clusters.ts @@ -15,12 +15,12 @@ const MAX_MEMBER_DISCUSSION = 4000; const MAX_PULSE_ITEMS = 12; function memberDiscussion(thread: Thread): string { - return (thread.timeline ?? []) + return thread.timeline .filter( (e) => (e.kind === "comment" || e.kind === "review") && typeof e.data.body === "string" && - !String(e.data.body).includes(NOTES_MARKER) && + !e.data.body.includes(NOTES_MARKER) && !(e.actor?.endsWith("[bot]") ?? false), ) .map((e) => `${speakerLabel(thread, e.actor)}: ${String(e.data.body)}`) @@ -34,8 +34,8 @@ const speakerLabel = (thread: Thread, actor: string | undefined): string => { }; const memberLabel = (member: { platform: PlatformId; title?: string }, index: number): string => { - if (member.platform === "slack") return `Thread ${index + 1}: Slack discussion`; - return `Thread ${index + 1}: ${member.title ?? "GitHub thread"}`; + if (member.platform === "slack") return `Thread ${String(index + 1)}: Slack discussion`; + return `Thread ${String(index + 1)}: ${member.title ?? "GitHub thread"}`; }; /** Default system-prompt instructions for the cross-thread cluster summary. */ @@ -155,7 +155,7 @@ export async function aggregateOrg( const sections = notes .map((n) => n.content.replace(CLUSTER_MARKER, "").trim()) .join("\n\n---\n\n"); - const content = `${ROLLUP_MARKER}\n# Org rollup — ${notes.length} cluster(s)\n\n${sections}\n\naipm · ${contentHash}`; + const content = `${ROLLUP_MARKER}\n# Org rollup — ${String(notes.length)} cluster(s)\n\n${sections}\n\naipm · ${contentHash}`; if (stored?.contentHash !== contentHash) { const upserted = await ctx.store.upsertWorkingNotes({ scope: "cluster", @@ -190,7 +190,7 @@ async function postDailyOrgPulse( const shadow = isShadowed(ctx.config, "orgRollup"); const unchanged = stored?.contentHash === contentHash; - if (unchanged && (shadow || stored?.externalRef)) return Ok(undefined); + if (unchanged && (shadow || stored.externalRef)) return Ok(undefined); if (shadow || !slack) { const upserted = await ctx.store.upsertWorkingNotes({ @@ -247,7 +247,7 @@ async function buildDailyOrgPulse(ctx: EngineContext, at: Date): Promise): string { new Map(), ); const body = [...counts] - .map(([kind, count]) => `${count} ${signalLabel(kind).toLowerCase()}`) + .map(([kind, count]) => `${String(count)} ${signalLabel(kind).toLowerCase()}`) .join(", "); return `*Low-noise summary*\n${body}.`; } diff --git a/packages/core/src/common.helper.ts b/packages/core/src/common.helper.ts index 1519a6c..ba566fb 100644 --- a/packages/core/src/common.helper.ts +++ b/packages/core/src/common.helper.ts @@ -42,7 +42,7 @@ const asyncUnfold = async >( ): Promise["value"]> => { let state = seed; let iterations = 0; - while (true) { + for (;;) { const result = await step(state); if (result.kind === "STOP") return result.value; state = result.next; diff --git a/packages/core/src/detectors.ts b/packages/core/src/detectors.ts index fec0a3c..875485b 100644 --- a/packages/core/src/detectors.ts +++ b/packages/core/src/detectors.ts @@ -31,8 +31,7 @@ export function isTerminal(thread: Thread): boolean { } const at = (e: TimelineEvent) => Date.parse(e.at); -const quiet = (ctx: DetectorContext, kind: SignalKind) => - ctx.config.signals[kind]?.quietPeriodHours ?? Infinity; +const quiet = (ctx: DetectorContext, kind: SignalKind) => ctx.config.signals[kind].quietPeriodHours; /** Business hours elapsed since `iso` (undefined => not yet eligible). */ function elapsed(iso: string | undefined, ctx: DetectorContext): number { diff --git a/packages/core/src/identity-source.ts b/packages/core/src/identity-source.ts index 24fe23e..0bf2e2e 100644 --- a/packages/core/src/identity-source.ts +++ b/packages/core/src/identity-source.ts @@ -41,13 +41,13 @@ export function configIdentitySource( const identities = mapped.flatMap((r) => (r.ok ? [r.data] : [])); return Ok({ - async list() { - return identities; + list() { + return Promise.resolve(identities); }, - async resolve(query) { + resolve(query) { if (query.email) { const byEmail = identities.find((i) => i.email && eq(i.email, query.email)); - if (byEmail) return byEmail; + if (byEmail) return Promise.resolve(byEmail); } if (query.handle) { const byHandle = identities.find((i) => @@ -55,9 +55,9 @@ export function configIdentitySource( ? i.handles[query.platform] === query.handle : Object.values(i.handles).includes(query.handle), ); - if (byHandle) return byHandle; + if (byHandle) return Promise.resolve(byHandle); } - return undefined; + return Promise.resolve(undefined); }, }); } @@ -100,7 +100,7 @@ export async function ensureIdentityForHandle( } const safeParse = (s: string): Result, Error> => { - const parsed = Result.fromSync(() => JSON.parse(s)); + const parsed = Result.fromSync(() => JSON.parse(s) as unknown); if (!parsed.ok) return parsed; const v = parsed.data; if (!Array.isArray(v)) return Err(new Error("identity roster must be a JSON array")); diff --git a/packages/core/src/judge.ts b/packages/core/src/judge.ts index 5613f24..b0460f1 100644 --- a/packages/core/src/judge.ts +++ b/packages/core/src/judge.ts @@ -21,7 +21,7 @@ export async function judgeUnansweredMentions( thread: Thread, ): Promise, Error>> { if (isTerminal(thread)) return Ok([]); - const quiet = ctx.config.signals.mentioned_no_response?.quietPeriodHours ?? Infinity; + const quiet = ctx.config.signals.mentioned_no_response.quietPeriodHours; const now = ctx.clock.now(); const msgs = thread.timeline.filter( diff --git a/packages/core/src/notes.ts b/packages/core/src/notes.ts index a6b7e7c..43840a6 100644 --- a/packages/core/src/notes.ts +++ b/packages/core/src/notes.ts @@ -47,7 +47,7 @@ export function buildNotesInput(thread: Thread): string { typeof e.data.body === "string" && // Exclude the bot's own sticky note (else it feeds back into the summary // and re-renders forever) and other bot chatter. - !String(e.data.body).includes(NOTES_MARKER) && + !e.data.body.includes(NOTES_MARKER) && !(e.actor?.endsWith("[bot]") ?? false), ) .map((e) => `@${e.actor ?? "unknown"} (${e.kind}, ${e.at}): ${String(e.data.body)}`) diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index 692cccd..2ab5fec 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -166,7 +166,11 @@ export const platformForNativeId = (nativeId: string): PlatformId => // `owner/repo#number`; /issues/N redirects to /pull/N for PRs, so it covers both. export const nativeIdWebUrl = (nativeId: string): string | undefined => { const m = /^([^/]+)\/([^#]+)#(\d+)$/.exec(nativeId); - return m ? `https://github.com/${m[1]}/${m[2]}/issues/${m[3]}` : undefined; + if (!m) return undefined; + const [, owner, repo, number] = m; + return owner && repo && number + ? `https://github.com/${owner}/${repo}/issues/${number}` + : undefined; }; // Slack mrkdwn ref: a clickable link when we can derive a URL, else inline code. @@ -201,15 +205,15 @@ export async function evaluate( const dctx: DetectorContext = { config: ctx.config, clock: ctx.clock }; const active: Array = detectors.flatMap((d) => { - const enabled = ctx.config.signals[d.kind]?.enabled; + const enabled = ctx.config.signals[d.kind].enabled; return enabled ? d.detect(thread, dctx) : []; }); - if (ctx.config.signals.blocker_cleared?.enabled) { + if (ctx.config.signals.blocker_cleared.enabled) { const blockerCleared = await detectBlockerCleared(ctx, thread); if (!blockerCleared.ok) return blockerCleared; active.push(...blockerCleared.data); } - if (ctx.config.llmJudge && ctx.config.signals.mentioned_no_response?.enabled) { + if (ctx.config.llmJudge && ctx.config.signals.mentioned_no_response.enabled) { const judgedMentions = await judgeUnansweredMentions(ctx, thread); if (!judgedMentions.ok) return judgedMentions; active.push(...judgedMentions.data); @@ -483,7 +487,7 @@ export async function route( // Backoff: one nudge per dedupeKey per quiet period; quiet 0 = fire once // (e.g. blocker_cleared), suppressed once any real nudge exists (DESIGN §7). if (priorReal?.sentAt) { - const quiet = sigCfg?.quietPeriodHours ?? 0; + const quiet = sigCfg.quietPeriodHours; if (quiet === 0) return Ok(undefined); const elapsed = businessHoursBetween(new Date(priorReal.sentAt), now, ctx.config.calendar); const withinQuiet = elapsed < quiet; @@ -491,13 +495,7 @@ export async function route( } const escalations = (priorReal?.escalations ?? 0) + 1; - let channel = chooseChannel( - sig.kind, - thread, - escalations, - sigCfg?.maxEscalations ?? Infinity, - elevated, - ); + let channel = chooseChannel(sig.kind, thread, escalations, sigCfg.maxEscalations, elevated); // Resolve the DM target; no Slack id OR no Slack sender → digest (DESIGN §5). // handles.slack may be a roster-supplied username (not a U… id) — resolve it. @@ -684,7 +682,7 @@ export async function aggregate(ctx: EngineContext): Promise const firstSentError = sentErrors[0]; if (firstSentError) return firstSentError; const lines = live.map((l) => l.line).join("\n"); - const body = `🗒️ *Your plate* — ${live.length} item(s):\n${lines}`; + const body = `🗒️ *Your plate* — ${String(live.length)} item(s):\n${lines}`; const dmIdentity = { ...identity, handles: { ...identity.handles, slack: slackId } }; const notified = await slack.notifyPerson(dmIdentity, body); if (!notified.ok) return notified; diff --git a/packages/core/src/preferences.ts b/packages/core/src/preferences.ts index a87ce25..7af6164 100644 --- a/packages/core/src/preferences.ts +++ b/packages/core/src/preferences.ts @@ -13,28 +13,44 @@ export function parsePreferenceText(text: string, now: Date): ParsedPreference | // A #N token anywhere after "mute" means a single-thread mute (incl. the // "mute repo owner/name#5" phrasing); otherwise "mute repo X" is repo-wide. - if ((m = /\bmute\b[^]*?(\S+#\d+)/i.exec(text))) - return { rule: "mute", selector: { threadId: m[1]! } }; - if ((m = /\bmute\s+repo\s+([^\s#]+)/i.exec(text))) - return { rule: "mute", selector: { repo: m[1]! } }; + if ((m = /\bmute\b[^]*?(\S+#\d+)/i.exec(text))) { + const threadId = m[1]; + if (threadId) return { rule: "mute", selector: { threadId } }; + } + if ((m = /\bmute\s+repo\s+([^\s#]+)/i.exec(text))) { + const repo = m[1]; + if (repo) return { rule: "mute", selector: { repo } }; + } if ((m = /\bsnooze\b[^]*?\bfor\s+(\d+)\s*(hour|day)s?/i.exec(text))) { - const unit = m[2]!.toLowerCase() === "day" ? 86_400 : 3_600; - return { - rule: "snooze", - selector: {}, - until: new Date(now.getTime() + Number(m[1]) * unit * 1000).toISOString(), - }; + const amount = m[1]; + const unitToken = m[2]; + if (amount && unitToken) { + const unit = unitToken.toLowerCase() === "day" ? 86_400 : 3_600; + return { + rule: "snooze", + selector: {}, + until: new Date(now.getTime() + Number(amount) * unit * 1000).toISOString(), + }; + } + } + if ((m = /\bsnooze\b[^]*?\b(?:to|until)\s+(\d{4}-\d{2}-\d{2})/i.exec(text))) { + const date = m[1]; + if (date) return { rule: "snooze", selector: {}, until: `${date}T00:00:00.000Z` }; } - if ((m = /\bsnooze\b[^]*?\b(?:to|until)\s+(\d{4}-\d{2}-\d{2})/i.exec(text))) - return { rule: "snooze", selector: {}, until: `${m[1]}T00:00:00.000Z` }; if ((m = /\bcare\s+about\s+repo\s+([^\s#]+)/i.exec(text))) { - const selector: Record = { repo: m[1]! }; - if (/high/i.test(text)) selector.priority = "high"; - return { rule: "route", selector }; + const repo = m[1]; + if (repo) { + const selector: Record = { repo }; + if (/high/i.test(text)) selector.priority = "high"; + return { rule: "route", selector }; + } + } + if ((m = /\bown\s+(\S+#\d+)/i.exec(text))) { + const threadId = m[1]; + if (threadId) return { rule: "own", selector: { threadId } }; } - if ((m = /\bown\s+(\S+#\d+)/i.exec(text))) return { rule: "own", selector: { threadId: m[1]! } }; return undefined; } @@ -45,8 +61,10 @@ export interface CaptureResult { preference?: Preference; } +const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); + const describe = (p: ParsedPreference): string => { - const target = p.selector.repo ?? p.selector.threadId ?? "everything"; + const target = asString(p.selector.repo) ?? asString(p.selector.threadId) ?? "everything"; if (p.rule === "snooze") return `snoozed until ${p.until ?? "later"}`; if (p.rule === "mute") return `muted ${target}`; if (p.rule === "own") return `noted you own ${target}`; diff --git a/packages/db/src/d1-store.ts b/packages/db/src/d1-store.ts index 3f17aa2..79067ef 100644 --- a/packages/db/src/d1-store.ts +++ b/packages/db/src/d1-store.ts @@ -19,7 +19,7 @@ const json = (v: unknown): Result => { const parse = (v: unknown, fallback: T): Result => { const hasJson = typeof v === "string" && v.length > 0; if (!hasJson) return Ok(fallback); - const parsed = Result.fromSync(() => JSON.parse(v)); + const parsed = Result.fromSync(() => JSON.parse(v) as unknown); if (!parsed.ok) return parsed; return Ok(parsed.data as T); }; @@ -40,7 +40,7 @@ interface ThreadRow { function rowToThread(r: ThreadRow): Result { const participants = parse(r.participants, [] as Array); if (!participants.ok) return participants; - const meta = parse(r.meta, {} as Record); + const meta = parse(r.meta, {}); if (!meta.ok) return meta; const timeline = parse(r.timeline, [] as Thread["timeline"]); if (!timeline.ok) return timeline; @@ -168,7 +168,7 @@ export class D1Store implements Store { } private rowToIdentity(r: Record): Result { - const handles = parse(r.handles, {} as Identity["handles"]); + const handles = parse(r.handles, {}); if (!handles.ok) return handles; return Ok({ id: r.id as string, @@ -480,7 +480,7 @@ export class D1Store implements Store { ); if (!queried.ok) return queried; const mappedRows = queried.data.results.map((r) => { - const selector = parse(r.selector, {} as Record); + const selector = parse(r.selector, {}); if (!selector.ok) return selector; return Ok({ person: r.person as string, @@ -490,7 +490,7 @@ export class D1Store implements Store { }); }); const firstErr = mappedRows.find((m) => !m.ok); - if (firstErr && !firstErr.ok) return firstErr; + if (firstErr) return firstErr; const preferences = mappedRows.flatMap((m) => (m.ok ? [m.data] : [])); return Ok(preferences); }