@oss-autopilot/core 3.4.1 → 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.
Files changed (46) hide show
  1. package/dist/cli-registry.js +50 -0
  2. package/dist/cli.bundle.cjs +81 -78
  3. package/dist/commands/compliance-score.d.ts +21 -0
  4. package/dist/commands/compliance-score.js +156 -0
  5. package/dist/commands/index.d.ts +4 -0
  6. package/dist/commands/index.js +4 -0
  7. package/dist/commands/list-mark-done.d.ts +48 -0
  8. package/dist/commands/list-mark-done.js +213 -0
  9. package/dist/commands/parse-list.js +86 -9
  10. package/dist/commands/repo-vet.d.ts +21 -0
  11. package/dist/commands/repo-vet.js +215 -0
  12. package/dist/commands/startup.js +18 -0
  13. package/dist/core/ci-enforced-tools.d.ts +35 -0
  14. package/dist/core/ci-enforced-tools.js +109 -0
  15. package/dist/core/comment-decision.d.ts +72 -0
  16. package/dist/core/comment-decision.js +74 -0
  17. package/dist/core/compliance-score.d.ts +127 -0
  18. package/dist/core/compliance-score.js +277 -0
  19. package/dist/core/config-registry.js +12 -0
  20. package/dist/core/contributing.d.ts +52 -0
  21. package/dist/core/contributing.js +139 -0
  22. package/dist/core/extraction-categories.d.ts +55 -0
  23. package/dist/core/extraction-categories.js +108 -0
  24. package/dist/core/follow-up-history.d.ts +41 -0
  25. package/dist/core/follow-up-history.js +71 -0
  26. package/dist/core/gist-state-store.d.ts +30 -7
  27. package/dist/core/gist-state-store.js +87 -11
  28. package/dist/core/issue-conversation.js +1 -0
  29. package/dist/core/issue-effort.d.ts +29 -0
  30. package/dist/core/issue-effort.js +41 -0
  31. package/dist/core/maintainer-hints.d.ts +23 -0
  32. package/dist/core/maintainer-hints.js +36 -0
  33. package/dist/core/pr-quality-rubric.d.ts +70 -0
  34. package/dist/core/pr-quality-rubric.js +121 -0
  35. package/dist/core/repo-vet.d.ts +90 -0
  36. package/dist/core/repo-vet.js +178 -0
  37. package/dist/core/state-schema.d.ts +76 -0
  38. package/dist/core/state-schema.js +75 -0
  39. package/dist/core/strategy.d.ts +75 -0
  40. package/dist/core/strategy.js +226 -0
  41. package/dist/core/types.d.ts +2 -0
  42. package/dist/core/workflow-state.d.ts +56 -0
  43. package/dist/core/workflow-state.js +101 -0
  44. package/dist/formatters/json.d.ts +147 -0
  45. package/dist/formatters/json.js +79 -0
  46. package/package.json +1 -1
@@ -0,0 +1,215 @@
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 { getOctokit, requireGitHubToken } from '../core/index.js';
14
+ import { errorMessage } from '../core/errors.js';
15
+ import { warn } from '../core/logger.js';
16
+ import { validateRepoIdentifier } from './validation.js';
17
+ import { computeRepoVet } from '../core/repo-vet.js';
18
+ const MODULE = 'repo-vet';
19
+ const DAY_MS = 86400000;
20
+ const COMMUNITY_HEALTH_PATHS = [
21
+ // Each entry can live at the repo root OR under .github/. We probe both
22
+ // because some repos prefer the visible-at-root convention and some
23
+ // prefer the .github/ folder convention.
24
+ { key: 'hasContributing', candidates: ['CONTRIBUTING.md', '.github/CONTRIBUTING.md'] },
25
+ { key: 'hasIssueTemplates', candidates: ['.github/ISSUE_TEMPLATE'] },
26
+ {
27
+ key: 'hasPRTemplate',
28
+ candidates: ['.github/pull_request_template.md', 'pull_request_template.md', 'PULL_REQUEST_TEMPLATE.md'],
29
+ },
30
+ { key: 'hasCodeOfConduct', candidates: ['CODE_OF_CONDUCT.md', '.github/CODE_OF_CONDUCT.md'] },
31
+ ];
32
+ /**
33
+ * Returns true when the path exists. Treats 404 as "absent" (the only
34
+ * shape we care about for community-health flags). Re-throws everything
35
+ * else — auth/rate-limit failures must not silently scrub the
36
+ * `hasContributing` etc. flags to `false`, which would understate a
37
+ * repo's community health on a token that lacks scope or has been
38
+ * throttled mid-sequence.
39
+ */
40
+ async function probePath(octokit, owner, repo, path) {
41
+ try {
42
+ await octokit.repos.getContent({ owner, repo, path });
43
+ return true;
44
+ }
45
+ catch (err) {
46
+ const status = err.status;
47
+ if (status === 404)
48
+ return false;
49
+ throw err;
50
+ }
51
+ }
52
+ /**
53
+ * Probe each community-health path. Treats 404 as "absent" (a definitive
54
+ * signal). On any other error (auth, rate-limit, 5xx) the per-key result
55
+ * stays `false` AND the whole-function `incomplete` flag is set so the
56
+ * caller knows community-health was unverified rather than confirmed-absent.
57
+ *
58
+ * This is the per-key analogue of the call-level catch: instead of either
59
+ * silently misclassifying 403 → absent OR aborting the entire repo-vet
60
+ * call, we degrade gracefully and surface the gap.
61
+ */
62
+ async function checkCommunityHealth(octokit, owner, repo) {
63
+ const out = {
64
+ hasContributing: false,
65
+ hasIssueTemplates: false,
66
+ hasPRTemplate: false,
67
+ hasCodeOfConduct: false,
68
+ };
69
+ let incomplete = false;
70
+ await Promise.all(COMMUNITY_HEALTH_PATHS.map(async ({ key, candidates }) => {
71
+ let sawError = false;
72
+ for (const path of candidates) {
73
+ try {
74
+ if (await probePath(octokit, owner, repo, path)) {
75
+ out[key] = true;
76
+ return;
77
+ }
78
+ }
79
+ catch (err) {
80
+ // probePath only throws on non-404 errors. Don't abandon
81
+ // remaining candidates — `CONTRIBUTING.md` failing with 403
82
+ // does NOT imply `.github/CONTRIBUTING.md` will fail too. Mark
83
+ // the per-key probe as incomplete only if every candidate
84
+ // either errored or returned absent.
85
+ warn(MODULE, `community-health probe for ${owner}/${repo}/${path} failed: ${errorMessage(err)}`);
86
+ sawError = true;
87
+ continue;
88
+ }
89
+ }
90
+ // Reached only when no candidate hit. If any candidate errored,
91
+ // the absent flag is unreliable — surface that to the caller so
92
+ // the agent prompt distinguishes "probed all candidates and
93
+ // absent" from "couldn't tell".
94
+ if (sawError)
95
+ incomplete = true;
96
+ }));
97
+ return {
98
+ hasContributing: out.hasContributing,
99
+ hasIssueTemplates: out.hasIssueTemplates,
100
+ hasPRTemplate: out.hasPRTemplate,
101
+ hasCodeOfConduct: out.hasCodeOfConduct,
102
+ incomplete,
103
+ };
104
+ }
105
+ function summarizePRMerges(prs, windowDays, now) {
106
+ const cutoff = now.getTime() - windowDays * DAY_MS;
107
+ const prMergeTimesDays = [];
108
+ let mergedCount = 0;
109
+ let openedCount = 0;
110
+ for (const pr of prs) {
111
+ const createdAt = new Date(pr.created_at).getTime();
112
+ if (createdAt >= cutoff)
113
+ openedCount += 1;
114
+ if (pr.merged_at) {
115
+ const mergedAt = new Date(pr.merged_at).getTime();
116
+ if (mergedAt >= cutoff) {
117
+ mergedCount += 1;
118
+ const days = (mergedAt - createdAt) / DAY_MS;
119
+ if (Number.isFinite(days) && days >= 0)
120
+ prMergeTimesDays.push(days);
121
+ }
122
+ }
123
+ }
124
+ return { prMergeTimesDays, mergedCount, openedCount };
125
+ }
126
+ function summarizeCommits(commits, now) {
127
+ const cutoff30 = now.getTime() - 30 * DAY_MS;
128
+ const cutoff90 = now.getTime() - 90 * DAY_MS;
129
+ const contributors90 = new Set();
130
+ let commitsLast30Days = 0;
131
+ let lastCommitMs = null;
132
+ for (const c of commits) {
133
+ const dateStr = c.commit.author?.date;
134
+ if (!dateStr)
135
+ continue;
136
+ const ts = new Date(dateStr).getTime();
137
+ if (Number.isNaN(ts))
138
+ continue;
139
+ if (lastCommitMs === null || ts > lastCommitMs)
140
+ lastCommitMs = ts;
141
+ if (ts >= cutoff30)
142
+ commitsLast30Days += 1;
143
+ if (ts >= cutoff90 && c.author?.login)
144
+ contributors90.add(c.author.login);
145
+ }
146
+ return {
147
+ commitsLast30Days,
148
+ contributorsLast90d: contributors90.size,
149
+ lastCommitISO: lastCommitMs ? new Date(lastCommitMs).toISOString() : null,
150
+ };
151
+ }
152
+ /**
153
+ * Run the repo-vet evaluation against an `owner/repo` slug.
154
+ *
155
+ * @throws {ValidationError} If the repo identifier is malformed.
156
+ */
157
+ export async function runRepoVet(options) {
158
+ validateRepoIdentifier(options.repo);
159
+ const [owner, repo] = options.repo.split('/');
160
+ const token = requireGitHubToken();
161
+ const octokit = getOctokit(token);
162
+ const now = new Date();
163
+ const [repoMetaResp, closedPRsResp, commitsResp, releasesResp, communityHealthSummary] = await Promise.all([
164
+ octokit.repos.get({ owner, repo }),
165
+ octokit.pulls.list({ owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 100 }),
166
+ octokit.repos.listCommits({ owner, repo, per_page: 100 }),
167
+ octokit.repos
168
+ .listReleases({ owner, repo, per_page: 1 })
169
+ .catch(() => ({ data: [] })),
170
+ checkCommunityHealth(octokit, owner, repo),
171
+ ]);
172
+ const prs = closedPRsResp.data.map((p) => ({
173
+ created_at: p.created_at,
174
+ merged_at: p.merged_at,
175
+ updated_at: p.updated_at,
176
+ }));
177
+ const { prMergeTimesDays, mergedCount, openedCount } = summarizePRMerges(prs, 90, now);
178
+ const commitSummary = summarizeCommits(commitsResp.data, now);
179
+ const releases = releasesResp.data;
180
+ const lastReleaseISO = releases.length > 0 ? (releases[0].published_at ?? releases[0].created_at ?? null) : null;
181
+ const input = {
182
+ stars: repoMetaResp.data.stargazers_count ?? 0,
183
+ forks: repoMetaResp.data.forks_count ?? 0,
184
+ openIssues: repoMetaResp.data.open_issues_count ?? 0,
185
+ watchers: repoMetaResp.data.subscribers_count ?? 0,
186
+ isArchived: repoMetaResp.data.archived ?? false,
187
+ lastPushed: repoMetaResp.data.pushed_at ?? new Date(0).toISOString(),
188
+ createdAt: repoMetaResp.data.created_at ?? new Date(0).toISOString(),
189
+ commitsLast30Days: commitSummary.commitsLast30Days,
190
+ prMergeTimesDays,
191
+ mergedCount90Days: mergedCount,
192
+ openedCount90Days: openedCount,
193
+ lastCommitISO: commitSummary.lastCommitISO,
194
+ contributorsLast90d: commitSummary.contributorsLast90d,
195
+ lastReleaseISO,
196
+ hasContributing: communityHealthSummary.hasContributing,
197
+ hasIssueTemplates: communityHealthSummary.hasIssueTemplates,
198
+ hasPRTemplate: communityHealthSummary.hasPRTemplate,
199
+ hasCodeOfConduct: communityHealthSummary.hasCodeOfConduct,
200
+ };
201
+ const result = computeRepoVet(input);
202
+ // The core function names its metadata object `repo`. Rename to `repoMeta`
203
+ // at the CLI boundary so the top-level slug doesn't collide with it.
204
+ // Also overlay the community-health `incomplete` flag the wrapper
205
+ // tracks (the core type doesn't carry it because computeRepoVet is
206
+ // pure — only the wrapper makes the API calls that can fail mid-probe).
207
+ const { repo: repoMeta, communityHealth, ...rest } = result;
208
+ return {
209
+ repoSlug: options.repo,
210
+ fetchedAt: now.toISOString(),
211
+ repoMeta,
212
+ communityHealth: { ...communityHealth, incomplete: communityHealthSummary.incomplete },
213
+ ...rest,
214
+ };
215
+ }
@@ -277,6 +277,22 @@ export async function runStartup() {
277
277
  }
278
278
  // 5. Detect issue list
279
279
  const issueList = detectIssueList();
280
+ // 6. Pass through dashboard-build status set by the workflow shell (#1293).
281
+ // The workflow runs the SPA build BEFORE invoking startup, so the status
282
+ // arrives here via env vars rather than being something runStartup itself
283
+ // computes. Validate the env value so a malformed export can't sneak an
284
+ // arbitrary string into a typed enum.
285
+ const rawBuildStatus = process.env.OSS_DASHBOARD_BUILD_STATUS;
286
+ const dashboardBuildStatus = rawBuildStatus === 'fresh' ||
287
+ rawBuildStatus === 'rebuilt' ||
288
+ rawBuildStatus === 'failed' ||
289
+ rawBuildStatus === 'missing-pnpm'
290
+ ? rawBuildStatus
291
+ : undefined;
292
+ const rawBuildErrorTail = process.env.OSS_DASHBOARD_BUILD_ERROR_TAIL;
293
+ const dashboardBuildErrorTail = (dashboardBuildStatus === 'failed' || dashboardBuildStatus === 'missing-pnpm') && rawBuildErrorTail
294
+ ? rawBuildErrorTail
295
+ : undefined;
280
296
  return {
281
297
  version,
282
298
  setupComplete: true,
@@ -284,6 +300,8 @@ export async function runStartup() {
284
300
  daily,
285
301
  dashboardUrl,
286
302
  dashboardError,
303
+ dashboardBuildStatus,
304
+ dashboardBuildErrorTail,
287
305
  issueList,
288
306
  };
289
307
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CI-enforced tool detection (#1286).
3
+ *
4
+ * Extracted from `workflows/pre-commit-review.md` Steps 2b and 6a so
5
+ * the same logic isn't reimplemented twice in markdown. Callers
6
+ * supply pre-fetched config snippets (the workflow runs `cat` /
7
+ * `head` against the well-known files); this function does the
8
+ * structured parse.
9
+ *
10
+ * Pure typed helper — no I/O.
11
+ */
12
+ export type CIToolName = 'lint' | 'format' | 'typecheck' | 'test' | 'build' | 'commit-format' | 'security-scan';
13
+ export interface CIEnforcedToolsInput {
14
+ /** `.pre-commit-config.yaml` contents, or null when the file is missing. */
15
+ preCommitConfigYaml: string | null;
16
+ /** Concatenated contents of `.github/workflows/*.yml` (or null when none). */
17
+ workflowYamls: string | null;
18
+ /** `Makefile` contents, or null when missing. */
19
+ makefile: string | null;
20
+ /** `package.json` contents — used to detect script names. */
21
+ packageJson: string | null;
22
+ }
23
+ export interface CIEnforcedTool {
24
+ tool: CIToolName;
25
+ source: 'pre-commit' | 'github-workflow' | 'makefile' | 'package-json';
26
+ /** Short snippet that surfaced this tool — useful for explainability. */
27
+ evidence: string;
28
+ }
29
+ /**
30
+ * Detect which class of tool each input source enforces. The output
31
+ * may contain duplicates (e.g., `test` enforced by both pre-commit
32
+ * and a GitHub workflow); callers can dedupe on `tool` or display
33
+ * each evidence pair separately.
34
+ */
35
+ export declare function getCIEnforcedTools(input: CIEnforcedToolsInput): CIEnforcedTool[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * CI-enforced tool detection (#1286).
3
+ *
4
+ * Extracted from `workflows/pre-commit-review.md` Steps 2b and 6a so
5
+ * the same logic isn't reimplemented twice in markdown. Callers
6
+ * supply pre-fetched config snippets (the workflow runs `cat` /
7
+ * `head` against the well-known files); this function does the
8
+ * structured parse.
9
+ *
10
+ * Pure typed helper — no I/O.
11
+ */
12
+ /**
13
+ * Detect which class of tool each input source enforces. The output
14
+ * may contain duplicates (e.g., `test` enforced by both pre-commit
15
+ * and a GitHub workflow); callers can dedupe on `tool` or display
16
+ * each evidence pair separately.
17
+ */
18
+ export function getCIEnforcedTools(input) {
19
+ const out = [];
20
+ if (input.preCommitConfigYaml) {
21
+ detectFromHaystack(input.preCommitConfigYaml, 'pre-commit', out);
22
+ }
23
+ if (input.workflowYamls) {
24
+ detectFromHaystack(input.workflowYamls, 'github-workflow', out);
25
+ }
26
+ if (input.makefile) {
27
+ detectFromHaystack(input.makefile, 'makefile', out);
28
+ }
29
+ if (input.packageJson) {
30
+ detectScriptsFromPackageJson(input.packageJson, out);
31
+ }
32
+ return out;
33
+ }
34
+ function detectFromHaystack(haystack, source, out) {
35
+ const lower = haystack.toLowerCase();
36
+ const checks = [
37
+ {
38
+ tool: 'lint',
39
+ pattern: /\b(?:eslint|biome|ruff|flake8|rubocop|golangci-lint|clippy|pylint)\b/i,
40
+ evidenceFor: ['eslint', 'biome', 'ruff', 'flake8', 'rubocop', 'golangci-lint', 'clippy', 'pylint'],
41
+ },
42
+ {
43
+ tool: 'format',
44
+ pattern: /\b(?:prettier|biome[\s-]+format|black|gofmt|rustfmt|clang-format)\b/i,
45
+ evidenceFor: ['prettier', 'biome', 'black', 'gofmt', 'rustfmt', 'clang-format'],
46
+ },
47
+ {
48
+ tool: 'typecheck',
49
+ pattern: /\b(?:tsc|mypy|sorbet|pyright|flow)\b/i,
50
+ evidenceFor: ['tsc', 'mypy', 'sorbet', 'pyright', 'flow'],
51
+ },
52
+ {
53
+ tool: 'test',
54
+ pattern: /\b(?:vitest|jest|pytest|rspec|go test|cargo test|mocha)\b/i,
55
+ evidenceFor: ['vitest', 'jest', 'pytest', 'rspec', 'go test', 'cargo test', 'mocha'],
56
+ },
57
+ {
58
+ // `tsc` alone (or `tsc --noEmit`) is a typecheck, not a build artifact
59
+ // step — keep it out of this pattern so a workflow with only
60
+ // `tsc --noEmit` doesn't fire BOTH `typecheck` and `build`. Real
61
+ // build steps that do produce artifacts (tsup, esbuild, webpack,
62
+ // cargo build, go build) stay here.
63
+ tool: 'build',
64
+ pattern: /\b(?:tsup|esbuild|webpack|cargo build|go build)\b/i,
65
+ evidenceFor: ['tsup', 'esbuild', 'webpack', 'cargo build', 'go build'],
66
+ },
67
+ {
68
+ tool: 'commit-format',
69
+ pattern: /\b(?:commitlint|conventional-commits)\b/i,
70
+ evidenceFor: ['commitlint', 'conventional-commits'],
71
+ },
72
+ {
73
+ tool: 'security-scan',
74
+ pattern: /\b(?:codeql|semgrep|trivy|gitleaks|snyk)\b/i,
75
+ evidenceFor: ['codeql', 'semgrep', 'trivy', 'gitleaks', 'snyk'],
76
+ },
77
+ ];
78
+ for (const c of checks) {
79
+ if (c.pattern.test(lower)) {
80
+ const evidence = c.evidenceFor.find((e) => lower.includes(e)) ?? c.tool;
81
+ out.push({ tool: c.tool, source, evidence });
82
+ }
83
+ }
84
+ }
85
+ function detectScriptsFromPackageJson(packageJson, out) {
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(packageJson);
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ const scripts = parsed.scripts ?? {};
94
+ const scriptToTool = [
95
+ { key: /^lint(:|$)/, tool: 'lint' },
96
+ { key: /^format(:|$)/, tool: 'format' },
97
+ { key: /^typecheck(:|$)|^tsc(:|$)/, tool: 'typecheck' },
98
+ { key: /^test(:|$)/, tool: 'test' },
99
+ { key: /^build(:|$)/, tool: 'build' },
100
+ ];
101
+ for (const [name] of Object.entries(scripts)) {
102
+ for (const m of scriptToTool) {
103
+ if (m.key.test(name)) {
104
+ out.push({ tool: m.tool, source: 'package-json', evidence: `scripts.${name}` });
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Post-push comment decision (#1286).
3
+ *
4
+ * Centralizes the "should I draft a comment, or does the diff speak
5
+ * for itself?" rule that was duplicated between
6
+ * `workflows/pre-commit-review.md` Step 7a and `agents/pr-responder.md`
7
+ * Step 3.
8
+ *
9
+ * Pure typed helper — no LLM, no I/O. Callers do the upstream natural-
10
+ * language classification (mapping a maintainer comment to a structured
11
+ * `FeedbackCategory` + flags) and pass in the result. This module owns
12
+ * the rule that decides skip vs. draft from the structured input.
13
+ *
14
+ * Same architectural shape as #1245 (compliance-score), #1242 (repo-vet),
15
+ * #1243 (strategy), #1252 (pr-quality-rubric), and #1264 (issue-effort /
16
+ * maintainer-hints).
17
+ */
18
+ export type FeedbackCategory = 'code_request' | 'question' | 'explanation_request' | 'style_request' | 'design_discussion' | 'approval_with_nit' | 'formatting_complaint';
19
+ export interface FeedbackClassification {
20
+ category: FeedbackCategory;
21
+ /**
22
+ * Whether this individual feedback item, considered in isolation,
23
+ * requires a drafted comment. Aggregated by `shouldDraftResponse`
24
+ * across the full feedback list.
25
+ */
26
+ needsComment: boolean;
27
+ /** Short rationale rendered alongside the decision. */
28
+ reason: string;
29
+ }
30
+ export interface CommentDecisionInput {
31
+ /**
32
+ * Pre-classified feedback items in chronological order. The caller
33
+ * (agent or workflow) is responsible for the natural-language
34
+ * categorization step; this function only consumes structured input.
35
+ */
36
+ feedback: readonly FeedbackClassification[];
37
+ /**
38
+ * True when the contributor pushed code that addresses every
39
+ * feedback item that requested a code change. The agent's diff
40
+ * inspection determines this; the function trusts the boolean.
41
+ */
42
+ allRequestedChangesAddressed: boolean;
43
+ }
44
+ export interface CommentDecisionResult {
45
+ shouldDraft: boolean;
46
+ reason: string;
47
+ }
48
+ /**
49
+ * Decide whether the contributor should draft a comment after a push.
50
+ *
51
+ * Rule (sourced verbatim from `workflows/pre-commit-review.md` Step 7a
52
+ * and `agents/pr-responder.md` "Comment Decision Logic"):
53
+ *
54
+ * - Skip the comment entirely when ALL of these are true:
55
+ * 1. Every piece of maintainer feedback that requested code changes
56
+ * has a corresponding code change.
57
+ * 2. No question was asked.
58
+ * 3. Nothing was intentionally left unchanged (a "yes the maintainer
59
+ * asked but I did not change it" item always needs explanation).
60
+ * 4. The diff makes the fix self-evident — modeled here as the
61
+ * `allRequestedChangesAddressed` flag the caller passes in.
62
+ *
63
+ * - Draft a comment if ANY of these are true:
64
+ * 1. The maintainer asked a question.
65
+ * 2. The feedback is conceptual or design-level (the diff alone
66
+ * cannot answer "why").
67
+ * 3. Something was intentionally left unchanged (`needsComment: true`
68
+ * on a code-request item where the contributor disagrees).
69
+ * 4. Only some of multiple requested changes were addressed.
70
+ * 5. The contributor deviated from exactly what was asked.
71
+ */
72
+ export declare function shouldDraftResponse(input: CommentDecisionInput): CommentDecisionResult;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Post-push comment decision (#1286).
3
+ *
4
+ * Centralizes the "should I draft a comment, or does the diff speak
5
+ * for itself?" rule that was duplicated between
6
+ * `workflows/pre-commit-review.md` Step 7a and `agents/pr-responder.md`
7
+ * Step 3.
8
+ *
9
+ * Pure typed helper — no LLM, no I/O. Callers do the upstream natural-
10
+ * language classification (mapping a maintainer comment to a structured
11
+ * `FeedbackCategory` + flags) and pass in the result. This module owns
12
+ * the rule that decides skip vs. draft from the structured input.
13
+ *
14
+ * Same architectural shape as #1245 (compliance-score), #1242 (repo-vet),
15
+ * #1243 (strategy), #1252 (pr-quality-rubric), and #1264 (issue-effort /
16
+ * maintainer-hints).
17
+ */
18
+ /**
19
+ * Decide whether the contributor should draft a comment after a push.
20
+ *
21
+ * Rule (sourced verbatim from `workflows/pre-commit-review.md` Step 7a
22
+ * and `agents/pr-responder.md` "Comment Decision Logic"):
23
+ *
24
+ * - Skip the comment entirely when ALL of these are true:
25
+ * 1. Every piece of maintainer feedback that requested code changes
26
+ * has a corresponding code change.
27
+ * 2. No question was asked.
28
+ * 3. Nothing was intentionally left unchanged (a "yes the maintainer
29
+ * asked but I did not change it" item always needs explanation).
30
+ * 4. The diff makes the fix self-evident — modeled here as the
31
+ * `allRequestedChangesAddressed` flag the caller passes in.
32
+ *
33
+ * - Draft a comment if ANY of these are true:
34
+ * 1. The maintainer asked a question.
35
+ * 2. The feedback is conceptual or design-level (the diff alone
36
+ * cannot answer "why").
37
+ * 3. Something was intentionally left unchanged (`needsComment: true`
38
+ * on a code-request item where the contributor disagrees).
39
+ * 4. Only some of multiple requested changes were addressed.
40
+ * 5. The contributor deviated from exactly what was asked.
41
+ */
42
+ export function shouldDraftResponse(input) {
43
+ if (input.feedback.length === 0) {
44
+ return { shouldDraft: false, reason: 'no maintainer feedback to address' };
45
+ }
46
+ // Categories that always demand a written reply, regardless of the
47
+ // diff. The diff cannot answer a question or carry a design decision.
48
+ const alwaysDraftCategories = new Set(['question', 'explanation_request', 'design_discussion']);
49
+ for (const item of input.feedback) {
50
+ if (alwaysDraftCategories.has(item.category)) {
51
+ return {
52
+ shouldDraft: true,
53
+ reason: `${item.category.replace(/_/g, ' ')} — diff alone cannot address this`,
54
+ };
55
+ }
56
+ }
57
+ // Caller flagged at least one item as "I'm intentionally not making
58
+ // this change" or "I deviated" — needs an explanation either way.
59
+ const intentionalGap = input.feedback.find((item) => item.needsComment);
60
+ if (intentionalGap) {
61
+ return {
62
+ shouldDraft: true,
63
+ reason: `requires written explanation: ${intentionalGap.reason}`,
64
+ };
65
+ }
66
+ // Caller signaled the diff covers everything that was requested.
67
+ if (input.allRequestedChangesAddressed) {
68
+ return { shouldDraft: false, reason: 'all requested changes are visible in the diff' };
69
+ }
70
+ return {
71
+ shouldDraft: true,
72
+ reason: 'not all requested changes are visible in the diff yet',
73
+ };
74
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * PR compliance scoring (#1245).
3
+ *
4
+ * Extracted from `agents/pr-compliance-checker.md`'s in-prompt scoring
5
+ * tables so the weights, thresholds, and per-check rules are
6
+ * deterministic, unit-testable, and tunable without editing markdown.
7
+ * Same architectural shape as success-grade (#858), linked-PR
8
+ * classifier (#910), and anti-AI scan (#911).
9
+ *
10
+ * The function intentionally does not fetch PR data — callers (the MCP
11
+ * tool, the CLI command, the agent) supply pre-fetched metadata so the
12
+ * score is reproducible against fixture data and the same input shape
13
+ * works for both live PRs and historical replay.
14
+ */
15
+ export type ComplianceCheckStatus = 'pass' | 'warn' | 'fail';
16
+ export interface ComplianceCheckResult {
17
+ status: ComplianceCheckStatus;
18
+ weight: number;
19
+ detail: string;
20
+ }
21
+ export type ComplianceRating = 'ready' | 'minor' | 'fix_first' | 'significant_work';
22
+ /** Emoji surfaced alongside the rating in agent output. */
23
+ export type ComplianceEmoji = '🌟' | '✅' | '⚠️' | '❌';
24
+ export interface ComplianceScoreResult {
25
+ /** 0–100 weighted score across the six checks. */
26
+ score: number;
27
+ rating: ComplianceRating;
28
+ emoji: ComplianceEmoji;
29
+ checks: {
30
+ issueReference: ComplianceCheckResult;
31
+ description: ComplianceCheckResult;
32
+ focusedChanges: ComplianceCheckResult;
33
+ tests: ComplianceCheckResult;
34
+ title: ComplianceCheckResult;
35
+ branch: ComplianceCheckResult;
36
+ };
37
+ }
38
+ /** Minimum PR metadata required to compute a compliance score. */
39
+ export interface PRMetadata {
40
+ title: string;
41
+ body: string;
42
+ branch: string;
43
+ filesChangedCount: number;
44
+ additions: number;
45
+ deletions: number;
46
+ /**
47
+ * Filenames touched by the PR. Used by the test-detection check to
48
+ * decide whether the PR includes a test file.
49
+ */
50
+ files: string[];
51
+ }
52
+ /**
53
+ * Verified state of an issue referenced from a PR body. Populated by
54
+ * the compliance-score command (which calls the Issues API per
55
+ * reference) and consumed by `checkIssueReference` to fail loud on
56
+ * broken links (#1246 Improvement B).
57
+ */
58
+ export interface LinkedIssueInfo {
59
+ /** Issue number parsed from the PR body. */
60
+ number: number;
61
+ /** Owner/repo where the issue lives — may differ from the PR's repo
62
+ * when a cross-repo reference like `owner/other#42` is used. */
63
+ repo: string;
64
+ /** True when the reference targeted a different repo than the PR. */
65
+ crossRepo: boolean;
66
+ /**
67
+ * Result of the verification API call.
68
+ * - `open` / `closed`: confirmed live state.
69
+ * - `not_found`: HTTP 404 — the referenced issue does not exist.
70
+ * - `unverifiable`: a non-404 failure (rate-limit, 5xx, network).
71
+ * The check treats this neutrally rather than as a broken link, so a
72
+ * GitHub API hiccup doesn't downgrade a valid PR's compliance score.
73
+ */
74
+ state: 'open' | 'closed' | 'not_found' | 'unverifiable';
75
+ /** Whole days since the issue was closed, when state === 'closed'.
76
+ * Used to distinguish "recently closed, may still apply" from "long
77
+ * stale, almost certainly the wrong reference." */
78
+ closedDaysAgo?: number;
79
+ }
80
+ /**
81
+ * Optional repo context used to fine-tune individual check thresholds
82
+ * (#1245). All fields are optional; absent fields use safe defaults that
83
+ * match the original in-prompt rules.
84
+ */
85
+ export interface RepoContext {
86
+ /**
87
+ * Whether the target repo has any visible test infrastructure
88
+ * (`test/`, `tests/`, `__tests__/`, `spec/`, etc.). When `false`, the
89
+ * tests check downgrades from `fail` to `warn` because tests aren't
90
+ * required by the project.
91
+ */
92
+ hasTestInfrastructure?: boolean;
93
+ /**
94
+ * Verified state of every issue/PR reference found in the PR body
95
+ * (#1246 Improvement B). When provided, `checkIssueReference` will
96
+ * fail-loud on broken or stale references rather than passing on the
97
+ * regex match alone. Absent / empty array preserves original
98
+ * regex-only behavior.
99
+ */
100
+ linkedIssues?: LinkedIssueInfo[];
101
+ }
102
+ /**
103
+ * After how many days a closed-issue reference flips from "warn"
104
+ * (probably still relevant) to "fail" (probably stale). Exported so
105
+ * callers can document the cutoff (#1246).
106
+ */
107
+ export declare const CLOSED_ISSUE_RECENT_DAYS = 30;
108
+ /** Title byte budget — Conventional Commits style fits comfortably under 72. */
109
+ export { TITLE_LENGTH_BUDGET } from './pr-quality-rubric.js';
110
+ /** "Focused changes" thresholds. Source of truth lives in pr-quality-rubric.ts. */
111
+ export declare const FOCUSED_CHANGES: {
112
+ readonly passFiles: 10;
113
+ readonly passLines: 400;
114
+ readonly warnFiles: 20;
115
+ readonly warnLines: 800;
116
+ };
117
+ /** Score → rating cutoffs. */
118
+ export declare const RATING_CUTOFFS: {
119
+ readonly ready: 90;
120
+ readonly minor: 75;
121
+ readonly fixFirst: 60;
122
+ };
123
+ /**
124
+ * Compute a compliance score from PR metadata, optionally fine-tuned by
125
+ * repo context (#1245). Pure function — no I/O, no global state.
126
+ */
127
+ export declare function computeComplianceScore(meta: PRMetadata, repoContext?: RepoContext): ComplianceScoreResult;