co-maintainer 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,7 @@ import { recoverOrphans, startWorkerLoop, stopWorkerLoop, } from "../../services
6
6
  import { registerSetupJobHandler } from "../../services/setup.js";
7
7
  import { registerReviewJobHandler } from "../../services/review.js";
8
8
  import { recoverReplyRequests, registerReplyJobHandler, } from "../../services/replies.js";
9
+ import { startRemakeScheduler, stopRemakeScheduler, } from "../../services/remake_cron.js";
9
10
  import { registerRemoteReviewHandler } from "../../services/remote_review.js";
10
11
  import { startRemoteWatchdog } from "../../remote/server/sessions.js";
11
12
  import { passwordProblem } from "../../util/password.js";
@@ -164,6 +165,7 @@ export async function runServe(args) {
164
165
  console.log(`[serve] requeued ${recoveredReplies} unfinished conversation repl${recoveredReplies === 1 ? "y" : "ies"}`);
165
166
  }
166
167
  startWorkerLoop();
168
+ startRemakeScheduler();
167
169
  if (auth.github)
168
170
  console.log(`[serve] GitHub sign-in enabled for ${githubOAuth.allowedUser}`);
169
171
  const inject500 = args.includes("--inject-500") || getEnv("CM_INJECT_500") === "1";
