Server core: config, GitHub GraphQL polling, SQLite persistence, timeline ingestion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
Joshua Coles
2026-07-28 14:21:25 +00:00
co-authored by Claude Fable 5
commit 1163357967
18 changed files with 2941 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import { getToken, invalidateToken } from './token.js';
export interface RateLimitInfo {
cost: number;
remaining: number;
resetAt: string;
}
export class GithubError extends Error {}
let lastRateLimit: RateLimitInfo | null = null;
export function getLastRateLimit(): RateLimitInfo | null {
return lastRateLimit;
}
export async function graphql<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
authorization: `bearer ${getToken()}`,
'content-type': 'application/json',
'user-agent': 'pr-monitor',
},
body: JSON.stringify({ query, variables }),
});
if (res.status === 401 && attempt === 0) {
invalidateToken();
continue;
}
if (!res.ok) {
throw new GithubError(`GitHub API ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const body = (await res.json()) as {
data?: T & { rateLimit?: RateLimitInfo };
errors?: { message: string }[];
};
// Partial data with errors (e.g. one inaccessible node) is usable; only fail when
// there is no data at all.
if (!body.data) {
const msg = body.errors?.map((e) => e.message).join('; ') ?? 'no data returned';
throw new GithubError(`GraphQL error: ${msg.slice(0, 500)}`);
}
if (body.data.rateLimit) {
lastRateLimit = body.data.rateLimit;
console.log(
`[github] cost=${lastRateLimit.cost} remaining=${lastRateLimit.remaining}`,
);
}
return body.data;
}
}
+143
View File
@@ -0,0 +1,143 @@
export const LIST_QUERY = /* GraphQL */ `
query List($q: String!, $cursor: String) {
rateLimit { cost remaining resetAt }
search(query: $q, type: ISSUE, first: 50, after: $cursor) {
issueCount
pageInfo { hasNextPage endCursor }
nodes {
... on PullRequest {
id
number
title
url
isDraft
updatedAt
author { login }
headRefName
headRefOid
repository { nameWithOwner }
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
reviewThreads(first: 50) { totalCount nodes { isResolved } }
comments(first: 1) { totalCount }
reviews(first: 1) { totalCount }
latestReviews(first: 10) { nodes { author { login } state submittedAt url } }
}
}
}
}
`;
export interface ListPr {
id: string;
number: number;
title: string;
url: string;
isDraft: boolean;
updatedAt: string;
author: { login: string } | null;
headRefName: string;
headRefOid: string;
repository: { nameWithOwner: string };
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
reviewThreads: { totalCount: number; nodes: { isResolved: boolean }[] };
comments: { totalCount: number };
reviews: { totalCount: number };
latestReviews: {
nodes: { author: { login: string } | null; state: string; submittedAt: string; url: string }[];
};
}
export interface ListResult {
search: {
issueCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: (ListPr | Record<string, never>)[];
};
}
export const DETAIL_QUERY = /* GraphQL */ `
query Details($ids: [ID!]!) {
rateLimit { cost remaining resetAt }
nodes(ids: $ids) {
... on PullRequest {
id
commits(last: 1) {
nodes {
commit {
oid
statusCheckRollup {
state
contexts(first: 100) {
nodes {
__typename
... on CheckRun { name status conclusion detailsUrl startedAt }
... on StatusContext { context state targetUrl }
}
}
}
}
}
}
comments(last: 50) {
nodes { id author { login } bodyText createdAt updatedAt url }
}
reviews(last: 30) {
nodes { id author { login } state bodyText submittedAt url }
}
reviewThreads(first: 100) {
nodes {
id
isResolved
path
comments(first: 30) {
nodes { id author { login } bodyText createdAt updatedAt url }
}
}
}
}
}
}
`;
export type CheckContext =
| { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null; detailsUrl: string | null; startedAt: string | null }
| { __typename: 'StatusContext'; context: string; state: string; targetUrl: string | null };
export interface DetailComment {
id: string;
author: { login: string } | null;
bodyText: string;
createdAt: string;
updatedAt: string;
url: string;
}
export interface DetailPr {
id: string;
commits: {
nodes: {
commit: {
oid: string;
statusCheckRollup: { state: string; contexts: { nodes: CheckContext[] } } | null;
};
}[];
};
comments: { nodes: DetailComment[] };
reviews: {
nodes: {
id: string;
author: { login: string } | null;
state: string;
bodyText: string;
submittedAt: string | null;
url: string;
}[];
};
reviewThreads: {
nodes: { id: string; isResolved: boolean; path: string | null; comments: { nodes: DetailComment[] } }[];
};
}
export interface DetailResult {
nodes: (DetailPr | null)[];
}
+20
View File
@@ -0,0 +1,20 @@
import { execFileSync } from 'node:child_process';
let cached: string | null = null;
export function getToken(): string {
if (cached) return cached;
const env = process.env.GITHUB_TOKEN;
if (env) {
cached = env;
return cached;
}
cached = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim();
if (!cached) throw new Error('gh auth token returned an empty token');
return cached;
}
/** Drop the cached token (called once on a 401 so the next call re-fetches). */
export function invalidateToken(): void {
cached = null;
}