Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions apps/worker/src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,14 @@ export class ClusterCoordinator extends DurableObject<Env> {
);
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,
Expand Down Expand Up @@ -384,15 +386,16 @@ export const debounceMs = (event: RawEvent): number => {
export function githubTypeHints(
thread: Pick<Thread, "body" | "timeline">,
): Map<string, ThreadType> {
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<string, ThreadType>(entries);
Expand Down
3 changes: 2 additions & 1 deletion apps/worker/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export interface Env {
INGEST_QUEUE: Queue<RawEvent>;
CLUSTER_COORDINATOR: DurableObjectNamespace<ClusterCoordinator>;
MERGE_REGISTRY: DurableObjectNamespace<MergeRegistry>;
AI: Ai;
/** Optional: absent in environments (e.g. local/test) without the AI binding configured. */
AI?: Ai;

// vars
SHADOW_GLOBAL: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export default {

function parseSweepRepos(raw: string | undefined): Array<SweepRepo> {
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<SweepRepo>) : [];
}
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/routes/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions apps/worker/src/routes/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,18 @@ 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;
// Slack Events API URL verification handshake.
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) {
Expand Down
8 changes: 7 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export default defineConfig(
"**/.turbo/**",
"**/node_modules/**",
"**/*.test.ts",
"**/vitest.config.ts",
"apps/worker/test/**",
"site/**",
"**/generated/**",
"**/worker-configuration.d.ts",
Expand All @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions packages/adapter-github/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type NormalizedRef,
type Platform,
type PostTarget,
type Link,
type RawEvent,
type Thread,
type ThreadType,
Expand Down Expand Up @@ -138,20 +139,20 @@ export class GitHubAdapter implements Platform {
});
}

async discoverLinks(thread: Thread) {
discoverLinks(thread: Thread): Promise<Result<Array<Link>, 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}`));
const textLinks = discoverLinksFromText(thread.nativeId, text, expandRef(`${owner}/${repo}`));
const extraLinks = textLinks.flatMap((l) => {
return seen.has(`${l.to}:${l.kind}`) ? [] : [l];
});
return Ok([...nativeLinks, ...extraLinks]);
return Promise.resolve(Ok([...nativeLinks, ...extraLinks]));
}

// --- outbound ---
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -237,9 +238,9 @@ export class GitHubAdapter implements Platform {
return Ok(undefined);
}

async notifyPerson(_identity: Identity, _body: string) {
notifyPerson(_identity: Identity, _body: string): Promise<Result<void, Error>> {
// 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 ---
Expand Down Expand Up @@ -315,7 +316,7 @@ const shallowThread = (
state: string,
): Thread => ({
platform: "github",
nativeId: `${repoFull}#${number}`,
nativeId: `${repoFull}#${String(number)}`,
type,
state,
participants: [],
Expand Down
10 changes: 5 additions & 5 deletions packages/adapter-github/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -137,14 +137,14 @@ export async function mintInstallationToken(
): Promise<Result<CachedToken, Error>> {
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);
Expand All @@ -163,7 +163,7 @@ export function installationTokenProvider(
config: InstallationTokenProviderConfig,
): () => Promise<Result<string, Error>> {
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));
Expand All @@ -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;
Expand Down
26 changes: 17 additions & 9 deletions packages/adapter-github/src/discover-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 = <T>(conn: { nodes?: Array<T> | null } | null | undefined): Array<T> => {
Expand All @@ -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)}`;
};
2 changes: 1 addition & 1 deletion packages/adapter-github/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function ghGraphQL<S extends z.ZodType>(
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());
Expand Down
33 changes: 23 additions & 10 deletions packages/adapter-github/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export interface ParsedNativeId {

export function parseNativeId(nativeId: string): Result<ParsedNativeId, Error> {
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<string> = []): boolean {
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-github/src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function ghRest<S extends z.ZodType>(
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 }));
Expand Down
5 changes: 4 additions & 1 deletion packages/adapter-github/src/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading