@oss-autopilot/core 1.15.0 → 1.15.1

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.
@@ -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
- shelvedPRUrls: state.config.shelvedPRUrls || [],
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 || [],
@@ -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
  });
@@ -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
+ }
@@ -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';
@@ -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;
@@ -0,0 +1,146 @@
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
+ const SEVERITY = { A: 0, B: 1, C: 2, F: 3 };
14
+ const LETTERS = ['A', 'B', 'C', 'F'];
15
+ function worst(grades) {
16
+ return grades.reduce((acc, g) => (SEVERITY[g.letter] > SEVERITY[acc.letter] ? g : acc));
17
+ }
18
+ function degradeOneStep(letter) {
19
+ return LETTERS[Math.min(SEVERITY[letter] + 1, LETTERS.length - 1)];
20
+ }
21
+ /**
22
+ * Coerce a candidate numeric signal to `number` if it's finite and in
23
+ * the given range, otherwise `null`. Non-finite, NaN, and out-of-range
24
+ * values are treated as unknown (and therefore trigger the degrade
25
+ * rule) rather than silently producing bogus grades from garbage input.
26
+ */
27
+ function sanitize(value, min, max) {
28
+ if (typeof value !== 'number' || !Number.isFinite(value))
29
+ return null;
30
+ if (value < min || value > max)
31
+ return null;
32
+ return value;
33
+ }
34
+ function gradeResponsiveness(avgResponseDays) {
35
+ if (avgResponseDays < 3)
36
+ return { letter: 'A', detail: `~${Math.round(avgResponseDays)}-day avg response` };
37
+ if (avgResponseDays < 14)
38
+ return { letter: 'B', detail: `${Math.round(avgResponseDays)}-day avg response` };
39
+ if (avgResponseDays <= 60)
40
+ return { letter: 'C', detail: `${Math.round(avgResponseDays)}-day avg response` };
41
+ return { letter: 'F', detail: 'unresponsive maintainers' };
42
+ }
43
+ function gradeMergeRate(mergeRate) {
44
+ const pct = Math.round(mergeRate * 100);
45
+ if (mergeRate > 0.7)
46
+ return { letter: 'A', detail: `merges ${pct}% of PRs` };
47
+ if (mergeRate >= 0.4)
48
+ return { letter: 'B', detail: `merges ${pct}% of PRs` };
49
+ if (mergeRate >= 0.1)
50
+ return { letter: 'C', detail: `merges ${pct}% of PRs` };
51
+ return { letter: 'F', detail: `merges ${pct}% of PRs` };
52
+ }
53
+ function gradeActivity(daysSinceLastCommit) {
54
+ if (daysSinceLastCommit < 7)
55
+ return { letter: 'A', detail: 'commits in last week' };
56
+ if (daysSinceLastCommit < 30)
57
+ return { letter: 'B', detail: 'commits in last month' };
58
+ if (daysSinceLastCommit < 90)
59
+ return { letter: 'C', detail: 'commits in last 90 days' };
60
+ return { letter: 'F', detail: `no commits in ${daysSinceLastCommit}+ days` };
61
+ }
62
+ /**
63
+ * Build `GradeSignals` from a vet candidate's project health plus the
64
+ * optional autopilot-tracked repo score.
65
+ *
66
+ * Notes on each signal:
67
+ *
68
+ * - `avgResponseDays` — `@oss-scout/core`'s `projectHealth.avgIssueResponseDays`
69
+ * is a hardcoded `0` placeholder (it doesn't yet make the additional API
70
+ * calls needed to compute a real value). We therefore prefer
71
+ * `repoScore.avgResponseDays`, which autopilot derives from its own PR
72
+ * tracking, and fall back to `null` (unknown) if neither source has a
73
+ * usable value.
74
+ * - `mergeRate` — derived from repoScore counts. Requires a non-empty PR
75
+ * history; `0/0` is `null`, not `0`.
76
+ * - `daysSinceLastCommit` — taken from scout's `projectHealth`, but only
77
+ * when scout's health check succeeded (`checkFailed` means scout filled
78
+ * in sentinels and shouldn't be trusted).
79
+ */
80
+ export function deriveGradeSignals(params) {
81
+ const { projectHealth, repoScore } = params;
82
+ const healthTrusted = !projectHealth.checkFailed;
83
+ const avgResponseDays = sanitize(repoScore?.avgResponseDays, 0, Number.MAX_SAFE_INTEGER) ??
84
+ (healthTrusted ? sanitize(projectHealth.avgIssueResponseDays, 0.001, Number.MAX_SAFE_INTEGER) : null);
85
+ const daysSinceLastCommit = healthTrusted
86
+ ? sanitize(projectHealth.daysSinceLastCommit, 0, Number.MAX_SAFE_INTEGER)
87
+ : null;
88
+ let mergeRate = null;
89
+ if (repoScore) {
90
+ const merged = sanitize(repoScore.mergedPRCount, 0, Number.MAX_SAFE_INTEGER);
91
+ const closed = sanitize(repoScore.closedWithoutMergeCount, 0, Number.MAX_SAFE_INTEGER);
92
+ if (merged !== null && closed !== null) {
93
+ const total = merged + closed;
94
+ mergeRate = total === 0 ? null : merged / total;
95
+ }
96
+ }
97
+ return { avgResponseDays, mergeRate, daysSinceLastCommit };
98
+ }
99
+ /**
100
+ * End-to-end helper for vet callers: reads the repo score, derives
101
+ * signals from a scout candidate, and returns the grade. Callers pass
102
+ * the `projectHealth` straight through from `scout.vetIssue()`.
103
+ */
104
+ export function gradeFromCandidate(params) {
105
+ const repoScore = params.getRepoScore(params.repo);
106
+ return computeSuccessGrade(deriveGradeSignals({
107
+ projectHealth: params.projectHealth,
108
+ repoScore: repoScore
109
+ ? {
110
+ mergedPRCount: repoScore.mergedPRCount,
111
+ closedWithoutMergeCount: repoScore.closedWithoutMergeCount,
112
+ avgResponseDays: repoScore.avgResponseDays,
113
+ }
114
+ : null,
115
+ }));
116
+ }
117
+ export function computeSuccessGrade(signals) {
118
+ const graded = [];
119
+ const unknowns = [];
120
+ const resp = sanitize(signals.avgResponseDays, 0, Number.MAX_SAFE_INTEGER);
121
+ if (resp === null)
122
+ unknowns.push('maintainer responsiveness');
123
+ else
124
+ graded.push(gradeResponsiveness(resp));
125
+ const mr = sanitize(signals.mergeRate, 0, 1);
126
+ if (mr === null)
127
+ unknowns.push('merge rate');
128
+ else
129
+ graded.push(gradeMergeRate(mr));
130
+ const act = sanitize(signals.daysSinceLastCommit, 0, Number.MAX_SAFE_INTEGER);
131
+ if (act === null)
132
+ unknowns.push('activity');
133
+ else
134
+ graded.push(gradeActivity(act));
135
+ // No signal available at all — give F so callers see missing data as a
136
+ // strong negative rather than a neutral grade.
137
+ if (graded.length === 0) {
138
+ return { letter: 'F', reason: `unknown ${unknowns.join(', ')}` };
139
+ }
140
+ const worstKnown = worst(graded);
141
+ const finalLetter = unknowns.length > 0 ? degradeOneStep(worstKnown.letter) : worstKnown.letter;
142
+ const reasonParts = [worstKnown.detail];
143
+ if (unknowns.length > 0)
144
+ reasonParts.push(`unknown ${unknowns.join(', ')}`);
145
+ return { letter: finalLetter, reason: reasonParts.join(', ') };
146
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Linked-PR classification (#910, #978).
3
+ *
4
+ * Given the first linked PR on an issue, decide how it affects whether
5
+ * the issue is actionable for the current user. Previously described as
6
+ * prose in agents/issue-scout.md; extracted here as a pure function so
7
+ * the classification is unit-testable and uniform across callers.
8
+ *
9
+ * Integration with the vet flow is deferred — scout does not yet surface
10
+ * linked-PR metadata on `IssueCandidate`, so consumers need to fetch the
11
+ * linked PR themselves (e.g. via Octokit) and hand the shape to this
12
+ * function. See #978 for the upstream data-contract work.
13
+ */
14
+ export type LinkedPRClassification = 'none' | 'user_open' | 'user_closed' | 'user_merged' | 'other_open' | 'other_closed' | 'other_merged';
15
+ export type LinkedPRState = 'open' | 'closed' | 'merged';
16
+ export interface LinkedPR {
17
+ /**
18
+ * May be `null` for deleted GitHub accounts ("ghost" users); the
19
+ * declared type on GitHub's API allows null here even though the REST
20
+ * schema example typically shows a populated user.
21
+ */
22
+ author: {
23
+ login: string;
24
+ } | null;
25
+ state: LinkedPRState;
26
+ }
27
+ export declare function classifyLinkedPR(params: {
28
+ linkedPR: LinkedPR | null;
29
+ userLogin: string;
30
+ }): LinkedPRClassification;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Linked-PR classification (#910, #978).
3
+ *
4
+ * Given the first linked PR on an issue, decide how it affects whether
5
+ * the issue is actionable for the current user. Previously described as
6
+ * prose in agents/issue-scout.md; extracted here as a pure function so
7
+ * the classification is unit-testable and uniform across callers.
8
+ *
9
+ * Integration with the vet flow is deferred — scout does not yet surface
10
+ * linked-PR metadata on `IssueCandidate`, so consumers need to fetch the
11
+ * linked PR themselves (e.g. via Octokit) and hand the shape to this
12
+ * function. See #978 for the upstream data-contract work.
13
+ */
14
+ /**
15
+ * Normalize a state value from either REST (lowercase `open`/`closed`
16
+ * plus a separate `merged` boolean) or GraphQL (uppercase `OPEN`/
17
+ * `CLOSED`/`MERGED` union) into our internal lowercase form. Callers
18
+ * converting from REST should pre-mix `merged` into the state before
19
+ * calling; see the tests for expected shapes.
20
+ */
21
+ function normalizeState(state) {
22
+ const lower = state.toLowerCase();
23
+ if (lower === 'open' || lower === 'closed' || lower === 'merged')
24
+ return lower;
25
+ return null;
26
+ }
27
+ export function classifyLinkedPR(params) {
28
+ const { linkedPR, userLogin } = params;
29
+ if (!linkedPR)
30
+ return 'none';
31
+ const state = normalizeState(linkedPR.state);
32
+ // Unknown state (e.g., future-extended values) is safest to treat as
33
+ // "closed" — skip-worthy but non-fatal — rather than throwing and
34
+ // breaking the whole vetting pipeline on one malformed payload.
35
+ const effectiveState = state ?? 'closed';
36
+ // GitHub usernames are case-insensitive ASCII. Ghost authors (deleted
37
+ // accounts) return `null` or an empty login; in both cases we can't
38
+ // prove the PR is the user's own, so we classify it as "other_*".
39
+ const authorLogin = linkedPR.author?.login ?? '';
40
+ const isUserOwn = authorLogin !== '' && userLogin !== '' && authorLogin.toLowerCase() === userLogin.toLowerCase();
41
+ if (isUserOwn) {
42
+ if (effectiveState === 'open')
43
+ return 'user_open';
44
+ if (effectiveState === 'merged')
45
+ return 'user_merged';
46
+ return 'user_closed';
47
+ }
48
+ if (effectiveState === 'open')
49
+ return 'other_open';
50
+ if (effectiveState === 'merged')
51
+ return 'other_merged';
52
+ return 'other_closed';
53
+ }
@@ -313,6 +313,11 @@ export interface VetOutput {
313
313
  reasonsToSkip: string[];
314
314
  projectHealth: unknown;
315
315
  vettingResult: unknown;
316
+ /** Success-likelihood grade (#858): predicts whether a PR will merge. */
317
+ grade: {
318
+ letter: 'A' | 'B' | 'C' | 'F';
319
+ reason: string;
320
+ };
316
321
  }
317
322
  /** Output of the comments command */
318
323
  export interface CommentsOutput {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {