@oss-scout/core 0.11.0 → 1.0.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 (63) hide show
  1. package/dist/cli.bundle.cjs +78 -61
  2. package/dist/cli.js +401 -425
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.js +63 -70
  10. package/dist/commands/setup.d.ts +2 -0
  11. package/dist/commands/setup.js +35 -6
  12. package/dist/commands/skip.d.ts +4 -0
  13. package/dist/commands/skip.js +45 -55
  14. package/dist/commands/sync.d.ts +10 -0
  15. package/dist/commands/sync.js +10 -0
  16. package/dist/commands/vet-list.js +3 -19
  17. package/dist/commands/vet.js +18 -25
  18. package/dist/commands/with-scout.d.ts +32 -0
  19. package/dist/commands/with-scout.js +41 -0
  20. package/dist/core/anti-llm-policy.js +4 -5
  21. package/dist/core/bootstrap.d.ts +2 -2
  22. package/dist/core/bootstrap.js +5 -9
  23. package/dist/core/errors.d.ts +10 -0
  24. package/dist/core/errors.js +20 -5
  25. package/dist/core/feature-discovery.d.ts +13 -1
  26. package/dist/core/feature-discovery.js +104 -81
  27. package/dist/core/gist-state-store.d.ts +13 -12
  28. package/dist/core/gist-state-store.js +128 -53
  29. package/dist/core/http-cache.d.ts +32 -2
  30. package/dist/core/http-cache.js +74 -19
  31. package/dist/core/issue-discovery.d.ts +2 -0
  32. package/dist/core/issue-discovery.js +44 -29
  33. package/dist/core/issue-eligibility.d.ts +10 -4
  34. package/dist/core/issue-eligibility.js +119 -67
  35. package/dist/core/issue-graphql.d.ts +58 -0
  36. package/dist/core/issue-graphql.js +108 -0
  37. package/dist/core/issue-vetting.d.ts +105 -8
  38. package/dist/core/issue-vetting.js +234 -107
  39. package/dist/core/local-state.d.ts +6 -2
  40. package/dist/core/local-state.js +23 -5
  41. package/dist/core/logger.d.ts +12 -4
  42. package/dist/core/logger.js +33 -7
  43. package/dist/core/personalization.d.ts +15 -10
  44. package/dist/core/personalization.js +30 -22
  45. package/dist/core/preference-fields.d.ts +47 -0
  46. package/dist/core/preference-fields.js +178 -0
  47. package/dist/core/repo-health.js +31 -15
  48. package/dist/core/roadmap.js +17 -3
  49. package/dist/core/schemas.d.ts +144 -26
  50. package/dist/core/schemas.js +74 -17
  51. package/dist/core/search-budget.d.ts +9 -0
  52. package/dist/core/search-budget.js +36 -3
  53. package/dist/core/search-phases.d.ts +0 -18
  54. package/dist/core/search-phases.js +27 -82
  55. package/dist/core/types.d.ts +136 -38
  56. package/dist/core/utils.js +60 -26
  57. package/dist/formatters/markdown.d.ts +10 -0
  58. package/dist/formatters/markdown.js +31 -0
  59. package/dist/index.d.ts +6 -2
  60. package/dist/index.js +8 -0
  61. package/dist/scout.d.ts +59 -10
  62. package/dist/scout.js +244 -20
  63. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Batched GraphQL prefetch of issue "core" data (#169).
3
+ *
4
+ * `vetIssue` re-fetches each issue's basic fields (title, body, state, labels,
5
+ * timestamps, comment count) via a per-issue REST `issues.get`. When a search
6
+ * surfaces N issues that all need vetting, that is N separate REST calls before
7
+ * any of the deeper checks even start.
8
+ *
9
+ * `prefetchIssueCores` collapses those N calls into ONE aliased GraphQL query.
10
+ * The result is a map keyed by `owner/repo#number`; `vetIssue` consumes a hit
11
+ * instead of calling `issues.get`, and falls back to REST for any miss (a
12
+ * deleted issue, a permission error on one repo, or a non-fatal GraphQL blip).
13
+ *
14
+ * Scope is deliberately limited to the `issues.get` fields. The other vetting
15
+ * calls (timeline-based PR detection, claim scanning, project health,
16
+ * contribution guidelines) stay REST — batching those has pagination-semantics
17
+ * divergence risk and is left as a follow-up.
18
+ */
19
+ import { rethrowIfFatal, errorMessage } from "./errors.js";
20
+ import { warn } from "./logger.js";
21
+ const MODULE = "issue-graphql";
22
+ /** Map key for a prefetched core, also used by callers to look one up. */
23
+ export function issueCoreKey(owner, repo, number) {
24
+ return `${owner}/${repo}#${number}`;
25
+ }
26
+ function normalizeNode(node) {
27
+ return {
28
+ id: node.databaseId,
29
+ title: node.title,
30
+ body: node.body ?? "",
31
+ state: node.state === "CLOSED" ? "closed" : "open",
32
+ labels: node.labels.nodes.map((l) => l.name),
33
+ commentCount: node.comments.totalCount,
34
+ createdAt: node.createdAt,
35
+ updatedAt: node.updatedAt,
36
+ };
37
+ }
38
+ /**
39
+ * Batch-fetch issue core data with one aliased GraphQL query. Returns a map of
40
+ * `owner/repo#number` to the normalized core. Issues that the query could not
41
+ * resolve are simply absent — the caller is expected to fall back to REST for
42
+ * any key not in the map.
43
+ *
44
+ * Failure handling mirrors the rest of the vetter: fatal errors (401 / rate
45
+ * limit) propagate via `rethrowIfFatal`; a partial-data GraphQL error (one bad
46
+ * issue in the batch) keeps the aliases that did resolve; any other non-fatal
47
+ * error returns whatever resolved so the caller degrades to all-REST.
48
+ */
49
+ export async function prefetchIssueCores(octokit, issues) {
50
+ const result = new Map();
51
+ if (issues.length === 0)
52
+ return result;
53
+ // Dedup by key so a repeated issue does not allocate a redundant alias.
54
+ const unique = [
55
+ ...new Map(issues.map((i) => [issueCoreKey(i.owner, i.repo, i.number), i])).values(),
56
+ ];
57
+ // Build a parameterized query — owner/repo/number go through GraphQL
58
+ // variables, never string-interpolated into the query body, so there is no
59
+ // injection surface even though parseGitHubUrl already validates them.
60
+ const varDefs = [];
61
+ const selections = [];
62
+ const variables = {};
63
+ unique.forEach((iss, i) => {
64
+ varDefs.push(`$o${i}: String!, $n${i}: String!, $num${i}: Int!`);
65
+ variables[`o${i}`] = iss.owner;
66
+ variables[`n${i}`] = iss.repo;
67
+ variables[`num${i}`] = iss.number;
68
+ selections.push(`i${i}: repository(owner: $o${i}, name: $n${i}) {
69
+ issue(number: $num${i}) {
70
+ databaseId
71
+ title
72
+ body
73
+ state
74
+ labels(first: 100) { nodes { name } }
75
+ comments { totalCount }
76
+ createdAt
77
+ updatedAt
78
+ }
79
+ }`);
80
+ });
81
+ const query = `query batchIssueCores(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`;
82
+ let data;
83
+ try {
84
+ data = await octokit.graphql(query, variables);
85
+ }
86
+ catch (err) {
87
+ rethrowIfFatal(err);
88
+ // octokit's GraphqlResponseError attaches the resolved aliases to `.data`
89
+ // when only some issues in the batch errored (e.g. one was deleted).
90
+ const partial = err.data;
91
+ if (partial) {
92
+ data = partial;
93
+ }
94
+ else {
95
+ warn(MODULE, `GraphQL prefetch failed, falling back to REST: ${errorMessage(err)}`);
96
+ return result;
97
+ }
98
+ }
99
+ unique.forEach((iss, i) => {
100
+ const node = data?.[`i${i}`]?.issue;
101
+ // A null node (deleted/inaccessible) or a null databaseId leaves the key
102
+ // absent so the caller fetches it via REST.
103
+ if (!node || node.databaseId == null)
104
+ return;
105
+ result.set(issueCoreKey(iss.owner, iss.repo, iss.number), normalizeNode(node));
106
+ });
107
+ return result;
108
+ }
@@ -7,7 +7,8 @@
7
7
  * - repo-health.ts — project health, contribution guidelines
