@oss-autopilot/core 1.15.0 → 1.15.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-registry.js +34 -1
- package/dist/cli.bundle.cjs +69 -65
- package/dist/commands/daily.d.ts +9 -0
- package/dist/commands/daily.js +4 -1
- package/dist/commands/dashboard-server.d.ts +10 -0
- package/dist/commands/dashboard-server.js +10 -2
- package/dist/commands/scout-bridge.js +2 -1
- package/dist/commands/skip-add.d.ts +25 -0
- package/dist/commands/skip-add.js +48 -0
- package/dist/commands/skip-file-parser.d.ts +25 -0
- package/dist/commands/skip-file-parser.js +92 -0
- package/dist/commands/vet-list.js +10 -0
- package/dist/commands/vet.js +8 -0
- package/dist/core/anti-llm-policy.d.ts +32 -0
- package/dist/core/anti-llm-policy.js +101 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/issue-grading.d.ts +75 -0
- package/dist/core/issue-grading.js +146 -0
- package/dist/core/linked-pr-classification.d.ts +30 -0
- package/dist/core/linked-pr-classification.js +53 -0
- package/dist/formatters/json.d.ts +5 -0
- package/package.json +1 -1
package/dist/commands/daily.d.ts
CHANGED
|
@@ -33,6 +33,15 @@ export interface DailyCheckResult {
|
|
|
33
33
|
repoGroups: RepoGroup[];
|
|
34
34
|
failures: PRCheckFailure[];
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
|
|
38
|
+
* Deduplicates PR objects: category arrays become PR URL references,
|
|
39
|
+
* full objects live only in digest.openPRs. Reduces JSON payload size ~60-70%.
|
|
40
|
+
*
|
|
41
|
+
* Exported for the `daily --json` contract test (#986), which pins this
|
|
42
|
+
* shape transformation without spinning up the full fetch pipeline.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toDailyOutput(result: DailyCheckResult): DailyOutput;
|
|
36
45
|
/**
|
|
37
46
|
* Core daily check logic, extracted for reuse by the startup command.
|
|
38
47
|
* Fetches all open PRs, updates state, and returns structured output.
|
package/dist/commands/daily.js
CHANGED
|
@@ -370,8 +370,11 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
|
|
|
370
370
|
* Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
|
|
371
371
|
* Deduplicates PR objects: category arrays become PR URL references,
|
|
372
372
|
* full objects live only in digest.openPRs. Reduces JSON payload size ~60-70%.
|
|
373
|
+
*
|
|
374
|
+
* Exported for the `daily --json` contract test (#986), which pins this
|
|
375
|
+
* shape transformation without spinning up the full fetch pipeline.
|
|
373
376
|
*/
|
|
374
|
-
function toDailyOutput(result) {
|
|
377
|
+
export function toDailyOutput(result) {
|
|
375
378
|
return {
|
|
376
379
|
digest: deduplicateDigest(result.digest),
|
|
377
380
|
capacity: result.capacity,
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Uses Node's built-in http module — no Express/Fastify.
|
|
7
7
|
*/
|
|
8
|
+
import { type DashboardJsonData } from './dashboard-data.js';
|
|
9
|
+
import { type DailyDigest, type AgentState, type CommentedIssue, type MergedPR, type ClosedPR } from '../core/types.js';
|
|
8
10
|
export { getDashboardPidPath, writeDashboardServerInfo, readDashboardServerInfo, removeDashboardServerInfo, isDashboardServerRunning, findRunningDashboardServer, type DashboardServerInfo, } from './dashboard-process.js';
|
|
9
11
|
export interface DashboardServerOptions {
|
|
10
12
|
port: number;
|
|
@@ -12,4 +14,12 @@ export interface DashboardServerOptions {
|
|
|
12
14
|
token: string | null;
|
|
13
15
|
open: boolean;
|
|
14
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the JSON payload that the SPA expects from GET /api/data.
|
|
19
|
+
*
|
|
20
|
+
* Exported for unit testing of response-shape concerns that the full
|
|
21
|
+
* handler harness can't reach (it bakes a stale cachedDigest at server
|
|
22
|
+
* start-up, so tests that need a specific digest should call this directly).
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildDashboardJson(digest: DailyDigest, state: Readonly<AgentState>, commentedIssues: CommentedIssue[], allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[]): DashboardJsonData;
|
|
15
25
|
export declare function startDashboardServer(options: DashboardServerOptions): Promise<void>;
|
|
@@ -68,8 +68,12 @@ function getIssueListMtimeMs() {
|
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
70
|
* Build the JSON payload that the SPA expects from GET /api/data.
|
|
71
|
+
*
|
|
72
|
+
* Exported for unit testing of response-shape concerns that the full
|
|
73
|
+
* handler harness can't reach (it bakes a stale cachedDigest at server
|
|
74
|
+
* start-up, so tests that need a specific digest should call this directly).
|
|
71
75
|
*/
|
|
72
|
-
function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs) {
|
|
76
|
+
export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs) {
|
|
73
77
|
const prsByRepo = computePRsByRepo(digest, state);
|
|
74
78
|
const topRepos = computeTopRepos(prsByRepo);
|
|
75
79
|
const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
|
|
@@ -104,7 +108,11 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
|
|
|
104
108
|
monthlyOpened,
|
|
105
109
|
monthlyClosed,
|
|
106
110
|
activePRs: applyStatusOverrides(digest.openPRs || [], state),
|
|
107
|
-
|
|
111
|
+
// Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
|
|
112
|
+
// and dormant-non-addressing PRs auto-shelved for display). Returning
|
|
113
|
+
// only state.config.shelvedPRUrls would under-count and desync from
|
|
114
|
+
// stats.shelvedPRs, which is already derived from digest.shelvedPRs. (#981)
|
|
115
|
+
shelvedPRUrls: (digest.shelvedPRs || []).map((ref) => ref.url),
|
|
108
116
|
recentlyMergedPRs: digest.recentlyMergedPRs || [],
|
|
109
117
|
recentlyClosedPRs: digest.recentlyClosedPRs || [],
|
|
110
118
|
autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createScout } from '@oss-scout/core';
|
|
6
6
|
import { getStateManager, requireGitHubToken } from '../core/index.js';
|
|
7
|
+
import { loadSkippedIssues } from './skip-file-parser.js';
|
|
7
8
|
/**
|
|
8
9
|
* Build a ScoutState from the current AgentState.
|
|
9
10
|
* Maps oss-autopilot's config and state fields to oss-scout's state format.
|
|
@@ -52,7 +53,7 @@ export function buildScoutState() {
|
|
|
52
53
|
openedAt: pr.createdAt,
|
|
53
54
|
})),
|
|
54
55
|
savedResults: [],
|
|
55
|
-
skippedIssues:
|
|
56
|
+
skippedIssues: loadSkippedIssues(config.skippedIssuesPath),
|
|
56
57
|
lastRunAt: state.lastRunAt,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SkipAddOptions {
|
|
2
|
+
issueUrl: string;
|
|
3
|
+
/** Override the configured `skippedIssuesPath`. */
|
|
4
|
+
skipFilePath?: string;
|
|
5
|
+
/** Injected for deterministic tests. Defaults to `new Date()`. */
|
|
6
|
+
now?: Date;
|
|
7
|
+
}
|
|
8
|
+
export interface SkipAddOutput {
|
|
9
|
+
added: boolean;
|
|
10
|
+
alreadyPresent: boolean;
|
|
11
|
+
url: string;
|
|
12
|
+
path: string;
|
|
13
|
+
/** Populated only when an entry was appended. */
|
|
14
|
+
date?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Append an issue URL to the skipped-issues file.
|
|
18
|
+
*
|
|
19
|
+
* Creates the file with the standard header if it doesn't exist. If the URL
|
|
20
|
+
* is already present (per the skip-file-parser's view of the file), this is
|
|
21
|
+
* a no-op and returns `alreadyPresent: true`.
|
|
22
|
+
*
|
|
23
|
+
* @throws when no path can be resolved or the URL isn't a GitHub issue/PR URL.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runSkipAdd(options: SkipAddOptions): SkipAddOutput;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { loadSkippedIssues } from './skip-file-parser.js';
|
|
3
|
+
import { getStateManager } from '../core/index.js';
|
|
4
|
+
// Keep in sync with GITHUB_URL_RE in skip-file-parser.ts.
|
|
5
|
+
const GITHUB_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/;
|
|
6
|
+
const FILE_HEADER = '# Skipped Issues — auto-culled after 90 days\n# Format: YYYY-MM-DD URL\n\n';
|
|
7
|
+
function formatUtcDate(d) {
|
|
8
|
+
return d.toISOString().slice(0, 10);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Append an issue URL to the skipped-issues file.
|
|
12
|
+
*
|
|
13
|
+
* Creates the file with the standard header if it doesn't exist. If the URL
|
|
14
|
+
* is already present (per the skip-file-parser's view of the file), this is
|
|
15
|
+
* a no-op and returns `alreadyPresent: true`.
|
|
16
|
+
*
|
|
17
|
+
* @throws when no path can be resolved or the URL isn't a GitHub issue/PR URL.
|
|
18
|
+
*/
|
|
19
|
+
export function runSkipAdd(options) {
|
|
20
|
+
const skipFilePath = options.skipFilePath ?? getStateManager().getState().config.skippedIssuesPath;
|
|
21
|
+
if (!skipFilePath) {
|
|
22
|
+
throw new Error('No skipped-issues path configured. Set one via `oss-autopilot config --set skippedIssuesPath=<path>` or pass --path.');
|
|
23
|
+
}
|
|
24
|
+
if (!GITHUB_URL_RE.test(options.issueUrl)) {
|
|
25
|
+
throw new Error(`Invalid GitHub issue or PR URL: ${options.issueUrl}`);
|
|
26
|
+
}
|
|
27
|
+
const existing = loadSkippedIssues(skipFilePath);
|
|
28
|
+
if (existing.some((entry) => entry.url === options.issueUrl)) {
|
|
29
|
+
return {
|
|
30
|
+
added: false,
|
|
31
|
+
alreadyPresent: true,
|
|
32
|
+
url: options.issueUrl,
|
|
33
|
+
path: skipFilePath,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!fs.existsSync(skipFilePath)) {
|
|
37
|
+
fs.writeFileSync(skipFilePath, FILE_HEADER);
|
|
38
|
+
}
|
|
39
|
+
const date = formatUtcDate(options.now ?? new Date());
|
|
40
|
+
fs.appendFileSync(skipFilePath, `${date} ${options.issueUrl}\n`);
|
|
41
|
+
return {
|
|
42
|
+
added: true,
|
|
43
|
+
alreadyPresent: false,
|
|
44
|
+
url: options.issueUrl,
|
|
45
|
+
path: skipFilePath,
|
|
46
|
+
date,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the skipped-issues markdown file (#989).
|
|
3
|
+
*
|
|
4
|
+
* The file format is one entry per line:
|
|
5
|
+
* 2026-04-15 https://github.com/owner/repo/issues/123
|
|
6
|
+
* Lines starting with `#` and blank lines are ignored.
|
|
7
|
+
*
|
|
8
|
+
* Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
|
|
9
|
+
* so the search engine filters already-skipped URLs out of results.
|
|
10
|
+
*/
|
|
11
|
+
import type { SkippedIssue } from '@oss-scout/core';
|
|
12
|
+
/**
|
|
13
|
+
* Parse the raw text of a skipped-issues file into SkippedIssue entries.
|
|
14
|
+
* Pure function — no I/O. Malformed lines are warned and skipped; the rest
|
|
15
|
+
* pass through unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseSkippedIssuesContent(content: string): SkippedIssue[];
|
|
18
|
+
/**
|
|
19
|
+
* Read the skipped-issues file from disk and parse it.
|
|
20
|
+
* Returns `[]` when:
|
|
21
|
+
* - `path` is undefined or empty,
|
|
22
|
+
* - the file does not exist,
|
|
23
|
+
* - the file cannot be read (a warning is logged).
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadSkippedIssues(path: string | undefined): SkippedIssue[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the skipped-issues markdown file (#989).
|
|
3
|
+
*
|
|
4
|
+
* The file format is one entry per line:
|
|
5
|
+
* 2026-04-15 https://github.com/owner/repo/issues/123
|
|
6
|
+
* Lines starting with `#` and blank lines are ignored.
|
|
7
|
+
*
|
|
8
|
+
* Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
|
|
9
|
+
* so the search engine filters already-skipped URLs out of results.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import { warn } from '../core/logger.js';
|
|
13
|
+
import { errorMessage } from '../core/errors.js';
|
|
14
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
const GITHUB_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/;
|
|
16
|
+
/**
|
|
17
|
+
* Parse the raw text of a skipped-issues file into SkippedIssue entries.
|
|
18
|
+
* Pure function — no I/O. Malformed lines are warned and skipped; the rest
|
|
19
|
+
* pass through unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export function parseSkippedIssuesContent(content) {
|
|
22
|
+
const results = [];
|
|
23
|
+
const lines = content.split(/\r?\n/);
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const lineNumber = i + 1;
|
|
26
|
+
const line = lines[i].trim();
|
|
27
|
+
if (line === '' || line.startsWith('#'))
|
|
28
|
+
continue;
|
|
29
|
+
// Split on first whitespace run: "YYYY-MM-DD <url>"
|
|
30
|
+
const match = line.match(/^(\S+)\s+(\S+)\s*$/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
warn('skip-file-parser', `Line ${lineNumber}: malformed (expected "<date> <url>"): ${line}`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const [, dateStr, url] = match;
|
|
36
|
+
if (!DATE_RE.test(dateStr)) {
|
|
37
|
+
warn('skip-file-parser', `Line ${lineNumber}: invalid date format (expected YYYY-MM-DD): ${line}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const dateMs = Date.parse(`${dateStr}T00:00:00.000Z`);
|
|
41
|
+
if (Number.isNaN(dateMs)) {
|
|
42
|
+
warn('skip-file-parser', `Line ${lineNumber}: unparseable date: ${line}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Guard against JS Date normalization — Date.parse silently shifts
|
|
46
|
+
// invalid calendar dates (e.g. 2026-02-30 → 2026-03-02). Without this
|
|
47
|
+
// round-trip check the entry would be stored under the wrong date and
|
|
48
|
+
// scout's 90-day cull would run against a shifted value.
|
|
49
|
+
const roundTrip = new Date(dateMs).toISOString().slice(0, 10);
|
|
50
|
+
if (roundTrip !== dateStr) {
|
|
51
|
+
warn('skip-file-parser', `Line ${lineNumber}: invalid calendar date ${dateStr} (would be normalized to ${roundTrip}): ${line}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const urlMatch = url.match(GITHUB_URL_RE);
|
|
55
|
+
if (!urlMatch) {
|
|
56
|
+
warn('skip-file-parser', `Line ${lineNumber}: non-GitHub-issue URL: ${line}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const [, repo, numberStr] = urlMatch;
|
|
60
|
+
const number = Number.parseInt(numberStr, 10);
|
|
61
|
+
results.push({
|
|
62
|
+
url,
|
|
63
|
+
repo,
|
|
64
|
+
number,
|
|
65
|
+
title: '',
|
|
66
|
+
skippedAt: new Date(dateMs).toISOString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read the skipped-issues file from disk and parse it.
|
|
73
|
+
* Returns `[]` when:
|
|
74
|
+
* - `path` is undefined or empty,
|
|
75
|
+
* - the file does not exist,
|
|
76
|
+
* - the file cannot be read (a warning is logged).
|
|
77
|
+
*/
|
|
78
|
+
export function loadSkippedIssues(path) {
|
|
79
|
+
if (!path)
|
|
80
|
+
return [];
|
|
81
|
+
if (!fs.existsSync(path))
|
|
82
|
+
return [];
|
|
83
|
+
let content;
|
|
84
|
+
try {
|
|
85
|
+
content = fs.readFileSync(path, 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
warn('skip-file-parser', `Failed to read skipped-issues file at ${path}: ${errorMessage(err)}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return parseSkippedIssuesContent(content);
|
|
92
|
+
}
|
|
@@ -6,6 +6,9 @@ import * as fs from 'fs';
|
|
|
6
6
|
import { createAutopilotScout } from './scout-bridge.js';
|
|
7
7
|
import { runParseList, pruneIssueList } from './parse-list.js';
|
|
8
8
|
import { detectIssueList } from './startup.js';
|
|
9
|
+
import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
|
|
10
|
+
import { getStateManager } from '../core/index.js';
|
|
11
|
+
const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
|
|
9
12
|
/**
|
|
10
13
|
* Determine the list status from vetting results.
|
|
11
14
|
* Maps vetting recommendation + reasons to a list-level status.
|
|
@@ -62,6 +65,11 @@ export async function runVetList(options = {}) {
|
|
|
62
65
|
const item = items[index++];
|
|
63
66
|
try {
|
|
64
67
|
const candidate = await scout.vetIssue(item.url);
|
|
68
|
+
const grade = gradeFromCandidate({
|
|
69
|
+
repo: candidate.issue.repo,
|
|
70
|
+
projectHealth: candidate.projectHealth,
|
|
71
|
+
getRepoScore: (repo) => getStateManager().getRepoScore(repo),
|
|
72
|
+
});
|
|
65
73
|
const vetResult = {
|
|
66
74
|
issue: {
|
|
67
75
|
repo: candidate.issue.repo,
|
|
@@ -75,6 +83,7 @@ export async function runVetList(options = {}) {
|
|
|
75
83
|
reasonsToSkip: candidate.reasonsToSkip,
|
|
76
84
|
projectHealth: candidate.projectHealth,
|
|
77
85
|
vettingResult: candidate.vettingResult,
|
|
86
|
+
grade,
|
|
78
87
|
};
|
|
79
88
|
results.push({
|
|
80
89
|
...vetResult,
|
|
@@ -90,6 +99,7 @@ export async function runVetList(options = {}) {
|
|
|
90
99
|
reasonsToSkip: [`Error: ${error instanceof Error ? error.message : String(error)}`],
|
|
91
100
|
projectHealth: {},
|
|
92
101
|
vettingResult: {},
|
|
102
|
+
grade: UNKNOWN_GRADE,
|
|
93
103
|
listStatus: 'error',
|
|
94
104
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
95
105
|
});
|
package/dist/commands/vet.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createAutopilotScout } from './scout-bridge.js';
|
|
6
6
|
import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
|
|
7
|
+
import { gradeFromCandidate } from '../core/issue-grading.js';
|
|
8
|
+
import { getStateManager } from '../core/index.js';
|
|
7
9
|
/**
|
|
8
10
|
* Vet a specific GitHub issue for claimability and project health.
|
|
9
11
|
*
|
|
@@ -17,6 +19,11 @@ export async function runVet(options) {
|
|
|
17
19
|
validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
|
|
18
20
|
const scout = await createAutopilotScout();
|
|
19
21
|
const candidate = await scout.vetIssue(options.issueUrl);
|
|
22
|
+
const grade = gradeFromCandidate({
|
|
23
|
+
repo: candidate.issue.repo,
|
|
24
|
+
projectHealth: candidate.projectHealth,
|
|
25
|
+
getRepoScore: (repo) => getStateManager().getRepoScore(repo),
|
|
26
|
+
});
|
|
20
27
|
return {
|
|
21
28
|
issue: {
|
|
22
29
|
repo: candidate.issue.repo,
|
|
@@ -30,5 +37,6 @@ export async function runVet(options) {
|
|
|
30
37
|
reasonsToSkip: candidate.reasonsToSkip,
|
|
31
38
|
projectHealth: candidate.projectHealth,
|
|
32
39
|
vettingResult: candidate.vettingResult,
|
|
40
|
+
grade,
|
|
33
41
|
};
|
|
34
42
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-LLM policy scan (#108, #911, #979).
|
|
3
|
+
*
|
|
4
|
+
* Scan concatenated repo docs (CONTRIBUTING.md, CODE_OF_CONDUCT.md,
|
|
5
|
+
* README) for language that indicates the project does not accept
|
|
6
|
+
* AI/LLM-generated contributions. Previously described as a keyword
|
|
7
|
+
* table in prose in agents/issue-scout.md.
|
|
8
|
+
*
|
|
9
|
+
* The long-term home for this logic is `@oss-scout/core`, where the
|
|
10
|
+
* relevant files are already fetched during vetting. Keeping it here
|
|
11
|
+
* for now lets the agent invoke it directly and gives scout a
|
|
12
|
+
* reference implementation + test fixtures to adopt. See #979.
|
|
13
|
+
*
|
|
14
|
+
* Precision matters more than recall. False positives (flagging a
|
|
15
|
+
* project that actually welcomes AI help) silently shrink the user's
|
|
16
|
+
* contribution surface without recourse. We only match on phrases
|
|
17
|
+
* that combine a rejection keyword (no / reject / will be closed /
|
|
18
|
+
* don't accept) with an AI/LLM noun.
|
|
19
|
+
*/
|
|
20
|
+
export type AntiLLMCategory = 'explicit_ban' | 'tool_ban' | 'reject_framing';
|
|
21
|
+
export interface AntiLLMMatch {
|
|
22
|
+
category: AntiLLMCategory;
|
|
23
|
+
/** The exact substring from the source text that triggered the match. */
|
|
24
|
+
phrase: string;
|
|
25
|
+
/** ~80 character window around the match, for surfacing to the user. */
|
|
26
|
+
excerpt: string;
|
|
27
|
+
}
|
|
28
|
+
export interface AntiLLMScanResult {
|
|
29
|
+
matched: boolean;
|
|
30
|
+
matches: AntiLLMMatch[];
|
|
31
|
+
}
|
|
32
|
+
export declare function scanForAntiLLMPolicy(text: string): AntiLLMScanResult;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-LLM policy scan (#108, #911, #979).
|
|
3
|
+
*
|
|
4
|
+
* Scan concatenated repo docs (CONTRIBUTING.md, CODE_OF_CONDUCT.md,
|
|
5
|
+
* README) for language that indicates the project does not accept
|
|
6
|
+
* AI/LLM-generated contributions. Previously described as a keyword
|
|
7
|
+
* table in prose in agents/issue-scout.md.
|
|
8
|
+
*
|
|
9
|
+
* The long-term home for this logic is `@oss-scout/core`, where the
|
|
10
|
+
* relevant files are already fetched during vetting. Keeping it here
|
|
11
|
+
* for now lets the agent invoke it directly and gives scout a
|
|
12
|
+
* reference implementation + test fixtures to adopt. See #979.
|
|
13
|
+
*
|
|
14
|
+
* Precision matters more than recall. False positives (flagging a
|
|
15
|
+
* project that actually welcomes AI help) silently shrink the user's
|
|
16
|
+
* contribution surface without recourse. We only match on phrases
|
|
17
|
+
* that combine a rejection keyword (no / reject / will be closed /
|
|
18
|
+
* don't accept) with an AI/LLM noun.
|
|
19
|
+
*/
|
|
20
|
+
const PATTERNS = [
|
|
21
|
+
// Explicit "no X" bans against AI/LLM nouns.
|
|
22
|
+
{ category: 'explicit_ban', regex: /\bno\s+(ai|llm)[-\s](generated|authored|written|assisted|contributions?)/i },
|
|
23
|
+
{ category: 'explicit_ban', regex: /\b(ban|banned|banning)\s+(ai|llm)\b/i },
|
|
24
|
+
// Named-tool bans. Optionally match a "-generated/-authored/-…"
|
|
25
|
+
// continuation (clear ban wording), and use a negative lookahead to
|
|
26
|
+
// reject unrelated hyphen-words like "no copilot-style autocomplete"
|
|
27
|
+
// (which describes a feature, not a contribution policy).
|
|
28
|
+
{
|
|
29
|
+
category: 'tool_ban',
|
|
30
|
+
regex: /\bno\s+(copilot|chatgpt|claude|cursor)(-(generated|authored|assisted|written))?(?![a-z-])/i,
|
|
31
|
+
},
|
|
32
|
+
{ category: 'tool_ban', regex: /\bno\s+ai\s+coding\s+tools?\b/i },
|
|
33
|
+
// Rejection framing. To avoid false positives like "AI PRs are closed
|
|
34
|
+
// to new comments" (closed means something else) or "AI suggestions
|
|
35
|
+
// from your IDE" (not a contribution), we require both an AI/LLM
|
|
36
|
+
// qualifier AND a contribution noun AND a rejection verb phrase. The
|
|
37
|
+
// two patterns cover "AI-generated code will be closed" (with
|
|
38
|
+
// participle) and "AI contributions will be closed" (without).
|
|
39
|
+
{
|
|
40
|
+
category: 'reject_framing',
|
|
41
|
+
regex: /\b(ai|llm)[-\s](generated|assisted|authored|written)\s+(code|prs?|contributions?)\s+(will\s+be\s+(closed|rejected)|are\s+rejected)/i,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
category: 'reject_framing',
|
|
45
|
+
regex: /\b(ai|llm)\s+(code|prs?|contributions?)\s+(will\s+be\s+(closed|rejected)|are\s+rejected)/i,
|
|
46
|
+
},
|
|
47
|
+
// "do/does not accept AI-{noun}" / "reject AI contributions" — both
|
|
48
|
+
// require a contribution noun to avoid matching "accept AI suggestions
|
|
49
|
+
// from your IDE" or similar incidental mentions.
|
|
50
|
+
{
|
|
51
|
+
category: 'reject_framing',
|
|
52
|
+
regex: /\b(do|does)(\s+not|n't)\s+accept\s+(ai|llm)[-\s](generated|assisted|authored|written|contributions?|code|prs?)\b/i,
|
|
53
|
+
},
|
|
54
|
+
{ category: 'reject_framing', regex: /\breject\s+(ai|llm)\s+contributions?\b/i },
|
|
55
|
+
];
|
|
56
|
+
const EXCERPT_RADIUS = 40;
|
|
57
|
+
function makeExcerpt(text, matchIndex, matchLength) {
|
|
58
|
+
const start = Math.max(0, matchIndex - EXCERPT_RADIUS);
|
|
59
|
+
const end = Math.min(text.length, matchIndex + matchLength + EXCERPT_RADIUS);
|
|
60
|
+
const prefix = start > 0 ? '…' : '';
|
|
61
|
+
const suffix = end < text.length ? '…' : '';
|
|
62
|
+
return `${prefix}${text.slice(start, end).replace(/\s+/g, ' ').trim()}${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Normalize exotic whitespace and hyphens so patterns written with plain
|
|
66
|
+
* ASCII still match real-world markdown. Covers non-breaking space,
|
|
67
|
+
* non-breaking hyphen, en dash, em dash, and figure dash — all of which
|
|
68
|
+
* show up in CONTRIBUTING files authored in rich-text editors.
|
|
69
|
+
*/
|
|
70
|
+
function normalizeText(text) {
|
|
71
|
+
return text
|
|
72
|
+
.normalize('NFKC')
|
|
73
|
+
.replace(/[\u00A0]/g, ' ')
|
|
74
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, '-');
|
|
75
|
+
}
|
|
76
|
+
export function scanForAntiLLMPolicy(text) {
|
|
77
|
+
if (typeof text !== 'string') {
|
|
78
|
+
throw new TypeError(`scanForAntiLLMPolicy: expected string, received ${typeof text}`);
|
|
79
|
+
}
|
|
80
|
+
if (text === '')
|
|
81
|
+
return { matched: false, matches: [] };
|
|
82
|
+
const normalized = normalizeText(text);
|
|
83
|
+
const seenLabels = new Set();
|
|
84
|
+
const matches = [];
|
|
85
|
+
for (const pattern of PATTERNS) {
|
|
86
|
+
const hit = normalized.match(pattern.regex);
|
|
87
|
+
if (!hit || hit.index === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
const phrase = hit[0];
|
|
90
|
+
const key = `${pattern.category}:${phrase.toLowerCase()}`;
|
|
91
|
+
if (seenLabels.has(key))
|
|
92
|
+
continue;
|
|
93
|
+
seenLabels.add(key);
|
|
94
|
+
matches.push({
|
|
95
|
+
category: pattern.category,
|
|
96
|
+
phrase,
|
|
97
|
+
excerpt: makeExcerpt(normalized, hit.index, phrase.length),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { matched: matches.length > 0, matches };
|
|
101
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -15,5 +15,7 @@ export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-
|
|
|
15
15
|
export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
|
|
16
16
|
export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
|
|
17
17
|
export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
|
|
18
|
+
export { classifyLinkedPR, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
|
|
19
|
+
export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
|
|
18
20
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
|
|
19
21
|
export * from './types.js';
|
package/dist/core/index.js
CHANGED
|
@@ -16,5 +16,7 @@ export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
|
|
|
16
16
|
export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
|
|
17
17
|
export { computeContributionStats } from './stats.js';
|
|
18
18
|
export { fetchPRTemplate } from './pr-template.js';
|
|
19
|
+
export { classifyLinkedPR, } from './linked-pr-classification.js';
|
|
20
|
+
export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
|
|
19
21
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
|
|
20
22
|
export * from './types.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue success-likelihood grade (#858).
|
|
3
|
+
*
|
|
4
|
+
* Predicts the probability that a contribution to a given repo will be
|
|
5
|
+
* accepted and merged, using signals already collected during vetting.
|
|
6
|
+
*
|
|
7
|
+
* The grade is letter-based (A/B/C/F — no D). Each signal is graded
|
|
8
|
+
* independently; the overall grade is the worst of the three, and is
|
|
9
|
+
* further degraded one step if any signal is unknown (missing data is
|
|
10
|
+
* treated as a risk, not ignored). This matches the policy previously
|
|
11
|
+
* described as prose in agents/issue-scout.md.
|
|
12
|
+
*/
|
|
13
|
+
export type GradeLetter = 'A' | 'B' | 'C' | 'F';
|
|
14
|
+
export interface GradeSignals {
|
|
15
|
+
/** Average maintainer response time in days; null if unknown. */
|
|
16
|
+
avgResponseDays: number | null;
|
|
17
|
+
/** Fraction of recent PRs that merged (0–1); null if unknown. */
|
|
18
|
+
mergeRate: number | null;
|
|
19
|
+
/** Days since the most recent commit on the default branch; null if unknown. */
|
|
20
|
+
daysSinceLastCommit: number | null;
|
|
21
|
+
}
|
|
22
|
+
export interface GradeResult {
|
|
23
|
+
letter: GradeLetter;
|
|
24
|
+
/** Short human-readable explanation of what drove the grade. */
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build `GradeSignals` from a vet candidate's project health plus the
|
|
29
|
+
* optional autopilot-tracked repo score.
|
|
30
|
+
*
|
|
31
|
+
* Notes on each signal:
|
|
32
|
+
*
|
|
33
|
+
* - `avgResponseDays` — `@oss-scout/core`'s `projectHealth.avgIssueResponseDays`
|
|
34
|
+
* is a hardcoded `0` placeholder (it doesn't yet make the additional API
|
|
35
|
+
* calls needed to compute a real value). We therefore prefer
|
|
36
|
+
* `repoScore.avgResponseDays`, which autopilot derives from its own PR
|
|
37
|
+
* tracking, and fall back to `null` (unknown) if neither source has a
|
|
38
|
+
* usable value.
|
|
39
|
+
* - `mergeRate` — derived from repoScore counts. Requires a non-empty PR
|
|
40
|
+
* history; `0/0` is `null`, not `0`.
|
|
41
|
+
* - `daysSinceLastCommit` — taken from scout's `projectHealth`, but only
|
|
42
|
+
* when scout's health check succeeded (`checkFailed` means scout filled
|
|
43
|
+
* in sentinels and shouldn't be trusted).
|
|
44
|
+
*/
|
|
45
|
+
export declare function deriveGradeSignals(params: {
|
|
46
|
+
projectHealth: {
|
|
47
|
+
avgIssueResponseDays: number | null;
|
|
48
|
+
daysSinceLastCommit: number | null;
|
|
49
|
+
checkFailed?: boolean;
|
|
50
|
+
};
|
|
51
|
+
repoScore: {
|
|
52
|
+
mergedPRCount: number;
|
|
53
|
+
closedWithoutMergeCount: number;
|
|
54
|
+
avgResponseDays?: number | null;
|
|
55
|
+
} | null;
|
|
56
|
+
}): GradeSignals;
|
|
57
|
+
/**
|
|
58
|
+
* End-to-end helper for vet callers: reads the repo score, derives
|
|
59
|
+
* signals from a scout candidate, and returns the grade. Callers pass
|
|
60
|
+
* the `projectHealth` straight through from `scout.vetIssue()`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function gradeFromCandidate(params: {
|
|
63
|
+
repo: string;
|
|
64
|
+
projectHealth: {
|
|
65
|
+
avgIssueResponseDays: number | null;
|
|
66
|
+
daysSinceLastCommit: number | null;
|
|
67
|
+
checkFailed?: boolean;
|
|
68
|
+
};
|
|
69
|
+
getRepoScore: (repo: string) => {
|
|
70
|
+
mergedPRCount: number;
|
|
71
|
+
closedWithoutMergeCount: number;
|
|
72
|
+
avgResponseDays: number | null;
|
|
73
|
+
} | undefined;
|
|
74
|
+
}): GradeResult;
|
|
75
|
+
export declare function computeSuccessGrade(signals: GradeSignals): GradeResult;
|