@@ -188,6 +190,7 @@ export async function runServe(args) {
188
190
  return;
189
191
  shuttingDown = true;
190
192
  console.log(`[serve] received ${signal}, stopping new requests and closing app.db`);
193
+ stopRemakeScheduler();
191
194
  await stopWorkerLoop();
192
195
  await server.shutdown();
193
196
  await closeAppDb();
@@ -17,6 +17,8 @@ export type RepoConfig = {
17
17
  includePullRequestChanges?: boolean;
18
18
  includeCommitHistory?: boolean;
19
19
  includeHowRepoWorks?: boolean;
20
+ /** Five field cron in UTC, checked by `serve` to queue a remake. */
21
+ remakeCron?: string;
20
22
  };
21
23
  export type UserConfig = {
22
24
  auth?: "gh" | "pat";
@@ -2,6 +2,7 @@ import { errorResponse } from "../errors.js";
2
2
  import { findInstallationForRepo } from "../../github/app.js";
3
3
  import { activateRepo, deactivateRepo, listActiveRepos, updateRepoSettings, } from "../../store/repos.js";
4
4
  import { writeRepoConfig } from "../../config.js";
5
+ import { parseRemakeCron } from "../../services/remake_cron.js";
5
6
  import { enqueueSetup } from "../../services/setup.js";
6
7
  import { enqueueManualReview } from "../../services/review.js";
7
8
  import { prDetail, repoKnowledge, RepoNotFound, repoOverview, repoPulls, requireActiveRepo, } from "../../services/dashboard.js";
@@ -64,6 +65,25 @@ export async function handleReposRoute(request, url, githubApp) {
64
65
  catch {
65
66
  return errorResponse(400, "bad_request", "expected a JSON body");
66
67
  }
68
+ const cronPatch = {};
69
+ if ("remakeCron" in body) {
70
+ const raw = body.remakeCron;
71
+ if (raw === null || raw === "") {
72
+ cronPatch.remakeCron = undefined;
73
+ }
74
+ else if (typeof raw === "string") {
75
+ try {
76
+ parseRemakeCron(raw);
77
+ }
78
+ catch (error) {
79
+ return errorResponse(422, "invalid_cron", error instanceof Error ? error.message : String(error));
80
+ }
81
+ cronPatch.remakeCron = raw.trim();
82
+ }
83
+ else {
84
+ return errorResponse(422, "invalid_cron", "remakeCron must be text or null");
85
+ }
86
+ }
67
87
  const patch = {};
68
88
  if (typeof body.autoReview === "boolean") {
69
89
  patch.auto_review = body.autoReview ? 1 : 0;
@@ -93,8 +113,9 @@ export async function handleReposRoute(request, url, githubApp) {
93
113
  init[key] = body[key];
94
114
  }
95
115
  }
96
- if (Object.keys(init).length > 0)
97
- await writeRepoConfig(fullName, init);
116
+ if (Object.keys(init).length > 0 || "remakeCron" in cronPatch) {
117
+ await writeRepoConfig(fullName, { ...init, ...cronPatch });
118
+ }
98
119
  return Response.json({ ok: true });
99
120
  }
100
121
  if (!sub && request.method === "DELETE") {
@@ -1,4 +1,24 @@
1
1
  import { html, layout, repoNav, skSlot, text } from "./layout.js";
2
+ import { parseRemakeCron } from "../../services/remake_cron.js";
3
+ import { nextCronRun } from "../../util/cron.js";
4
+ function cronHint(repo, expression) {
5
+ const notBuilt = repo.knowledge_built_at
6
+ ? ""
7
+ : " A schedule only runs after the first setup has finished.";
8
+ if (!expression) {
9
+ return `Leave blank to turn it off. For example, 0 3 * * 1 runs every Monday at 03:00.${notBuilt}`;
10
+ }
11
+ try {
12
+ const next = nextCronRun(parseRemakeCron(expression), new Date());
13
+ if (!next)
14
+ return `This schedule has no run in the next year.${notBuilt}`;
15
+ return `Next run ${next.toISOString().slice(0, 16).replace("T", " ")} UTC.${notBuilt}`;
16
+ }
17
+ catch (error) {
18
+ const reason = error instanceof Error ? error.message : String(error);
19
+ return `This schedule is ignored, ${reason}.${notBuilt}`;
20
+ }
21
+ }
2
22
  export function renderRepoSettings(username, repo, config) {
3
23
  const name = repo.full_name;
4
24
  const skip = repo.skip_drafts && repo.skip_bots
@@ -68,6 +88,17 @@ export function renderRepoSettings(username, repo, config) {
68
88
  </div>
69
89
  <div class="ft"><button class="primary" id="save-init">Save</button></div>
70
90
  </div>
91
+ <div class="card" data-async>
92
+ ${skSlot()}
93
+ <div class="hd"><h2>Scheduled remake</h2></div>
94
+ <div class="bd">
95
+ <p class="muted" style="margin:0 0 18px">Rebuild the knowledge for this repository on a schedule. Times are UTC and a remake runs at most once an hour.</p>
96
+ <div class="field wide"><label>Cron schedule</label>
97
+ <input id="cron" placeholder="0 3 * * 1" value="${text(config.remakeCron ?? "")}">
98
+ <div class="hint">${text(cronHint(repo, config.remakeCron))}</div></div>
99
+ </div>
100
+ <div class="ft"><button class="primary" id="save-cron">Save</button></div>
101
+ </div>
71
102
  <div class="card" data-async>
72
103
  ${skSlot()}
73
104
  <div class="hd"><h2>Remove</h2></div>
@@ -114,6 +145,9 @@ document.getElementById("save-init").addEventListener("click", function() {
114
145
  maxComments: num("comments")
115
146
  });
116
147
  });
148
+ document.getElementById("save-cron").addEventListener("click", function() {
149
+ save(this, { remakeCron: document.getElementById("cron").value.trim() });
150
+ });
117
151
  document.getElementById("remove").addEventListener("click", function() {
118
152
  if (!confirm("Stop reviewing this repository?")) return;
119
153
  var btn = this;
@@ -0,0 +1,8 @@
1
+ import type { Cron } from "../util/cron.ts";
2
+ /** A remake costs model tokens, so a schedule may fire at most once an hour. */
3
+ export declare function parseRemakeCron(expression: string): Cron;
4
+ /** Each minute is handled once however often the ticker is called. A minute
5
+ * the process slept through is not made up. */
6
+ export declare function createRemakeCronTicker(): (now: Date) => string[];
7
+ export declare function startRemakeScheduler(intervalMs?: number): void;
8
+ export declare function stopRemakeScheduler(): void;
@@ -0,0 +1,63 @@
1
+ import { readConfig } from "../config.js";
2
+ import { getQueuedJobByKey, getRunningJobByKey } from "../store/jobs.js";
3
+ import { listActiveRepos } from "../store/repos.js";
4
+ import { cronMatches, parseCron } from "../util/cron.js";
5
+ import { enqueueSetup } from "./setup.js";
6
+ /** A remake costs model tokens, so a schedule may fire at most once an hour. */
7
+ export function parseRemakeCron(expression) {
8
+ const cron = parseCron(expression);
9
+ if (cron.minutes.size !== 1) {
10
+ throw new Error("the minute field must be a single value, so a remake runs at most once an hour");
11
+ }
12
+ return cron;
13
+ }
14
+ /** Each minute is handled once however often the ticker is called. A minute
15
+ * the process slept through is not made up. */
16
+ export function createRemakeCronTicker() {
17
+ let lastMinute = -1;
18
+ return (now) => {
19
+ const minute = Math.floor(now.getTime() / 60_000);
20
+ if (minute === lastMinute)
21
+ return [];
22
+ lastMinute = minute;
23
+ const config = readConfig();
24
+ const queued = [];
25
+ for (const repo of listActiveRepos()) {
26
+ const expression = config.repos?.[repo.full_name]?.remakeCron;
27
+ if (!expression || !repo.knowledge_built_at)
28
+ continue;
29
+ let cron;
30
+ try {
31
+ cron = parseRemakeCron(expression);
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ if (!cronMatches(cron, now))
37
+ continue;
38
+ const key = `setup:${repo.full_name}`;
39
+ if (getQueuedJobByKey(key) || getRunningJobByKey(key))
40
+ continue;
41
+ enqueueSetup(repo.full_name, "remake");
42
+ queued.push(repo.full_name);
43
+ }
44
+ return queued;
45
+ };
46
+ }
47
+ let timer;
48
+ export function startRemakeScheduler(intervalMs = 15_000) {
49
+ if (timer !== undefined)
50
+ return;
51
+ const ticker = createRemakeCronTicker();
52
+ timer = setInterval(() => {
53
+ for (const repo of ticker(new Date())) {
54
+ console.log(`[cron] queued a scheduled remake for ${repo}`);
55
+ }
56
+ }, intervalMs);
57
+ }
58
+ export function stopRemakeScheduler() {
59
+ if (timer === undefined)
60
+ return;
61
+ clearInterval(timer);
62
+ timer = undefined;
63
+ }
@@ -0,0 +1,14 @@
1
+ export type Cron = {
2
+ minutes: Set<number>;
3
+ hours: Set<number>;
4
+ daysOfMonth: Set<number>;
5
+ months: Set<number>;
6
+ daysOfWeek: Set<number>;
7
+ anyDayOfMonth: boolean;
8
+ anyDayOfWeek: boolean;
9
+ };
10
+ export declare function parseCron(expression: string): Cron;
11
+ /** All times are UTC. When both day fields are restricted a date matches if
12
+ * either one does, as in classic cron. */
13
+ export declare function cronMatches(cron: Cron, date: Date): boolean;
14
+ export declare function nextCronRun(cron: Cron, after: Date): Date | undefined;
@@ -0,0 +1,85 @@
1
+ const FIELDS = [
2
+ { name: "minute", min: 0, max: 59 },
3
+ { name: "hour", min: 0, max: 23 },
4
+ { name: "day of month", min: 1, max: 31 },
5
+ { name: "month", min: 1, max: 12 },
6
+ { name: "day of week", min: 0, max: 7 },
7
+ ];
8
+ function parseNumber(value, name) {
9
+ if (!/^\d+$/.test(value)) {
10
+ throw new Error(`${name} has an invalid value "${value}"`);
11
+ }
12
+ return Number(value);
13
+ }
14
+ function parseField(text, name, min, max) {
15
+ const values = new Set();
16
+ for (const part of text.split(",")) {
17
+ const [range, stepText, extra] = part.split("/");
18
+ if (extra !== undefined)
19
+ throw new Error(`${name} has an invalid step`);
20
+ const step = stepText === undefined ? 1 : parseNumber(stepText, name);
21
+ if (step < 1)
22
+ throw new Error(`${name} step must be at least 1`);
23
+ let from;
24
+ let to;
25
+ if (range === "*") {
26
+ from = min;
27
+ to = max;
28
+ }
29
+ else if (range.includes("-")) {
30
+ const [start, end, more] = range.split("-");
31
+ if (more !== undefined)
32
+ throw new Error(`${name} has an invalid range`);
33
+ from = parseNumber(start, name);
34
+ to = parseNumber(end ?? "", name);
35
+ }
36
+ else {
37
+ from = parseNumber(range, name);
38
+ to = stepText === undefined ? from : max;
39
+ }
40
+ if (from < min || to > max || from > to) {
41
+ throw new Error(`${name} must be between ${min} and ${max}`);
42
+ }
43
+ for (let value = from; value <= to; value += step)
44
+ values.add(value);
45
+ }
46
+ return values;
47
+ }
48
+ export function parseCron(expression) {
49
+ const fields = expression.trim().split(/\s+/);
50
+ if (fields.length !== 5) {
51
+ throw new Error("a cron expression needs 5 fields: minute, hour, day of month, month, day of week");
52
+ }
53
+ const [minutes, hours, daysOfMonth, months, daysOfWeek] = FIELDS.map((field, index) => parseField(fields[index], field.name, field.min, field.max));
54
+ return {
55
+ minutes,
56
+ hours,
57
+ daysOfMonth,
58
+ months,
59
+ daysOfWeek: new Set([...daysOfWeek].map((day) => day % 7)),
60
+ anyDayOfMonth: fields[2].startsWith("*"),
61
+ anyDayOfWeek: fields[4].startsWith("*"),
62
+ };
63
+ }
64
+ /** All times are UTC. When both day fields are restricted a date matches if
65
+ * either one does, as in classic cron. */
66
+ export function cronMatches(cron, date) {
67
+ if (!cron.minutes.has(date.getUTCMinutes()) ||
68
+ !cron.hours.has(date.getUTCHours()) ||
69
+ !cron.months.has(date.getUTCMonth() + 1)) {
70
+ return false;
71
+ }
72
+ const dayOfMonth = cron.daysOfMonth.has(date.getUTCDate());
73
+ const dayOfWeek = cron.daysOfWeek.has(date.getUTCDay());
74
+ return cron.anyDayOfMonth || cron.anyDayOfWeek
75
+ ? dayOfMonth && dayOfWeek
76
+ : dayOfMonth || dayOfWeek;
77
+ }
78
+ const SEARCH_MINUTES = 366 * 24 * 60;
79
+ export function nextCronRun(cron, after) {
80
+ let time = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
81
+ for (let step = 0; step < SEARCH_MINUTES; step++, time += 60_000) {
82
+ if (cronMatches(cron, new Date(time)))
83
+ return new Date(time);
84
+ }
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {