co-maintainer 0.4.9 → 0.4.11

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.9",
3
+ "version": "0.4.11",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,6 +53,7 @@ export async function parseArgs(args) {
53
53
  console.log(" --include-commit-history --include-how-repo-works");
54
54
  console.log(" --max-commits=N --max-pr-months=N --max-pull-request-change-lines=N --max-comment=N");
55
55
  console.log(" --only-request-changed-pr");
56
+ console.log(" --pr-state=open,closed,merged");
56
57
  console.log(" --auth=gh|pat --github-pat=... --ai=none|openrouter|hetzner --token=... --low-model=... --high-model=...");
57
58
  process.exit(0);
58
59
  }
@@ -111,6 +112,24 @@ export async function parseArgs(args) {
111
112
  : undefined;
112
113
  };
113
114
  const text = (name) => rest.find((item) => item.startsWith(`--${name}=`))?.slice(name.length + 3);
115
+ const prStateOption = (args, saved) => {
116
+ const prefix = "--pr-state=";
117
+ const raw = args
118
+ .find((arg) => arg.startsWith(prefix))
119
+ ?.slice(prefix.length);
120
+ if (raw === undefined)
121
+ return saved && saved.length > 0 ? saved : undefined;
122
+ const allowed = ["open", "closed", "merged"];
123
+ const parts = raw
124
+ .split(",")
125
+ .map((part) => part.trim())
126
+ .filter(Boolean);
127
+ if (parts.length === 0 ||
128
+ parts.some((part) => !allowed.includes(part))) {
129
+ die("pr-state must be a comma-separated list of: open, closed, merged");
130
+ }
131
+ return [...new Set(parts)];
132
+ };
114
133
  const choice = (name, allowed, fallback) => {
115
134
  const prefix = `--${name}=`;
116
135
  const raw = rest
@@ -130,6 +149,7 @@ export async function parseArgs(args) {
130
149
  "max-pr-months",
131
150
  "max-pull-request-change-lines",
132
151
  "max-comment",
152
+ "pr-state",
133
153
  "improve-matrix",
134
154
  "env",
135
155
  "gh-concurrent",
@@ -245,6 +265,7 @@ export async function parseArgs(args) {
245
265
  includeHowRepoWorks: enabled("include-how-repo-works"),
246
266
  onlyRequestChangedPr: rest.includes("--only-request-changed-pr") ||
247
267
  repoConfig.onlyRequestChangedPr === true,
268
+ prState: prStateOption(rest, repoConfig.prState),
248
269
  maxCommits: value("max-commits") ?? repoConfig.maxCommits ?? configDefault.maxCommits,
249
270
  maxPrMonths: value("max-pr-months") ??
250
271
  repoConfig.maxPrMonths ??
@@ -18,6 +18,7 @@ export type RepoConfig = {
18
18
  includeCommitHistory?: boolean;
19
19
  includeHowRepoWorks?: boolean;
20
20
  onlyRequestChangedPr?: boolean;
21
+ prState?: ("open" | "closed" | "merged")[];
21
22
  /** Five field cron in UTC, checked by `serve` to queue a remake. */
22
23
  remakeCron?: string;
23
24
  };
@@ -30,6 +30,36 @@ function listingCovers(cachedMonths, current) {
30
30
  return false;
31
31
  return cachedMonths >= current;
32
32
  }
33
+ function stateKey(states) {
34
+ if (!states || states.length === 0)
35
+ return "";
36
+ return [...states].sort().join(",");
37
+ }
38
+ /** `closed` is closed and not merged. GitHub's list has no merged state. */
39
+ function matchesPrState(pr, states) {
40
+ if (!states || states.length === 0)
41
+ return true;
42
+ const merged = Boolean(pr.merged_at);
43
+ const state = String(pr.state ?? "");
44
+ return states.some((wanted) => {
45
+ if (wanted === "merged")
46
+ return merged;
47
+ if (wanted === "open")
48
+ return state === "open";
49
+ return state === "closed" && !merged;
50
+ });
51
+ }
52
+ function listQueryState(states) {
53
+ if (!states || states.length === 0)
54
+ return "all";
55
+ const wantsOpen = states.includes("open");
56
+ const wantsClosed = states.includes("closed") || states.includes("merged");
57
+ if (wantsOpen && wantsClosed)
58
+ return "all";
59
+ if (wantsOpen)
60
+ return "open";
61
+ return "closed";
62
+ }
33
63
  async function loadListing(repo) {
34
64
  const raw = await cacheGet("pr-listing", repo);
35
65
  if (!raw)
@@ -39,20 +69,26 @@ async function loadListing(repo) {
39
69
  return undefined;
40
70
  return parsed;
41
71
  }
42
- async function saveListing(repo, maxPrMonths, items) {
43
- await cacheSet("pr-listing", repo, JSON.stringify({ maxPrMonths: maxPrMonths ?? 0, items }));
72
+ async function saveListing(repo, maxPrMonths, states, items) {
73
+ await cacheSet("pr-listing", repo, JSON.stringify({
74
+ maxPrMonths: maxPrMonths ?? 0,
75
+ states: stateKey(states),
76
+ items,
77
+ }));
44
78
  }
45
79
  async function listPullRequestPages(client, options, phase) {
46
80
  const cached = await loadListing(options.repo);
47
81
  const canCatchUp = cached !== undefined &&
48
- listingCovers(cached.maxPrMonths, options.maxPrMonths);
82
+ listingCovers(cached.maxPrMonths, options.maxPrMonths) &&
83
+ (cached.states ?? "") === stateKey(options.prState);
49
84
  const cachedByNumber = new Map((cached?.items ?? []).map((pr) => [Number(pr.number), pr]));
50
85
  const selected = [];
51
86
  const seen = new Set();
52
87
  const concurrency = Math.max(1, options.ghConcurrent);
53
- log("fetch", `pull request listing · concurrency=${concurrency}`);
88
+ const states = stateKey(options.prState) || "all";
89
+ log("fetch", `pull request listing · ${states} · concurrency=${concurrency}`);
54
90
  const fetchPage = async (page) => {
55
- const pageItems = await client.request(`repos/${options.repo}/pulls?state=all&sort=updated&direction=desc&per_page=100&page=${page}`);
91
+ const pageItems = await client.request(`repos/${options.repo}/pulls?state=${listQueryState(options.prState)}&sort=updated&direction=desc&per_page=100&page=${page}`);
56
92
  return Array.isArray(pageItems) ? pageItems : [];
57
93
  };
58
94
  const ingest = (page, pageItems) => {
@@ -72,6 +108,8 @@ async function listPullRequestPages(client, options, phase) {
72
108
  reason = "window reached";
73
109
  break;
74
110
  }
111
+ if (!matchesPrState(pr, options.prState))
112
+ continue;
75
113
  const number = Number(pr.number);
76
114
  selected.push(pr);
77
115
  seen.add(number);
@@ -111,6 +149,8 @@ async function listPullRequestPages(client, options, phase) {
111
149
  if (!withinPrWindow(String(pr.updated_at ?? ""), options.maxPrMonths)) {
112
150
  continue;
113
151
  }
152
+ if (!matchesPrState(pr, options.prState))
153
+ continue;
114
154
  selected.push(pr);
115
155
  seen.add(number);
116
156
  reused++;
@@ -119,7 +159,7 @@ async function listPullRequestPages(client, options, phase) {
119
159
  log("fetch", `pull request listing · reused ${reused} cached PRs · ${selected.length} total`);
120
160
  }
121
161
  }
122
- await saveListing(options.repo, options.maxPrMonths, selected);
162
+ await saveListing(options.repo, options.maxPrMonths, options.prState, selected);
123
163
  return selected;
124
164
  }
125
165
  async function mapPool(items, concurrency, fn) {
@@ -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 };
@@ -204,19 +204,19 @@ export async function runInitOrRemake(options) {
204
204
  let result = await timed("assemble skill", options.logTime, () => assembleSkill(options.repo, facts, previousMarkdown, previous?.sectionHashes ?? {}, overrides));
205
205
  const reviewDocuments = buildReviewDocuments(facts);
206
206
  result.markdown = addReviewLink(result.markdown, reviewDocuments);
207
- const validation = await timed("validate skill", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source));
207
+ const validation = await timed("validate skill", options.logTime, () => validateSkill(result.markdown, source));
208
208
  if (!validation.valid && Object.keys(overrides).length) {
209
209
  log("validate", `AI output rejected: ${validation.errors.join("; ")}`);
210
210
  overrides = {};
211
211
  result = await timed("reassemble valid skill", options.logTime, () => assembleSkill(options.repo, facts, previousMarkdown, previous?.sectionHashes ?? {}, overrides));
212
212
  result.markdown = addReviewLink(result.markdown, reviewDocuments);
213
213
  }
214
- const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source, "warning"));
214
+ const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, source, "warning"));
215
215
  if (finalValidation.warnings.length) {
216
216
  log("validate", `stale references kept: ${finalValidation.warnings.join(", ")}`);
217
217
  }
218
218
  if (!finalValidation.valid) {
219
- throw new Error(`generated skill is invalid: ${finalValidation.errors.join("; ")}`);
219
+ log("validate", `skill problems kept: ${finalValidation.errors.join("; ")}`);
220
220
  }
221
221
  await timed("write skill and state", options.logTime, async () => {
222
222
  const { withKnowledgeLock } = await import("../review/guides.js");
@@ -260,6 +260,7 @@ export async function runInitOrRemake(options) {
260
260
  includeCommitHistory: options.includeCommitHistory,
261
261
  includeHowRepoWorks: options.includeHowRepoWorks,
262
262
  onlyRequestChangedPr: options.onlyRequestChangedPr,
263
+ prState: options.prState,
263
264
  });
264
265
  });
265
266
  log("write", `${path} · ${result.changed.length
@@ -308,6 +309,7 @@ export function optionsFromConfig(repo, command) {
308
309
  includeCommitHistory: repoConfig.includeCommitHistory ?? true,
309
310
  includeHowRepoWorks: repoConfig.includeHowRepoWorks ?? true,
310
311
  onlyRequestChangedPr: repoConfig.onlyRequestChangedPr === true,
312
+ prState: repoConfig.prState,
311
313
  maxCommits: repoConfig.maxCommits ?? config.defaults?.maxCommits,
312
314
  maxPrMonths: repoConfig.maxPrMonths ?? config.defaults?.maxPrMonths,
313
315
  maxPullRequestChangeLines: repoConfig.maxPullRequestChangeLines ??
@@ -37,6 +37,8 @@ export type Options = {
37
37
  /** Keep only pull requests in the window that have at least one
38
38
  * `CHANGES_REQUESTED` review. Off by default, so the window is unchanged. */
39
39
  onlyRequestChangedPr: boolean;
40
+ /** Empty or omitted means every state. `closed` is closed and not merged. */
41
+ prState?: ("open" | "closed" | "merged")[];
40
42
  maxCommits?: number;
41
43
  maxPrMonths?: number;
42
44
  maxPullRequestChangeLines?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {