@oss-autopilot/core 3.4.0 → 3.5.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/dist/cli-registry.js +50 -0
- package/dist/cli.bundle.cjs +81 -78
- package/dist/commands/compliance-score.d.ts +21 -0
- package/dist/commands/compliance-score.js +156 -0
- package/dist/commands/index.d.ts +4 -0
- package/dist/commands/index.js +4 -0
- package/dist/commands/list-mark-done.d.ts +48 -0
- package/dist/commands/list-mark-done.js +213 -0
- package/dist/commands/parse-list.js +86 -9
- package/dist/commands/repo-vet.d.ts +21 -0
- package/dist/commands/repo-vet.js +215 -0
- package/dist/commands/startup.js +18 -0
- package/dist/core/ci-enforced-tools.d.ts +35 -0
- package/dist/core/ci-enforced-tools.js +109 -0
- package/dist/core/comment-decision.d.ts +72 -0
- package/dist/core/comment-decision.js +74 -0
- package/dist/core/compliance-score.d.ts +127 -0
- package/dist/core/compliance-score.js +277 -0
- package/dist/core/config-registry.js +12 -0
- package/dist/core/contributing.d.ts +52 -0
- package/dist/core/contributing.js +139 -0
- package/dist/core/extraction-categories.d.ts +55 -0
- package/dist/core/extraction-categories.js +108 -0
- package/dist/core/follow-up-history.d.ts +41 -0
- package/dist/core/follow-up-history.js +71 -0
- package/dist/core/gist-state-store.d.ts +30 -7
- package/dist/core/gist-state-store.js +87 -11
- package/dist/core/issue-conversation.js +1 -0
- package/dist/core/issue-effort.d.ts +29 -0
- package/dist/core/issue-effort.js +41 -0
- package/dist/core/maintainer-hints.d.ts +23 -0
- package/dist/core/maintainer-hints.js +36 -0
- package/dist/core/placeholder-usernames.js +7 -0
- package/dist/core/pr-quality-rubric.d.ts +70 -0
- package/dist/core/pr-quality-rubric.js +121 -0
- package/dist/core/repo-vet.d.ts +90 -0
- package/dist/core/repo-vet.js +178 -0
- package/dist/core/state-schema.d.ts +76 -0
- package/dist/core/state-schema.js +75 -0
- package/dist/core/strategy.d.ts +75 -0
- package/dist/core/strategy.js +226 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/workflow-state.d.ts +56 -0
- package/dist/core/workflow-state.js +101 -0
- package/dist/formatters/json.d.ts +147 -0
- package/dist/formatters/json.js +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compliance-score command (#1245).
|
|
3
|
+
*
|
|
4
|
+
* Fetches PR metadata via the GitHub API, runs the typed
|
|
5
|
+
* `computeComplianceScore` core function, and returns the structured
|
|
6
|
+
* result. Used by the `pr-compliance-checker` agent (and the
|
|
7
|
+
* `compliance-score` MCP tool) to replace per-prompt scoring tables
|
|
8
|
+
* with a deterministic, testable evaluation.
|
|
9
|
+
*
|
|
10
|
+
* Same architectural shape as `track`: read-only API call, no state
|
|
11
|
+
* mutation, runs against a public PR URL.
|
|
12
|
+
*/
|
|
13
|
+
import type { ComplianceScoreOutput } from '../formatters/json.js';
|
|
14
|
+
/**
|
|
15
|
+
* Run the compliance evaluation against a PR URL.
|
|
16
|
+
*
|
|
17
|
+
* @throws {ValidationError} If the URL is not a valid GitHub PR URL.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runComplianceScore(options: {
|
|
20
|
+
prUrl: string;
|
|
21
|
+
}): Promise<ComplianceScoreOutput>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compliance-score command (#1245).
|
|
3
|
+
*
|
|
4
|
+
* Fetches PR metadata via the GitHub API, runs the typed
|
|
5
|
+
* `computeComplianceScore` core function, and returns the structured
|
|
6
|
+
* result. Used by the `pr-compliance-checker` agent (and the
|
|
7
|
+
* `compliance-score` MCP tool) to replace per-prompt scoring tables
|
|
8
|
+
* with a deterministic, testable evaluation.
|
|
9
|
+
*
|
|
10
|
+
* Same architectural shape as `track`: read-only API call, no state
|
|
11
|
+
* mutation, runs against a public PR URL.
|
|
12
|
+
*/
|
|
13
|
+
import { getOctokit, requireGitHubToken } from '../core/index.js';
|
|
14
|
+
import { ValidationError } from '../core/errors.js';
|
|
15
|
+
import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
|
|
16
|
+
import { parseGitHubUrl } from '../core/urls.js';
|
|
17
|
+
import { computeComplianceScore, } from '../core/compliance-score.js';
|
|
18
|
+
/**
|
|
19
|
+
* Detect whether the target repo has visible test infrastructure. Looks
|
|
20
|
+
* for the well-known directories at the repo root in a single contents
|
|
21
|
+
* call. Failures (missing repo, rate limit, network) surface as
|
|
22
|
+
* `undefined` so the score function falls back to its strict default.
|
|
23
|
+
*/
|
|
24
|
+
async function detectTestInfrastructure(octokit, owner, repo) {
|
|
25
|
+
try {
|
|
26
|
+
const { data } = await octokit.repos.getContent({ owner, repo, path: '' });
|
|
27
|
+
if (!Array.isArray(data))
|
|
28
|
+
return undefined;
|
|
29
|
+
const TEST_DIR = /^(?:tests?|__tests__|spec)$/i;
|
|
30
|
+
return data.some((entry) => entry.type === 'dir' && TEST_DIR.test(entry.name));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse every issue/PR reference out of the PR body into a flat list of
|
|
38
|
+
* `{ owner, repo, number }` triples (#1246 Improvement B). Handles the
|
|
39
|
+
* three forms that PR bodies use in practice:
|
|
40
|
+
* - Bare same-repo: `#42` (with closing keyword or referencing keyword)
|
|
41
|
+
* - Cross-repo shorthand: `owner/repo#42`
|
|
42
|
+
* - Direct URL: `https://github.com/owner/repo/issues/42`
|
|
43
|
+
*
|
|
44
|
+
* Deduplicates by `(owner, repo, number)` so a body that mentions the
|
|
45
|
+
* same issue twice (`Closes #42 — see #42`) results in one API call.
|
|
46
|
+
*/
|
|
47
|
+
function parseLinkedIssueReferences(body, defaultOwner, defaultRepo) {
|
|
48
|
+
const out = new Map();
|
|
49
|
+
const push = (owner, repo, number) => {
|
|
50
|
+
const key = `${owner}/${repo}#${number}`;
|
|
51
|
+
if (!out.has(key))
|
|
52
|
+
out.set(key, { owner, repo, number });
|
|
53
|
+
};
|
|
54
|
+
// Bare `#42` only counts when accompanied by a closing or referencing
|
|
55
|
+
// verb — random hashtags shouldn't trigger an API call.
|
|
56
|
+
const BARE_REF = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|relates?\s+to|refs?|references?|see)\s+#(\d+)/gi;
|
|
57
|
+
for (const match of body.matchAll(BARE_REF)) {
|
|
58
|
+
push(defaultOwner, defaultRepo, Number.parseInt(match[1], 10));
|
|
59
|
+
}
|
|
60
|
+
const CROSS_REPO = /\b([\w.-]+)\/([\w.-]+)#(\d+)/g;
|
|
61
|
+
for (const match of body.matchAll(CROSS_REPO)) {
|
|
62
|
+
push(match[1], match[2], Number.parseInt(match[3], 10));
|
|
63
|
+
}
|
|
64
|
+
const ISSUE_OR_PR_URL = /https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/gi;
|
|
65
|
+
for (const match of body.matchAll(ISSUE_OR_PR_URL)) {
|
|
66
|
+
push(match[1], match[2], Number.parseInt(match[3], 10));
|
|
67
|
+
}
|
|
68
|
+
return [...out.values()];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetch each linked issue/PR via the Issues API and classify its
|
|
72
|
+
* state (#1246 Improvement B). Failures (404 / rate limit / network)
|
|
73
|
+
* map to `state: 'not_found'` for that single reference rather than
|
|
74
|
+
* aborting the score — the function falls back to surfacing what it
|
|
75
|
+
* could verify.
|
|
76
|
+
*/
|
|
77
|
+
async function verifyLinkedIssues(octokit, prRepo, refs, now = new Date()) {
|
|
78
|
+
return Promise.all(refs.map(async ({ owner, repo, number }) => {
|
|
79
|
+
const crossRepo = owner !== prRepo.owner || repo !== prRepo.repo;
|
|
80
|
+
const fullRepo = `${owner}/${repo}`;
|
|
81
|
+
try {
|
|
82
|
+
const { data } = await octokit.issues.get({ owner, repo, issue_number: number });
|
|
83
|
+
if (data.state === 'open') {
|
|
84
|
+
return { number, repo: fullRepo, crossRepo, state: 'open' };
|
|
85
|
+
}
|
|
86
|
+
const closedAt = data.closed_at ? new Date(data.closed_at) : null;
|
|
87
|
+
const closedDaysAgo = closedAt ? Math.floor((now.getTime() - closedAt.getTime()) / 86400000) : undefined;
|
|
88
|
+
return { number, repo: fullRepo, crossRepo, state: 'closed', closedDaysAgo };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
const status = err.status;
|
|
92
|
+
if (status === 404) {
|
|
93
|
+
return { number, repo: fullRepo, crossRepo, state: 'not_found' };
|
|
94
|
+
}
|
|
95
|
+
// Rate limit, 5xx, network — distinct from "not found" so the
|
|
96
|
+
// score function treats it neutrally. Mapping a 429 on a valid
|
|
97
|
+
// open issue to `not_found` would falsely fail the
|
|
98
|
+
// issue-reference check on a perfectly good PR.
|
|
99
|
+
return { number, repo: fullRepo, crossRepo, state: 'unverifiable' };
|
|
100
|
+
}
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run the compliance evaluation against a PR URL.
|
|
105
|
+
*
|
|
106
|
+
* @throws {ValidationError} If the URL is not a valid GitHub PR URL.
|
|
107
|
+
*/
|
|
108
|
+
export async function runComplianceScore(options) {
|
|
109
|
+
validateUrl(options.prUrl);
|
|
110
|
+
validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
|
|
111
|
+
const token = requireGitHubToken();
|
|
112
|
+
const octokit = getOctokit(token);
|
|
113
|
+
const parsed = parseGitHubUrl(options.prUrl);
|
|
114
|
+
if (!parsed || parsed.type !== 'pull') {
|
|
115
|
+
throw new ValidationError(`Invalid PR URL: ${options.prUrl}`);
|
|
116
|
+
}
|
|
117
|
+
const { owner, repo, number } = parsed;
|
|
118
|
+
// Fetch the PR + its files in parallel; the file list drives the
|
|
119
|
+
// tests check and the focused-changes check.
|
|
120
|
+
const [{ data: pr }, filesResponse, hasTestInfrastructure] = await Promise.all([
|
|
121
|
+
octokit.pulls.get({ owner, repo, pull_number: number }),
|
|
122
|
+
octokit.pulls.listFiles({ owner, repo, pull_number: number, per_page: 100 }),
|
|
123
|
+
detectTestInfrastructure(octokit, owner, repo),
|
|
124
|
+
]);
|
|
125
|
+
const meta = {
|
|
126
|
+
title: pr.title,
|
|
127
|
+
body: pr.body ?? '',
|
|
128
|
+
branch: pr.head.ref,
|
|
129
|
+
filesChangedCount: pr.changed_files,
|
|
130
|
+
additions: pr.additions,
|
|
131
|
+
deletions: pr.deletions,
|
|
132
|
+
files: filesResponse.data.map((f) => f.filename),
|
|
133
|
+
};
|
|
134
|
+
// Verify every linked issue/PR reference in the body (#1246 B) so
|
|
135
|
+
// `Closes #999` against a non-existent or stale issue fails loud
|
|
136
|
+
// instead of passing on the regex match.
|
|
137
|
+
const issueRefs = parseLinkedIssueReferences(meta.body, owner, repo);
|
|
138
|
+
const linkedIssues = issueRefs.length > 0 ? await verifyLinkedIssues(octokit, { owner, repo }, issueRefs) : undefined;
|
|
139
|
+
const repoContext = {
|
|
140
|
+
hasTestInfrastructure,
|
|
141
|
+
linkedIssues,
|
|
142
|
+
};
|
|
143
|
+
const result = computeComplianceScore(meta, repoContext);
|
|
144
|
+
return {
|
|
145
|
+
pr: {
|
|
146
|
+
repo: `${owner}/${repo}`,
|
|
147
|
+
number,
|
|
148
|
+
title: pr.title,
|
|
149
|
+
url: options.prUrl,
|
|
150
|
+
},
|
|
151
|
+
score: result.score,
|
|
152
|
+
rating: result.rating,
|
|
153
|
+
emoji: result.emoji,
|
|
154
|
+
checks: result.checks,
|
|
155
|
+
};
|
|
156
|
+
}
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export { runVet } from './vet.js';
|
|
|
29
29
|
export { runVetList } from './vet-list.js';
|
|
30
30
|
/** Fetch PR metadata from GitHub (informational; nothing is persisted). */
|
|
31
31
|
export { runTrack } from './track.js';
|
|
32
|
+
/** Score a PR against opensource.guide best practices via the typed core function (#1245). */
|
|
33
|
+
export { runComplianceScore } from './compliance-score.js';
|
|
34
|
+
/** Compute repo health rubric (1–10 score + verdict) via the typed core function (#1271, follow-up to #1242). */
|
|
35
|
+
export { runRepoVet } from './repo-vet.js';
|
|
32
36
|
/** Temporarily hide a PR from the daily digest. */
|
|
33
37
|
export { runShelve } from './shelve.js';
|
|
34
38
|
/** Restore a shelved PR to the daily digest. */
|
package/dist/commands/index.js
CHANGED
|
@@ -31,6 +31,10 @@ export { runVetList } from './vet-list.js';
|
|
|
31
31
|
// ── PR Management ───────────────────────────────────────────────────────────
|
|
32
32
|
/** Fetch PR metadata from GitHub (informational; nothing is persisted). */
|
|
33
33
|
export { runTrack } from './track.js';
|
|
34
|
+
/** Score a PR against opensource.guide best practices via the typed core function (#1245). */
|
|
35
|
+
export { runComplianceScore } from './compliance-score.js';
|
|
36
|
+
/** Compute repo health rubric (1–10 score + verdict) via the typed core function (#1271, follow-up to #1242). */
|
|
37
|
+
export { runRepoVet } from './repo-vet.js';
|
|
34
38
|
/** Temporarily hide a PR from the daily digest. */
|
|
35
39
|
export { runShelve } from './shelve.js';
|
|
36
40
|
/** Restore a shelved PR to the daily digest. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-mark-done command (#1299).
|
|
3
|
+
*
|
|
4
|
+
* Mark an issue line in a curated list as done by wrapping it in
|
|
5
|
+
* `~~strikethrough~~` and appending a `**Done** — PR [#N](url) ...` sub-bullet.
|
|
6
|
+
* If every issue under the same `### repo/name` heading is now struck through,
|
|
7
|
+
* also strikes through the heading. Replaces the markdown-prose strikethrough
|
|
8
|
+
* logic in `draft-first-workflow.md` Step 10.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: re-running with an already-marked URL is a no-op.
|
|
11
|
+
*
|
|
12
|
+
* No GitHub calls — pure read/transform/write of a local file.
|
|
13
|
+
*/
|
|
14
|
+
export interface MarkDoneOptions {
|
|
15
|
+
issueUrl: string;
|
|
16
|
+
prUrl: string;
|
|
17
|
+
/** Free-text trailing status, e.g. "CI passing", "merged". */
|
|
18
|
+
prStatus: string;
|
|
19
|
+
listPath: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MarkDoneOutput {
|
|
22
|
+
/** True if the line was found and updated. False on already-marked or not-found. */
|
|
23
|
+
marked: boolean;
|
|
24
|
+
/** Fully-resolved file path that was inspected. */
|
|
25
|
+
filePath: string;
|
|
26
|
+
/** The issue URL that was searched for. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** True if the parent `### repo/name` heading was also struck through this run. */
|
|
29
|
+
repoHeadingStruck: boolean;
|
|
30
|
+
/** Issue lines under the same repo heading that are still open after the update. */
|
|
31
|
+
remainingUnderRepo: number;
|
|
32
|
+
/** Human-readable explanation when `marked` is false. */
|
|
33
|
+
reason?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Pure transform — exposed for unit testing. */
|
|
36
|
+
export declare function markIssueAsDone(content: string, opts: {
|
|
37
|
+
issueUrl: string;
|
|
38
|
+
prUrl: string;
|
|
39
|
+
prStatus: string;
|
|
40
|
+
}): {
|
|
41
|
+
content: string;
|
|
42
|
+
marked: boolean;
|
|
43
|
+
repoHeadingStruck: boolean;
|
|
44
|
+
remainingUnderRepo: number;
|
|
45
|
+
reason?: string;
|
|
46
|
+
};
|
|
47
|
+
/** Read → transform → write atomically (tmp + rename) so a crash mid-write can't corrupt the file. */
|
|
48
|
+
export declare function runMarkIssueListItemDone(options: MarkDoneOptions): Promise<MarkDoneOutput>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-mark-done command (#1299).
|
|
3
|
+
*
|
|
4
|
+
* Mark an issue line in a curated list as done by wrapping it in
|
|
5
|
+
* `~~strikethrough~~` and appending a `**Done** — PR [#N](url) ...` sub-bullet.
|
|
6
|
+
* If every issue under the same `### repo/name` heading is now struck through,
|
|
7
|
+
* also strikes through the heading. Replaces the markdown-prose strikethrough
|
|
8
|
+
* logic in `draft-first-workflow.md` Step 10.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: re-running with an already-marked URL is a no-op.
|
|
11
|
+
*
|
|
12
|
+
* No GitHub calls — pure read/transform/write of a local file.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { errorMessage } from '../core/errors.js';
|
|
17
|
+
const STRIKE = '~~';
|
|
18
|
+
const DONE_PREFIX = ' - **Done**';
|
|
19
|
+
const ISSUE_LINE_RE = /^[*+-]\s/;
|
|
20
|
+
const REPO_HEADING_RE = /^###\s/;
|
|
21
|
+
const SECTION_BREAK_RE = /^#{1,3}\s/;
|
|
22
|
+
const PR_NUMBER_RE = /\/(?:pull|issues)\/(\d+)(?:[/?#]|$)/;
|
|
23
|
+
/** Extract a PR number from a GitHub PR URL, returning a string like "#42". */
|
|
24
|
+
function prNumberLabel(prUrl) {
|
|
25
|
+
const match = prUrl.match(PR_NUMBER_RE);
|
|
26
|
+
return match ? `#${match[1]}` : 'PR';
|
|
27
|
+
}
|
|
28
|
+
/** Wrap a line in `~~...~~` if it isn't already. Returns the input unchanged when already wrapped. */
|
|
29
|
+
function strikeLine(line) {
|
|
30
|
+
// Strip the leading list marker so we wrap the content, not the bullet.
|
|
31
|
+
const markerMatch = line.match(/^(?:[*+-]\s+|#{1,6}\s+)/);
|
|
32
|
+
const prefix = markerMatch ? markerMatch[0] : '';
|
|
33
|
+
const body = line.slice(prefix.length);
|
|
34
|
+
if (body.startsWith(STRIKE) && body.endsWith(STRIKE) && body.length >= 4) {
|
|
35
|
+
return { line, alreadyStruck: true };
|
|
36
|
+
}
|
|
37
|
+
return { line: `${prefix}${STRIKE}${body}${STRIKE}`, alreadyStruck: false };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Match the URL only when followed by a non-digit, non-word character (or
|
|
41
|
+
* end of line). A bare `includes(issueUrl)` would match `issues/1` against
|
|
42
|
+
* a line containing `issues/10`, so finding/marking issue 1 would mark
|
|
43
|
+
* whichever number-prefix line appears first.
|
|
44
|
+
*/
|
|
45
|
+
function lineMentionsUrl(line, issueUrl) {
|
|
46
|
+
const idx = line.indexOf(issueUrl);
|
|
47
|
+
if (idx === -1)
|
|
48
|
+
return false;
|
|
49
|
+
const next = line.charCodeAt(idx + issueUrl.length);
|
|
50
|
+
// NaN (end of string) → digit boundary OK. Otherwise reject any digit
|
|
51
|
+
// immediately after the URL so 'issues/1' doesn't match 'issues/10'.
|
|
52
|
+
return Number.isNaN(next) || next < 48 /* '0' */ || next > 57; /* '9' */
|
|
53
|
+
}
|
|
54
|
+
/** Find the issue block (issue line plus any indented sub-bullets) that mentions the URL. */
|
|
55
|
+
function findIssueBlock(lines, issueUrl) {
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
if (!ISSUE_LINE_RE.test(line))
|
|
59
|
+
continue;
|
|
60
|
+
if (!lineMentionsUrl(line, issueUrl))
|
|
61
|
+
continue;
|
|
62
|
+
let end = i + 1;
|
|
63
|
+
while (end < lines.length && /^\s{2,}/.test(lines[end]))
|
|
64
|
+
end++;
|
|
65
|
+
return { start: i, end };
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Find the `### repo/...` heading enclosing a given line index, plus its end (next heading at depth ≤ 3). */
|
|
70
|
+
function findRepoSection(lines, lineIndex) {
|
|
71
|
+
let headingIndex;
|
|
72
|
+
for (let i = lineIndex - 1; i >= 0; i--) {
|
|
73
|
+
if (REPO_HEADING_RE.test(lines[i])) {
|
|
74
|
+
headingIndex = i;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (/^#{1,2}\s/.test(lines[i]))
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (headingIndex === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
let end = lines.length;
|
|
83
|
+
for (let i = headingIndex + 1; i < lines.length; i++) {
|
|
84
|
+
if (SECTION_BREAK_RE.test(lines[i])) {
|
|
85
|
+
end = i;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { headingIndex, end };
|
|
90
|
+
}
|
|
91
|
+
/** Count issue lines under a section that are NOT yet struck through. */
|
|
92
|
+
function countOpenIssues(lines, section) {
|
|
93
|
+
let open = 0;
|
|
94
|
+
for (let i = section.headingIndex + 1; i < section.end; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
if (!ISSUE_LINE_RE.test(line))
|
|
97
|
+
continue;
|
|
98
|
+
const body = line.replace(/^[*+-]\s+/, '');
|
|
99
|
+
if (!(body.startsWith(STRIKE) && body.endsWith(STRIKE)))
|
|
100
|
+
open++;
|
|
101
|
+
}
|
|
102
|
+
return open;
|
|
103
|
+
}
|
|
104
|
+
/** Pure transform — exposed for unit testing. */
|
|
105
|
+
export function markIssueAsDone(content, opts) {
|
|
106
|
+
const hadTrailingNewline = content.endsWith('\n');
|
|
107
|
+
const lines = (hadTrailingNewline ? content.slice(0, -1) : content).split('\n');
|
|
108
|
+
const block = findIssueBlock(lines, opts.issueUrl);
|
|
109
|
+
if (!block) {
|
|
110
|
+
return {
|
|
111
|
+
content,
|
|
112
|
+
marked: false,
|
|
113
|
+
repoHeadingStruck: false,
|
|
114
|
+
remainingUnderRepo: 0,
|
|
115
|
+
reason: 'issue URL not found in the list',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const issueLine = lines[block.start];
|
|
119
|
+
const issueBody = issueLine.replace(/^[*+-]\s+/, '');
|
|
120
|
+
const alreadyMarked = issueBody.startsWith(STRIKE) && issueBody.endsWith(STRIKE);
|
|
121
|
+
if (alreadyMarked) {
|
|
122
|
+
const section = findRepoSection(lines, block.start);
|
|
123
|
+
return {
|
|
124
|
+
content,
|
|
125
|
+
marked: false,
|
|
126
|
+
repoHeadingStruck: false,
|
|
127
|
+
remainingUnderRepo: section ? countOpenIssues(lines, section) : 0,
|
|
128
|
+
reason: 'already marked done',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// 1. Strike the issue line.
|
|
132
|
+
const struck = strikeLine(issueLine);
|
|
133
|
+
lines[block.start] = struck.line;
|
|
134
|
+
// 2. Insert the **Done** sub-bullet after the issue line and any existing sub-bullets.
|
|
135
|
+
const doneSubBullet = `${DONE_PREFIX} — PR [${prNumberLabel(opts.prUrl)}](${opts.prUrl}) submitted, ${opts.prStatus}.`;
|
|
136
|
+
// If the previously-existing sub-bullets already contain a Done line, treat as idempotent.
|
|
137
|
+
const existingSubBullets = lines.slice(block.start + 1, block.end);
|
|
138
|
+
const hasDoneAlready = existingSubBullets.some((l) => l.trim().startsWith('- **Done**'));
|
|
139
|
+
if (!hasDoneAlready) {
|
|
140
|
+
lines.splice(block.end, 0, doneSubBullet);
|
|
141
|
+
}
|
|
142
|
+
// 3. If every issue under this repo heading is now struck, strike the heading too.
|
|
143
|
+
const section = findRepoSection(lines, block.start);
|
|
144
|
+
let repoHeadingStruck = false;
|
|
145
|
+
let remaining = 0;
|
|
146
|
+
if (section) {
|
|
147
|
+
// Recompute end since we may have inserted a sub-bullet.
|
|
148
|
+
const refreshed = findRepoSection(lines, block.start);
|
|
149
|
+
if (refreshed) {
|
|
150
|
+
remaining = countOpenIssues(lines, refreshed);
|
|
151
|
+
const headingLine = lines[refreshed.headingIndex];
|
|
152
|
+
const headingBody = headingLine.replace(/^#{3}\s+/, '');
|
|
153
|
+
const headingAlreadyStruck = headingBody.startsWith(STRIKE) && headingBody.endsWith(STRIKE);
|
|
154
|
+
if (remaining === 0 && !headingAlreadyStruck) {
|
|
155
|
+
const result = strikeLine(headingLine);
|
|
156
|
+
lines[refreshed.headingIndex] = result.line;
|
|
157
|
+
repoHeadingStruck = !result.alreadyStruck;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let next = lines.join('\n');
|
|
162
|
+
if (hadTrailingNewline)
|
|
163
|
+
next += '\n';
|
|
164
|
+
return {
|
|
165
|
+
content: next,
|
|
166
|
+
marked: true,
|
|
167
|
+
repoHeadingStruck,
|
|
168
|
+
remainingUnderRepo: remaining,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Read → transform → write atomically (tmp + rename) so a crash mid-write can't corrupt the file. */
|
|
172
|
+
export async function runMarkIssueListItemDone(options) {
|
|
173
|
+
const filePath = path.resolve(options.listPath);
|
|
174
|
+
if (!fs.existsSync(filePath)) {
|
|
175
|
+
throw new Error(`File not found: ${filePath}`);
|
|
176
|
+
}
|
|
177
|
+
let content;
|
|
178
|
+
try {
|
|
179
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw new Error(`Failed to read file: ${errorMessage(error)}`, { cause: error });
|
|
183
|
+
}
|
|
184
|
+
const result = markIssueAsDone(content, {
|
|
185
|
+
issueUrl: options.issueUrl,
|
|
186
|
+
prUrl: options.prUrl,
|
|
187
|
+
prStatus: options.prStatus,
|
|
188
|
+
});
|
|
189
|
+
if (result.marked) {
|
|
190
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
191
|
+
try {
|
|
192
|
+
fs.writeFileSync(tmp, result.content, 'utf8');
|
|
193
|
+
fs.renameSync(tmp, filePath);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
try {
|
|
197
|
+
fs.unlinkSync(tmp);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// best-effort cleanup
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
marked: result.marked,
|
|
207
|
+
filePath,
|
|
208
|
+
url: options.issueUrl,
|
|
209
|
+
repoHeadingStruck: result.repoHeadingStruck,
|
|
210
|
+
remainingUnderRepo: result.remainingUnderRepo,
|
|
211
|
+
reason: result.reason,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -53,13 +53,61 @@ function extractScore(line) {
|
|
|
53
53
|
const match = line.match(/Score\s+(\d+(?:\.\d+)?)\/10/i);
|
|
54
54
|
return match ? parseFloat(match[1]) : undefined;
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Extract the first bold span (`**...**`) from a line, trimmed.
|
|
58
|
+
* Anchoring status detection to the first bold span avoids false positives
|
|
59
|
+
* from explanatory tails that happen to bold an English verb (`...we should
|
|
60
|
+
* **hold** off until next quarter...`). Returns null when the line has no
|
|
61
|
+
* bold markup.
|
|
62
|
+
*/
|
|
63
|
+
function firstBoldSpan(line) {
|
|
64
|
+
const m = line.match(/\*\*([^*]+)\*\*/);
|
|
65
|
+
return m ? m[1].trim() : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if a sub-bullet's first bold span is a terminal status — completed,
|
|
69
|
+
* abandoned, or being held back from pursuit (#1179). Used by
|
|
70
|
+
* `parseIssueList` for in-memory bucketing.
|
|
71
|
+
*
|
|
72
|
+
* Curators' freshness-sweep vocabulary has grown beyond the original five
|
|
73
|
+
* keywords. `hold`, `continue watch`, and `downgrade` all mean "this is no
|
|
74
|
+
* longer in the actionable bucket" — different from "completed", but
|
|
75
|
+
* equivalent for the question `parseIssueList` answers
|
|
76
|
+
* ("which URLs are still pursuable?").
|
|
77
|
+
*
|
|
78
|
+
* `pruneIssueList` uses the stricter {@link isSubBulletDeletable} instead,
|
|
79
|
+
* since on-disk deletion must not silently remove items the curator
|
|
80
|
+
* explicitly parked.
|
|
81
|
+
*/
|
|
57
82
|
function isSubBulletTerminal(line) {
|
|
58
|
-
|
|
83
|
+
const kw = firstBoldSpan(line);
|
|
84
|
+
if (!kw)
|
|
85
|
+
return false;
|
|
86
|
+
return /^(?:Skip|Done|Dropped|Merged|Closed|Hold|Continue watch|Downgrade)$/i.test(kw);
|
|
59
87
|
}
|
|
60
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Check if a sub-bullet's first bold span is a "delete-from-disk" status.
|
|
90
|
+
* Strictly the original five keywords — `pruneIssueList` mutates the
|
|
91
|
+
* markdown file in place, and a curator's `**Hold**` annotation means
|
|
92
|
+
* "park", not "delete" (#1179).
|
|
93
|
+
*/
|
|
94
|
+
function isSubBulletDeletable(line) {
|
|
95
|
+
const kw = firstBoldSpan(line);
|
|
96
|
+
if (!kw)
|
|
97
|
+
return false;
|
|
98
|
+
return /^(?:Skip|Done|Dropped|Merged|Closed)$/i.test(kw);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check if a sub-bullet's first bold span is in-progress (not available,
|
|
102
|
+
* but NOT safe to prune). Includes the "decision pending" vocabulary
|
|
103
|
+
* curators use when an issue is being investigated but not yet bucketed
|
|
104
|
+
* (#1179).
|
|
105
|
+
*/
|
|
61
106
|
function isSubBulletInProgress(line) {
|
|
62
|
-
|
|
107
|
+
const kw = firstBoldSpan(line);
|
|
108
|
+
if (!kw)
|
|
109
|
+
return false;
|
|
110
|
+
return /^(?:In Progress|Wait|Waiting|Post findings first|Ship now|Viable not urgent)$/i.test(kw);
|
|
63
111
|
}
|
|
64
112
|
/** Parse a markdown string into structured issue items */
|
|
65
113
|
export function parseIssueList(content) {
|
|
@@ -122,11 +170,40 @@ export function parseIssueList(content) {
|
|
|
122
170
|
}
|
|
123
171
|
lastItem = item;
|
|
124
172
|
}
|
|
173
|
+
// Dedupe by URL across both buckets (#1179). Curated lists routinely
|
|
174
|
+
// mention the same issue in multiple sections (e.g. an item under
|
|
175
|
+
// "Pursue" plus a freshness-sweep entry under "Pending Vet"), and the
|
|
176
|
+
// raw per-line counts overstate availability. Rules:
|
|
177
|
+
// - If a URL appears in `completed[]` at all, it is filtered out of
|
|
178
|
+
// `available[]`. Curators add a terminal annotation (`**hold**`,
|
|
179
|
+
// `Done`, etc.) on a later occurrence precisely to override an
|
|
180
|
+
// earlier "pursue" line; the terminal classification wins.
|
|
181
|
+
// - Within each bucket, the first occurrence is kept (preserves the
|
|
182
|
+
// parse order and the tier of the original entry).
|
|
183
|
+
const completedUrls = new Set(completed.map((item) => item.url));
|
|
184
|
+
const dedupedAvailable = [];
|
|
185
|
+
const seenAvailable = new Set();
|
|
186
|
+
for (const item of available) {
|
|
187
|
+
if (completedUrls.has(item.url))
|
|
188
|
+
continue;
|
|
189
|
+
if (seenAvailable.has(item.url))
|
|
190
|
+
continue;
|
|
191
|
+
seenAvailable.add(item.url);
|
|
192
|
+
dedupedAvailable.push(item);
|
|
193
|
+
}
|
|
194
|
+
const dedupedCompleted = [];
|
|
195
|
+
const seenCompleted = new Set();
|
|
196
|
+
for (const item of completed) {
|
|
197
|
+
if (seenCompleted.has(item.url))
|
|
198
|
+
continue;
|
|
199
|
+
seenCompleted.add(item.url);
|
|
200
|
+
dedupedCompleted.push(item);
|
|
201
|
+
}
|
|
125
202
|
return {
|
|
126
|
-
available,
|
|
127
|
-
completed,
|
|
128
|
-
availableCount:
|
|
129
|
-
completedCount:
|
|
203
|
+
available: dedupedAvailable,
|
|
204
|
+
completed: dedupedCompleted,
|
|
205
|
+
availableCount: dedupedAvailable.length,
|
|
206
|
+
completedCount: dedupedCompleted.length,
|
|
130
207
|
};
|
|
131
208
|
}
|
|
132
209
|
/**
|
|
@@ -171,7 +248,7 @@ export function pruneIssueList(content, minScore = 6) {
|
|
|
171
248
|
let shouldRemove = false;
|
|
172
249
|
let j = i + 1;
|
|
173
250
|
while (j < lines.length && /^\s{2,}/.test(lines[j])) {
|
|
174
|
-
if (
|
|
251
|
+
if (isSubBulletDeletable(lines[j])) {
|
|
175
252
|
shouldRemove = true;
|
|
176
253
|
}
|
|
177
254
|
const score = extractScore(lines[j]);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repo-vet command (#1271, follow-up to #1242).
|
|
3
|
+
*
|
|
4
|
+
* Fetches repo health signals via the GitHub API, runs the typed
|
|
5
|
+
* `computeRepoVet` core function, and returns the structured result.
|
|
6
|
+
* Used by the `repo-evaluator` agent (and the future `repo-vet` MCP tool)
|
|
7
|
+
* to replace per-prompt rubric assembly with a deterministic, testable
|
|
8
|
+
* evaluation.
|
|
9
|
+
*
|
|
10
|
+
* Same architectural shape as `compliance-score`: read-only API calls,
|
|
11
|
+
* no state mutation, runs against a public `owner/repo` slug.
|
|
12
|
+
*/
|
|
13
|
+
import type { RepoVetOutput } from '../formatters/json.js';
|
|
14
|
+
/**
|
|
15
|
+
* Run the repo-vet evaluation against an `owner/repo` slug.
|
|
16
|
+
*
|
|
17
|
+
* @throws {ValidationError} If the repo identifier is malformed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runRepoVet(options: {
|
|
20
|
+
repo: string;
|
|
21
|
+
}): Promise<RepoVetOutput>;
|