fix(github): fetch latest issue timeline without pagination

This commit is contained in:
DIYgod
2026-10-15 01:45:20 +08:00
parent 5556d17b10
commit 50a82ef8c3
+213 -8
View File
@@ -2,6 +2,8 @@ import { escapeAttribute, escapeText } from 'entities';
import MarkdownIt from 'markdown-it';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import NotFoundError from '@/errors/types/not-found';
import type { DataItem, Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
@@ -13,6 +15,111 @@ const md = MarkdownIt({
const rootUrl = 'https://github.com';
const apiUrl = 'https://api.github.com';
const issueSelection = `
id
number
title
url
body
bodyHTML
createdAt
author { login }
`;
const timelineSelection = `
pageInfo { hasPreviousPage }
nodes { __typename ...TimelineItem }
`;
const timelineQuery = `
query($user: String!, $repo: String!, $number: Int!, $limit: Int!) {
repository(owner: $user, name: $repo) {
issueOrPullRequest(number: $number) {
__typename
... on Issue {
${issueSelection}
timelineItems(last: $limit, itemTypes: [CLOSED_EVENT, ISSUE_COMMENT, CROSS_REFERENCED_EVENT, RENAMED_TITLE_EVENT]) {
${timelineSelection}
}
}
... on PullRequest {
${issueSelection}
timelineItems(last: $limit, itemTypes: [CLOSED_EVENT, ISSUE_COMMENT, CROSS_REFERENCED_EVENT, RENAMED_TITLE_EVENT, PULL_REQUEST_REVIEW]) {
${timelineSelection}
}
}
}
}
}
fragment TimelineItem on Node {
id
... on ClosedEvent {
actor { login }
createdAt
url
}
... on IssueComment {
author { login }
body
bodyHTML
createdAt
url
}
... on CrossReferencedEvent {
actor { login }
createdAt
source {
... on Issue { number title url }
... on PullRequest { number title url }
}
}
... on RenamedTitleEvent {
actor { login }
createdAt
previousTitle
currentTitle
}
... on PullRequestReview {
author { login }
body
bodyHTML
state
submittedAt
url
}
}
`;
interface GraphqlTimelineItem {
id: string;
__typename: 'ClosedEvent' | 'IssueComment' | 'CrossReferencedEvent' | 'RenamedTitleEvent' | 'PullRequestReview';
actor?: { login: string };
author?: { login: string };
createdAt?: string;
submittedAt?: string;
url?: string;
body?: string;
bodyHTML?: string;
state?: string;
previousTitle?: string;
currentTitle?: string;
source?: { number: number; title: string; url: string };
}
interface GraphqlIssue {
id: string;
__typename: 'Issue' | 'PullRequest';
number: number;
title: string;
url: string;
body: string;
bodyHTML: string;
createdAt: string;
author?: { login: string };
timelineItems: {
pageInfo: { hasPreviousPage: boolean };
nodes: Array<GraphqlTimelineItem | null>;
};
}
const typeDict = {
issue: {
title: 'Issue',
@@ -34,6 +141,15 @@ export const route: Route = {
repo: 'Repo name',
number: 'Issue or pull number (if omitted: all)',
},
features: {
requireConfig: [
{
name: 'GITHUB_ACCESS_TOKEN',
optional: true,
description: 'Required for the latest events of a single issue or pull request whose timeline exceeds one REST API page. Optional for smaller public timelines and repository-wide comments.',
},
],
},
radar: [
{
source: ['github.com/:user/:repo/:type', 'github.com/:user/:repo/:type/:number'],
@@ -113,23 +229,28 @@ async function allIssues(ctx, user, repo, limit, headers) {
}
async function singleIssue(ctx, user, repo, number, limit, headers) {
if (config.github.access_token) {
return singleIssueGraphql(ctx, user, repo, number, Math.min(limit, 100), headers);
}
const response = await ofetch.raw(`${apiUrl}/repos/${user}/${repo}/issues/${number}`, {
headers,
});
const issue = response._data;
const type = issue.pull_request ? 'pull' : 'issue';
let timelineResponse = await ofetch.raw(issue.timeline_url, {
const timelineResponse = await ofetch.raw(issue.timeline_url, {
headers,
query: {
per_page: limit,
per_page: 100,
},
});
const items: DataItem[] = [];
const lastUrl = timelineResponse.headers.get('link')?.match(/<(\S+?)>; rel="last"/)?.[1];
if (lastUrl) {
timelineResponse = await ofetch.raw(lastUrl, { headers });
} else {
if (/rel="(?:next|last)"/.test(timelineResponse.headers.get('link') ?? '')) {
throw new ConfigNotFoundError('This GitHub timeline spans multiple REST API pages. Configure GITHUB_ACCESS_TOKEN to retrieve its latest events without pagination.');
}
const timeline = timelineResponse._data.filter((item) => ['closed', 'commented', 'cross-referenced', 'renamed', 'reviewed'].includes(item.event)).slice(-limit);
if (timeline.length < limit) {
items.push({
title: `${issue.user.login} created ${user}/${repo}: ${typeDict[type].title} #${issue.number}`,
description: issue.body ? md.render(issue.body) : null,
@@ -138,8 +259,6 @@ async function singleIssue(ctx, user, repo, number, limit, headers) {
link: `${issue.html_url}#issue-${issue.id}`,
});
}
const timeline = timelineResponse._data;
for (const item of timeline) {
const actor = item.actor?.login ?? item.user?.login ?? 'ghost';
switch (item.event) {
@@ -214,3 +333,89 @@ async function singleIssue(ctx, user, repo, number, limit, headers) {
});
return ret;
}
async function singleIssueGraphql(ctx, user: string, repo: string, number: number, limit: number, headers) {
const response = await ofetch.raw<{
data?: { repository?: { issueOrPullRequest?: GraphqlIssue } };
errors?: Array<{ message: string }>;
}>(`${apiUrl}/graphql`, {
method: 'POST',
headers,
body: { query: timelineQuery, variables: { user, repo, number, limit } },
});
if (response._data?.errors?.length) {
throw new Error(`GitHub GraphQL timeline request failed: ${response._data.errors.map((error) => error.message).join('; ')}`);
}
if (!response._data?.data) {
throw new Error('GitHub GraphQL timeline request returned no data.');
}
const issue = response._data?.data?.repository?.issueOrPullRequest;
if (!issue) {
throw new NotFoundError(`GitHub issue or pull request ${user}/${repo}#${number} was not found or is inaccessible to GITHUB_ACCESS_TOKEN.`);
}
const items = issue.timelineItems.nodes.flatMap((item) => (item ? renderGraphqlTimelineItem(item, issue, user, repo) : []));
if (!issue.timelineItems.pageInfo.hasPreviousPage && issue.timelineItems.nodes.length < limit) {
const actor = issue.author?.login ?? 'ghost';
items.unshift({
title: `${actor} created ${user}/${repo}: ${issue.__typename === 'PullRequest' ? 'Pull request' : 'Issue'} #${issue.number}`,
description: issue.bodyHTML || (issue.body ? md.render(issue.body) : undefined),
author: actor,
pubDate: parseDate(issue.createdAt),
link: issue.url,
guid: issue.id,
});
}
const ret = {
title: `${user}/${repo}: ${issue.__typename === 'PullRequest' ? 'Pull request' : 'Issue'} #${number} - ${issue.title}`,
link: issue.url,
item: items.toReversed(),
};
const resetTimestamp = Number.parseInt(response.headers.get('x-ratelimit-reset') ?? '');
ctx.set('json', {
...ret,
rateLimit: {
limit: Number.parseInt(response.headers.get('x-ratelimit-limit') ?? ''),
remaining: Number.parseInt(response.headers.get('x-ratelimit-remaining') ?? ''),
reset: parseDate(resetTimestamp * 1000),
resoure: response.headers.get('x-ratelimit-resource'),
used: Number.parseInt(response.headers.get('x-ratelimit-used') ?? ''),
},
});
return ret;
}
function renderGraphqlTimelineItem(item: GraphqlTimelineItem, issue: GraphqlIssue, user: string, repo: string): DataItem[] {
const actor = item.actor?.login ?? item.author?.login ?? 'ghost';
const subject = `${user}/${repo}: ${issue.__typename === 'PullRequest' ? 'Pull request' : 'Issue'} #${issue.number}`;
const createdAt = item.__typename === 'PullRequestReview' ? item.submittedAt : item.createdAt;
const base = {
author: actor,
pubDate: createdAt ? parseDate(createdAt) : undefined,
link: item.url ?? issue.url,
guid: item.id,
};
switch (item.__typename) {
case 'ClosedEvent':
return [{ ...base, title: `${actor} closed ${subject}` }];
case 'IssueComment':
return [{ ...base, title: `${actor} commented on ${subject}`, description: item.bodyHTML || md.render(item.body ?? '') }];
case 'CrossReferencedEvent':
return item.source
? [
{
...base,
title: `${actor} cross-referenced on ${subject}`,
description: `${escapeText(actor)} mentioned this issue in <a href="${escapeAttribute(item.source.url)}"><b>${escapeText(item.source.title)}</b> #${item.source.number}</a>`,
link: item.source.url,
},
]
: [];
case 'RenamedTitleEvent':
return [{ ...base, title: `${actor} renamed on ${subject}`, description: `${escapeText(actor)} changed the title <del>${escapeText(item.previousTitle ?? '')}</del> ${escapeText(item.currentTitle ?? '')}` }];
case 'PullRequestReview':
return item.submittedAt ? [{ ...base, title: `${actor} reviewed on ${subject}`, description: item.bodyHTML || (item.body ? md.render(item.body) : item.state?.replaceAll('_', ' ')) }] : [];
default:
return [];
}
}