@oss-autopilot/core 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/cli.bundle.cjs +17657 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +325 -0
- package/dist/commands/check-integration.d.ts +10 -0
- package/dist/commands/check-integration.js +192 -0
- package/dist/commands/comments.d.ts +24 -0
- package/dist/commands/comments.js +311 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/daily.d.ts +29 -0
- package/dist/commands/daily.js +433 -0
- package/dist/commands/dashboard-data.d.ts +45 -0
- package/dist/commands/dashboard-data.js +132 -0
- package/dist/commands/dashboard-templates.d.ts +23 -0
- package/dist/commands/dashboard-templates.js +1627 -0
- package/dist/commands/dashboard.d.ts +18 -0
- package/dist/commands/dashboard.js +134 -0
- package/dist/commands/dismiss.d.ts +13 -0
- package/dist/commands/dismiss.js +49 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/local-repos.d.ts +14 -0
- package/dist/commands/local-repos.js +155 -0
- package/dist/commands/parse-list.d.ts +13 -0
- package/dist/commands/parse-list.js +139 -0
- package/dist/commands/read.d.ts +12 -0
- package/dist/commands/read.js +33 -0
- package/dist/commands/search.d.ts +10 -0
- package/dist/commands/search.js +74 -0
- package/dist/commands/setup.d.ts +15 -0
- package/dist/commands/setup.js +276 -0
- package/dist/commands/shelve.d.ts +13 -0
- package/dist/commands/shelve.js +49 -0
- package/dist/commands/snooze.d.ts +18 -0
- package/dist/commands/snooze.js +83 -0
- package/dist/commands/startup.d.ts +33 -0
- package/dist/commands/startup.js +197 -0
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/track.d.ts +16 -0
- package/dist/commands/track.js +59 -0
- package/dist/commands/validation.d.ts +43 -0
- package/dist/commands/validation.js +112 -0
- package/dist/commands/vet.d.ts +10 -0
- package/dist/commands/vet.js +36 -0
- package/dist/core/checklist-analysis.d.ts +17 -0
- package/dist/core/checklist-analysis.js +39 -0
- package/dist/core/ci-analysis.d.ts +78 -0
- package/dist/core/ci-analysis.js +163 -0
- package/dist/core/comment-utils.d.ts +15 -0
- package/dist/core/comment-utils.js +52 -0
- package/dist/core/concurrency.d.ts +5 -0
- package/dist/core/concurrency.js +15 -0
- package/dist/core/daily-logic.d.ts +77 -0
- package/dist/core/daily-logic.js +512 -0
- package/dist/core/display-utils.d.ts +10 -0
- package/dist/core/display-utils.js +100 -0
- package/dist/core/errors.d.ts +24 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/github-stats.d.ts +73 -0
- package/dist/core/github-stats.js +272 -0
- package/dist/core/github.d.ts +19 -0
- package/dist/core/github.js +60 -0
- package/dist/core/http-cache.d.ts +97 -0
- package/dist/core/http-cache.js +269 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +15 -0
- package/dist/core/issue-conversation.d.ts +29 -0
- package/dist/core/issue-conversation.js +231 -0
- package/dist/core/issue-discovery.d.ts +85 -0
- package/dist/core/issue-discovery.js +589 -0
- package/dist/core/issue-filtering.d.ts +51 -0
- package/dist/core/issue-filtering.js +103 -0
- package/dist/core/issue-scoring.d.ts +40 -0
- package/dist/core/issue-scoring.js +92 -0
- package/dist/core/issue-vetting.d.ts +49 -0
- package/dist/core/issue-vetting.js +536 -0
- package/dist/core/logger.d.ts +21 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/maintainer-analysis.d.ts +10 -0
- package/dist/core/maintainer-analysis.js +59 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +20 -0
- package/dist/core/pr-monitor.d.ts +109 -0
- package/dist/core/pr-monitor.js +594 -0
- package/dist/core/review-analysis.d.ts +72 -0
- package/dist/core/review-analysis.js +163 -0
- package/dist/core/state.d.ts +371 -0
- package/dist/core/state.js +1089 -0
- package/dist/core/types.d.ts +507 -0
- package/dist/core/types.js +34 -0
- package/dist/core/utils.d.ts +249 -0
- package/dist/core/utils.js +422 -0
- package/dist/formatters/json.d.ts +269 -0
- package/dist/formatters/json.js +88 -0
- package/package.json +67 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Stats - Fetching merged/closed PR counts and repository star counts.
|
|
3
|
+
* Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
|
|
4
|
+
*/
|
|
5
|
+
import { Octokit } from '@octokit/rest';
|
|
6
|
+
import { ClosedPR, MergedPR } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
9
|
+
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername: string): Promise<{
|
|
12
|
+
repos: Map<string, {
|
|
13
|
+
count: number;
|
|
14
|
+
lastMergedAt: string;
|
|
15
|
+
}>;
|
|
16
|
+
monthlyCounts: Record<string, number>;
|
|
17
|
+
monthlyOpenedCounts: Record<string, number>;
|
|
18
|
+
dailyActivityCounts: Record<string, number>;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
22
|
+
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchUserClosedPRCounts(octokit: Octokit, githubUsername: string): Promise<{
|
|
25
|
+
repos: Map<string, number>;
|
|
26
|
+
monthlyCounts: Record<string, number>;
|
|
27
|
+
monthlyOpenedCounts: Record<string, number>;
|
|
28
|
+
dailyActivityCounts: Record<string, number>;
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* Fetch GitHub star counts for a list of repositories.
|
|
32
|
+
* Used to populate stargazersCount in repo scores for dashboard filtering by minStars.
|
|
33
|
+
* Fetches concurrently with per-repo error isolation (missing/private repos are skipped).
|
|
34
|
+
*/
|
|
35
|
+
export declare function fetchRepoStarCounts(octokit: Octokit, repos: string[]): Promise<Map<string, number>>;
|
|
36
|
+
/**
|
|
37
|
+
* Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
|
|
38
|
+
* Returns parsed search results that pass all filters.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fetchRecentPRs<T>(octokit: Octokit, config: {
|
|
41
|
+
githubUsername: string;
|
|
42
|
+
excludeRepos: string[];
|
|
43
|
+
excludeOrgs?: string[];
|
|
44
|
+
}, query: string, label: string, days: number, mapItem: (item: {
|
|
45
|
+
html_url: string;
|
|
46
|
+
title: string;
|
|
47
|
+
closed_at: string | null;
|
|
48
|
+
pull_request?: {
|
|
49
|
+
merged_at?: string | null;
|
|
50
|
+
};
|
|
51
|
+
}, parsed: {
|
|
52
|
+
owner: string;
|
|
53
|
+
repo: string;
|
|
54
|
+
number: number;
|
|
55
|
+
}) => T): Promise<T[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Fetch PRs closed without merge in the last N days.
|
|
58
|
+
* Returns lightweight ClosedPR objects for surfacing in the daily digest.
|
|
59
|
+
*/
|
|
60
|
+
export declare function fetchRecentlyClosedPRs(octokit: Octokit, config: {
|
|
61
|
+
githubUsername: string;
|
|
62
|
+
excludeRepos: string[];
|
|
63
|
+
excludeOrgs?: string[];
|
|
64
|
+
}, days?: number): Promise<ClosedPR[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Fetch PRs merged in the last N days.
|
|
67
|
+
* Returns lightweight MergedPR objects for surfacing as wins in the dashboard.
|
|
68
|
+
*/
|
|
69
|
+
export declare function fetchRecentlyMergedPRs(octokit: Octokit, config: {
|
|
70
|
+
githubUsername: string;
|
|
71
|
+
excludeRepos: string[];
|
|
72
|
+
excludeOrgs?: string[];
|
|
73
|
+
}, days?: number): Promise<MergedPR[]>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Stats - Fetching merged/closed PR counts and repository star counts.
|
|
3
|
+
* Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
|
|
4
|
+
*/
|
|
5
|
+
import { extractOwnerRepo, parseGitHubUrl } from './utils.js';
|
|
6
|
+
import { ValidationError } from './errors.js';
|
|
7
|
+
import { debug, warn } from './logger.js';
|
|
8
|
+
const MODULE = 'github-stats';
|
|
9
|
+
/**
|
|
10
|
+
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
11
|
+
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
14
|
+
if (!githubUsername) {
|
|
15
|
+
return { repos: new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
|
|
16
|
+
}
|
|
17
|
+
debug(MODULE, `Fetching merged PR counts for @${githubUsername}...`);
|
|
18
|
+
const repos = new Map();
|
|
19
|
+
const monthlyCounts = {};
|
|
20
|
+
const monthlyOpenedCounts = {};
|
|
21
|
+
const dailyActivityCounts = {};
|
|
22
|
+
let page = 1;
|
|
23
|
+
let fetched = 0;
|
|
24
|
+
while (true) {
|
|
25
|
+
const { data } = await octokit.search.issuesAndPullRequests({
|
|
26
|
+
q: `is:pr is:merged author:${githubUsername}`,
|
|
27
|
+
sort: 'updated',
|
|
28
|
+
order: 'desc',
|
|
29
|
+
per_page: 100,
|
|
30
|
+
page,
|
|
31
|
+
});
|
|
32
|
+
for (const item of data.items) {
|
|
33
|
+
const parsed = extractOwnerRepo(item.html_url);
|
|
34
|
+
if (!parsed) {
|
|
35
|
+
warn(MODULE, `Skipping merged PR with unparseable URL: ${item.html_url}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const { owner } = parsed;
|
|
39
|
+
const repo = `${owner}/${parsed.repo}`;
|
|
40
|
+
// Skip own repos (PRs to your own repos aren't OSS contributions)
|
|
41
|
+
if (owner.toLowerCase() === githubUsername.toLowerCase())
|
|
42
|
+
continue;
|
|
43
|
+
// Note: excludeRepos/excludeOrgs are intentionally NOT filtered here.
|
|
44
|
+
// Those filters control issue discovery/search, not historical statistics.
|
|
45
|
+
// A merged PR is a merged PR regardless of current tracking preferences.
|
|
46
|
+
const mergedAt = item.pull_request?.merged_at || item.closed_at || '';
|
|
47
|
+
// Per-repo tracking
|
|
48
|
+
const existing = repos.get(repo);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.count += 1;
|
|
51
|
+
if (mergedAt && mergedAt > existing.lastMergedAt) {
|
|
52
|
+
existing.lastMergedAt = mergedAt;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
repos.set(repo, { count: 1, lastMergedAt: mergedAt });
|
|
57
|
+
}
|
|
58
|
+
// Monthly histogram (every PR counted individually)
|
|
59
|
+
if (mergedAt) {
|
|
60
|
+
const month = mergedAt.slice(0, 7); // "YYYY-MM"
|
|
61
|
+
monthlyCounts[month] = (monthlyCounts[month] || 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
// Track when this PR was opened (for monthly opened histogram)
|
|
64
|
+
if (item.created_at) {
|
|
65
|
+
const openedMonth = item.created_at.slice(0, 7); // "YYYY-MM"
|
|
66
|
+
monthlyOpenedCounts[openedMonth] = (monthlyOpenedCounts[openedMonth] || 0) + 1;
|
|
67
|
+
// Daily activity: PR opened
|
|
68
|
+
const openedDay = item.created_at.slice(0, 10);
|
|
69
|
+
if (openedDay.length === 10)
|
|
70
|
+
dailyActivityCounts[openedDay] = (dailyActivityCounts[openedDay] || 0) + 1;
|
|
71
|
+
}
|
|
72
|
+
// Daily activity: PR merged
|
|
73
|
+
if (mergedAt) {
|
|
74
|
+
const mergedDay = mergedAt.slice(0, 10);
|
|
75
|
+
if (mergedDay.length === 10)
|
|
76
|
+
dailyActivityCounts[mergedDay] = (dailyActivityCounts[mergedDay] || 0) + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
fetched += data.items.length;
|
|
80
|
+
// Stop if we've fetched all results or hit the API limit (1000)
|
|
81
|
+
if (fetched >= data.total_count || fetched >= 1000 || data.items.length === 0) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
page++;
|
|
85
|
+
}
|
|
86
|
+
debug(MODULE, `Found ${fetched} merged PRs across ${repos.size} repos`);
|
|
87
|
+
return { repos, monthlyCounts, monthlyOpenedCounts, dailyActivityCounts };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
91
|
+
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
92
|
+
*/
|
|
93
|
+
export async function fetchUserClosedPRCounts(octokit, githubUsername) {
|
|
94
|
+
if (!githubUsername) {
|
|
95
|
+
return { repos: new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
|
|
96
|
+
}
|
|
97
|
+
debug(MODULE, `Fetching closed PR counts for @${githubUsername}...`);
|
|
98
|
+
const repos = new Map();
|
|
99
|
+
const monthlyCounts = {};
|
|
100
|
+
const monthlyOpenedCounts = {};
|
|
101
|
+
const dailyActivityCounts = {};
|
|
102
|
+
let page = 1;
|
|
103
|
+
let fetched = 0;
|
|
104
|
+
while (true) {
|
|
105
|
+
const { data } = await octokit.search.issuesAndPullRequests({
|
|
106
|
+
q: `is:pr is:closed is:unmerged author:${githubUsername}`,
|
|
107
|
+
sort: 'updated',
|
|
108
|
+
order: 'desc',
|
|
109
|
+
per_page: 100,
|
|
110
|
+
page,
|
|
111
|
+
});
|
|
112
|
+
for (const item of data.items) {
|
|
113
|
+
const parsed = extractOwnerRepo(item.html_url);
|
|
114
|
+
if (!parsed) {
|
|
115
|
+
warn(MODULE, `Skipping closed PR with unparseable URL: ${item.html_url}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const { owner } = parsed;
|
|
119
|
+
const repo = `${owner}/${parsed.repo}`;
|
|
120
|
+
// Skip own repos
|
|
121
|
+
if (owner.toLowerCase() === githubUsername.toLowerCase())
|
|
122
|
+
continue;
|
|
123
|
+
// Note: excludeRepos/excludeOrgs are intentionally NOT filtered here.
|
|
124
|
+
// Those filters control issue discovery/search, not historical statistics.
|
|
125
|
+
// A closed PR is a closed PR regardless of current tracking preferences.
|
|
126
|
+
repos.set(repo, (repos.get(repo) || 0) + 1);
|
|
127
|
+
// Track when this PR was closed (for monthly closed histogram)
|
|
128
|
+
if (item.closed_at) {
|
|
129
|
+
const closedMonth = item.closed_at.slice(0, 7); // "YYYY-MM"
|
|
130
|
+
monthlyCounts[closedMonth] = (monthlyCounts[closedMonth] || 0) + 1;
|
|
131
|
+
// Daily activity: PR closed
|
|
132
|
+
const closedDay = item.closed_at.slice(0, 10);
|
|
133
|
+
if (closedDay.length === 10)
|
|
134
|
+
dailyActivityCounts[closedDay] = (dailyActivityCounts[closedDay] || 0) + 1;
|
|
135
|
+
}
|
|
136
|
+
// Track when this PR was opened (for monthly opened histogram)
|
|
137
|
+
if (item.created_at) {
|
|
138
|
+
const openedMonth = item.created_at.slice(0, 7); // "YYYY-MM"
|
|
139
|
+
monthlyOpenedCounts[openedMonth] = (monthlyOpenedCounts[openedMonth] || 0) + 1;
|
|
140
|
+
// Daily activity: PR opened
|
|
141
|
+
const openedDay = item.created_at.slice(0, 10);
|
|
142
|
+
if (openedDay.length === 10)
|
|
143
|
+
dailyActivityCounts[openedDay] = (dailyActivityCounts[openedDay] || 0) + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
fetched += data.items.length;
|
|
147
|
+
if (fetched >= data.total_count || fetched >= 1000 || data.items.length === 0) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
page++;
|
|
151
|
+
}
|
|
152
|
+
debug(MODULE, `Found ${fetched} closed (unmerged) PRs across ${repos.size} repos`);
|
|
153
|
+
return { repos, monthlyCounts, monthlyOpenedCounts, dailyActivityCounts };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Fetch GitHub star counts for a list of repositories.
|
|
157
|
+
* Used to populate stargazersCount in repo scores for dashboard filtering by minStars.
|
|
158
|
+
* Fetches concurrently with per-repo error isolation (missing/private repos are skipped).
|
|
159
|
+
*/
|
|
160
|
+
export async function fetchRepoStarCounts(octokit, repos) {
|
|
161
|
+
if (repos.length === 0)
|
|
162
|
+
return new Map();
|
|
163
|
+
debug(MODULE, `Fetching star counts for ${repos.length} repos...`);
|
|
164
|
+
const results = new Map();
|
|
165
|
+
// Fetch in parallel chunks to avoid overwhelming the API
|
|
166
|
+
const chunkSize = 10;
|
|
167
|
+
for (let i = 0; i < repos.length; i += chunkSize) {
|
|
168
|
+
const chunk = repos.slice(i, i + chunkSize);
|
|
169
|
+
const settled = await Promise.allSettled(chunk.map(async (repo) => {
|
|
170
|
+
const parts = repo.split('/');
|
|
171
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
172
|
+
throw new ValidationError(`Malformed repo identifier: "${repo}"`);
|
|
173
|
+
}
|
|
174
|
+
const [owner, name] = parts;
|
|
175
|
+
const { data } = await octokit.repos.get({ owner, repo: name });
|
|
176
|
+
return { repo, stars: data.stargazers_count };
|
|
177
|
+
}));
|
|
178
|
+
let chunkFailures = 0;
|
|
179
|
+
for (let j = 0; j < settled.length; j++) {
|
|
180
|
+
const result = settled[j];
|
|
181
|
+
if (result.status === 'fulfilled') {
|
|
182
|
+
results.set(result.value.repo, result.value.stars);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
chunkFailures++;
|
|
186
|
+
warn(MODULE, `Failed to fetch stars for ${chunk[j]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// If entire chunk failed, likely a systemic issue (rate limit, auth, outage) — abort remaining
|
|
190
|
+
if (chunkFailures === chunk.length && chunk.length > 0) {
|
|
191
|
+
const remaining = repos.length - i - chunkSize;
|
|
192
|
+
if (remaining > 0) {
|
|
193
|
+
warn(MODULE, `Entire chunk failed, aborting remaining ${remaining} repos`);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
debug(MODULE, `Fetched star counts for ${results.size}/${repos.length} repos`);
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
|
|
203
|
+
* Returns parsed search results that pass all filters.
|
|
204
|
+
*/
|
|
205
|
+
export async function fetchRecentPRs(octokit, config, query, label, days, mapItem) {
|
|
206
|
+
if (!config.githubUsername) {
|
|
207
|
+
warn(MODULE, `Skipping recently ${label} PRs fetch: no githubUsername configured. Run /setup-oss to configure.`);
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const sinceDate = new Date();
|
|
211
|
+
sinceDate.setDate(sinceDate.getDate() - days);
|
|
212
|
+
const since = sinceDate.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
213
|
+
debug(MODULE, `Fetching recently ${label} PRs for @${config.githubUsername} (since ${since})...`);
|
|
214
|
+
const { data } = await octokit.search.issuesAndPullRequests({
|
|
215
|
+
q: query.replace('{username}', config.githubUsername).replace('{since}', since),
|
|
216
|
+
sort: 'updated',
|
|
217
|
+
order: 'desc',
|
|
218
|
+
per_page: 100,
|
|
219
|
+
});
|
|
220
|
+
const results = [];
|
|
221
|
+
for (const item of data.items) {
|
|
222
|
+
const parsed = parseGitHubUrl(item.html_url);
|
|
223
|
+
if (!parsed) {
|
|
224
|
+
warn(MODULE, `Could not parse GitHub URL from API response: ${item.html_url}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const repo = `${parsed.owner}/${parsed.repo}`;
|
|
228
|
+
// Skip own repos
|
|
229
|
+
if (parsed.owner.toLowerCase() === config.githubUsername.toLowerCase())
|
|
230
|
+
continue;
|
|
231
|
+
// Skip excluded repos and orgs
|
|
232
|
+
if (config.excludeRepos.includes(repo))
|
|
233
|
+
continue;
|
|
234
|
+
if (config.excludeOrgs?.some((org) => parsed.owner.toLowerCase() === org.toLowerCase()))
|
|
235
|
+
continue;
|
|
236
|
+
results.push(mapItem(item, { owner: parsed.owner, repo, number: parsed.number }));
|
|
237
|
+
}
|
|
238
|
+
debug(MODULE, `Found ${results.length} recently ${label} PRs`);
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Fetch PRs closed without merge in the last N days.
|
|
243
|
+
* Returns lightweight ClosedPR objects for surfacing in the daily digest.
|
|
244
|
+
*/
|
|
245
|
+
export async function fetchRecentlyClosedPRs(octokit, config, days = 7) {
|
|
246
|
+
return fetchRecentPRs(octokit, config, 'is:pr is:closed is:unmerged author:{username} closed:>={since}', 'closed', days, (item, { repo, number }) => ({
|
|
247
|
+
url: item.html_url,
|
|
248
|
+
repo,
|
|
249
|
+
number,
|
|
250
|
+
title: item.title,
|
|
251
|
+
closedAt: item.closed_at || '',
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Fetch PRs merged in the last N days.
|
|
256
|
+
* Returns lightweight MergedPR objects for surfacing as wins in the dashboard.
|
|
257
|
+
*/
|
|
258
|
+
export async function fetchRecentlyMergedPRs(octokit, config, days = 7) {
|
|
259
|
+
return fetchRecentPRs(octokit, config, 'is:pr is:merged author:{username} merged:>={since}', 'merged', days, (item, { repo, number }) => {
|
|
260
|
+
const mergedAt = item.pull_request?.merged_at;
|
|
261
|
+
if (!mergedAt) {
|
|
262
|
+
warn(MODULE, `merged_at missing for merged PR ${item.html_url}${item.closed_at ? ', falling back to closed_at' : ', no date available'}`);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
url: item.html_url,
|
|
266
|
+
repo,
|
|
267
|
+
number,
|
|
268
|
+
title: item.title,
|
|
269
|
+
mergedAt: mergedAt || item.closed_at || '',
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub API client with rate limiting and throttling
|
|
3
|
+
*/
|
|
4
|
+
import { Octokit } from '@octokit/rest';
|
|
5
|
+
/** Rate limit info returned by {@link checkRateLimit}. */
|
|
6
|
+
export interface RateLimitInfo {
|
|
7
|
+
/** Remaining search API requests in current window. */
|
|
8
|
+
remaining: number;
|
|
9
|
+
/** Total search API request limit per window. */
|
|
10
|
+
limit: number;
|
|
11
|
+
/** ISO timestamp when the rate limit window resets. */
|
|
12
|
+
resetAt: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function getOctokit(token: string): Octokit;
|
|
15
|
+
/**
|
|
16
|
+
* Check the GitHub Search API rate limit quota.
|
|
17
|
+
* Returns the remaining requests, total limit, and reset time for the search endpoint.
|
|
18
|
+
*/
|
|
19
|
+
export declare function checkRateLimit(token: string): Promise<RateLimitInfo>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub API client with rate limiting and throttling
|
|
3
|
+
*/
|
|
4
|
+
import { Octokit } from '@octokit/rest';
|
|
5
|
+
import { throttling } from '@octokit/plugin-throttling';
|
|
6
|
+
import { warn } from './logger.js';
|
|
7
|
+
const MODULE = 'github';
|
|
8
|
+
const ThrottledOctokit = Octokit.plugin(throttling);
|
|
9
|
+
let _octokit = null;
|
|
10
|
+
let _currentToken = null;
|
|
11
|
+
/** Format a Date as HH:MM:SS for log messages. */
|
|
12
|
+
function formatResetTime(date) {
|
|
13
|
+
return date.toLocaleTimeString('en-US', { hour12: false });
|
|
14
|
+
}
|
|
15
|
+
export function getOctokit(token) {
|
|
16
|
+
// Return cached instance only if token matches
|
|
17
|
+
if (_octokit && _currentToken === token)
|
|
18
|
+
return _octokit;
|
|
19
|
+
_octokit = new ThrottledOctokit({
|
|
20
|
+
auth: token,
|
|
21
|
+
throttle: {
|
|
22
|
+
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
|
23
|
+
const opts = options;
|
|
24
|
+
const resetAt = new Date(Date.now() + retryAfter * 1000);
|
|
25
|
+
if (retryCount < 2) {
|
|
26
|
+
warn(MODULE, `Rate limit hit (retry ${retryCount + 1}/2, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
warn(MODULE, `Rate limit exceeded, not retrying — ${opts.method} ${opts.url} (resets at ${formatResetTime(resetAt)})`);
|
|
30
|
+
return false;
|
|
31
|
+
},
|
|
32
|
+
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
|
33
|
+
const opts = options;
|
|
34
|
+
const resetAt = new Date(Date.now() + retryAfter * 1000);
|
|
35
|
+
if (retryCount < 1) {
|
|
36
|
+
warn(MODULE, `Secondary rate limit hit (retry ${retryCount + 1}/1, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
warn(MODULE, `Secondary rate limit exceeded, not retrying — ${opts.method} ${opts.url} (resets at ${formatResetTime(resetAt)})`);
|
|
40
|
+
return false;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
_currentToken = token;
|
|
45
|
+
return _octokit;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check the GitHub Search API rate limit quota.
|
|
49
|
+
* Returns the remaining requests, total limit, and reset time for the search endpoint.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkRateLimit(token) {
|
|
52
|
+
const octokit = getOctokit(token);
|
|
53
|
+
const { data } = await octokit.rateLimit.get();
|
|
54
|
+
const search = data.resources.search;
|
|
55
|
+
return {
|
|
56
|
+
remaining: search.remaining,
|
|
57
|
+
limit: search.limit,
|
|
58
|
+
resetAt: new Date(search.reset * 1000).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP caching with ETags for GitHub API responses.
|
|
3
|
+
*
|
|
4
|
+
* Stores ETags and response bodies for cacheable GET endpoints in
|
|
5
|
+
* `~/.oss-autopilot/cache/`. On subsequent requests, sends `If-None-Match`
|
|
6
|
+
* headers — 304 responses don't count against GitHub rate limits.
|
|
7
|
+
*
|
|
8
|
+
* Also provides in-flight request deduplication so that concurrent calls
|
|
9
|
+
* for the same endpoint (e.g., star counts for two PRs in the same repo)
|
|
10
|
+
* share a single HTTP round-trip.
|
|
11
|
+
*/
|
|
12
|
+
/** Shape of a single cache entry on disk. */
|
|
13
|
+
export interface CacheEntry {
|
|
14
|
+
etag: string;
|
|
15
|
+
url: string;
|
|
16
|
+
body: unknown;
|
|
17
|
+
cachedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* File-based HTTP cache backed by `~/.oss-autopilot/cache/`.
|
|
21
|
+
*
|
|
22
|
+
* Each cache entry is stored as a separate JSON file keyed by the SHA-256
|
|
23
|
+
* hash of the request URL. This avoids filesystem issues with URL-based
|
|
24
|
+
* filenames and keeps lookup O(1).
|
|
25
|
+
*/
|
|
26
|
+
export declare class HttpCache {
|
|
27
|
+
private readonly cacheDir;
|
|
28
|
+
/** In-flight request deduplication map: URL -> Promise<response>. */
|
|
29
|
+
private readonly inflightRequests;
|
|
30
|
+
constructor(cacheDir?: string);
|
|
31
|
+
/** Derive a filesystem-safe cache key from a URL. */
|
|
32
|
+
private keyFor;
|
|
33
|
+
/** Full path to the cache file for a given URL. */
|
|
34
|
+
private pathFor;
|
|
35
|
+
/**
|
|
36
|
+
* Look up a cached response. Returns `null` if no cache entry exists.
|
|
37
|
+
*/
|
|
38
|
+
get(url: string): CacheEntry | null;
|
|
39
|
+
/**
|
|
40
|
+
* Store a response with its ETag.
|
|
41
|
+
*/
|
|
42
|
+
set(url: string, etag: string, body: unknown): void;
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a URL has an in-flight request.
|
|
45
|
+
*/
|
|
46
|
+
hasInflight(url: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Get the in-flight promise for a URL (for deduplication).
|
|
49
|
+
*/
|
|
50
|
+
getInflight(url: string): Promise<unknown> | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Register an in-flight request for deduplication.
|
|
53
|
+
* Returns a cleanup function to call when the request completes.
|
|
54
|
+
*/
|
|
55
|
+
setInflight(url: string, promise: Promise<unknown>): () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Remove stale entries older than `maxAgeMs` from the cache directory.
|
|
58
|
+
* Intended to be called periodically (e.g., once per daily run).
|
|
59
|
+
*/
|
|
60
|
+
evictStale(maxAgeMs?: number): number;
|
|
61
|
+
/**
|
|
62
|
+
* Remove all entries from the cache.
|
|
63
|
+
*/
|
|
64
|
+
clear(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Return the number of entries currently in the cache.
|
|
67
|
+
*/
|
|
68
|
+
size(): number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get (or create) the shared HttpCache singleton.
|
|
72
|
+
* The singleton is lazily initialized on first access.
|
|
73
|
+
*/
|
|
74
|
+
export declare function getHttpCache(): HttpCache;
|
|
75
|
+
/** Reset the singleton (for tests). */
|
|
76
|
+
export declare function resetHttpCache(): void;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps an Octokit `repos.get`-style call with ETag caching and request
|
|
79
|
+
* deduplication.
|
|
80
|
+
*
|
|
81
|
+
* Usage:
|
|
82
|
+
* ```ts
|
|
83
|
+
* const data = await cachedRequest(cache, octokit, '/repos/owner/repo', () =>
|
|
84
|
+
* octokit.repos.get({ owner, repo: name }),
|
|
85
|
+
* );
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* 1. If an identical request is already in-flight, returns the existing promise
|
|
89
|
+
* (request deduplication).
|
|
90
|
+
* 2. If a cached ETag exists, sends `If-None-Match`. On 304, returns the
|
|
91
|
+
* cached body without consuming a rate-limit point.
|
|
92
|
+
* 3. On a fresh 200, caches the ETag + body for next time.
|
|
93
|
+
*/
|
|
94
|
+
export declare function cachedRequest<T>(cache: HttpCache, url: string, fetcher: (headers: Record<string, string>) => Promise<{
|
|
95
|
+
data: T;
|
|
96
|
+
headers?: Record<string, string>;
|
|
97
|
+
}>): Promise<T>;
|