Skip to content

Commit f1fa64d

Browse files
committed
Merge branch 'copilot/add-includes-option-and-counter-update'
2 parents c0b1cf7 + 603bedb commit f1fa64d

File tree

4 files changed

+349
-12
lines changed

4 files changed

+349
-12
lines changed

action.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
name: GitHub Readme Stats Action
2-
description: Generate GitHub Readme Stats cards in GitHub Actions.
1+
name: GitHub PR Stats Action
2+
description: Generate stat card images about merged PR in GitHub Actions (aggregated by organization).
33
author: readme-tools
44
inputs:
55
card:
@@ -58,6 +58,10 @@ inputs:
5858
description: Comma-separated repo name substrings to exclude. Overrides `exclude` in options.
5959
required: false
6060
default: ""
61+
includes:
62+
description: Comma-separated "owner/repo" names to always include, even if they have no merged PRs or are forks. Overrides `includes` in options.
63+
required: false
64+
default: ""
6165
custom_images:
6266
description: >
6367
Custom image URLs for specific repositories, overriding the default owner
@@ -104,6 +108,7 @@ runs:
104108
INPUT_HIDE_BORDER: ${{ inputs.hide_border }}
105109
INPUT_BORDER_RADIUS: ${{ inputs.border_radius }}
106110
INPUT_EXCLUDE: ${{ inputs.exclude }}
111+
INPUT_INCLUDES: ${{ inputs.includes }}
107112
branding:
108113
icon: bar-chart-2
109114
color: blue

index.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import core from "@actions/core";
22
import { mkdir, writeFile, readFile } from "node:fs/promises";
33
import path from "node:path";
4-
import { fetchUserPRs, renderOrgCard, parseExcludeList, parseCustomImages } from "./prs.js";
4+
import {
5+
fetchUserPRs,
6+
renderOrgCard,
7+
parseExcludeList,
8+
parseIncludeList,
9+
parseCustomImages,
10+
} from "./prs.js";
511

612
/**
713
* Normalize option values to strings.
@@ -68,6 +74,7 @@ const OPTION_KEYS = [
6874
"hide_border",
6975
"border_radius",
7076
"exclude",
77+
"includes",
7178
];
7279

7380
/**
@@ -116,8 +123,16 @@ const run = async () => {
116123
}
117124

118125
const excludeList = parseExcludeList(query.exclude);
119-
const customImages = parseCustomImages(core.getInput("custom_images") || "");
120-
const result = await fetchUserPRs(query.username, token, excludeList);
126+
const includeList = parseIncludeList(query.includes);
127+
const customImages = parseCustomImages(
128+
core.getInput("custom_images") || "",
129+
);
130+
const result = await fetchUserPRs(
131+
query.username,
132+
token,
133+
excludeList,
134+
includeList,
135+
);
121136

122137
const allOrgs = [...result.external, ...result.own];
123138

@@ -150,7 +165,12 @@ const run = async () => {
150165
const rawName = orgData.repo ? orgData.repo : orgData.org;
151166
const safeName = rawName.replace(/[^a-zA-Z0-9._-]/g, "-");
152167
const filePath = path.join(baseDir, `${prefix}${safeName}.svg`);
153-
const svg = await renderOrgCard(orgData, query, languageColors, customImages);
168+
const svg = await renderOrgCard(
169+
orgData,
170+
query,
171+
languageColors,
172+
customImages,
173+
);
154174
await writeFile(filePath, svg, "utf8");
155175
core.info(`Wrote ${filePath}`);
156176
written.push(path.relative(process.cwd(), filePath));
@@ -161,7 +181,12 @@ const run = async () => {
161181
const rawName = ownData.repo ? ownData.repo : ownData.org;
162182
const safeName = rawName.replace(/[^a-zA-Z0-9._-]/g, "-");
163183
const filePath = path.join(baseDir, `${prefix}own-${safeName}.svg`);
164-
const svg = await renderOrgCard(ownData, query, languageColors, customImages);
184+
const svg = await renderOrgCard(
185+
ownData,
186+
query,
187+
languageColors,
188+
customImages,
189+
);
165190
await writeFile(filePath, svg, "utf8");
166191
core.info(`Wrote ${filePath}`);
167192
written.push(path.relative(process.cwd(), filePath));

prs.js

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ const parseExcludeList = (value) => {
109109
.filter(Boolean);
110110
};
111111

112+
/**
113+
* Parse a comma-separated include list of repository names ("owner/repo").
114+
* @param {string | undefined} value
115+
* @returns {string[]}
116+
*/
117+
const parseIncludeList = (value) => {
118+
if (!value) return [];
119+
return value
120+
.split(",")
121+
.map((entry) => entry.trim())
122+
.filter(Boolean);
123+
};
124+
112125
/**
113126
* Check if a repository name should be excluded.
114127
* @param {string} repoName
@@ -149,6 +162,23 @@ const resolveOrgDisplayName = (ownerType, orgDisplayName, repoName) => {
149162
// GitHub GraphQL fetcher
150163
// ---------------------------------------------------------------------------
151164

165+
const REPO_INFO_QUERY = `
166+
query($owner: String!, $name: String!) {
167+
repository(owner: $owner, name: $name) {
168+
nameWithOwner
169+
isFork
170+
owner {
171+
__typename
172+
login
173+
avatarUrl
174+
... on Organization { name }
175+
}
176+
stargazerCount
177+
primaryLanguage { name }
178+
}
179+
}
180+
`;
181+
152182
const SEARCH_MERGED_PRS_QUERY = `
153183
query($searchQuery: String!, $after: String) {
154184
search(query: $searchQuery, type: ISSUE, first: 100, after: $after) {
@@ -188,9 +218,15 @@ const SEARCH_MERGED_PRS_QUERY = `
188218
* @param {string} username GitHub username.
189219
* @param {string} token GitHub PAT.
190220
* @param {string[]} [excludeList] List of repo name substrings to skip.
221+
* @param {string[]} [includeList] List of "owner/repo" names to always include.
191222
* @returns {Promise<UserPRsResult>} Aggregated PR data separated by external and own repos.
192223
*/
193-
const fetchUserPRs = async (username, token, excludeList = []) => {
224+
const fetchUserPRs = async (
225+
username,
226+
token,
227+
excludeList = [],
228+
includeList = [],
229+
) => {
194230
const headers = {
195231
Authorization: `bearer ${token}`,
196232
"Content-Type": "application/json",
@@ -333,6 +369,80 @@ const fetchUserPRs = async (username, token, excludeList = []) => {
333369
// Sort own repos descending by merged PRs to mirror external ordering.
334370
ownResult.sort((a, b) => b.mergedPRs - a.mergedPRs);
335371

372+
// Process forced-include repos that may have no merged PRs or be forks.
373+
for (const includeRepo of includeList) {
374+
const slashIdx = includeRepo.indexOf("/");
375+
if (slashIdx === -1) continue;
376+
const owner = includeRepo.slice(0, slashIdx);
377+
const name = includeRepo.slice(slashIdx + 1);
378+
if (!owner || !name) continue;
379+
const fullName = `${owner}/${name}`;
380+
const fullNameLower = fullName.toLowerCase();
381+
382+
// Skip if already in results.
383+
const alreadyExternal = externalResult.some(
384+
(e) => e.repo.toLowerCase() === fullNameLower,
385+
);
386+
const alreadyOwn = ownResult.some(
387+
(e) => e.repo.toLowerCase() === fullNameLower,
388+
);
389+
if (alreadyExternal || alreadyOwn) continue;
390+
391+
// Fetch repo info from GitHub.
392+
let repoNode;
393+
try {
394+
const res = await fetch("https://api.github.com/graphql", {
395+
method: "POST",
396+
headers,
397+
body: JSON.stringify({
398+
query: REPO_INFO_QUERY,
399+
variables: { owner, name },
400+
}),
401+
});
402+
if (!res.ok) {
403+
console.warn(
404+
`Could not fetch included repository ${fullName}: HTTP ${res.status}`,
405+
);
406+
continue;
407+
}
408+
const json = await res.json();
409+
repoNode = json.data?.repository;
410+
if (!repoNode) {
411+
console.warn(
412+
`Included repository ${fullName} was not found on GitHub.`,
413+
);
414+
}
415+
} catch (err) {
416+
console.warn(`Failed to fetch included repository ${fullName}: ${err}`);
417+
continue;
418+
}
419+
if (!repoNode) continue;
420+
421+
const ownerLogin = repoNode.owner.login;
422+
const ownerType = repoNode.owner.__typename || "User";
423+
const displayName = resolveOrgDisplayName(
424+
ownerType,
425+
repoNode.owner.name || ownerLogin,
426+
repoNode.nameWithOwner,
427+
);
428+
429+
const entry = {
430+
org: ownerLogin,
431+
orgDisplayName: displayName,
432+
avatarUrl: repoNode.owner.avatarUrl,
433+
repo: repoNode.nameWithOwner,
434+
stars: repoNode.stargazerCount,
435+
mergedPRs: 0,
436+
language: repoNode.primaryLanguage?.name || "",
437+
};
438+
439+
if (ownerLogin.toLowerCase() === username.toLowerCase()) {
440+
ownResult.push(entry);
441+
} else {
442+
externalResult.push(entry);
443+
}
444+
}
445+
336446
return {
337447
external: externalResult,
338448
own: ownResult,
@@ -502,6 +612,14 @@ const renderOrgCard = async (
502612
return String(n);
503613
};
504614

615+
const mergedPRsSvg =
616+
data.mergedPRs > 0
617+
? `<g transform="translate(80, 0)">
618+
${mergedIcon}
619+
<text x="20" y="13" class="stat">${data.mergedPRs} merged</text>
620+
</g>`
621+
: "";
622+
505623
const svg = `<svg
506624
width="${width}" height="${height}"
507625
viewBox="0 0 ${width} ${height}"
@@ -541,10 +659,7 @@ const renderOrgCard = async (
541659
${starIcon}
542660
<text x="20" y="13" class="stat">${formatCount(data.stars)}</text>
543661
</g>
544-
<g transform="translate(80, 0)">
545-
${mergedIcon}
546-
<text x="20" y="13" class="stat">${data.mergedPRs} merged</text>
547-
</g>
662+
${mergedPRsSvg}
548663
</g>
549664
</svg>`;
550665

@@ -577,6 +692,7 @@ export {
577692
LANG_ICON_SLUGS,
578693
parseCustomImages,
579694
parseExcludeList,
695+
parseIncludeList,
580696
shouldExcludeRepo,
581697
getRepoShortName,
582698
resolveOrgDisplayName,

0 commit comments

Comments
 (0)