@oss-scout/core 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/cli.bundle.cjs +51 -47
  2. package/dist/cli.js +218 -87
  3. package/dist/commands/config.d.ts +2 -4
  4. package/dist/commands/config.js +76 -78
  5. package/dist/commands/results.d.ts +1 -1
  6. package/dist/commands/results.js +1 -1
  7. package/dist/commands/search.d.ts +2 -2
  8. package/dist/commands/search.js +16 -6
  9. package/dist/commands/setup.d.ts +1 -1
  10. package/dist/commands/setup.js +25 -25
  11. package/dist/commands/skip.d.ts +33 -0
  12. package/dist/commands/skip.js +89 -0
  13. package/dist/commands/validation.d.ts +1 -1
  14. package/dist/commands/validation.js +1 -1
  15. package/dist/commands/vet-list.d.ts +2 -2
  16. package/dist/commands/vet-list.js +12 -5
  17. package/dist/commands/vet.d.ts +3 -3
  18. package/dist/commands/vet.js +9 -5
  19. package/dist/core/bootstrap.d.ts +1 -1
  20. package/dist/core/bootstrap.js +20 -16
  21. package/dist/core/category-mapping.d.ts +1 -1
  22. package/dist/core/category-mapping.js +104 -13
  23. package/dist/core/errors.d.ts +8 -1
  24. package/dist/core/errors.js +31 -19
  25. package/dist/core/gist-state-store.d.ts +1 -1
  26. package/dist/core/gist-state-store.js +55 -28
  27. package/dist/core/github.d.ts +1 -1
  28. package/dist/core/github.js +5 -5
  29. package/dist/core/http-cache.js +26 -22
  30. package/dist/core/issue-discovery.d.ts +6 -6
  31. package/dist/core/issue-discovery.js +279 -286
  32. package/dist/core/issue-eligibility.d.ts +2 -2
  33. package/dist/core/issue-eligibility.js +26 -21
  34. package/dist/core/issue-filtering.js +23 -15
  35. package/dist/core/issue-scoring.js +1 -1
  36. package/dist/core/issue-vetting.d.ts +2 -4
  37. package/dist/core/issue-vetting.js +65 -56
  38. package/dist/core/local-state.d.ts +1 -1
  39. package/dist/core/local-state.js +16 -14
  40. package/dist/core/repo-health.d.ts +2 -2
  41. package/dist/core/repo-health.js +46 -35
  42. package/dist/core/schemas.d.ts +17 -9
  43. package/dist/core/schemas.js +47 -19
  44. package/dist/core/search-budget.js +3 -3
  45. package/dist/core/search-phases.d.ts +6 -6
  46. package/dist/core/search-phases.js +23 -19
  47. package/dist/core/types.d.ts +9 -9
  48. package/dist/core/types.js +15 -3
  49. package/dist/core/utils.d.ts +10 -1
  50. package/dist/core/utils.js +44 -25
  51. package/dist/formatters/json.d.ts +1 -1
  52. package/dist/index.d.ts +7 -7
  53. package/dist/index.js +5 -5
  54. package/dist/scout.d.ts +30 -6
  55. package/dist/scout.js +141 -34
  56. package/package.json +7 -3
@@ -2,22 +2,23 @@
2
2
  * First-run bootstrap — fetches starred repos and PR history from GitHub
3
3
  * to seed the scout's state with the user's contribution context.
4
4
  */
5
- import { getOctokit, checkRateLimit } from './github.js';
6
- import { debug, warn } from './logger.js';
7
- import { errorMessage } from './errors.js';
8
- const MODULE = 'bootstrap';
5
+ import { getOctokit, checkRateLimit } from "./github.js";
6
+ import { debug, warn } from "./logger.js";
7
+ import { ConfigurationError, errorMessage } from "./errors.js";
8
+ import { extractRepoFromUrl } from "./utils.js";
9
+ const MODULE = "bootstrap";
9
10
  const STARRED_MAX_PAGES = 5;
10
11
  const SEARCH_MAX_PAGES = 3;
11
12
  const PER_PAGE = 100;
12
13
  export async function bootstrapScout(scout, token) {
13
14
  const username = scout.getPreferences().githubUsername;
14
15
  if (!username) {
15
- throw new Error('GitHub username not configured. Run `oss-scout setup` first.');
16
+ throw new ConfigurationError("GitHub username not configured. Run `oss-scout setup` first.");
16
17
  }
17
18
  const rateLimit = await checkRateLimit(token);
18
19
  debug(MODULE, `Rate limit: ${rateLimit.remaining}/${rateLimit.limit}, resets at ${rateLimit.resetAt}`);
19
20
  if (rateLimit.remaining < 15) {
20
- debug(MODULE, 'Insufficient rate limit, skipping bootstrap');
21
+ debug(MODULE, "Insufficient rate limit, skipping bootstrap");
21
22
  return {
22
23
  starredRepoCount: 0,
23
24
  mergedPRCount: 0,
@@ -33,7 +34,10 @@ export async function bootstrapScout(scout, token) {
33
34
  const starredRepos = [];
34
35
  try {
35
36
  let starredPage = 0;
36
- for await (const response of octokit.paginate.iterator(octokit.activity.listReposStarredByAuthenticatedUser, { per_page: PER_PAGE, headers: { accept: 'application/vnd.github.v3+json' } })) {
37
+ for await (const response of octokit.paginate.iterator(octokit.activity.listReposStarredByAuthenticatedUser, {
38
+ per_page: PER_PAGE,
39
+ headers: { accept: "application/vnd.github.v3+json" },
40
+ })) {
37
41
  for (const repo of response.data) {
38
42
  const r = repo;
39
43
  starredRepos.push(r.full_name);
@@ -47,7 +51,7 @@ export async function bootstrapScout(scout, token) {
47
51
  }
48
52
  catch (err) {
49
53
  warn(MODULE, `Failed to fetch starred repos: ${errorMessage(err)}`);
50
- errors.push('starred repos fetch failed');
54
+ errors.push("starred repos fetch failed");
51
55
  }
52
56
  // 2. Fetch merged PRs via Search API
53
57
  let mergedPRCount = 0;
@@ -59,14 +63,14 @@ export async function bootstrapScout(scout, token) {
59
63
  page,
60
64
  });
61
65
  for (const item of data.items) {
62
- const repoMatch = item.html_url.match(/github\.com\/([^/]+\/[^/]+)\//);
63
- if (!repoMatch)
66
+ const repo = extractRepoFromUrl(item.html_url);
67
+ if (!repo)
64
68
  continue;
65
69
  scout.recordMergedPR({
66
70
  url: item.html_url,
67
71
  title: item.title,
68
72
  mergedAt: item.closed_at ?? new Date().toISOString(),
69
- repo: repoMatch[1],
73
+ repo,
70
74
  });
71
75
  mergedPRCount++;
72
76
  }
@@ -77,7 +81,7 @@ export async function bootstrapScout(scout, token) {
77
81
  }
78
82
  catch (err) {
79
83
  warn(MODULE, `Failed to fetch merged PRs: ${errorMessage(err)}`);
80
- errors.push('merged PR fetch failed');
84
+ errors.push("merged PR fetch failed");
81
85
  }
82
86
  // 3. Fetch closed-without-merge PRs via Search API
83
87
  let closedPRCount = 0;
@@ -89,14 +93,14 @@ export async function bootstrapScout(scout, token) {
89
93
  page,
90
94
  });
91
95
  for (const item of data.items) {
92
- const repoMatch = item.html_url.match(/github\.com\/([^/]+\/[^/]+)\//);
93
- if (!repoMatch)
96
+ const repo = extractRepoFromUrl(item.html_url);
97
+ if (!repo)
94
98
  continue;
95
99
  scout.recordClosedPR({
96
100
  url: item.html_url,
97
101
  title: item.title,
98
102
  closedAt: item.closed_at ?? new Date().toISOString(),
99
- repo: repoMatch[1],
103
+ repo,
100
104
  });
101
105
  closedPRCount++;
102
106
  }
@@ -107,7 +111,7 @@ export async function bootstrapScout(scout, token) {
107
111
  }
108
112
  catch (err) {
109
113
  warn(MODULE, `Failed to fetch closed PRs: ${errorMessage(err)}`);
110
- errors.push('closed PR fetch failed');
114
+ errors.push("closed PR fetch failed");
111
115
  }
112
116
  const state = scout.getState();
113
117
  const reposScoredCount = Object.keys(state.repoScores).length;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Used by issue discovery to prioritize repos matching user's category preferences.
5
5
  */
6
- import type { ProjectCategory } from './types.js';
6
+ import type { ProjectCategory } from "./types.js";
7
7
  /** GitHub topics associated with each project category, used for `topic:` search queries. */
8
8
  export declare const CATEGORY_TOPICS: Record<ProjectCategory, string[]>;
9
9
  /** Well-known GitHub organizations associated with each project category. */
@@ -5,21 +5,112 @@
5
5
  */
6
6
  /** GitHub topics associated with each project category, used for `topic:` search queries. */
7
7
  export const CATEGORY_TOPICS = {
8
- nonprofit: ['nonprofit', 'social-good', 'humanitarian', 'charity', 'social-impact', 'civic-tech'],
9
- devtools: ['developer-tools', 'devtools', 'cli', 'sdk', 'linter', 'formatter', 'build-tool'],
10
- infrastructure: ['infrastructure', 'cloud', 'kubernetes', 'docker', 'devops', 'monitoring', 'observability'],
11
- 'web-frameworks': ['web-framework', 'frontend', 'backend', 'fullstack', 'nextjs', 'react', 'vue'],
12
- 'data-ml': ['machine-learning', 'data-science', 'deep-learning', 'nlp', 'data-pipeline', 'analytics'],
13
- education: ['education', 'learning', 'tutorial', 'courseware', 'edtech', 'teaching'],
8
+ nonprofit: [
9
+ "nonprofit",
10
+ "social-good",
11
+ "humanitarian",
12
+ "charity",
13
+ "social-impact",
14
+ "civic-tech",
15
+ ],
16
+ devtools: [
17
+ "developer-tools",
18
+ "devtools",
19
+ "cli",
20
+ "sdk",
21
+ "linter",
22
+ "formatter",
23
+ "build-tool",
24
+ ],
25
+ infrastructure: [
26
+ "infrastructure",
27
+ "cloud",
28
+ "kubernetes",
29
+ "docker",
30
+ "devops",
31
+ "monitoring",
32
+ "observability",
33
+ ],
34
+ "web-frameworks": [
35
+ "web-framework",
36
+ "frontend",
37
+ "backend",
38
+ "fullstack",
39
+ "nextjs",
40
+ "react",
41
+ "vue",
42
+ ],
43
+ "data-ml": [
44
+ "machine-learning",
45
+ "data-science",
46
+ "deep-learning",
47
+ "nlp",
48
+ "data-pipeline",
49
+ "analytics",
50
+ ],
51
+ education: [
52
+ "education",
53
+ "learning",
54
+ "tutorial",
55
+ "courseware",
56
+ "edtech",
57
+ "teaching",
58
+ ],
14
59
  };
15
60
  /** Well-known GitHub organizations associated with each project category. */
16
61
  export const CATEGORY_ORGS = {
17
- nonprofit: ['code-for-america', 'opengovfoundation', 'ushahidi', 'hotosm', 'openfn', 'democracyearth'],
18
- devtools: ['eslint', 'prettier', 'vitejs', 'biomejs', 'oxc-project', 'ast-grep', 'turbot'],
19
- infrastructure: ['kubernetes', 'hashicorp', 'grafana', 'prometheus', 'open-telemetry', 'envoyproxy', 'cncf'],
20
- 'web-frameworks': ['vercel', 'remix-run', 'sveltejs', 'nuxt', 'astro', 'redwoodjs', 'blitz-js'],
21
- 'data-ml': ['huggingface', 'mlflow', 'apache', 'dbt-labs', 'dagster-io', 'prefecthq', 'langchain-ai'],
22
- education: ['freeCodeCamp', 'TheOdinProject', 'exercism', 'codecademy', 'oppia', 'Khan'],
62
+ nonprofit: [
63
+ "code-for-america",
64
+ "opengovfoundation",
65
+ "ushahidi",
66
+ "hotosm",
67
+ "openfn",
68
+ "democracyearth",
69
+ ],
70
+ devtools: [
71
+ "eslint",
72
+ "prettier",
73
+ "vitejs",
74
+ "biomejs",
75
+ "oxc-project",
76
+ "ast-grep",
77
+ "turbot",
78
+ ],
79
+ infrastructure: [
80
+ "kubernetes",
81
+ "hashicorp",
82
+ "grafana",
83
+ "prometheus",
84
+ "open-telemetry",
85
+ "envoyproxy",
86
+ "cncf",
87
+ ],
88
+ "web-frameworks": [
89
+ "vercel",
90
+ "remix-run",
91
+ "sveltejs",
92
+ "nuxt",
93
+ "astro",
94
+ "redwoodjs",
95
+ "blitz-js",
96
+ ],
97
+ "data-ml": [
98
+ "huggingface",
99
+ "mlflow",
100
+ "apache",
101
+ "dbt-labs",
102
+ "dagster-io",
103
+ "prefecthq",
104
+ "langchain-ai",
105
+ ],
106
+ education: [
107
+ "freeCodeCamp",
108
+ "TheOdinProject",
109
+ "exercism",
110
+ "codecademy",
111
+ "oppia",
112
+ "Khan",
113
+ ],
23
114
  };
24
115
  /**
25
116
  * Check if a repo belongs to any of the given categories based on its owner matching a category org.
@@ -28,7 +119,7 @@ export const CATEGORY_ORGS = {
28
119
  export function repoBelongsToCategory(repoFullName, categories) {
29
120
  if (categories.length === 0)
30
121
  return false;
31
- const owner = repoFullName.split('/')[0]?.toLowerCase();
122
+ const owner = repoFullName.split("/")[0]?.toLowerCase();
32
123
  if (!owner)
33
124
  return false;
34
125
  for (const category of categories) {
@@ -1,5 +1,12 @@
1
1
  /**
2
2
  * Custom error type hierarchy for oss-scout.
3
+ *
4
+ * Error strategy:
5
+ * - Auth errors (401) and rate limit errors (429, 403+rate-limit): ALWAYS propagate
6
+ * - Network errors (ENOTFOUND, ECONNREFUSED, ETIMEDOUT): propagate with context
7
+ * - Validation errors: propagate
8
+ * - Cache/filesystem errors: degrade gracefully with warn logging
9
+ * - API data errors (unexpected shapes): degrade gracefully with warn logging
3
10
  */
4
11
  export declare class OssScoutError extends Error {
5
12
  readonly code: string;
@@ -15,7 +22,7 @@ export declare function errorMessage(e: unknown): string;
15
22
  export declare function getHttpStatusCode(error: unknown): number | undefined;
16
23
  export declare function isRateLimitError(error: unknown): boolean;
17
24
  /** Error codes for JSON output. */
18
- export type ErrorCode = 'AUTH_REQUIRED' | 'CONFIGURATION' | 'NETWORK' | 'NOT_FOUND' | 'RATE_LIMITED' | 'STATE_CORRUPTED' | 'UNKNOWN' | 'VALIDATION';
25
+ export type ErrorCode = "AUTH_REQUIRED" | "CONFIGURATION" | "NETWORK" | "NOT_FOUND" | "RATE_LIMITED" | "STATE_CORRUPTED" | "UNKNOWN" | "VALIDATION";
19
26
  /**
20
27
  * Map an unknown error to a structured ErrorCode for JSON output.
21
28
  */
@@ -1,33 +1,42 @@
1
1
  /**
2
2
  * Custom error type hierarchy for oss-scout.
3
+ *
4
+ * Error strategy:
5
+ * - Auth errors (401) and rate limit errors (429, 403+rate-limit): ALWAYS propagate
6
+ * - Network errors (ENOTFOUND, ECONNREFUSED, ETIMEDOUT): propagate with context
7
+ * - Validation errors: propagate
8
+ * - Cache/filesystem errors: degrade gracefully with warn logging
9
+ * - API data errors (unexpected shapes): degrade gracefully with warn logging
3
10
  */
4
11
  export class OssScoutError extends Error {
5
12
  code;
6
13
  constructor(message, code) {
7
14
  super(message);
8
15
  this.code = code;
9
- this.name = 'OssScoutError';
16
+ this.name = "OssScoutError";
10
17
  }
11
18
  }
12
19
  export class ConfigurationError extends OssScoutError {
13
20
  constructor(message) {
14
- super(message, 'CONFIGURATION_ERROR');
15
- this.name = 'ConfigurationError';
21
+ super(message, "CONFIGURATION_ERROR");
22
+ this.name = "ConfigurationError";
16
23
  }
17
24
  }
18
25
  export class ValidationError extends OssScoutError {
19
26
  constructor(message) {
20
- super(message, 'VALIDATION_ERROR');
21
- this.name = 'ValidationError';
27
+ super(message, "VALIDATION_ERROR");
28
+ this.name = "ValidationError";
22
29
  }
23
30
  }
24
31
  export function errorMessage(e) {
25
32
  return e instanceof Error ? e.message : String(e);
26
33
  }
27
34
  export function getHttpStatusCode(error) {
28
- if (error && typeof error === 'object' && 'status' in error) {
35
+ if (error && typeof error === "object" && "status" in error) {
29
36
  const status = error.status;
30
- return typeof status === 'number' && Number.isFinite(status) ? status : undefined;
37
+ return typeof status === "number" && Number.isFinite(status)
38
+ ? status
39
+ : undefined;
31
40
  }
32
41
  return undefined;
33
42
  }
@@ -37,7 +46,7 @@ export function isRateLimitError(error) {
37
46
  return true;
38
47
  if (status === 403) {
39
48
  const msg = errorMessage(error).toLowerCase();
40
- return msg.includes('rate limit');
49
+ return msg.includes("rate limit");
41
50
  }
42
51
  return false;
43
52
  }
@@ -46,24 +55,27 @@ export function isRateLimitError(error) {
46
55
  */
47
56
  export function resolveErrorCode(err) {
48
57
  if (err instanceof ConfigurationError)
49
- return 'CONFIGURATION';
58
+ return "CONFIGURATION";
50
59
  if (err instanceof ValidationError)
51
- return 'VALIDATION';
60
+ return "VALIDATION";
52
61
  const status = getHttpStatusCode(err);
53
62
  if (status === 401)
54
- return 'AUTH_REQUIRED';
63
+ return "AUTH_REQUIRED";
55
64
  if (status === 403) {
56
65
  const msg = errorMessage(err).toLowerCase();
57
- if (msg.includes('rate limit') || msg.includes('abuse detection'))
58
- return 'RATE_LIMITED';
59
- return 'AUTH_REQUIRED';
66
+ if (msg.includes("rate limit") || msg.includes("abuse detection"))
67
+ return "RATE_LIMITED";
68
+ return "AUTH_REQUIRED";
60
69
  }
61
70
  if (status === 404)
62
- return 'NOT_FOUND';
71
+ return "NOT_FOUND";
63
72
  if (status === 429)
64
- return 'RATE_LIMITED';
73
+ return "RATE_LIMITED";
65
74
  const msg = errorMessage(err).toLowerCase();
66
- if (msg.includes('enotfound') || msg.includes('econnrefused') || msg.includes('etimedout') || msg.includes('fetch failed'))
67
- return 'NETWORK';
68
- return 'UNKNOWN';
75
+ if (msg.includes("enotfound") ||
76
+ msg.includes("econnrefused") ||
77
+ msg.includes("etimedout") ||
78
+ msg.includes("fetch failed"))
79
+ return "NETWORK";
80
+ return "UNKNOWN";
69
81
  }
@@ -4,7 +4,7 @@
4
4
  * Stores ScoutState as a private GitHub Gist, with a local file cache
5
5
  * as fallback when the API is unavailable.
6
6
  */
7
- import type { ScoutState } from './schemas.js';
7
+ import type { ScoutState } from "./schemas.js";
8
8
  /** Minimal Octokit interface for gist operations — keeps the class testable. */
9
9
  export interface GistOctokitLike {
10
10
  gists: {
@@ -4,17 +4,17 @@
4
4
  * Stores ScoutState as a private GitHub Gist, with a local file cache
5
5
  * as fallback when the API is unavailable.
6
6
  */
7
- import * as fs from 'fs';
8
- import * as path from 'path';
9
- import { ScoutStateSchema } from './schemas.js';
10
- import { getDataDir } from './utils.js';
11
- import { debug, warn } from './logger.js';
12
- import { errorMessage } from './errors.js';
13
- const MODULE = 'gist-state';
14
- const GIST_DESCRIPTION = 'oss-scout-state';
15
- const GIST_FILENAME = 'state.json';
16
- const GIST_ID_FILE = 'gist-id';
17
- const CACHE_FILE = 'state-cache.json';
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { ScoutStateSchema } from "./schemas.js";
10
+ import { getDataDir } from "./utils.js";
11
+ import { debug, warn } from "./logger.js";
12
+ import { errorMessage } from "./errors.js";
13
+ const MODULE = "gist-state";
14
+ const GIST_DESCRIPTION = "oss-scout-state";
15
+ const GIST_FILENAME = "state.json";
16
+ const GIST_ID_FILE = "gist-id";
17
+ const CACHE_FILE = "state-cache.json";
18
18
  const SEARCH_MAX_PAGES = 5;
19
19
  function getGistIdPath() {
20
20
  return path.join(getDataDir(), GIST_ID_FILE);
@@ -47,17 +47,22 @@ export class GistStateStore {
47
47
  async push(state) {
48
48
  this.writeCache(state);
49
49
  if (!this.gistId) {
50
- warn(MODULE, 'No gist ID — cannot push');
50
+ warn(MODULE, "No gist ID — cannot push");
51
+ return false;
52
+ }
53
+ const json = JSON.stringify(state, null, 2);
54
+ if (json.length > 900000) {
55
+ warn(MODULE, `State too large for gist (${Math.round(json.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
51
56
  return false;
52
57
  }
53
58
  try {
54
59
  await this.octokit.gists.update({
55
60
  gist_id: this.gistId,
56
61
  files: {
57
- [GIST_FILENAME]: { content: JSON.stringify(state, null, 2) },
62
+ [GIST_FILENAME]: { content: json },
58
63
  },
59
64
  });
60
- debug(MODULE, 'State pushed to gist');
65
+ debug(MODULE, "State pushed to gist");
61
66
  return true;
62
67
  }
63
68
  catch (err) {
@@ -104,7 +109,7 @@ export class GistStateStore {
104
109
  catch (err) {
105
110
  debug(MODULE, `Cached gist ID invalid: ${errorMessage(err)}`);
106
111
  }
107
- debug(MODULE, 'Cached gist ID invalid, searching...');
112
+ debug(MODULE, "Cached gist ID invalid, searching...");
108
113
  }
109
114
  // 2. Search user's gists
110
115
  const foundId = await this.searchForGist();
@@ -117,9 +122,13 @@ export class GistStateStore {
117
122
  this.writeCache(state);
118
123
  return { gistId: foundId, state, created: false };
119
124
  }
125
+ // Gist exists but content failed validation — fall back to cache
126
+ // to avoid overwriting the user's data by creating a new gist.
127
+ warn(MODULE, `Found existing gist ${foundId} but content failed validation. Using local cache to avoid data loss.`);
128
+ return this.bootstrapFromCache();
120
129
  }
121
130
  // 3. Create new gist
122
- debug(MODULE, 'No existing gist found, creating new one');
131
+ debug(MODULE, "No existing gist found, creating new one");
123
132
  const freshState = ScoutStateSchema.parse({ version: 1 });
124
133
  const newId = await this.createGist(freshState);
125
134
  this.saveGistId(newId);
@@ -130,20 +139,20 @@ export class GistStateStore {
130
139
  bootstrapFromCache() {
131
140
  const cached = this.readCache();
132
141
  if (cached) {
133
- debug(MODULE, 'Bootstrapped from local cache (degraded mode)');
142
+ debug(MODULE, "Bootstrapped from local cache (degraded mode)");
134
143
  const cachedId = this.readCachedGistId();
135
144
  if (cachedId)
136
145
  this.gistId = cachedId;
137
146
  return {
138
- gistId: cachedId ?? '',
147
+ gistId: cachedId ?? "",
139
148
  state: cached,
140
149
  created: false,
141
150
  degraded: true,
142
151
  };
143
152
  }
144
- debug(MODULE, 'No cache available, using fresh state (degraded mode)');
153
+ debug(MODULE, "No cache available, using fresh state (degraded mode)");
145
154
  const fresh = ScoutStateSchema.parse({ version: 1 });
146
- return { gistId: '', state: fresh, created: false, degraded: true };
155
+ return { gistId: "", state: fresh, created: false, degraded: true };
147
156
  }
148
157
  // ── Gist API operations ──────────────────────────────────────────────
149
158
  async fetchGistState(gistId) {
@@ -187,28 +196,28 @@ export class GistStateStore {
187
196
  // ── Local file helpers ───────────────────────────────────────────────
188
197
  readCachedGistId() {
189
198
  try {
190
- const id = fs.readFileSync(getGistIdPath(), 'utf-8').trim();
199
+ const id = fs.readFileSync(getGistIdPath(), "utf-8").trim();
191
200
  return id || null;
192
201
  }
193
202
  catch (err) {
194
203
  const code = err?.code;
195
- if (code !== 'ENOENT') {
204
+ if (code !== "ENOENT") {
196
205
  warn(MODULE, `Failed to read cached gist ID: ${errorMessage(err)}`);
197
206
  }
198
207
  return null;
199
208
  }
200
209
  }
201
210
  saveGistId(id) {
202
- fs.writeFileSync(getGistIdPath(), id + '\n', { mode: 0o600 });
211
+ fs.writeFileSync(getGistIdPath(), id + "\n", { mode: 0o600 });
203
212
  }
204
213
  readCache() {
205
214
  try {
206
- const raw = fs.readFileSync(getCachePath(), 'utf-8');
215
+ const raw = fs.readFileSync(getCachePath(), "utf-8");
207
216
  return ScoutStateSchema.parse(JSON.parse(raw));
208
217
  }
209
218
  catch (err) {
210
219
  const code = err?.code;
211
- if (code !== 'ENOENT') {
220
+ if (code !== "ENOENT") {
212
221
  warn(MODULE, `Failed to read state cache: ${errorMessage(err)}`);
213
222
  }
214
223
  return null;
@@ -216,7 +225,9 @@ export class GistStateStore {
216
225
  }
217
226
  writeCache(state) {
218
227
  try {
219
- fs.writeFileSync(getCachePath(), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
228
+ fs.writeFileSync(getCachePath(), JSON.stringify(state, null, 2) + "\n", {
229
+ mode: 0o600,
230
+ });
220
231
  }
221
232
  catch (err) {
222
233
  warn(MODULE, `Failed to write cache: ${errorMessage(err)}`);
@@ -242,8 +253,10 @@ export function mergeStates(local, remote) {
242
253
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
243
254
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
244
255
  savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
256
+ skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
245
257
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
246
- lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? new Date().toISOString(),
258
+ lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ??
259
+ new Date().toISOString(),
247
260
  gistId: remote.gistId ?? local.gistId,
248
261
  };
249
262
  }
@@ -266,7 +279,9 @@ function mergeStarredRepos(local, remote) {
266
279
  const localTs = local.starredReposLastFetched;
267
280
  const remoteTs = remote.starredReposLastFetched;
268
281
  if (!localTs && !remoteTs)
269
- return remote.starredRepos.length >= local.starredRepos.length ? remote.starredRepos : local.starredRepos;
282
+ return remote.starredRepos.length >= local.starredRepos.length
283
+ ? remote.starredRepos
284
+ : local.starredRepos;
270
285
  if (!localTs)
271
286
  return remote.starredRepos;
272
287
  if (!remoteTs)
@@ -293,6 +308,18 @@ function mergeSavedResults(local, remote) {
293
308
  }
294
309
  return [...merged.values()];
295
310
  }
311
+ function mergeSkippedIssues(local, remote) {
312
+ const merged = new Map();
313
+ for (const item of local)
314
+ merged.set(item.url, item);
315
+ for (const item of remote) {
316
+ const existing = merged.get(item.url);
317
+ if (!existing || item.skippedAt > existing.skippedAt) {
318
+ merged.set(item.url, item);
319
+ }
320
+ }
321
+ return [...merged.values()];
322
+ }
296
323
  function pickFresherTimestamp(a, b) {
297
324
  if (!a)
298
325
  return b;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared GitHub API client with rate limiting and throttling.
3
3
  */
4
- import { Octokit } from '@octokit/rest';
4
+ import { Octokit } from "@octokit/rest";
5
5
  interface RateLimitInfo {
6
6
  remaining: number;
7
7
  limit: number;
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Shared GitHub API client with rate limiting and throttling.
3
3
  */
4
- import { Octokit } from '@octokit/rest';
5
- import { throttling } from '@octokit/plugin-throttling';
6
- import { warn } from './logger.js';
7
- const MODULE = 'github';
4
+ import { Octokit } from "@octokit/rest";
5
+ import { throttling } from "@octokit/plugin-throttling";
6
+ import { warn } from "./logger.js";
7
+ const MODULE = "github";
8
8
  const ThrottledOctokit = Octokit.plugin(throttling);
9
9
  let _octokit = null;
10
10
  let _currentToken = null;
11
11
  function formatResetTime(date) {
12
- return date.toLocaleTimeString('en-US', { hour12: false });
12
+ return date.toLocaleTimeString("en-US", { hour12: false });
13
13
  }
14
14
  export function getRateLimitCallbacks() {
15
15
  return {