mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-16 21:02:57 +08:00
fix(github): fetch latest issue timeline without pagination
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user