@oss-autopilot/core 0.42.0 → 0.42.2
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/dist/cli.bundle.cjs +1026 -1018
- package/dist/cli.js +18 -30
- package/dist/commands/check-integration.js +5 -4
- package/dist/commands/comments.js +24 -24
- package/dist/commands/daily.d.ts +0 -1
- package/dist/commands/daily.js +18 -16
- package/dist/commands/dashboard-components.d.ts +33 -0
- package/dist/commands/dashboard-components.js +57 -0
- package/dist/commands/dashboard-data.js +7 -6
- package/dist/commands/dashboard-formatters.d.ts +20 -0
- package/dist/commands/dashboard-formatters.js +33 -0
- package/dist/commands/dashboard-scripts.d.ts +7 -0
- package/dist/commands/dashboard-scripts.js +281 -0
- package/dist/commands/dashboard-server.js +3 -2
- package/dist/commands/dashboard-styles.d.ts +5 -0
- package/dist/commands/dashboard-styles.js +765 -0
- package/dist/commands/dashboard-templates.d.ts +6 -18
- package/dist/commands/dashboard-templates.js +30 -1134
- package/dist/commands/dashboard.js +2 -1
- package/dist/commands/dismiss.d.ts +6 -6
- package/dist/commands/dismiss.js +13 -13
- package/dist/commands/local-repos.js +2 -1
- package/dist/commands/parse-list.js +2 -1
- package/dist/commands/startup.js +6 -16
- package/dist/commands/validation.d.ts +3 -1
- package/dist/commands/validation.js +12 -6
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +17 -0
- package/dist/core/github-stats.d.ts +14 -21
- package/dist/core/github-stats.js +84 -138
- package/dist/core/http-cache.d.ts +6 -0
- package/dist/core/http-cache.js +16 -4
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.js +3 -2
- package/dist/core/issue-conversation.js +4 -4
- package/dist/core/issue-discovery.d.ts +5 -0
- package/dist/core/issue-discovery.js +70 -93
- package/dist/core/issue-vetting.js +17 -17
- package/dist/core/logger.d.ts +5 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/pr-monitor.d.ts +6 -20
- package/dist/core/pr-monitor.js +16 -52
- package/dist/core/review-analysis.js +8 -6
- package/dist/core/state.js +4 -5
- package/dist/core/test-utils.d.ts +14 -0
- package/dist/core/test-utils.js +125 -0
- package/dist/core/utils.d.ts +11 -0
- package/dist/core/utils.js +21 -0
- package/dist/formatters/json.d.ts +0 -1
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { execFile } from 'child_process';
|
|
9
9
|
import { getStateManager, getDashboardPath, getGitHubToken } from '../core/index.js';
|
|
10
|
+
import { errorMessage } from '../core/errors.js';
|
|
10
11
|
import { outputJson } from '../formatters/json.js';
|
|
11
12
|
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
|
|
12
13
|
import { buildDashboardStats, generateDashboardHtml } from './dashboard-templates.js';
|
|
@@ -39,7 +40,7 @@ export async function runDashboard(options) {
|
|
|
39
40
|
commentedIssues = result.commentedIssues;
|
|
40
41
|
}
|
|
41
42
|
catch (error) {
|
|
42
|
-
console.error('Failed to fetch fresh data:', error
|
|
43
|
+
console.error('Failed to fetch fresh data:', errorMessage(error));
|
|
43
44
|
console.error('Falling back to cached data (issue conversations unavailable)...');
|
|
44
45
|
digest = stateManager.getState().lastDigest;
|
|
45
46
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dismiss/Undismiss commands
|
|
3
|
-
* Manages dismissing issue
|
|
4
|
-
* Dismissed
|
|
3
|
+
* Manages dismissing issue and PR notifications without posting a comment.
|
|
4
|
+
* Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { ISSUE_OR_PR_URL_PATTERN } from './validation.js';
|
|
7
7
|
export interface DismissOutput {
|
|
8
8
|
dismissed: boolean;
|
|
9
9
|
url: string;
|
|
@@ -12,10 +12,10 @@ export interface UndismissOutput {
|
|
|
12
12
|
undismissed: boolean;
|
|
13
13
|
url: string;
|
|
14
14
|
}
|
|
15
|
-
export {
|
|
15
|
+
export { ISSUE_OR_PR_URL_PATTERN };
|
|
16
16
|
export declare function runDismiss(options: {
|
|
17
|
-
|
|
17
|
+
url: string;
|
|
18
18
|
}): Promise<DismissOutput>;
|
|
19
19
|
export declare function runUndismiss(options: {
|
|
20
|
-
|
|
20
|
+
url: string;
|
|
21
21
|
}): Promise<UndismissOutput>;
|
package/dist/commands/dismiss.js
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dismiss/Undismiss commands
|
|
3
|
-
* Manages dismissing issue
|
|
4
|
-
* Dismissed
|
|
3
|
+
* Manages dismissing issue and PR notifications without posting a comment.
|
|
4
|
+
* Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
|
|
5
5
|
*/
|
|
6
6
|
import { getStateManager } from '../core/index.js';
|
|
7
|
-
import {
|
|
8
|
-
// Re-export for
|
|
9
|
-
export {
|
|
7
|
+
import { ISSUE_OR_PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
|
|
8
|
+
// Re-export for tests
|
|
9
|
+
export { ISSUE_OR_PR_URL_PATTERN };
|
|
10
10
|
export async function runDismiss(options) {
|
|
11
|
-
validateUrl(options.
|
|
12
|
-
validateGitHubUrl(options.
|
|
11
|
+
validateUrl(options.url);
|
|
12
|
+
validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
|
|
13
13
|
const stateManager = getStateManager();
|
|
14
|
-
const added = stateManager.dismissIssue(options.
|
|
14
|
+
const added = stateManager.dismissIssue(options.url, new Date().toISOString());
|
|
15
15
|
if (added) {
|
|
16
16
|
stateManager.save();
|
|
17
17
|
}
|
|
18
|
-
return { dismissed: added, url: options.
|
|
18
|
+
return { dismissed: added, url: options.url };
|
|
19
19
|
}
|
|
20
20
|
export async function runUndismiss(options) {
|
|
21
|
-
validateUrl(options.
|
|
22
|
-
validateGitHubUrl(options.
|
|
21
|
+
validateUrl(options.url);
|
|
22
|
+
validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
|
|
23
23
|
const stateManager = getStateManager();
|
|
24
|
-
const removed = stateManager.undismissIssue(options.
|
|
24
|
+
const removed = stateManager.undismissIssue(options.url);
|
|
25
25
|
if (removed) {
|
|
26
26
|
stateManager.save();
|
|
27
27
|
}
|
|
28
|
-
return { undismissed: removed, url: options.
|
|
28
|
+
return { undismissed: removed, url: options.url };
|
|
29
29
|
}
|
|
@@ -7,6 +7,7 @@ import * as path from 'path';
|
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import { execFileSync } from 'child_process';
|
|
9
9
|
import { getStateManager, debug } from '../core/index.js';
|
|
10
|
+
import { errorMessage } from '../core/errors.js';
|
|
10
11
|
/** Default directories to scan for local clones */
|
|
11
12
|
const DEFAULT_SCAN_PATHS = [
|
|
12
13
|
path.join(os.homedir(), 'Documents', 'oss'),
|
|
@@ -115,7 +116,7 @@ export async function runLocalRepos(options) {
|
|
|
115
116
|
stateManager.save();
|
|
116
117
|
}
|
|
117
118
|
catch (error) {
|
|
118
|
-
const msg =
|
|
119
|
+
const msg = errorMessage(error);
|
|
119
120
|
debug('local-repos', `Failed to cache scan results: ${msg}`);
|
|
120
121
|
}
|
|
121
122
|
return {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
+
import { errorMessage } from '../core/errors.js';
|
|
7
8
|
/** Extract GitHub issue/PR URLs from a markdown line */
|
|
8
9
|
function extractGitHubUrl(line) {
|
|
9
10
|
const match = line.match(/https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/);
|
|
@@ -100,7 +101,7 @@ export async function runParseList(options) {
|
|
|
100
101
|
content = fs.readFileSync(filePath, 'utf-8');
|
|
101
102
|
}
|
|
102
103
|
catch (error) {
|
|
103
|
-
const msg =
|
|
104
|
+
const msg = errorMessage(error);
|
|
104
105
|
throw new Error(`Failed to read file: ${msg}`, { cause: error });
|
|
105
106
|
}
|
|
106
107
|
return parseIssueList(content);
|
package/dist/commands/startup.js
CHANGED
|
@@ -7,21 +7,11 @@
|
|
|
7
7
|
* `node cli.bundle.cjs startup --json` call, reducing UI noise in Claude Code.
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
10
|
import { execFile } from 'child_process';
|
|
12
|
-
import { getStateManager, getGitHubToken } from '../core/index.js';
|
|
11
|
+
import { getStateManager, getGitHubToken, getCLIVersion } from '../core/index.js';
|
|
12
|
+
import { errorMessage } from '../core/errors.js';
|
|
13
13
|
import { executeDailyCheck } from './daily.js';
|
|
14
14
|
import { writeDashboardFromState } from './dashboard.js';
|
|
15
|
-
function getVersion() {
|
|
16
|
-
try {
|
|
17
|
-
const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
|
|
18
|
-
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
|
|
19
|
-
}
|
|
20
|
-
catch (error) {
|
|
21
|
-
console.error('[STARTUP] Failed to detect CLI version:', error instanceof Error ? error.message : error);
|
|
22
|
-
return '0.0.0';
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
15
|
/**
|
|
26
16
|
* Parse issueListPath from a config file's YAML frontmatter.
|
|
27
17
|
* Returns the path string or undefined if not found.
|
|
@@ -75,7 +65,7 @@ export function detectIssueList() {
|
|
|
75
65
|
}
|
|
76
66
|
}
|
|
77
67
|
catch (error) {
|
|
78
|
-
console.error('[STARTUP] Failed to read config:', error
|
|
68
|
+
console.error('[STARTUP] Failed to read config:', errorMessage(error));
|
|
79
69
|
}
|
|
80
70
|
}
|
|
81
71
|
// 2. Probe known paths
|
|
@@ -98,7 +88,7 @@ export function detectIssueList() {
|
|
|
98
88
|
return { path: issueListPath, source, availableCount, completedCount };
|
|
99
89
|
}
|
|
100
90
|
catch (error) {
|
|
101
|
-
console.error(`[STARTUP] Failed to read issue list at ${issueListPath}:`, error
|
|
91
|
+
console.error(`[STARTUP] Failed to read issue list at ${issueListPath}:`, errorMessage(error));
|
|
102
92
|
return { path: issueListPath, source, availableCount: 0, completedCount: 0 };
|
|
103
93
|
}
|
|
104
94
|
}
|
|
@@ -122,7 +112,7 @@ function openInBrowser(filePath) {
|
|
|
122
112
|
* Errors from the daily check propagate to the caller.
|
|
123
113
|
*/
|
|
124
114
|
export async function runStartup() {
|
|
125
|
-
const version =
|
|
115
|
+
const version = getCLIVersion();
|
|
126
116
|
const stateManager = getStateManager();
|
|
127
117
|
// 1. Check setup
|
|
128
118
|
if (!stateManager.isSetupComplete()) {
|
|
@@ -151,7 +141,7 @@ export async function runStartup() {
|
|
|
151
141
|
}
|
|
152
142
|
}
|
|
153
143
|
catch (error) {
|
|
154
|
-
console.error('[STARTUP] Dashboard generation failed:', error
|
|
144
|
+
console.error('[STARTUP] Dashboard generation failed:', errorMessage(error));
|
|
155
145
|
}
|
|
156
146
|
// Append dashboard status to brief summary (only startup opens the browser, not daily)
|
|
157
147
|
if (dashboardOpened) {
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
export declare const PR_URL_PATTERN: RegExp;
|
|
6
6
|
/** Matches GitHub issue URLs: https://github.com/owner/repo/issues/123 */
|
|
7
7
|
export declare const ISSUE_URL_PATTERN: RegExp;
|
|
8
|
+
/** Matches GitHub issue or PR URLs: /issues/123 or /pull/123 */
|
|
9
|
+
export declare const ISSUE_OR_PR_URL_PATTERN: RegExp;
|
|
8
10
|
/**
|
|
9
11
|
* Validate a GitHub URL against a pattern. Throws if invalid.
|
|
10
12
|
*/
|
|
11
|
-
export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: 'PR' | 'issue'): void;
|
|
13
|
+
export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: 'PR' | 'issue' | 'issue or PR'): void;
|
|
12
14
|
/**
|
|
13
15
|
* Validate that a URL does not exceed the maximum allowed length.
|
|
14
16
|
* Returns the URL if valid, throws if too long.
|
|
@@ -6,6 +6,8 @@ import { ValidationError } from '../core/errors.js';
|
|
|
6
6
|
export const PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
|
|
7
7
|
/** Matches GitHub issue URLs: https://github.com/owner/repo/issues/123 */
|
|
8
8
|
export const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
|
|
9
|
+
/** Matches GitHub issue or PR URLs: /issues/123 or /pull/123 */
|
|
10
|
+
export const ISSUE_OR_PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+$/;
|
|
9
11
|
/** Maximum allowed URL length */
|
|
10
12
|
const MAX_URL_LENGTH = 2048;
|
|
11
13
|
/** Maximum allowed PR/issue number */
|
|
@@ -20,8 +22,12 @@ const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
|
20
22
|
export function validateGitHubUrl(url, pattern, entityType) {
|
|
21
23
|
if (pattern.test(url))
|
|
22
24
|
return;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
+
const examples = {
|
|
26
|
+
PR: 'https://github.com/owner/repo/pull/123',
|
|
27
|
+
issue: 'https://github.com/owner/repo/issues/123',
|
|
28
|
+
'issue or PR': 'https://github.com/owner/repo/issues/123 or https://github.com/owner/repo/pull/123',
|
|
29
|
+
};
|
|
30
|
+
throw new ValidationError(`Invalid ${entityType} URL: ${url}. Expected format: ${examples[entityType]}`);
|
|
25
31
|
}
|
|
26
32
|
/**
|
|
27
33
|
* Validate that a URL does not exceed the maximum allowed length.
|
|
@@ -29,7 +35,7 @@ export function validateGitHubUrl(url, pattern, entityType) {
|
|
|
29
35
|
*/
|
|
30
36
|
export function validateUrl(url) {
|
|
31
37
|
if (url.length > MAX_URL_LENGTH) {
|
|
32
|
-
throw new
|
|
38
|
+
throw new ValidationError(`URL exceeds maximum length of ${MAX_URL_LENGTH} characters`);
|
|
33
39
|
}
|
|
34
40
|
return url;
|
|
35
41
|
}
|
|
@@ -39,7 +45,7 @@ export function validateUrl(url) {
|
|
|
39
45
|
*/
|
|
40
46
|
export function validatePRNumber(num) {
|
|
41
47
|
if (!Number.isInteger(num) || num < 1 || num > MAX_PR_NUMBER) {
|
|
42
|
-
throw new
|
|
48
|
+
throw new ValidationError(`PR number must be a positive integer up to ${MAX_PR_NUMBER}`);
|
|
43
49
|
}
|
|
44
50
|
return num;
|
|
45
51
|
}
|
|
@@ -49,7 +55,7 @@ export function validatePRNumber(num) {
|
|
|
49
55
|
*/
|
|
50
56
|
export function validateMessage(message) {
|
|
51
57
|
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
52
|
-
throw new
|
|
58
|
+
throw new ValidationError(`Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`);
|
|
53
59
|
}
|
|
54
60
|
return message;
|
|
55
61
|
}
|
|
@@ -59,7 +65,7 @@ export function validateMessage(message) {
|
|
|
59
65
|
*/
|
|
60
66
|
export function validateRepoIdentifier(repo) {
|
|
61
67
|
if (!REPO_PATTERN.test(repo)) {
|
|
62
|
-
throw new
|
|
68
|
+
throw new ValidationError(`Invalid repository format: "${repo}". Expected "owner/repo".`);
|
|
63
69
|
}
|
|
64
70
|
return repo;
|
|
65
71
|
}
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -22,3 +22,12 @@ export declare class ConfigurationError extends OssAutopilotError {
|
|
|
22
22
|
export declare class ValidationError extends OssAutopilotError {
|
|
23
23
|
constructor(message: string);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract a human-readable message from an unknown error value.
|
|
27
|
+
*/
|
|
28
|
+
export declare function errorMessage(e: unknown): string;
|
|
29
|
+
/**
|
|
30
|
+
* Safely extract an HTTP status code from an unknown error (e.g. Octokit errors).
|
|
31
|
+
* Returns undefined if the error doesn't have a numeric `status` property.
|
|
32
|
+
*/
|
|
33
|
+
export declare function getHttpStatusCode(error: unknown): number | undefined;
|
package/dist/core/errors.js
CHANGED
|
@@ -32,3 +32,20 @@ export class ValidationError extends OssAutopilotError {
|
|
|
32
32
|
this.name = 'ValidationError';
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract a human-readable message from an unknown error value.
|
|
37
|
+
*/
|
|
38
|
+
export function errorMessage(e) {
|
|
39
|
+
return e instanceof Error ? e.message : String(e);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Safely extract an HTTP status code from an unknown error (e.g. Octokit errors).
|
|
43
|
+
* Returns undefined if the error doesn't have a numeric `status` property.
|
|
44
|
+
*/
|
|
45
|
+
export function getHttpStatusCode(error) {
|
|
46
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
47
|
+
const status = error.status;
|
|
48
|
+
return typeof status === 'number' && Number.isFinite(status) ? status : undefined;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -4,35 +4,28 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Octokit } from '@octokit/rest';
|
|
6
6
|
import { ClosedPR, MergedPR } from './types.js';
|
|
7
|
+
/** TTL for cached PR count results (1 hour). */
|
|
8
|
+
export declare const PR_COUNTS_CACHE_TTL_MS: number;
|
|
9
|
+
/** Return type shared by both merged and closed PR count functions. */
|
|
10
|
+
export interface PRCountsResult<R> {
|
|
11
|
+
repos: Map<string, R>;
|
|
12
|
+
monthlyCounts: Record<string, number>;
|
|
13
|
+
monthlyOpenedCounts: Record<string, number>;
|
|
14
|
+
dailyActivityCounts: Record<string, number>;
|
|
15
|
+
}
|
|
7
16
|
/**
|
|
8
17
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
9
18
|
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
10
19
|
*/
|
|
11
|
-
export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername: string): Promise<{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}>;
|
|
16
|
-
monthlyCounts: Record<string, number>;
|
|
17
|
-
monthlyOpenedCounts: Record<string, number>;
|
|
18
|
-
dailyActivityCounts: Record<string, number>;
|
|
19
|
-
}>;
|
|
20
|
+
export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername: string): Promise<PRCountsResult<{
|
|
21
|
+
count: number;
|
|
22
|
+
lastMergedAt: string;
|
|
23
|
+
}>>;
|
|
20
24
|
/**
|
|
21
25
|
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
22
26
|
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
23
27
|
*/
|
|
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>>;
|
|
28
|
+
export declare function fetchUserClosedPRCounts(octokit: Octokit, githubUsername: string): Promise<PRCountsResult<number>>;
|
|
36
29
|
/**
|
|
37
30
|
* Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
|
|
38
31
|
* Returns parsed search results that pass all filters.
|
|
@@ -2,19 +2,51 @@
|
|
|
2
2
|
* GitHub Stats - Fetching merged/closed PR counts and repository star counts.
|
|
3
3
|
* Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
|
|
4
4
|
*/
|
|
5
|
-
import { extractOwnerRepo, parseGitHubUrl } from './utils.js';
|
|
6
|
-
import { ValidationError } from './errors.js';
|
|
5
|
+
import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './utils.js';
|
|
7
6
|
import { debug, warn } from './logger.js';
|
|
7
|
+
import { getHttpCache } from './http-cache.js';
|
|
8
8
|
const MODULE = 'github-stats';
|
|
9
|
+
/** TTL for cached PR count results (1 hour). */
|
|
10
|
+
export const PR_COUNTS_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
11
|
+
/** Type guard for deserialized cache data — prevents crashes on corrupt/stale cache. */
|
|
12
|
+
function isCachedPRCounts(v) {
|
|
13
|
+
if (typeof v !== 'object' || v === null)
|
|
14
|
+
return false;
|
|
15
|
+
const obj = v;
|
|
16
|
+
return (Array.isArray(obj.reposEntries) &&
|
|
17
|
+
typeof obj.monthlyCounts === 'object' &&
|
|
18
|
+
obj.monthlyCounts !== null &&
|
|
19
|
+
typeof obj.monthlyOpenedCounts === 'object' &&
|
|
20
|
+
obj.monthlyOpenedCounts !== null &&
|
|
21
|
+
typeof obj.dailyActivityCounts === 'object' &&
|
|
22
|
+
obj.dailyActivityCounts !== null);
|
|
23
|
+
}
|
|
9
24
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
25
|
+
* Shared paginated search for user PR counts with histogram tracking.
|
|
26
|
+
*
|
|
27
|
+
* Handles: pagination, owner extraction, skip-own-repos, monthly/daily histograms.
|
|
28
|
+
* The `accumulateRepo` callback handles per-repo data and returns the primary date
|
|
29
|
+
* string (e.g. mergedAt or closedAt) used for monthly counts and daily activity.
|
|
30
|
+
* Return an empty string to skip histogram tracking for that item.
|
|
12
31
|
*/
|
|
13
|
-
|
|
32
|
+
async function fetchUserPRCounts(octokit, githubUsername, query, label, accumulateRepo) {
|
|
14
33
|
if (!githubUsername) {
|
|
15
34
|
return { repos: new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
|
|
16
35
|
}
|
|
17
|
-
|
|
36
|
+
// Check for a fresh cached result (avoids 10-20 paginated API calls)
|
|
37
|
+
const cache = getHttpCache();
|
|
38
|
+
const cacheKey = `pr-counts:${label}:${githubUsername}`;
|
|
39
|
+
const cached = cache.getIfFresh(cacheKey, PR_COUNTS_CACHE_TTL_MS);
|
|
40
|
+
if (cached && isCachedPRCounts(cached)) {
|
|
41
|
+
debug(MODULE, `Using cached ${label} PR counts for @${githubUsername}`);
|
|
42
|
+
return {
|
|
43
|
+
repos: new Map(cached.reposEntries),
|
|
44
|
+
monthlyCounts: cached.monthlyCounts,
|
|
45
|
+
monthlyOpenedCounts: cached.monthlyOpenedCounts,
|
|
46
|
+
dailyActivityCounts: cached.dailyActivityCounts,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
debug(MODULE, `Fetching ${label} PR counts for @${githubUsername}...`);
|
|
18
50
|
const repos = new Map();
|
|
19
51
|
const monthlyCounts = {};
|
|
20
52
|
const monthlyOpenedCounts = {};
|
|
@@ -23,7 +55,7 @@ export async function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
|
23
55
|
let fetched = 0;
|
|
24
56
|
while (true) {
|
|
25
57
|
const { data } = await octokit.search.issuesAndPullRequests({
|
|
26
|
-
q: `is:pr
|
|
58
|
+
q: `is:pr ${query} author:${githubUsername}`,
|
|
27
59
|
sort: 'updated',
|
|
28
60
|
order: 'desc',
|
|
29
61
|
per_page: 100,
|
|
@@ -32,49 +64,35 @@ export async function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
|
32
64
|
for (const item of data.items) {
|
|
33
65
|
const parsed = extractOwnerRepo(item.html_url);
|
|
34
66
|
if (!parsed) {
|
|
35
|
-
warn(MODULE, `Skipping
|
|
67
|
+
warn(MODULE, `Skipping ${label} PR with unparseable URL: ${item.html_url}`);
|
|
36
68
|
continue;
|
|
37
69
|
}
|
|
38
70
|
const { owner } = parsed;
|
|
39
71
|
const repo = `${owner}/${parsed.repo}`;
|
|
40
72
|
// Skip own repos (PRs to your own repos aren't OSS contributions)
|
|
41
|
-
if (owner
|
|
73
|
+
if (isOwnRepo(owner, githubUsername))
|
|
42
74
|
continue;
|
|
43
75
|
// Note: excludeRepos/excludeOrgs are intentionally NOT filtered here.
|
|
44
76
|
// Those filters control issue discovery/search, not historical statistics.
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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"
|
|
77
|
+
// Per-repo accumulation + get primary date for histograms
|
|
78
|
+
const primaryDate = accumulateRepo(repos, repo, item);
|
|
79
|
+
// Monthly histogram for primary date (merged/closed)
|
|
80
|
+
if (primaryDate) {
|
|
81
|
+
const month = primaryDate.slice(0, 7); // "YYYY-MM"
|
|
61
82
|
monthlyCounts[month] = (monthlyCounts[month] || 0) + 1;
|
|
83
|
+
// Daily activity for primary date
|
|
84
|
+
const day = primaryDate.slice(0, 10);
|
|
85
|
+
if (day.length === 10)
|
|
86
|
+
dailyActivityCounts[day] = (dailyActivityCounts[day] || 0) + 1;
|
|
62
87
|
}
|
|
63
|
-
// Track when this PR was opened (for monthly opened histogram)
|
|
88
|
+
// Track when this PR was opened (for monthly opened histogram + daily activity)
|
|
64
89
|
if (item.created_at) {
|
|
65
90
|
const openedMonth = item.created_at.slice(0, 7); // "YYYY-MM"
|
|
66
91
|
monthlyOpenedCounts[openedMonth] = (monthlyOpenedCounts[openedMonth] || 0) + 1;
|
|
67
|
-
// Daily activity: PR opened
|
|
68
92
|
const openedDay = item.created_at.slice(0, 10);
|
|
69
93
|
if (openedDay.length === 10)
|
|
70
94
|
dailyActivityCounts[openedDay] = (dailyActivityCounts[openedDay] || 0) + 1;
|
|
71
95
|
}
|
|
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
96
|
}
|
|
79
97
|
fetched += data.items.length;
|
|
80
98
|
// Stop if we've fetched all results or hit the API limit (1000)
|
|
@@ -83,120 +101,48 @@ export async function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
|
83
101
|
}
|
|
84
102
|
page++;
|
|
85
103
|
}
|
|
86
|
-
debug(MODULE, `Found ${fetched}
|
|
104
|
+
debug(MODULE, `Found ${fetched} ${label} PRs across ${repos.size} repos`);
|
|
105
|
+
// Cache the aggregated result (Map → entries array for JSON serialization)
|
|
106
|
+
cache.set(cacheKey, '', {
|
|
107
|
+
reposEntries: Array.from(repos.entries()),
|
|
108
|
+
monthlyCounts,
|
|
109
|
+
monthlyOpenedCounts,
|
|
110
|
+
dailyActivityCounts,
|
|
111
|
+
});
|
|
87
112
|
return { repos, monthlyCounts, monthlyOpenedCounts, dailyActivityCounts };
|
|
88
113
|
}
|
|
89
114
|
/**
|
|
90
|
-
* Fetch
|
|
91
|
-
*
|
|
115
|
+
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
116
|
+
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
92
117
|
*/
|
|
93
|
-
export
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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;
|
|
118
|
+
export function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
119
|
+
return fetchUserPRCounts(octokit, githubUsername, 'is:merged', 'merged', (repos, repo, item) => {
|
|
120
|
+
if (!item.pull_request?.merged_at) {
|
|
121
|
+
warn(MODULE, `merged_at missing for merged PR ${item.html_url}${item.closed_at ? ', falling back to closed_at' : ', no date available'}`);
|
|
122
|
+
}
|
|
123
|
+
const mergedAt = item.pull_request?.merged_at || item.closed_at || '';
|
|
124
|
+
const existing = repos.get(repo);
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.count += 1;
|
|
127
|
+
if (mergedAt && mergedAt > existing.lastMergedAt) {
|
|
128
|
+
existing.lastMergedAt = mergedAt;
|
|
144
129
|
}
|
|
145
130
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
break;
|
|
131
|
+
else {
|
|
132
|
+
repos.set(repo, { count: 1, lastMergedAt: mergedAt });
|
|
149
133
|
}
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
debug(MODULE, `Found ${fetched} closed (unmerged) PRs across ${repos.size} repos`);
|
|
153
|
-
return { repos, monthlyCounts, monthlyOpenedCounts, dailyActivityCounts };
|
|
134
|
+
return mergedAt;
|
|
135
|
+
});
|
|
154
136
|
}
|
|
155
137
|
/**
|
|
156
|
-
* Fetch
|
|
157
|
-
* Used to populate
|
|
158
|
-
* Fetches concurrently with per-repo error isolation (missing/private repos are skipped).
|
|
138
|
+
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
139
|
+
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
159
140
|
*/
|
|
160
|
-
export
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
141
|
+
export function fetchUserClosedPRCounts(octokit, githubUsername) {
|
|
142
|
+
return fetchUserPRCounts(octokit, githubUsername, 'is:closed is:unmerged', 'closed', (repos, repo, item) => {
|
|
143
|
+
repos.set(repo, (repos.get(repo) || 0) + 1);
|
|
144
|
+
return item.closed_at || '';
|
|
145
|
+
});
|
|
200
146
|
}
|
|
201
147
|
/**
|
|
202
148
|
* Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
|
|
@@ -226,7 +172,7 @@ export async function fetchRecentPRs(octokit, config, query, label, days, mapIte
|
|
|
226
172
|
}
|
|
227
173
|
const repo = `${parsed.owner}/${parsed.repo}`;
|
|
228
174
|
// Skip own repos
|
|
229
|
-
if (parsed.owner
|
|
175
|
+
if (isOwnRepo(parsed.owner, config.githubUsername))
|
|
230
176
|
continue;
|
|
231
177
|
// Skip excluded repos and orgs
|
|
232
178
|
if (config.excludeRepos.includes(repo))
|
|
@@ -32,6 +32,12 @@ export declare class HttpCache {
|
|
|
32
32
|
private keyFor;
|
|
33
33
|
/** Full path to the cache file for a given URL. */
|
|
34
34
|
private pathFor;
|
|
35
|
+
/**
|
|
36
|
+
* Return the cached body if the entry exists and is younger than `maxAgeMs`.
|
|
37
|
+
* Useful for time-based caching where ETag validation isn't applicable
|
|
38
|
+
* (e.g., caching aggregated results from paginated API calls).
|
|
39
|
+
*/
|
|
40
|
+
getIfFresh(key: string, maxAgeMs: number): unknown | null;
|
|
35
41
|
/**
|
|
36
42
|
* Look up a cached response. Returns `null` if no cache entry exists.
|
|
37
43
|
*/
|