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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user