mikser-io-git 2.1.0 → 2.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.
package/lib/sync.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // stays queued on the write branch until a human intervenes.
14
14
 
15
15
  import * as git from './git.js'
16
+ import { changeSetTrailers } from './changeset-commit.js'
16
17
  import * as github from './forge/github.js'
17
18
  import * as gitea from './forge/gitea.js'
18
19
 
@@ -38,10 +39,42 @@ const ADAPTERS = { github, gitea }
38
39
  // returning a string — a count rather than a file list, since a full
39
40
  // list of paths in a commit message gets unwieldy past a handful of
40
41
  // files and git status is cheap to re-derive if anyone needs detail.
41
- export async function commitAndPushWriteBranch(folder, { paths, writeBranch, message, author, token }) {
42
+ export async function commitAndPushWriteBranch(
43
+ folder, { paths, writeBranch, message, author, token, changeSets = [], onCommitted },
44
+ ) {
42
45
  const porcelain = await git.statusPorcelain(folder, paths)
43
46
  if (!porcelain) return { committed: false, pushed: false }
44
- const fileCount = porcelain.split('\n').filter(Boolean).length
47
+
48
+ let committedAny = false
49
+
50
+ // Claimed writes first, each set its own commit staged to exactly the
51
+ // paths that set wrote.
52
+ //
53
+ // Staging by FOLDER instead would sweep in whatever else happened to be
54
+ // dirty — a document created through the API a second earlier lands in the
55
+ // agent's commit, and undoing the agent then deletes that document. The
56
+ // commit's contents have to match its label, or the label is a lie that
57
+ // only shows up at undo time.
58
+ for (const set of changeSets) {
59
+ const scoped = withinPaths(set.paths, paths)
60
+ if (!scoped.length) continue
61
+ await git.addPaths(folder, scoped)
62
+ if (!(await git.hasStagedChanges(folder, scoped))) continue
63
+ await git.commit(folder, changeSetMessage(set, scoped), { author })
64
+ committedAny = true
65
+ onCommitted?.(set.id)
66
+ }
67
+
68
+ // Then everything still dirty and unclaimed: API writes, human edits, a
69
+ // set whose attribution was lost to a crash. Unattributed and therefore
70
+ // not undoable, but never dropped.
71
+ const remaining = await git.statusPorcelain(folder, paths)
72
+ if (!remaining) {
73
+ return committedAny
74
+ ? { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
75
+ : { committed: false, pushed: false }
76
+ }
77
+ const fileCount = remaining.split('\n').filter(Boolean).length
45
78
 
46
79
  await git.addAll(folder, paths)
47
80
  if (!(await git.hasStagedChanges(folder, paths))) {
@@ -49,14 +82,36 @@ export async function commitAndPushWriteBranch(folder, { paths, writeBranch, mes
49
82
  // end up staging as a diff from HEAD in edge cases (e.g. a
50
83
  // file that matches .gitignore was force-added) — defensive,
51
84
  // should not normally happen.
52
- return { committed: false, pushed: false }
85
+ return committedAny
86
+ ? { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
87
+ : { committed: false, pushed: false }
53
88
  }
54
89
  const resolvedMessage = typeof message === 'function' ? message({ fileCount }) : message
55
90
  await git.commit(folder, resolvedMessage, { author })
56
91
 
92
+ return { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
93
+ }
94
+
95
+ // Only the paths this instance is scoped to. A change set can span
96
+ // collections this git instance does not manage; those stay for whichever
97
+ // instance does own them, rather than being committed here.
98
+ function withinPaths(candidates, paths) {
99
+ if (!paths?.length) return candidates
100
+ return candidates.filter(rel => paths.some(p => rel === p || rel.startsWith(`${p.replace(/\/$/, '')}/`)))
101
+ }
102
+
103
+ // Subject is the caller's own summary, so `git log --oneline` reads as a list
104
+ // of what was asked for. Trailers carry the machine-readable half.
105
+ function changeSetMessage(set, scoped) {
106
+ const subject = set.summary?.trim().split('\n')[0]
107
+ || `content: ${scoped.length} file(s) via mikser`
108
+ return `${subject}\n\n${changeSetTrailers(set)}`
109
+ }
110
+
111
+ async function pushWriteBranch(folder, { writeBranch, token }) {
57
112
  try {
58
113
  await git.push(folder, writeBranch, { token })
59
- return { committed: true, pushed: true }
114
+ return true
60
115
  } catch (err) {
61
116
  if (!git.isNonFastForwardError(err)) throw err
62
117
  // The remote write branch moved (an inbound pull landed
@@ -69,7 +124,7 @@ export async function commitAndPushWriteBranch(folder, { paths, writeBranch, mes
69
124
  await git.fetch(folder, { token })
70
125
  await git.run(folder, ['rebase', `origin/${writeBranch}`])
71
126
  await git.push(folder, writeBranch, { token })
72
- return { committed: true, pushed: true }
127
+ return true
73
128
  }
74
129
  }
75
130
 
package/lib/undo.js ADDED
@@ -0,0 +1,147 @@
1
+ // Remove one change set's contribution from the current state.
2
+ //
3
+ // Not "restore a previous state". The site has moved on — documents were added
4
+ // through the API, a human edited something — and the request is to take back
5
+ // ONE change while keeping everything that came after. That is a merge-shaped
6
+ // operation, which is exactly what `git revert` is for and exactly what a
7
+ // snapshot restore is not.
8
+ //
9
+ // Git handles the textual half. It cannot handle the other half: a document
10
+ // added after the change set may DEPEND on what the change set created, so a
11
+ // revert that applies with no conflict at all can still leave the site
12
+ // referencing a layout that no longer exists. Git reports success; the site is
13
+ // broken. That check needs the reference graph, so it happens here.
14
+
15
+ import { findEntities, lookupKeys } from 'mikser-io'
16
+
17
+ import * as git from './git.js'
18
+ import { parseChangeSetLog } from './changeset-commit.js'
19
+
20
+ // Change sets in the branch's history, newest first.
21
+ export async function listChangeSets(folder, { branch, limit = 20 } = {}) {
22
+ const raw = await git.logChangeSets(folder, { branch, limit: limit * 3 })
23
+ return parseChangeSetLog(raw).sort((a, b) => b.at - a.at).slice(0, limit)
24
+ }
25
+
26
+ export async function findChangeSet(folder, id, { branch } = {}) {
27
+ const raw = await git.logChangeSets(folder, { branch, limit: 500, id })
28
+ return parseChangeSetLog(raw).find(set => set.id === id) ?? null
29
+ }
30
+
31
+ // The reverse patch for a whole set: every commit undone, newest first.
32
+ //
33
+ // Newest-first because the commits stack — undoing the oldest edit to a file
34
+ // before the newest one would be applying a patch to content that has moved
35
+ // on since.
36
+ export async function reversePatchFor(folder, set) {
37
+ const parts = []
38
+ for (const sha of [...set.commits].reverse()) {
39
+ const patch = await git.reversePatch(folder, sha)
40
+ if (patch?.trim()) parts.push(patch)
41
+ }
42
+ return parts.join('\n')
43
+ }
44
+
45
+ // Paths the undo would touch, and which of them it would REMOVE.
46
+ //
47
+ // A removal is where the danger is: reverting a create deletes a file, and
48
+ // anything that came to depend on it since is what breaks.
49
+ export function pathsInPatch(patch) {
50
+ const touched = new Set()
51
+ const deleted = new Set()
52
+ let current = null
53
+ for (const line of String(patch ?? '').split('\n')) {
54
+ const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
55
+ if (m) { current = m[2]; touched.add(current); continue }
56
+ // In a REVERSE patch, "new file mode" means the forward commit
57
+ // deleted it and undoing restores it; "deleted file mode" means the
58
+ // forward commit created it and undoing removes it.
59
+ if (line.startsWith('deleted file mode') && current) deleted.add(current)
60
+ }
61
+ return { touched: [...touched], deleted: [...deleted] }
62
+ }
63
+
64
+ // What would still point at the things this undo removes.
65
+ //
66
+ // Uses the engine's own inverse reference index rather than a text search, so
67
+ // it sees a `$`-ref exactly the way invalidation does — and asks about every
68
+ // key the entity can be referenced BY (`lookupKeys`), not just its id, since
69
+ // content refers to served paths far more often than to catalog ids.
70
+ //
71
+ // Referrers belonging to the change set itself are excluded: undoing a
72
+ // document together with the layout only it used is coherent, and counting
73
+ // that as breakage would make every complete undo look dangerous.
74
+ export async function danglingAfterUndo({ runtime, deletedPaths, setPaths }) {
75
+ const refs = runtime?.refs
76
+ const workingFolder = runtime?.options?.workingFolder
77
+ if (!refs?.inboundFor || !workingFolder || !deletedPaths.length) return []
78
+
79
+ const own = new Set(setPaths)
80
+ const relOf = (uri) => (uri && uri.startsWith(`${workingFolder}/`)
81
+ ? uri.slice(workingFolder.length + 1)
82
+ : null)
83
+
84
+ const broken = []
85
+ for (const rel of deletedPaths) {
86
+ const [entity] = await findEntities({ uri: `${workingFolder}/${rel}` }) ?? []
87
+ if (!entity) continue
88
+
89
+ const referrers = new Map()
90
+ for (const key of [entity.id, ...(lookupKeys(entity) ?? [])]) {
91
+ if (!key) continue
92
+ let inbound = []
93
+ try { inbound = refs.inboundFor(key) ?? [] } catch { continue }
94
+ for (const ref of inbound) {
95
+ if (!ref?.id || ref.id === entity.id) continue
96
+ referrers.set(ref.id, ref.field ?? null)
97
+ }
98
+ }
99
+
100
+ const outside = []
101
+ for (const [sourceId, field] of referrers) {
102
+ const [source] = await findEntities({ id: sourceId }) ?? []
103
+ const sourceRel = relOf(source?.uri)
104
+ if (sourceRel && own.has(sourceRel)) continue
105
+ outside.push({ id: sourceId, ...(field ? { field } : {}) })
106
+ }
107
+ if (outside.length) broken.push({ removes: rel, id: entity.id, referencedBy: outside })
108
+ }
109
+ return broken
110
+ }
111
+
112
+ // Everything a caller needs to decide, computed without touching the tree.
113
+ export async function previewUndo(folder, { id, branch, runtime } = {}) {
114
+ const set = await findChangeSet(folder, id, { branch })
115
+ if (!set) {
116
+ return { ok: false, refused: 'unknown-change-set',
117
+ error: `No change set ${id} in this branch's history.` }
118
+ }
119
+ const patch = await reversePatchFor(folder, set)
120
+ if (!patch.trim()) {
121
+ return { ok: false, refused: 'nothing-to-undo', set,
122
+ error: 'That change set left nothing to reverse.' }
123
+ }
124
+ const { touched, deleted } = pathsInPatch(patch)
125
+ const applies = await git.patchApplies(folder, patch)
126
+ const dangling = await danglingAfterUndo({ runtime, deletedPaths: deleted, setPaths: touched })
127
+
128
+ return {
129
+ ok: true,
130
+ set: { id: set.id, summary: set.summary, at: set.at, principal: set.principal, commits: set.commits.length },
131
+ touched,
132
+ removes: deleted,
133
+ applies,
134
+ dangling,
135
+ patch,
136
+ // Two independent reasons to stop, reported separately because the
137
+ // answers differ: a conflict means "not automatically", dangling refs
138
+ // mean "this will break something that arrived later".
139
+ ...(applies ? {} : {
140
+ conflict: 'A later change edited the same content, so this undo cannot be applied automatically.',
141
+ }),
142
+ ...(dangling.length ? {
143
+ warning: `Undoing this removes ${dangling.length} entit${dangling.length === 1 ? 'y' : 'ies'} that `
144
+ + 'something added since still references.',
145
+ } : {}),
146
+ }
147
+ }
package/package.json CHANGED
@@ -1,8 +1,14 @@
1
1
  {
2
2
  "name": "mikser-io-git",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Two-way git sync for mikser-io. The working folder itself is the checkout, on a dedicated `mikser` branch (durable write log for API/MCP/agent edits). `paths` (optional) scopes auto-commits to specific collection folders (documents, layouts, files, ...) as a hard pathspec; omit it and the whole working folder is committed, subject to .gitignore. Every green cycle commits + pushes whatever's in scope and attempts to merge into the target branch (`main`) via a pull request; a red cycle (any render/postprocess failure) holds the merge and leaves the PR open for a human to resolve. Inbound changes on the target branch merge back into the working copy via poll. Forge-portable: GitHub and Gitea adapters, plus a no-forge fast-forward-only floor for any bare remote.",
5
5
  "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
6
12
  "type": "module",
7
13
  "scripts": {
8
14
  "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
@@ -18,6 +24,7 @@
18
24
  },
19
25
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-git#readme",
20
26
  "peerDependencies": {
21
- "mikser-io": "^9.0.0"
27
+ "mikser-io": "^9.44.0",
28
+ "zod": "^4.0.0"
22
29
  }
23
30
  }
@@ -1,59 +0,0 @@
1
- // decideBootstrap is pure — plain facts in, a decision out — so every
2
- // folder-state combination is directly testable without touching a
3
- // filesystem or a git binary.
4
-
5
- import { describe, it } from 'node:test'
6
- import assert from 'node:assert/strict'
7
-
8
- import { decideBootstrap } from '../lib/bootstrap.js'
9
-
10
- const EXPECTED = 'https://github.com/org/content.git'
11
-
12
- describe('decideBootstrap', () => {
13
- it('clones into a folder that does not exist yet', () => {
14
- const d = decideBootstrap({ folderExists: false, folderEmpty: true, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
15
- assert.equal(d.action, 'clone')
16
- })
17
-
18
- it('clones into an existing empty folder', () => {
19
- const d = decideBootstrap({ folderExists: true, folderEmpty: true, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
20
- assert.equal(d.action, 'clone')
21
- })
22
-
23
- it('refuses a non-empty folder that is not a git repo — the destructive case', () => {
24
- const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
25
- assert.equal(d.action, 'refuse')
26
- assert.match(d.reason, /not a git repository/i)
27
- assert.match(d.reason, /git init/) // the manual recipe must be in the message
28
- })
29
-
30
- it('verifies an existing checkout whose remote matches', () => {
31
- const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: true, remoteUrl: EXPECTED, expectedUrl: EXPECTED })
32
- assert.equal(d.action, 'verify')
33
- })
34
-
35
- it('verify tolerates a trailing-slash / missing .git cosmetic difference', () => {
36
- const d = decideBootstrap({
37
- folderExists: true, folderEmpty: false, isRepo: true,
38
- remoteUrl: 'https://github.com/org/content', // no .git suffix
39
- expectedUrl: EXPECTED, // has .git suffix
40
- })
41
- assert.equal(d.action, 'verify')
42
- })
43
-
44
- it('refuses an existing repo with no origin remote at all', () => {
45
- const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: true, remoteUrl: null, expectedUrl: EXPECTED })
46
- assert.equal(d.action, 'refuse')
47
- assert.match(d.reason, /no "origin" remote/i)
48
- })
49
-
50
- it('refuses an existing repo whose remote points somewhere else', () => {
51
- const d = decideBootstrap({
52
- folderExists: true, folderEmpty: false, isRepo: true,
53
- remoteUrl: 'https://github.com/someone-else/other-repo.git',
54
- expectedUrl: EXPECTED,
55
- })
56
- assert.equal(d.action, 'refuse')
57
- assert.match(d.reason, /does not match the configured repo/i)
58
- })
59
- })
@@ -1,101 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { resolveConfig } from '../lib/config.js'
5
-
6
- describe('resolveConfig', () => {
7
- it('requires url', () => {
8
- assert.throws(() => resolveConfig({}), /`url` is required/)
9
- })
10
-
11
- it('rejects an unknown forge', () => {
12
- assert.throws(
13
- () => resolveConfig({ url: 'https://github.com/org/repo.git', forge: 'bitbucket' }),
14
- /must be "github", "gitea", or "none"/,
15
- )
16
- })
17
-
18
- it('rejects an empty paths array', () => {
19
- assert.throws(
20
- () => resolveConfig({ url: 'https://example.com/org/repo.git', paths: [] }),
21
- /`paths` must be a non-empty/,
22
- )
23
- })
24
-
25
- it('normalizes a single path string to a one-element array', () => {
26
- const cfg = resolveConfig({ url: 'https://example.com/org/repo.git', paths: 'layouts' })
27
- assert.deepEqual(cfg.paths, ['layouts'])
28
- })
29
-
30
- it('accepts an array of paths for multiple collections sharing one checkout', () => {
31
- const cfg = resolveConfig({ url: 'https://example.com/org/repo.git', paths: ['documents', 'layouts', 'files'] })
32
- assert.deepEqual(cfg.paths, ['documents', 'layouts', 'files'])
33
- })
34
-
35
- it('omitting paths means "no scope" — the whole working folder, not a narrower default', () => {
36
- // Deliberately distinct from '[\'documents\']': the zero-config
37
- // case should match the model's own framing ("the working
38
- // folder is the checkout"), not silently narrow to one
39
- // collection nobody asked for.
40
- const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
41
- assert.equal(cfg.paths, null)
42
- })
43
-
44
- it('applies sane defaults for a minimal forge:none config', () => {
45
- const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
46
- assert.equal(cfg.forge, 'none')
47
- assert.equal(cfg.paths, null)
48
- assert.equal(cfg.targetBranch, 'main')
49
- assert.equal(cfg.writeBranch, 'mikser')
50
- assert.equal(cfg.afterMs, 60_000)
51
- assert.equal(cfg.maxWaitMs, 600_000)
52
- assert.equal(cfg.pollIntervalMs, 300_000)
53
- // forge:none never needs owner/repo/apiBase — no API involved.
54
- assert.equal(cfg.owner, undefined)
55
- assert.equal(cfg.repo, undefined)
56
- assert.equal(cfg.apiBase, undefined)
57
- })
58
-
59
- it('derives owner/repo/apiBase from the url when forge is github', () => {
60
- const cfg = resolveConfig({ url: 'https://github.com/almero-digital-marketing/gpoint-content.git', forge: 'github', token: 'x' })
61
- assert.equal(cfg.owner, 'almero-digital-marketing')
62
- assert.equal(cfg.repo, 'gpoint-content')
63
- assert.equal(cfg.apiBase, 'https://api.github.com')
64
- })
65
-
66
- it('derives owner/repo/apiBase from the url when forge is gitea', () => {
67
- const cfg = resolveConfig({ url: 'https://git.almero.bg/org/content.git', forge: 'gitea', token: 'x' })
68
- assert.equal(cfg.owner, 'org')
69
- assert.equal(cfg.repo, 'content')
70
- assert.equal(cfg.apiBase, 'https://git.almero.bg')
71
- })
72
-
73
- it('explicit owner/repo/apiBase override the url-derived values', () => {
74
- const cfg = resolveConfig({
75
- url: 'https://github.com/org/repo.git', forge: 'github', token: 'x',
76
- owner: 'other-org', repo: 'other-repo', apiBase: 'https://ghe.example.com/api/v3',
77
- })
78
- assert.equal(cfg.owner, 'other-org')
79
- assert.equal(cfg.repo, 'other-repo')
80
- assert.equal(cfg.apiBase, 'https://ghe.example.com/api/v3')
81
- })
82
-
83
- it('respects custom branch names, paths, and durations', () => {
84
- const cfg = resolveConfig({
85
- url: 'https://example.com/org/repo.git',
86
- branch: 'live', writeBranch: 'agents', paths: ['content'],
87
- after: '30s', maxWait: '5m', pollInterval: '1m',
88
- })
89
- assert.equal(cfg.targetBranch, 'live')
90
- assert.equal(cfg.writeBranch, 'agents')
91
- assert.deepEqual(cfg.paths, ['content'])
92
- assert.equal(cfg.afterMs, 30_000)
93
- assert.equal(cfg.maxWaitMs, 300_000)
94
- assert.equal(cfg.pollIntervalMs, 60_000)
95
- })
96
-
97
- it('the default message builder includes the file count', () => {
98
- const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
99
- assert.equal(cfg.message({ fileCount: 3 }), 'content: 3 file(s) via mikser')
100
- })
101
- })
@@ -1,57 +0,0 @@
1
- // reduceDebounce is pure — feed a sequence of green/red events with
2
- // explicit timestamps, assert the resulting fireAt. Covers both halves
3
- // of the design: the short debounce after a green cycle, and the
4
- // maxWait ceiling that keeps a steady stream of green cycles from
5
- // starving the sync forever.
6
-
7
- import { describe, it } from 'node:test'
8
- import assert from 'node:assert/strict'
9
-
10
- import { reduceDebounce, IDLE_DEBOUNCE_STATE } from '../lib/debounce.js'
11
-
12
- const CFG = { afterMs: 60_000, maxWaitMs: 600_000 } // 1m / 10m, matches the plugin's defaults
13
-
14
- describe('reduceDebounce', () => {
15
- it('a single green event fires after `after`', () => {
16
- const s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 1000 }, CFG)
17
- assert.equal(s.pendingSince, 1000)
18
- assert.equal(s.fireAt, 1000 + CFG.afterMs)
19
- })
20
-
21
- it('a red event clears the window outright', () => {
22
- const afterGreen = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 1000 }, CFG)
23
- const afterRed = reduceDebounce(afterGreen, { type: 'red', now: 2000 }, CFG)
24
- assert.deepEqual(afterRed, IDLE_DEBOUNCE_STATE)
25
- })
26
-
27
- it('a later green event pushes fireAt out again (trailing debounce)', () => {
28
- let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
29
- s = reduceDebounce(s, { type: 'green', now: 30_000 }, CFG)
30
- // pendingSince stays at the FIRST green (0); fireAt tracks the latest green + after,
31
- // capped by pendingSince + maxWait (0 + 600_000 = 600_000, not yet reached).
32
- assert.equal(s.pendingSince, 0)
33
- assert.equal(s.fireAt, 30_000 + CFG.afterMs)
34
- })
35
-
36
- it('a steady stream of green events is bounded by maxWait — never starves forever', () => {
37
- let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
38
- // Green every 30s, well under the 60s `after` window, for 20 minutes —
39
- // a pure trailing debounce would never fire.
40
- for (let now = 30_000; now <= 1_200_000; now += 30_000) {
41
- s = reduceDebounce(s, { type: 'green', now }, CFG)
42
- }
43
- // fireAt must never exceed pendingSince + maxWait (0 + 600_000).
44
- assert.ok(s.fireAt <= CFG.maxWaitMs, `fireAt ${s.fireAt} exceeded the maxWait ceiling ${CFG.maxWaitMs}`)
45
- assert.equal(s.fireAt, CFG.maxWaitMs)
46
- })
47
-
48
- it('a red event after being interrupted then going green again starts a FRESH window', () => {
49
- let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
50
- s = reduceDebounce(s, { type: 'red', now: 100_000 }, CFG)
51
- s = reduceDebounce(s, { type: 'green', now: 200_000 }, CFG)
52
- // pendingSince resets to the new green's timestamp, not the original 0 —
53
- // otherwise the maxWait ceiling would already be looming from unrelated history.
54
- assert.equal(s.pendingSince, 200_000)
55
- assert.equal(s.fireAt, 200_000 + CFG.afterMs)
56
- })
57
- })
@@ -1,32 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { parseDuration } from '../lib/duration.js'
5
-
6
- describe('parseDuration', () => {
7
- it('passes a plain number through unchanged', () => {
8
- assert.equal(parseDuration(5000), 5000)
9
- })
10
-
11
- it('returns the fallback for null/undefined', () => {
12
- assert.equal(parseDuration(undefined, 42), 42)
13
- assert.equal(parseDuration(null, 42), 42)
14
- })
15
-
16
- it('parses each unit', () => {
17
- assert.equal(parseDuration('500ms'), 500)
18
- assert.equal(parseDuration('30s'), 30_000)
19
- assert.equal(parseDuration('1m'), 60_000)
20
- assert.equal(parseDuration('10m'), 600_000)
21
- assert.equal(parseDuration('2h'), 7_200_000)
22
- assert.equal(parseDuration('1d'), 86_400_000)
23
- })
24
-
25
- it('is case-insensitive on the unit', () => {
26
- assert.equal(parseDuration('1M'), 60_000)
27
- })
28
-
29
- it('throws on an unparseable string', () => {
30
- assert.throws(() => parseDuration('soon'), /invalid duration/i)
31
- })
32
- })
@@ -1,91 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { ensurePR, mergePR } from '../../lib/forge/gitea.js'
5
-
6
- function fakeResponse({ ok = true, status = 200, body = {} } = {}) {
7
- return {
8
- ok,
9
- status,
10
- statusText: ok ? 'OK' : 'Error',
11
- json: async () => body,
12
- }
13
- }
14
-
15
- const CTX = { apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', head: 'mikser', base: 'main', title: 'Promote', token: 'tok' }
16
-
17
- describe('gitea forge adapter', () => {
18
- it('ensurePR reuses an existing open PR matched by head/base ref', async () => {
19
- const calls = []
20
- const fetchImpl = async (url, opts) => {
21
- calls.push({ url, method: opts?.method ?? 'GET' })
22
- return fakeResponse({
23
- body: [
24
- { number: 3, head: { ref: 'some-other-branch' }, base: { ref: 'main' } },
25
- { number: 5, head: { ref: 'mikser' }, base: { ref: 'main' }, html_url: 'https://git.almero.bg/org/content/pulls/5' },
26
- ],
27
- })
28
- }
29
- const pr = await ensurePR({ ...CTX, fetchImpl })
30
- assert.deepEqual(pr, { number: 5, url: 'https://git.almero.bg/org/content/pulls/5' })
31
- assert.equal(calls.length, 1)
32
- assert.match(calls[0].url, /\/api\/v1\/repos\/org\/content\/pulls\?state=open/)
33
- })
34
-
35
- it('ensurePR creates a new PR when the open list has no head/base match', async () => {
36
- const calls = []
37
- const fetchImpl = async (url, opts) => {
38
- calls.push({ url, method: opts?.method ?? 'GET' })
39
- if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
40
- return fakeResponse({ status: 201, body: { number: 11, html_url: 'https://git.almero.bg/org/content/pulls/11' } })
41
- }
42
- const pr = await ensurePR({ ...CTX, fetchImpl })
43
- assert.deepEqual(pr, { number: 11, url: 'https://git.almero.bg/org/content/pulls/11' })
44
- assert.equal(calls[1].method, 'POST')
45
- })
46
-
47
- it('ensurePR posts the correct body shape when creating', async () => {
48
- let capturedBody = null
49
- const fetchImpl = async (url, opts) => {
50
- if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
51
- capturedBody = JSON.parse(opts.body)
52
- return fakeResponse({ status: 201, body: { number: 1, html_url: 'x' } })
53
- }
54
- await ensurePR({ ...CTX, fetchImpl })
55
- assert.deepEqual(capturedBody, { title: 'Promote', head: 'mikser', base: 'main' })
56
- })
57
-
58
- it('mergePR posts { Do: "merge" } — Gitea-specific field name and casing', async () => {
59
- let capturedBody = null
60
- let capturedMethod = null
61
- const fetchImpl = async (url, opts) => {
62
- capturedMethod = opts.method
63
- capturedBody = JSON.parse(opts.body)
64
- return fakeResponse({ body: {} })
65
- }
66
- const result = await mergePR({ apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', number: 5, token: 'tok', fetchImpl })
67
- assert.deepEqual(result, { merged: true })
68
- assert.equal(capturedMethod, 'POST') // NOT PUT — differs from the GitHub adapter
69
- assert.deepEqual(capturedBody, { Do: 'merge' })
70
- })
71
-
72
- it('mergePR reports merged:false with a reason on failure', async () => {
73
- const fetchImpl = async () => fakeResponse({ ok: false, status: 409, body: { message: 'merge conflict' } })
74
- const result = await mergePR({ apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', number: 5, token: 'tok', fetchImpl })
75
- assert.equal(result.merged, false)
76
- assert.match(result.reason, /merge conflict/)
77
- })
78
-
79
- it('auth header uses the Gitea "token" scheme, not Bearer', async () => {
80
- const seenHeaders = []
81
- const fetchImpl = async (url, opts) => {
82
- seenHeaders.push(opts.headers)
83
- return fakeResponse({ body: [] }) // empty list → falls through to create, also hit by this fetchImpl
84
- }
85
- await ensurePR({ ...CTX, fetchImpl })
86
- assert.ok(seenHeaders.length > 0)
87
- for (const headers of seenHeaders) {
88
- assert.equal(headers.Authorization, 'token tok')
89
- }
90
- })
91
- })