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/README.md +68 -2
- package/index.js +54 -5
- package/lib/changeset-commit.js +59 -0
- package/lib/git.js +102 -15
- package/lib/inbound.js +36 -2
- package/lib/mcp.js +141 -0
- package/lib/queue.js +24 -0
- package/lib/sync.js +60 -5
- package/lib/undo.js +147 -0
- package/package.json +9 -2
- package/test/bootstrap.test.js +0 -59
- package/test/config.test.js +0 -101
- package/test/debounce.test.js +0 -57
- package/test/duration.test.js +0 -32
- package/test/forge/gitea.test.js +0 -91
- package/test/forge/github.test.js +0 -70
- package/test/git.test.js +0 -220
- package/test/repo-url.test.js +0 -35
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
27
|
+
"mikser-io": "^9.44.0",
|
|
28
|
+
"zod": "^4.0.0"
|
|
22
29
|
}
|
|
23
30
|
}
|
package/test/bootstrap.test.js
DELETED
|
@@ -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
|
-
})
|
package/test/config.test.js
DELETED
|
@@ -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
|
-
})
|
package/test/debounce.test.js
DELETED
|
@@ -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
|
-
})
|
package/test/duration.test.js
DELETED
|
@@ -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
|
-
})
|
package/test/forge/gitea.test.js
DELETED
|
@@ -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
|
-
})
|