8
8
  */
9
9
  import { Octokit } from "@octokit/rest";
10
- import { type SearchPriority, type IssueCandidate, type ProjectCategory } from "./types.js";
10
+ import { type SearchPriority, type IssueCandidate, type ProjectCategory, type ScoutPreferences, type ScoutState, type MergedPRRecord, type ClosedPRRecord, type OpenPRRecord } from "./types.js";
11
+ import { type PrefetchedIssueCore } from "./issue-graphql.js";
11
12
  /**
12
13
  * Feature-mode signals supplied by the caller (orchestrator) — the vetter
13
14
  * does NOT extract these from the GitHub issue itself. When passed, they
@@ -30,6 +31,15 @@ export type FeatureSignals = {
30
31
  */
31
32
  wontfixNoContributor?: boolean;
32
33
  };
34
+ /**
35
+ * SLM pre-triage configuration (oss-autopilot#1122). `host` is the Ollama
36
+ * endpoint; an empty `host` means "use the triage default". A `null` config
37
+ * (not this shape) means SLM triage is disabled (#158).
38
+ */
39
+ export interface SLMConfig {
40
+ model: string;
41
+ host: string;
42
+ }
33
43
  /**
34
44
  * Read-only interface for accessing scout state during issue vetting.
35
45
  * Implementations may be backed by gist persistence, in-memory state, etc.
@@ -46,15 +56,89 @@ export interface ScoutStateReader {
46
56
  /** Numeric quality score for a repo, or null if not evaluated. */
47
57
  getRepoScore(repo: string): number | null;
48
58
  /**
49
- * SLM pre-triage config (oss-autopilot#1122). Returns the configured
50
- * model id and Ollama host, or empty strings when not configured —
51
- * vetIssue treats either of these as "skip the SLM call".
59
+ * SLM pre-triage config (oss-autopilot#1122). Returns the configured model
60
+ * id and Ollama host, or `null` when SLM triage is not configured — vetIssue
61
+ * skips the SLM call on `null`. Required (#158): the old optional method with
62
+ * an empty-string sentinel could not distinguish "not configured" from "host
63
+ * defaulted"; `SLMConfig | null` makes the absence explicit.
64
+ */
65
+ getSLMTriageConfig(): SLMConfig | null;
66
+ /**
67
+ * Number of the user's PRs closed without merge in this repo (#125).
68
+ * Optional so existing implementations keep compiling; absent reads as 0.
69
+ */
70
+ getClosedWithoutMergeCount?(repo: string): number;
71
+ /**
72
+ * The configured GitHub username, used to tell the user's own in-flight PR
73
+ * apart from a competing one (#166). Optional; absent reads as "unknown".
74
+ */
75
+ getGitHubUsername?(): string;
76
+ }
77
+ /**
78
+ * Write side of the scout state, consumed by the bootstrap flow. Defined here
79
+ * next to ScoutStateReader so core/bootstrap.ts can depend on this contract
80
+ * instead of importing the OssScout facade from the package root (an upward
81
+ * dependency). OssScout implements it (#156).
82
+ */
83
+ export interface ScoutStateWriter {
84
+ /** Read current preferences (bootstrap reads githubUsername). */
85
+ getPreferences(): Readonly<ScoutPreferences>;
86
+ /** Replace the cached starred-repo list. */
87
+ setStarredRepos(repos: string[]): void;
88
+ /** Record a merged PR (deduplicated by URL). */
89
+ recordMergedPR(pr: MergedPRRecord): void;
90
+ /** Record a PR closed without merge (deduplicated by URL). */
91
+ recordClosedPR(pr: ClosedPRRecord): void;
92
+ /** Record an open PR (deduplicated by URL). */
93
+ recordOpenPR(pr: OpenPRRecord): void;
94
+ /** Snapshot the current state (bootstrap reports counts from it). */
95
+ getState(): Readonly<ScoutState>;
96
+ }
97
+ /**
98
+ * Inputs to deriveRecommendation: the already-computed check results and
99
+ * affinity signals. Kept as a flat record of primitives so the derivation is a
100
+ * pure function, independently unit-testable (#157).
101
+ */
102
+ export interface RecommendationInput {
103
+ noExistingPR: boolean;
104
+ /**
105
+ * The linked PR is the user's own open PR (#166). When true, the existing-PR
106
+ * block is reframed as "you're already on it" — a skip with a clear reason —
107
+ * instead of a competing-PR penalty.
52
108
  */
53
- getSLMTriageConfig?(): {
54
- model: string;
55
- host: string;
56
- };
109
+ ownPR: boolean;
110
+ notClaimed: boolean;
111
+ clearRequirements: boolean;
112
+ contributionGuidelinesFound: boolean;
113
+ projectIsActive: boolean;
114
+ projectCheckFailed: boolean;
115
+ projectFailureReason?: string;
116
+ existingPRInconclusive: boolean;
117
+ existingPRReason?: string;
118
+ claimInconclusive: boolean;
119
+ claimReason?: string;
120
+ mergedCountInconclusive: boolean;
121
+ effectiveMergedCount: number;
122
+ orgName: string;
123
+ orgHasMergedPRs: boolean;
124
+ matchesCategory: boolean;
125
+ issueClosed: boolean;
126
+ /** noExistingPR && notClaimed && projectActive && clearRequirements. */
127
+ passedAllChecks: boolean;
57
128
  }
129
+ export interface RecommendationOutput {
130
+ notes: string[];
131
+ reasonsToApprove: string[];
132
+ reasonsToSkip: string[];
133
+ recommendation: "approve" | "skip" | "needs_review";
134
+ }
135
+ /**
136
+ * Derive the human-readable notes, approve/skip reasons, and the final
137
+ * recommendation from a vet's check results. Pure: no I/O, no state reads — the
138
+ * caller computes the inputs and threads them in. Extracted from vetIssue so
139
+ * the recommendation logic is testable in isolation (#157).
140
+ */
141
+ export declare function deriveRecommendation(input: RecommendationInput): RecommendationOutput;
58
142
  export declare class IssueVetter {
59
143
  private octokit;
60
144
  private stateReader;
@@ -70,7 +154,20 @@ export declare class IssueVetter {
70
154
  */
71
155
  vetIssue(issueUrl: string, opts?: {
72
156
  featureSignals?: FeatureSignals;
157
+ /**
158
+ * Issue core data already fetched in a batch GraphQL query (#169). When
159
+ * present it replaces the per-issue REST `issues.get`; otherwise the
160
+ * core is fetched via REST. The two paths are normalized to the same
161
+ * shape so behaviour is identical either way.
162
+ */
163
+ prefetched?: PrefetchedIssueCore;
73
164
  }): Promise<IssueCandidate>;
165
+ /**
166
+ * Fetch a single issue's core fields via REST and normalize them to the same
167
+ * shape as a GraphQL prefetch (#169). The REST fallback path when no
168
+ * prefetched core was supplied.
169
+ */
170
+ private fetchIssueCore;
74
171
  /**
75
172
  * Vet multiple issues in parallel with concurrency limit
76
173
  */