co-maintainer 0.4.10 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import type { GitHubClient, Options } from "../types.ts";
2
2
  import type { PullRequest, Source, State } from "../knowledge/types.ts";
3
- export declare function collectSource(client: GitHubClient, options: Options, previous?: State, progress?: (items: PullRequest[]) => Promise<void>, codebaseProgress?: (data: {
3
+ export declare function collectSource(client: GitHubClient, options: Options, previous?: State, progress?: (items: PullRequest[], cache: PullRequest[]) => Promise<void>, codebaseProgress?: (data: {
4
4
  tree: string[];
5
5
  treeSha: Record<string, string>;
6
6
  files: Record<string, string>;
@@ -248,8 +248,10 @@ async function pullRequests(client, options, previous, phase, progress) {
248
248
  if (phase)
249
249
  phase.text = "listing pull requests · page 0 · fetched 0";
250
250
  const selected = await listPullRequestPages(client, options, phase);
251
- const previousByNumber = new Map((previous?.source.pullRequests ?? []).map((pr) => [pr.number, pr]));
251
+ const keptByNumber = new Map((previous?.source.pullRequests ?? []).map((pr) => [pr.number, pr]));
252
+ const cacheByNumber = new Map((previous?.source.pullRequestCache ?? []).map((pr) => [pr.number, pr]));
252
253
  const result = new Array(selected.length);
254
+ const cache = new Array(selected.length);
253
255
  let completed = 0;
254
256
  let save = Promise.resolve();
255
257
  if (phase) {
@@ -258,7 +260,8 @@ async function pullRequests(client, options, previous, phase, progress) {
258
260
  log("fetch", `${selected.length} pull requests · concurrency=${options.ghConcurrent}`);
259
261
  await mapPool(selected, options.ghConcurrent, async (pr, index) => {
260
262
  const number = Number(pr.number);
261
- const cached = previousByNumber.get(number);
263
+ const full = keptByNumber.get(number);
264
+ const cached = full ?? cacheByNumber.get(number);
262
265
  const listedAdditions = Number(pr.additions);
263
266
  const listedDeletions = Number(pr.deletions);
264
267
  const listedStatsAvailable = Number.isFinite(listedAdditions) &&
@@ -308,10 +311,12 @@ async function pullRequests(client, options, previous, phase, progress) {
308
311
  current.changesRequested = cached.changesRequested;
309
312
  }
310
313
  const only = options.onlyRequestChangedPr;
311
- const discussionUnchanged = cached?.updatedAt === current.updatedAt;
314
+ const discussionUnchanged = full?.updatedAt === current.updatedAt;
312
315
  const decisionKnown = typeof cached?.changesRequested === "boolean";
313
- const diffUnchanged = cached?.headSha === current.headSha && Boolean(cached?.diff);
314
- let droppedFromCache = only && discussionUnchanged && cached?.changesRequested === false;
316
+ const diffUnchanged = full?.headSha === current.headSha && Boolean(full?.diff);
317
+ let droppedFromCache = only &&
318
+ cached?.updatedAt === current.updatedAt &&
319
+ cached.changesRequested === false;
315
320
  let dropped = droppedFromCache;
316
321
  const loadComments = async () => {
317
322
  const comments = await client.pages(`repos/${options.repo}/issues/${number}/comments`);
@@ -383,6 +388,7 @@ async function pullRequests(client, options, previous, phase, progress) {
383
388
  current.diff = "";
384
389
  }
385
390
  }
391
+ cache[index] = current;
386
392
  if (!dropped)
387
393
  result[index] = current;
388
394
  completed++;
@@ -391,29 +397,82 @@ async function pullRequests(client, options, previous, phase, progress) {
391
397
  }
392
398
  log("fetch", `PR ${completed}/${selected.length} · ${percent(completed, selected.length)} #${number} · ${discussionStatus} · ${diffStatus}`);
393
399
  if (progress) {
394
- save = save.then(() => progress(result.filter((item) => !!item)));
400
+ save = save.then(() => progress(result.filter((item) => !!item), cache.filter((item) => !!item)));
395
401
  await save;
396
402
  }
397
403
  });
398
404
  const kept = result.filter((item) => !!item);
405
+ const decided = cache.filter((item) => !!item);
399
406
  if (options.onlyRequestChangedPr) {
400
407
  log("fetch", `only request-changed pr · kept ${kept.length} · dropped ${selected.length - kept.length}`);
401
408
  }
402
- return kept;
409
+ return { kept, cache: decided };
403
410
  }
404
- async function commits(client, options, meta, phase) {
411
+ function commitLimit(maxCommits) {
412
+ return maxCommits && maxCommits > 0 ? maxCommits : undefined;
413
+ }
414
+ function commitsCovered(cached, previousMax, limit) {
415
+ if (cached.length === 0)
416
+ return false;
417
+ const previousAll = commitLimit(previousMax) === undefined;
418
+ if (limit === undefined)
419
+ return previousAll;
420
+ return previousAll || cached.length >= limit;
421
+ }
422
+ async function commits(client, options, meta, previous, phase) {
405
423
  const branch = String(meta.default_branch ?? "main");
406
- const limit = options.maxCommits && options.maxCommits > 0
407
- ? options.maxCommits
408
- : undefined;
409
- if (phase)
410
- phase.text = "commit history · page 0 · fetched 0";
411
- return client.pages(`repos/${options.repo}/commits?sha=${encodeURIComponent(branch)}`, limit, (page, fetched) => {
424
+ const limit = commitLimit(options.maxCommits);
425
+ const cached = previous?.source.commits ?? [];
426
+ const cachedTip = String(cached[0]?.sha ?? "");
427
+ const endpoint = `repos/${options.repo}/commits?sha=${encodeURIComponent(branch)}`;
428
+ const note = (page, fetched) => {
412
429
  const message = `commit history · page ${page} · fetched ${fetched} · total unknown`;
413
430
  if (phase)
414
431
  phase.text = message;
415
432
  log("fetch", message);
416
- });
433
+ };
434
+ if (phase)
435
+ phase.text = "commit history · page 0 · fetched 0";
436
+ if (!cachedTip) {
437
+ return client.pages(endpoint, limit, note);
438
+ }
439
+ const fresh = [];
440
+ let found = false;
441
+ for (let page = 1;; page++) {
442
+ const pageItems = await client.request(`${endpoint}&per_page=100&page=${page}`);
443
+ const items = Array.isArray(pageItems) ? pageItems : [];
444
+ for (const commit of items) {
445
+ if (String(commit.sha ?? "") === cachedTip) {
446
+ found = true;
447
+ break;
448
+ }
449
+ fresh.push(commit);
450
+ if (limit !== undefined && fresh.length >= limit)
451
+ break;
452
+ }
453
+ note(page, fresh.length);
454
+ if (found ||
455
+ items.length < 100 ||
456
+ (limit !== undefined && fresh.length >= limit)) {
457
+ break;
458
+ }
459
+ }
460
+ if (!found || !commitsCovered(cached, previous?.options.maxCommits, limit)) {
461
+ if (found)
462
+ return client.pages(endpoint, limit, note);
463
+ return limit === undefined ? fresh : fresh.slice(0, limit);
464
+ }
465
+ const selected = [...fresh];
466
+ for (const commit of cached) {
467
+ if (limit !== undefined && selected.length >= limit)
468
+ break;
469
+ selected.push(commit);
470
+ }
471
+ const reused = selected.length - fresh.length;
472
+ if (reused > 0) {
473
+ log("fetch", `commit history · reused ${reused} cached commits · ${selected.length} total`);
474
+ }
475
+ return selected;
417
476
  }
418
477
  export async function collectSource(client, options, previous, progress, codebaseProgress) {
419
478
  const phase = { text: "reading repository metadata" };
@@ -426,16 +485,17 @@ export async function collectSource(client, options, previous, progress, codebas
426
485
  : { tree: [], treeSha: {}, files: {} };
427
486
  const pullRequestData = options.includePullRequests
428
487
  ? await pullRequests(client, options, previous, phase, progress)
429
- : [];
488
+ : { kept: [], cache: previous?.source.pullRequestCache ?? [] };
430
489
  const commitData = options.includeCommitHistory
431
- ? await commits(client, options, repo, phase)
490
+ ? await commits(client, options, repo, previous, phase)
432
491
  : [];
433
492
  return {
434
493
  repo,
435
494
  tree: files.tree,
436
495
  treeSha: files.treeSha,
437
496
  files: files.files,
438
- pullRequests: pullRequestData,
497
+ pullRequests: pullRequestData.kept,
498
+ pullRequestCache: pullRequestData.cache,
439
499
  commits: commitData,
440
500
  };
441
501
  }
@@ -23,7 +23,11 @@ export type Source = {
23
23
  tree: string[];
24
24
  treeSha: Record<string, string>;
25
25
  files: Record<string, string>;
26
+ /** Pull requests selected for facts and the skill. */
26
27
  pullRequests: PullRequest[];
28
+ /** Every pull request already decided, including ones left out of the skill.
29
+ * The next fetch reads this so a `changesRequested: false` result is not downloaded again. */
30
+ pullRequestCache?: PullRequest[];
27
31
  commits: Json[];
28
32
  };
29
33
  export type Fact = {
@@ -4,4 +4,4 @@ export type ValidationResult = {
4
4
  errors: string[];
5
5
  warnings: string[];
6
6
  };
7
- export declare function validateSkill(markdown: string, outputDirectory: string, source?: Source, referenceIssues?: "error" | "warning"): Promise<ValidationResult>;
7
+ export declare function validateSkill(markdown: string, source?: Source, referenceIssues?: "error" | "warning"): Promise<ValidationResult>;
@@ -1,5 +1,4 @@
1
- import { stat } from "../util/runtime.js";
2
- export async function validateSkill(markdown, outputDirectory, source, referenceIssues = "error") {
1
+ export async function validateSkill(markdown, source, referenceIssues = "error") {
3
2
  const errors = [];
4
3
  const warnings = [];
5
4
  const referenceProblems = referenceIssues === "error" ? errors : warnings;
@@ -33,54 +32,6 @@ export async function validateSkill(markdown, outputDirectory, source, reference
33
32
  if (source) {
34
33
  const paths = new Set([...source.tree, ...Object.keys(source.files)]);
35
34
  const repositoryName = String(source.repo.full_name ?? "");
36
- const commands = new Set();
37
- for (const [path, content] of Object.entries(source.files)) {
38
- if (path.endsWith("package.json")) {
39
- try {
40
- const scripts = JSON.parse(content)
41
- .scripts ?? {};
42
- const prefix = source.tree.some((item) => /pnpm-lock\.yaml$/.test(item))
43
- ? "pnpm"
44
- : source.tree.some((item) => /yarn\.lock$/.test(item))
45
- ? "yarn"
46
- : "npm";
47
- for (const name of Object.keys(scripts)) {
48
- commands.add(`${prefix} ${name}`);
49
- commands.add(`${prefix} run ${name}`);
50
- commands.add(String(scripts[name]));
51
- }
52
- }
53
- catch {
54
- // Ignore malformed manifests; syntax validation is outside this gate.
55
- }
56
- }
57
- if (/deno\.jsonc?$/.test(path)) {
58
- try {
59
- const tasks = JSON.parse(content).tasks ??
60
- {};
61
- for (const [name, task] of Object.entries(tasks)) {
62
- commands.add(`deno task ${name}`);
63
- commands.add(String(task));
64
- }
65
- }
66
- catch {
67
- // Ignore malformed manifests; syntax validation is outside this gate.
68
- }
69
- }
70
- for (const match of content.matchAll(/^\s*run:\s*([^\s#].*?)\s*$/gm)) {
71
- commands.add(match[1].replace(/^['"]|['"]$/g, ""));
72
- }
73
- for (const match of content.matchAll(/`((?:npm|pnpm|yarn|bun|deno|cargo|make|go|python|node|git)\s+[^`\n]+)`/g)) {
74
- commands.add(match[1]);
75
- }
76
- for (const match of content.matchAll(/^\s*((?:npm|pnpm|yarn|bun|deno|cargo|make|go|python|node|git)\s+[^\n`]+)$/gm)) {
77
- commands.add(match[1].trim());
78
- }
79
- }
80
- if (source.tree.some((path) => /Cargo\.toml$/.test(path))) {
81
- commands.add("cargo test");
82
- commands.add("cargo build");
83
- }
84
35
  const references = [...markdown.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
85
36
  for (const reference of references) {
86
37
  const isQualifier = /^[a-z][a-z\d_-]*:/i.test(reference);
@@ -109,27 +60,14 @@ export async function validateSkill(markdown, outputDirectory, source, reference
109
60
  referenceProblems.push(`referenced path is absent from source: ${reference}`);
110
61
  }
111
62
  }
112
- if (/^(?:npm|pnpm|yarn|bun|deno|cargo|make|go|python|node|git)\s/.test(reference) &&
113
- ![...commands].some((command) => reference === command ||
114
- reference.startsWith(`${command} `) ||
115
- command.startsWith(`${reference} `))) {
116
- referenceProblems.push(`referenced command is absent from source: ${reference}`);
117
- }
118
63
  }
119
64
  }
120
- const links = [...markdown.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)].map((match) => match[1]);
121
- for (const link of links) {
122
- if (/^[a-z][a-z\d+.-]*:/i.test(link) ||
123
- link.startsWith("/") ||
124
- link.includes("..")) {
125
- errors.push(`link is not relative: ${link}`);
126
- continue;
127
- }
128
- try {
129
- await stat(`${outputDirectory}/${link}`);
130
- }
131
- catch {
132
- errors.push(`linked file does not exist: ${link}`);
65
+ for (const link of markdown.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) {
66
+ const target = link[1];
67
+ if (/^[a-z][a-z\d+.-]*:/i.test(target) ||
68
+ target.startsWith("/") ||
69
+ target.includes("..")) {
70
+ errors.push(`link is not relative: ${target}`);
133
71
  }
134
72
  }
135
73
  return { valid: errors.length === 0, errors, warnings };
@@ -136,8 +136,9 @@ export async function runInitOrRemake(options) {
136
136
  log("ai", `${options.ai} providers configured · low=${options.lowModel} · high=${options.highModel}`);
137
137
  }
138
138
  log("fetch", `${options.repo} via ${options.auth}`);
139
- const source = await timed("fetch repository data", options.logTime, () => collectSource(client, options, previous, async (pullRequests) => {
139
+ const source = await timed("fetch repository data", options.logTime, () => collectSource(client, options, previous, async (pullRequests, cache) => {
140
140
  checkpoint.source.pullRequests = pullRequests;
141
+ checkpoint.source.pullRequestCache = cache;
141
142
  checkpoint.scanDone = {
142
143
  ...checkpoint.scanDone,
143
144
  pullRequests: pullRequests.length,
@@ -204,19 +205,19 @@ export async function runInitOrRemake(options) {
204
205
  let result = await timed("assemble skill", options.logTime, () => assembleSkill(options.repo, facts, previousMarkdown, previous?.sectionHashes ?? {}, overrides));
205
206
  const reviewDocuments = buildReviewDocuments(facts);
206
207
  result.markdown = addReviewLink(result.markdown, reviewDocuments);
207
- const validation = await timed("validate skill", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source));
208
+ const validation = await timed("validate skill", options.logTime, () => validateSkill(result.markdown, source));
208
209
  if (!validation.valid && Object.keys(overrides).length) {
209
210
  log("validate", `AI output rejected: ${validation.errors.join("; ")}`);
210
211
  overrides = {};
211
212
  result = await timed("reassemble valid skill", options.logTime, () => assembleSkill(options.repo, facts, previousMarkdown, previous?.sectionHashes ?? {}, overrides));
212
213
  result.markdown = addReviewLink(result.markdown, reviewDocuments);
213
214
  }
214
- const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source, "warning"));
215
+ const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, source, "warning"));
215
216
  if (finalValidation.warnings.length) {
216
217
  log("validate", `stale references kept: ${finalValidation.warnings.join(", ")}`);
217
218
  }
218
219
  if (!finalValidation.valid) {
219
- throw new Error(`generated skill is invalid: ${finalValidation.errors.join("; ")}`);
220
+ log("validate", `skill problems kept: ${finalValidation.errors.join("; ")}`);
220
221
  }
221
222
  await timed("write skill and state", options.logTime, async () => {
222
223
  const { withKnowledgeLock } = await import("../review/guides.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {