@forwardimpact/libwiki 0.1.3 → 0.2.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.
@@ -1,43 +1,90 @@
1
- const OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
2
- const CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
1
+ const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
2
+ const ISSUE_OPEN_RE =
3
+ /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
4
+ const XMR_CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
5
+ const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
3
6
 
4
- /** Scan text for paired xmr open/close HTML comment markers and return their line positions and metadata. */
7
+ function openLabel(open) {
8
+ return open.kind === "xmr" ? open.metric : open.topic;
9
+ }
10
+
11
+ function warnDangling(open) {
12
+ process.stderr.write(
13
+ `dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`,
14
+ );
15
+ }
16
+
17
+ function tryOpen(line, i) {
18
+ const xmrMatch = line.match(XMR_OPEN_RE);
19
+ if (xmrMatch) {
20
+ return {
21
+ kind: "xmr",
22
+ metric: xmrMatch[1],
23
+ csvPath: xmrMatch[2],
24
+ openLine: i,
25
+ };
26
+ }
27
+ const issueMatch = line.match(ISSUE_OPEN_RE);
28
+ if (issueMatch) {
29
+ return {
30
+ kind: "issue-list",
31
+ topic: issueMatch[1],
32
+ state: issueMatch[2],
33
+ window: issueMatch[3] || null,
34
+ openLine: i,
35
+ };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function closePair(open, i) {
41
+ if (open.kind === "xmr") {
42
+ return {
43
+ kind: "xmr",
44
+ metric: open.metric,
45
+ csvPath: open.csvPath,
46
+ openLine: open.openLine,
47
+ closeLine: i,
48
+ };
49
+ }
50
+ return {
51
+ kind: "issue-list",
52
+ topic: open.topic,
53
+ state: open.state,
54
+ window: open.window,
55
+ openLine: open.openLine,
56
+ closeLine: i,
57
+ };
58
+ }
59
+
60
+ function matchClose(line, open) {
61
+ if (!open) return false;
62
+ if (open.kind === "xmr") return XMR_CLOSE_RE.test(line);
63
+ const m = line.match(ISSUE_CLOSE_RE);
64
+ return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
65
+ }
66
+
67
+ /** Scan text for paired marker blocks (xmr or issue-list). Returns positions and metadata. */
5
68
  export function scanMarkers(text) {
6
69
  const lines = text.split("\n");
7
70
  const pairs = [];
8
71
  let open = null;
9
72
 
10
73
  for (let i = 0; i < lines.length; i++) {
11
- const openMatch = lines[i].match(OPEN_RE);
12
- if (openMatch) {
13
- if (open) {
14
- process.stderr.write(
15
- `dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
16
- );
17
- }
18
- open = { metric: openMatch[1], csvPath: openMatch[2], openLine: i };
74
+ const line = lines[i];
75
+ const newOpen = tryOpen(line, i);
76
+ if (newOpen) {
77
+ if (open) warnDangling(open);
78
+ open = newOpen;
19
79
  continue;
20
80
  }
21
-
22
- if (CLOSE_RE.test(lines[i])) {
23
- if (open) {
24
- pairs.push({
25
- metric: open.metric,
26
- csvPath: open.csvPath,
27
- openLine: open.openLine,
28
- closeLine: i,
29
- });
30
- open = null;
31
- }
32
- continue;
81
+ if (matchClose(line, open)) {
82
+ pairs.push(closePair(open, i));
83
+ open = null;
33
84
  }
34
85
  }
35
86
 
36
- if (open) {
37
- process.stderr.write(
38
- `dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
39
- );
40
- }
87
+ if (open) warnDangling(open);
41
88
 
42
89
  return pairs;
43
90
  }
@@ -0,0 +1,91 @@
1
+ import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
4
+
5
+ /** Compute ISO 8601 year-week for a Date. Returns { year, week } where year is the ISO week-year (not necessarily the calendar year for edge weeks). */
6
+ export function isoWeek(date) {
7
+ const d = new Date(
8
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
9
+ );
10
+ // Thursday of week: ISO weeks are anchored on Thursday.
11
+ const day = d.getUTCDay() || 7;
12
+ d.setUTCDate(d.getUTCDate() + 4 - day);
13
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
14
+ const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
15
+ return { year: d.getUTCFullYear(), week };
16
+ }
17
+
18
+ function formatIsoWeek(date) {
19
+ const { year, week } = isoWeek(date);
20
+ return `${year}-W${String(week).padStart(2, "0")}`;
21
+ }
22
+
23
+ /** Return the path of the current weekly log file for an agent. */
24
+ export function weeklyLogPath(wikiRoot, agent, today) {
25
+ const date = today instanceof Date ? today : new Date(today);
26
+ return path.join(wikiRoot, `${agent}-${formatIsoWeek(date)}.md`);
27
+ }
28
+
29
+ function countLines(text) {
30
+ if (text.length === 0) return 0;
31
+ let n = 0;
32
+ for (const ch of text) if (ch === "\n") n++;
33
+ if (!text.endsWith("\n")) n++;
34
+ return n;
35
+ }
36
+
37
+ function nextPartPath(filePath) {
38
+ const dir = path.dirname(filePath);
39
+ const base = path.basename(filePath, ".md");
40
+ let n = 1;
41
+ while (existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
42
+ return path.join(dir, `${base}-part${n}.md`);
43
+ }
44
+
45
+ function agentTitle(agent) {
46
+ return agent
47
+ .split("-")
48
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
49
+ .join(" ");
50
+ }
51
+
52
+ function defaultH1(filePath, agent, isoWeekStr) {
53
+ return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
54
+ }
55
+
56
+ /** Rotate the current weekly log if next append would exceed the budget. Returns { rotated, fromPath, toPath }. */
57
+ export function rotateIfOverBudget(
58
+ wikiRoot,
59
+ agent,
60
+ today,
61
+ appendLines = 0,
62
+ options = {},
63
+ ) {
64
+ const filePath = weeklyLogPath(wikiRoot, agent, today);
65
+ const { force = false } = options;
66
+ if (!existsSync(filePath)) return { rotated: false, fromPath: filePath };
67
+ const text = readFileSync(filePath, "utf-8");
68
+ const current = countLines(text);
69
+ if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
70
+ return { rotated: false, fromPath: filePath };
71
+ }
72
+ const toPath = nextPartPath(filePath);
73
+ renameSync(filePath, toPath);
74
+ const date = today instanceof Date ? today : new Date(today);
75
+ writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
76
+ return { rotated: true, fromPath: filePath, toPath };
77
+ }
78
+
79
+ /** Append a body to a weekly log file. Creates it with an H1 if missing. */
80
+ export function appendEntry(filePath, body, agent, today) {
81
+ const date = today instanceof Date ? today : new Date(today);
82
+ if (!existsSync(filePath)) {
83
+ writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
84
+ }
85
+ const text = readFileSync(filePath, "utf-8");
86
+ const separator = text.endsWith("\n") ? "\n" : "\n\n";
87
+ writeFileSync(
88
+ filePath,
89
+ text + separator + body + (body.endsWith("\n") ? "" : "\n"),
90
+ );
91
+ }
package/src/wiki-repo.js CHANGED
@@ -31,11 +31,31 @@ export function buildAuthArgs(args, token) {
31
31
  export class WikiRepo {
32
32
  #wikiDir;
33
33
  #parentDir;
34
+ #resolveToken;
34
35
 
35
- /** Create a WikiRepo targeting the given wiki directory and its parent project directory. */
36
- constructor({ wikiDir, parentDir }) {
36
+ /**
37
+ * Create a WikiRepo targeting the given wiki directory and its parent project directory.
38
+ * @param {{ wikiDir: string, parentDir: string, resolveToken: () => string | null }} opts
39
+ * `resolveToken` is called lazily before each network operation. Return a
40
+ * GitHub token string to authenticate, or `null` to run anonymously. The
41
+ * callback owns the entire resolution policy — libwiki does not read
42
+ * `process.env` directly. Throws propagate to the caller so credential
43
+ * misconfiguration surfaces loudly. Commands typically pass
44
+ * `() => config.ghToken()` from `@forwardimpact/libconfig`.
45
+ */
46
+ constructor({ wikiDir, parentDir, resolveToken }) {
47
+ if (typeof wikiDir !== "string" || wikiDir === "") {
48
+ throw new TypeError("WikiRepo: wikiDir must be a non-empty string");
49
+ }
50
+ if (typeof parentDir !== "string" || parentDir === "") {
51
+ throw new TypeError("WikiRepo: parentDir must be a non-empty string");
52
+ }
53
+ if (typeof resolveToken !== "function") {
54
+ throw new TypeError("WikiRepo: resolveToken callback is required");
55
+ }
37
56
  this.#wikiDir = wikiDir;
38
57
  this.#parentDir = parentDir;
58
+ this.#resolveToken = resolveToken;
39
59
  }
40
60
 
41
61
  /** Check whether the wiki directory is an initialized git repository. */
@@ -92,11 +112,16 @@ export class WikiRepo {
92
112
  }
93
113
  }
94
114
 
95
- /** Stage all changes, commit with the given message, fetch, rebase on origin/master (falling back to a merge with -X ours if rebase fails), and push. */
115
+ /** Stage and commit any working-tree changes, then fetch, rebase on origin/master (falling back to a merge with -X ours if rebase fails), and push if HEAD is ahead of origin/master. The commit gate and the push gate are independent so a clean tree with local commits still pushes. */
96
116
  commitAndPush(message) {
97
- if (this.isClean()) return { pushed: false, reason: "clean" };
98
- this.#git(["add", "-A"]);
99
- this.#git(["commit", "-m", message]);
117
+ const hasWorkingTreeChanges = !this.isClean();
118
+ if (hasWorkingTreeChanges) {
119
+ this.#git(["add", "-A"]);
120
+ this.#git(["commit", "-m", message]);
121
+ }
122
+ if (!this.#hasCommitsAhead()) {
123
+ return { pushed: false, reason: "clean" };
124
+ }
100
125
  this.fetch();
101
126
  const rebase = this.#git(["rebase", "origin/master"]);
102
127
  if (rebase.status !== 0) {
@@ -107,6 +132,12 @@ export class WikiRepo {
107
132
  return { pushed: true, reason: "pushed" };
108
133
  }
109
134
 
135
+ #hasCommitsAhead() {
136
+ const r = this.#git(["rev-list", "--count", "origin/master..HEAD"]);
137
+ const count = parseInt(r.stdout?.toString().trim() || "0", 10);
138
+ return count > 0;
139
+ }
140
+
110
141
  #parentConfig(key) {
111
142
  const r = spawnSync(
112
143
  "git",
@@ -123,11 +154,14 @@ export class WikiRepo {
123
154
  }
124
155
 
125
156
  #authGit(args) {
126
- const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
157
+ const token = this.#resolveToken();
127
158
  const fullArgs = buildAuthArgs(args, token);
128
- return spawnSync("git", fullArgs, {
129
- stdio: "pipe",
130
- env: token ? process.env : undefined,
131
- });
159
+ // The credential helper body keeps `${GH_TOKEN:-$GITHUB_TOKEN}` literal so
160
+ // git's child shell expands it at auth time — the token never sits in argv.
161
+ // Inject the resolved token into the spawn env so the helper's lazy
162
+ // expansion finds it even when the resolver pulled from `.env` or
163
+ // `gh auth token` rather than the ambient process env.
164
+ const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
165
+ return spawnSync("git", fullArgs, { stdio: "pipe", env });
132
166
  }
133
167
  }