Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/adapter-github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"test": "vitest run"
},
"dependencies": {
"@aipm/core": "workspace:*"
"@aipm/core": "workspace:*",
"zod": "^4.4.3"
},
"devDependencies": {
"@aipm/tsconfig": "workspace:*",
Expand Down
55 changes: 28 additions & 27 deletions packages/adapter-github/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ import {
} from "./normalize.js";
import { GET_ISSUE, GET_PULL_REQUEST, LIST_THREADS_BY_REPO } from "./queries.js";
import { ghRest, type GhRestOptions } from "./rest.js";
import {
repoNodeDataSchema,
repoThreadsDataSchema,
type GraphqlNode,
type GraphqlTimelineNode,
} from "./graphql-schema.js";
import { z } from "zod";

export interface GitHubAdapterConfig {
/** Installation token, or a provider that mints/caches one (see auth.ts). */
Expand All @@ -46,7 +53,7 @@ const COMMENTS_PAGE_SIZE = 100;
*/
export class GitHubAdapter implements Platform {
readonly id = "github" as const;
private readonly rawByNativeId = new Map<string, Record<string, unknown>>();
private readonly rawByNativeId = new Map<string, GraphqlNode>();

constructor(private readonly config: GitHubAdapterConfig) {}

Expand Down Expand Up @@ -105,10 +112,11 @@ export class GitHubAdapter implements Platform {
acc: [],
};
return asyncUnfold(seed, async (state) => {
const dataResult = await ghGraphQL<RepoThreadsData>(
const dataResult = await ghGraphQL(
token.data,
LIST_THREADS_BY_REPO,
{ owner, repo, issuesAfter: state.issuesAfter, prsAfter: state.prsAfter },
repoThreadsDataSchema,
{ apiBaseUrl: this.config.apiBaseUrl, fetchImpl: this.config.fetchImpl },
);
if (!dataResult.ok) return { kind: "STOP", value: dataResult };
Expand Down Expand Up @@ -158,11 +166,12 @@ export class GitHubAdapter implements Platform {
const { owner, repo, number } = parsedNativeId.data;
const token = await this.resolveToken();
if (!token.ok) return token;
const response = await ghRest<{ url: string }>(
const response = await ghRest(
token.data,
"POST",
`/repos/${owner}/${repo}/issues/${number}/comments`,
{ body },
z.object({ url: z.string() }),
this.restOpts(),
);
if (!response.ok) return response;
Expand All @@ -173,7 +182,14 @@ export class GitHubAdapter implements Platform {
async editMessage(messageId: string, body: string) {
const token = await this.resolveToken();
if (!token.ok) return token;
const edited = await ghRest(token.data, "PATCH", messageId, { body }, this.restOpts());
const edited = await ghRest(
token.data,
"PATCH",
messageId,
{ body },
z.unknown(),
this.restOpts(),
);
if (!edited.ok) return edited;
return Ok(undefined);
}
Expand All @@ -187,11 +203,12 @@ export class GitHubAdapter implements Platform {
if (!token.ok) return token;
const seed = 1;
return asyncUnfold(seed, async (page) => {
const commentsResult = await ghRest<Array<{ url: string; body?: string }>>(
const commentsResult = await ghRest(
token.data,
"GET",
`/repos/${owner}/${repo}/issues/${number}/comments?per_page=${COMMENTS_PAGE_SIZE}&page=${page}`,
undefined,
z.array(z.object({ url: z.string(), body: z.string().optional() })),
this.restOpts(),
);
if (!commentsResult.ok) return { kind: "STOP", value: commentsResult };
Expand All @@ -213,6 +230,7 @@ export class GitHubAdapter implements Platform {
"POST",
`${messageId}/reactions`,
{ content: emoji },
z.unknown(),
this.restOpts(),
);
if (!reacted.ok) return reacted;
Expand Down Expand Up @@ -244,17 +262,18 @@ export class GitHubAdapter implements Platform {

const seed: FetchNodeState = { cursor: undefined, acc: [] };
return asyncUnfold(seed, async (state) => {
const dataResult = await ghGraphQL<RepoNodeData>(
const dataResult = await ghGraphQL(
token.data,
query,
{ owner, repo, number, timelineCount: TIMELINE_PAGE, afterTimeline: state.cursor },
repoNodeDataSchema,
{ apiBaseUrl: this.config.apiBaseUrl, fetchImpl: this.config.fetchImpl },
);
if (!dataResult.ok) return { kind: "STOP", value: dataResult };
const data = dataResult.data;
const fetched = data.repository?.[field] as Record<string, unknown> | null | undefined;
const fetched = data.repository?.[field];
if (!fetched) return { kind: "STOP", value: Ok(undefined) };
const ti = fetched.timelineItems as TimelineConn | undefined;
const ti = fetched.timelineItems;
const acc = [...state.acc, ...(ti?.nodes ?? [])];
const cursor = ti?.pageInfo?.hasNextPage ? ti.pageInfo.endCursor : undefined;
if (cursor) return { kind: "CONTINUE", next: { cursor, acc } };
Expand All @@ -278,24 +297,6 @@ export class GitHubAdapter implements Platform {

// --- shapes + helpers ---------------------------------------------------------

interface TimelineConn {
nodes?: Array<unknown>;
pageInfo?: { hasNextPage?: boolean; endCursor?: string };
}
interface RepoNodeData {
repository?: Record<string, unknown> | null;
}
interface RepoConn<T> {
nodes?: Array<T>;
pageInfo?: { hasNextPage?: boolean; endCursor?: string };
}
interface RepoThreadsData {
repository?: {
issues?: RepoConn<{ number: number }>;
pullRequests?: RepoConn<{ number: number; isDraft?: boolean }>;
} | null;
}

interface ListThreadsState {
issuesAfter: string | undefined;
prsAfter: string | undefined;
Expand All @@ -304,7 +305,7 @@ interface ListThreadsState {

interface FetchNodeState {
cursor: string | undefined;
acc: Array<unknown>;
acc: Array<GraphqlTimelineNode>;
}

const shallowThread = (
Expand Down
37 changes: 26 additions & 11 deletions packages/adapter-github/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// App JWT (RS256) -> installation access token -> KV-cached for ~1h.

import { Err, Ok, Result } from "@aipm/core";
import { z } from "zod";

/** Minimal KV surface so this module needn't depend on @cloudflare/workers-types. */
export interface KVLike {
Expand Down Expand Up @@ -31,6 +32,12 @@ export interface InstallationTokenProviderConfig {
const DEFAULT_BASE = "https://api.github.com";
const SKEW_SECONDS = 300;

// Boundary schemas: GitHub REST replies (loose — only the consumed field is
// declared) and the KV round-trip of our own CachedToken.
const installationSchema = z.looseObject({ id: z.number() });
const installationTokenSchema = z.looseObject({ token: z.string(), expires_at: z.string() });
const cachedTokenSchema = z.object({ token: z.string(), expiresAt: z.string() });

// --- PEM / base64url ----------------------------------------------------------

export function pkcs8PemToArrayBuffer(pem: string): Result<ArrayBuffer, Error> {
Expand Down Expand Up @@ -118,7 +125,9 @@ export async function resolveRepoInstallationId(
if (!res.ok) return Err(new Error(`installation lookup HTTP ${res.status}`));
const parsed = await Result.from(() => res.json());
if (!parsed.ok) return parsed;
return Ok((parsed.data as { id: number }).id);
const validated = installationSchema.safeParse(parsed.data);
if (!validated.success) return Err(new Error(`installation lookup: ${validated.error.message}`));
return Ok(validated.data.id);
}

export async function mintInstallationToken(
Expand All @@ -138,8 +147,9 @@ export async function mintInstallationToken(
if (!res.ok) return Err(new Error(`installation token HTTP ${res.status}`));
const parsed = await Result.from(() => res.json());
if (!parsed.ok) return parsed;
const json = parsed.data as { token: string; expires_at: string };
return Ok({ token: json.token, expiresAt: json.expires_at });
const validated = installationTokenSchema.safeParse(parsed.data);
if (!validated.success) return Err(new Error(`installation token: ${validated.error.message}`));
return Ok({ token: validated.data.token, expiresAt: validated.data.expires_at });
}

// --- Provider (KV-cached) -----------------------------------------------------
Expand All @@ -159,14 +169,19 @@ export function installationTokenProvider(
const cachedResult = await Result.from(() => config.kv.get(key));
if (!cachedResult.ok) return cachedResult;
const cached = cachedResult.data;
if (cached) {
// Treat a corrupt/unparseable entry as a miss rather than wedging forever.
const parsed = Result.fromSync(() => JSON.parse(cached) as CachedToken);
if (parsed.ok) {
const { token, expiresAt } = parsed.data;
if (token && (Date.parse(expiresAt) - now()) / 1000 > SKEW_SECONDS) return Ok(token);
}
}
// 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));
if (!json.ok) return null;
const parsed = cachedTokenSchema.safeParse(json.data);
if (!parsed.success) return null;
const token = parsed.data.token;
const stillValid = (Date.parse(parsed.data.expiresAt) - now()) / 1000 > SKEW_SECONDS;
if (!token || !stillValid) return null;
return token;
})();
if (validCachedToken) return Ok(validCachedToken);

const jwt = await mintAppJwt(config.privateKeyPem, config.clientId, now());
if (!jwt.ok) return jwt;
Expand Down
42 changes: 23 additions & 19 deletions packages/adapter-github/src/discover-links.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { Link, LinkKind } from "@aipm/core";
import type { GraphqlNode } from "./graphql-schema.js";

type Ref = { number?: number; repository?: { nameWithOwner?: string } } | null | undefined;

/**
* Native link discovery from a fetched GraphQL issue/PR node (DESIGN §4). Live
Expand All @@ -10,10 +13,7 @@ import type { Link, LinkKind } from "@aipm/core";
* from CrossReferencedEvent.willCloseTarget (that would duplicate it in the
* opposite direction).
*/
export function discoverLinksFromGraphql(
fromNativeId: string,
node: Record<string, unknown>,
): Array<Link> {
export function discoverLinksFromGraphql(fromNativeId: string, node: GraphqlNode): Array<Link> {
const links = new Map<string, Link>();
const key = (l: Link) => `${l.from}|${l.to}|${l.kind}`;
const add = (from: string | undefined, to: string | undefined, kind: LinkKind) => {
Expand All @@ -26,16 +26,17 @@ export function discoverLinksFromGraphql(
};

// --- live connections (authoritative) ---
conn(node, "closingIssuesReferences").forEach((r) => add(fromNativeId, ref(r), "closes"));
conn(node, "closedByPullRequestsReferences").forEach((r) => add(ref(r), fromNativeId, "closes"));
add(fromNativeId, ref((node as { parent?: unknown }).parent), "sub_issue");
conn(node, "subIssues").forEach((r) => add(ref(r), fromNativeId, "sub_issue"));
conn(node, "blockedBy").forEach((r) => add(fromNativeId, ref(r), "blocked_by"));
conn(node, "blocking").forEach((r) => add(ref(r), fromNativeId, "blocked_by"));
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"));

// --- timeline (event-only kinds + Connected/Disconnected negation) ---
conn(node, "timelineItems").forEach((ev) => {
const e = ev as Record<string, unknown>;
connNodes(node.timelineItems).forEach((e) => {
switch (e.__typename) {
case "ConnectedEvent":
add(fromNativeId, ref(e.subject), "refs");
Expand All @@ -59,13 +60,16 @@ export function linkNativeId(repoNameWithOwner: string, number: number): string
return `${repoNameWithOwner}#${number}`;
}

const conn = (node: Record<string, unknown>, field: string): Array<unknown> => {
const nodes = (node[field] as { nodes?: unknown } | undefined)?.nodes;
return Array.isArray(nodes) ? nodes : [];
const connNodes = <T>(conn: { nodes?: Array<T> | null } | null | undefined): Array<T> => {
const nodes = conn?.nodes;
if (!nodes) return [];
return nodes;
};

const ref = (n: unknown): string | undefined => {
const x = n as { number?: number; repository?: { nameWithOwner?: string } } | null | undefined;
if (!x?.number || !x.repository?.nameWithOwner) return undefined;
return `${x.repository.nameWithOwner}#${x.number}`;
const ref = (n: Ref): string | undefined => {
if (!n) return undefined;
if (!n.number) return undefined;
const owner = n.repository?.nameWithOwner;
if (!owner) return undefined;
return `${owner}#${n.number}`;
};
103 changes: 103 additions & 0 deletions packages/adapter-github/src/graphql-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { z } from "zod";

// Lenient schemas for the GitHub GraphQL reads (DESIGN §4). `looseObject` keeps
// every node tolerant of fields GitHub adds (and of the extra fields our tests
// pass), while every field we actually consume in normalize + discoverLinks is
// declared and optional. Parsing happens once at the boundary (ghGraphQL); every
// consumer downstream receives these typed nodes, never `unknown`.

const loginSchema = z.object({ login: z.string().optional() });
const reviewerSchema = z.object({ login: z.string().optional(), slug: z.string().optional() });
const labelSchema = z.object({ name: z.string().optional() });
const pageInfoSchema = z.object({
hasNextPage: z.boolean().optional(),
endCursor: z.string().optional(),
});

const refSchema = z.object({
number: z.number().optional(),
repository: z.object({ nameWithOwner: z.string().optional() }).optional(),
});

const connSchema = <S extends z.ZodType>(node: S) =>
z.object({ nodes: z.array(node).optional(), pageInfo: pageInfoSchema.optional() });

/** One timeline union node — wide + loose: every field read across the issue and
* PR timelines is optional, unknown variants/fields pass through untouched. */
export const graphqlTimelineNodeSchema = z.looseObject({
__typename: z.string().optional(),
actor: loginSchema.nullish(),
author: loginSchema.nullish(),
createdAt: z.string().optional(),
submittedAt: z.string().optional(),
body: z.string().optional(),
state: z.string().optional(),
stateReason: z.string().nullish(),
label: labelSchema.nullish(),
assignee: loginSchema.nullish(),
requestedReviewer: reviewerSchema.nullish(),
commit: z.object({ oid: z.string().optional() }).nullish(),
previousTitle: z.string().nullish(),
currentTitle: z.string().nullish(),
// link-only targets (discoverLinks)
source: refSchema.nullish(),
subject: refSchema.nullish(),
canonical: refSchema.nullish(),
});
export type GraphqlTimelineNode = z.infer<typeof graphqlTimelineNodeSchema>;

/** A fetched issue/PR node with its (paginated) timeline. Loose so a node missing
* issue-only or PR-only fields still parses; consumers read declared fields. */
export const graphqlNodeSchema = z.looseObject({
number: z.number().optional(),
title: z.string().nullish(),
body: z.string().nullish(),
state: z.string().optional(),
stateReason: z.string().nullish(),
isDraft: z.boolean().optional(),
merged: z.boolean().optional(),
mergedAt: z.string().nullish(),
reviewDecision: z.string().nullish(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
author: loginSchema.nullish(),
assignees: connSchema(loginSchema).nullish(),
labels: connSchema(labelSchema).nullish(),
reviews: connSchema(z.object({ author: loginSchema.nullish() })).nullish(),
reviewRequests: connSchema(z.object({ requestedReviewer: reviewerSchema.nullish() })).nullish(),
reviewThreads: connSchema(
z.object({ comments: connSchema(z.object({ author: loginSchema.nullish() })).nullish() }),
).nullish(),
subIssuesSummary: z.unknown().optional(),
issueDependenciesSummary: z.unknown().optional(),
timelineItems: connSchema(graphqlTimelineNodeSchema).nullish(),
// live link connections (discoverLinks)
parent: refSchema.nullish(),
subIssues: connSchema(refSchema).nullish(),
blockedBy: connSchema(refSchema).nullish(),
blocking: connSchema(refSchema).nullish(),
closingIssuesReferences: connSchema(refSchema).nullish(),
closedByPullRequestsReferences: connSchema(refSchema).nullish(),
});
export type GraphqlNode = z.infer<typeof graphqlNodeSchema>;

/** `repository.issue` / `repository.pullRequest` envelope for a single-node read. */
export const repoNodeDataSchema = z.object({
repository: z
.object({ issue: graphqlNodeSchema.nullish(), pullRequest: graphqlNodeSchema.nullish() })
.nullish(),
});
export type RepoNodeData = z.infer<typeof repoNodeDataSchema>;

/** Shallow issue/PR lists for repo sweeps. */
export const repoThreadsDataSchema = z.object({
repository: z
.object({
issues: connSchema(z.object({ number: z.number() })).nullish(),
pullRequests: connSchema(
z.object({ number: z.number(), isDraft: z.boolean().optional() }),
).nullish(),
})
.nullish(),
});
export type RepoThreadsData = z.infer<typeof repoThreadsDataSchema>;
Loading
Loading