mikser-io-git 2.1.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/LICENSE +21 -0
- package/README.md +225 -0
- package/index.js +197 -0
- package/lib/bootstrap.js +146 -0
- package/lib/config.js +82 -0
- package/lib/debounce.js +26 -0
- package/lib/duration.js +14 -0
- package/lib/forge/gitea.js +71 -0
- package/lib/forge/github.js +64 -0
- package/lib/git.js +191 -0
- package/lib/inbound.js +43 -0
- package/lib/repo-url.js +28 -0
- package/lib/sync.js +123 -0
- package/package.json +23 -0
- package/test/bootstrap.test.js +59 -0
- package/test/config.test.js +101 -0
- package/test/debounce.test.js +57 -0
- package/test/duration.test.js +32 -0
- package/test/forge/gitea.test.js +91 -0
- package/test/forge/github.test.js +70 -0
- package/test/git.test.js +220 -0
- package/test/repo-url.test.js +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-git",
|
|
3
|
+
"version": "2.1.0",
|
|
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
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-git.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-git/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-git#readme",
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"mikser-io": "^9.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { ensurePR, mergePR } from '../../lib/forge/github.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 = { owner: 'org', repo: 'content', head: 'mikser', base: 'main', title: 'Promote', token: 'tok' }
|
|
16
|
+
|
|
17
|
+
describe('github forge adapter', () => {
|
|
18
|
+
it('ensurePR reuses an existing open PR instead of creating a new one', async () => {
|
|
19
|
+
const calls = []
|
|
20
|
+
const fetchImpl = async (url, opts) => {
|
|
21
|
+
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
22
|
+
return fakeResponse({ body: [{ number: 7, html_url: 'https://github.com/org/content/pull/7' }] })
|
|
23
|
+
}
|
|
24
|
+
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
25
|
+
assert.deepEqual(pr, { number: 7, url: 'https://github.com/org/content/pull/7' })
|
|
26
|
+
assert.equal(calls.length, 1, 'must not call the create endpoint when one already exists')
|
|
27
|
+
assert.match(calls[0].url, /head=org:mikser&base=main&state=open/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('ensurePR creates a new PR when none is open', async () => {
|
|
31
|
+
const calls = []
|
|
32
|
+
const fetchImpl = async (url, opts) => {
|
|
33
|
+
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
34
|
+
if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
|
|
35
|
+
return fakeResponse({ status: 201, body: { number: 9, html_url: 'https://github.com/org/content/pull/9' } })
|
|
36
|
+
}
|
|
37
|
+
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
38
|
+
assert.deepEqual(pr, { number: 9, url: 'https://github.com/org/content/pull/9' })
|
|
39
|
+
assert.equal(calls.length, 2)
|
|
40
|
+
assert.equal(calls[1].method, 'POST')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ensurePR throws with a readable message when creation fails', async () => {
|
|
44
|
+
const fetchImpl = async (url, opts) => {
|
|
45
|
+
if (!opts?.method) return fakeResponse({ body: [] })
|
|
46
|
+
return fakeResponse({ ok: false, status: 422, body: { message: 'Validation failed' } })
|
|
47
|
+
}
|
|
48
|
+
await assert.rejects(ensurePR({ ...CTX, fetchImpl }), /Validation failed/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('mergePR reports merged:true on success', async () => {
|
|
52
|
+
const fetchImpl = async () => fakeResponse({ body: { merged: true, sha: 'abc' } })
|
|
53
|
+
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
54
|
+
assert.deepEqual(result, { merged: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('mergePR reports merged:false with a reason on conflict', async () => {
|
|
58
|
+
const fetchImpl = async () => fakeResponse({ ok: false, status: 405, body: { message: 'Pull Request is not mergeable' } })
|
|
59
|
+
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
60
|
+
assert.equal(result.merged, false)
|
|
61
|
+
assert.match(result.reason, /not mergeable/)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('mergePR falls back to statusText when the error body is not JSON', async () => {
|
|
65
|
+
const fetchImpl = async () => ({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => { throw new Error('not json') } })
|
|
66
|
+
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
67
|
+
assert.equal(result.merged, false)
|
|
68
|
+
assert.equal(result.reason, 'Internal Server Error')
|
|
69
|
+
})
|
|
70
|
+
})
|
package/test/git.test.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// isNonFastForwardError is pure string-matching, tested directly.
|
|
2
|
+
// Everything else in lib/git.js is a thin execFile wrapper — real
|
|
3
|
+
// confidence there comes from exercising it against an actual git
|
|
4
|
+
// repo in a temp directory. This only covers the LOCAL-only
|
|
5
|
+
// operations (init, add, commit, status, branch) — clone/fetch/push
|
|
6
|
+
// need a real remote and are exercised by hand against a real forge,
|
|
7
|
+
// not in this suite.
|
|
8
|
+
|
|
9
|
+
import { describe, it, before, after } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import * as git from '../lib/git.js'
|
|
16
|
+
|
|
17
|
+
describe('isNonFastForwardError', () => {
|
|
18
|
+
it('recognizes the common rejection messages', () => {
|
|
19
|
+
assert.ok(git.isNonFastForwardError({ stderr: '! [rejected] main -> main (non-fast-forward)' }))
|
|
20
|
+
assert.ok(git.isNonFastForwardError({ message: 'failed to push some refs (fetch first)' }))
|
|
21
|
+
assert.ok(git.isNonFastForwardError({ stderr: 'Updates were rejected because the tip of your current branch is behind' }))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not misclassify an unrelated error', () => {
|
|
25
|
+
assert.equal(git.isNonFastForwardError({ message: 'fatal: repository not found' }), false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('handles a bare/malformed error object without throwing', () => {
|
|
29
|
+
assert.equal(git.isNonFastForwardError({}), false)
|
|
30
|
+
assert.equal(git.isNonFastForwardError(null), false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('git.js against a real local repo', () => {
|
|
35
|
+
let dir
|
|
36
|
+
|
|
37
|
+
before(async () => {
|
|
38
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-'))
|
|
39
|
+
await git.run(dir, ['init', '-b', 'main'])
|
|
40
|
+
// Local-only identity so commit() works in a sandboxed CI
|
|
41
|
+
// environment with no global git user configured.
|
|
42
|
+
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
43
|
+
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
after(async () => {
|
|
47
|
+
await rm(dir, { recursive: true, force: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('an empty repo has no changes', async () => {
|
|
51
|
+
assert.equal(await git.hasChanges(dir), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('a new file registers as a change', async () => {
|
|
55
|
+
await writeFile(path.join(dir, 'a.md'), 'hello')
|
|
56
|
+
assert.equal(await git.hasChanges(dir), true)
|
|
57
|
+
const status = await git.statusPorcelain(dir)
|
|
58
|
+
assert.match(status, /a\.md/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('addAll stages it, then commit records it', async () => {
|
|
62
|
+
await git.addAll(dir)
|
|
63
|
+
assert.equal(await git.hasStagedChanges(dir), true)
|
|
64
|
+
await git.commit(dir, 'first commit')
|
|
65
|
+
assert.equal(await git.hasChanges(dir), false)
|
|
66
|
+
assert.equal(await git.hasStagedChanges(dir), false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('currentBranch reports the init branch', async () => {
|
|
70
|
+
assert.equal(await git.currentBranch(dir), 'main')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('checkoutBranch creates and switches to a new branch', async () => {
|
|
74
|
+
await git.checkoutBranch(dir, 'mikser', { create: true })
|
|
75
|
+
assert.equal(await git.currentBranch(dir), 'mikser')
|
|
76
|
+
assert.equal(await git.branchExistsLocal(dir, 'mikser'), true)
|
|
77
|
+
assert.equal(await git.branchExistsLocal(dir, 'nonexistent'), false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('a second file + commit round-trips through the same flow on the new branch', async () => {
|
|
81
|
+
await writeFile(path.join(dir, 'b.md'), 'world')
|
|
82
|
+
await git.addAll(dir)
|
|
83
|
+
await git.commit(dir, 'second commit')
|
|
84
|
+
assert.equal(await git.hasChanges(dir), false)
|
|
85
|
+
assert.equal(await git.revParse(dir, 'HEAD') !== '', true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('resetHard moves the branch tip and the working tree together', async () => {
|
|
89
|
+
const beforeReset = await git.revParse(dir, 'HEAD')
|
|
90
|
+
await writeFile(path.join(dir, 'c.md'), 'discard me')
|
|
91
|
+
await git.addAll(dir)
|
|
92
|
+
await git.commit(dir, 'to be discarded')
|
|
93
|
+
assert.notEqual(await git.revParse(dir, 'HEAD'), beforeReset)
|
|
94
|
+
await git.resetHard(dir, beforeReset)
|
|
95
|
+
assert.equal(await git.revParse(dir, 'HEAD'), beforeReset)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// The hard scope boundary the working-folder-as-checkout model depends
|
|
100
|
+
// on: mikser.config.js, node_modules/, runtime/, out/, .env can live in
|
|
101
|
+
// the SAME checkout this plugin manages, alongside collections it
|
|
102
|
+
// SHOULD auto-commit (documents/, layouts/), as long as every git
|
|
103
|
+
// operation is scoped to `paths`. Proven directly here, not assumed.
|
|
104
|
+
describe('git.js pathspec scoping (paths param)', () => {
|
|
105
|
+
let dir
|
|
106
|
+
|
|
107
|
+
before(async () => {
|
|
108
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-scope-'))
|
|
109
|
+
await git.run(dir, ['init', '-b', 'main'])
|
|
110
|
+
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
111
|
+
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
112
|
+
await mkdir(path.join(dir, 'documents'))
|
|
113
|
+
await mkdir(path.join(dir, 'node_modules'))
|
|
114
|
+
await writeFile(path.join(dir, 'mikser.config.js'), 'export default {}')
|
|
115
|
+
await writeFile(path.join(dir, 'documents', 'post.md'), 'hello')
|
|
116
|
+
await writeFile(path.join(dir, 'node_modules', 'dep.js'), 'module.exports = {}')
|
|
117
|
+
await git.addAll(dir) // baseline commit — everything present so far
|
|
118
|
+
await git.commit(dir, 'baseline')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
after(async () => {
|
|
122
|
+
await rm(dir, { recursive: true, force: true })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('statusPorcelain with paths only reports changes inside the scope', async () => {
|
|
126
|
+
await writeFile(path.join(dir, 'documents', 'post.md'), 'edited') // in scope
|
|
127
|
+
await writeFile(path.join(dir, 'mikser.config.js'), 'export default { x: 1 }') // OUT of scope
|
|
128
|
+
await writeFile(path.join(dir, 'node_modules', 'dep.js'), 'changed') // OUT of scope
|
|
129
|
+
|
|
130
|
+
const scoped = await git.statusPorcelain(dir, ['documents'])
|
|
131
|
+
assert.match(scoped, /post\.md/)
|
|
132
|
+
assert.doesNotMatch(scoped, /mikser\.config\.js/)
|
|
133
|
+
assert.doesNotMatch(scoped, /dep\.js/)
|
|
134
|
+
|
|
135
|
+
// Unscoped status proves the OTHER files really were dirty —
|
|
136
|
+
// the scoped call above wasn't just quiet because nothing changed.
|
|
137
|
+
const unscoped = await git.statusPorcelain(dir)
|
|
138
|
+
assert.match(unscoped, /mikser\.config\.js/)
|
|
139
|
+
assert.match(unscoped, /dep\.js/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('addAll with paths stages only the in-scope file — the config edit stays untracked', async () => {
|
|
143
|
+
await git.addAll(dir, ['documents'])
|
|
144
|
+
assert.equal(await git.hasStagedChanges(dir, ['documents']), true)
|
|
145
|
+
// Prove mikser.config.js's edit was NOT staged: the unscoped
|
|
146
|
+
// status must still show it as an unstaged modification (' M'),
|
|
147
|
+
// not staged ('M ').
|
|
148
|
+
const status = await git.statusPorcelain(dir)
|
|
149
|
+
assert.match(status, / M mikser\.config\.js/)
|
|
150
|
+
assert.doesNotMatch(status, /^M {2}mikser\.config\.js/m)
|
|
151
|
+
|
|
152
|
+
await git.commit(dir, 'documents-only change')
|
|
153
|
+
|
|
154
|
+
// The committed tree must NOT include the config edit or the
|
|
155
|
+
// node_modules change — only documents/post.md's new content.
|
|
156
|
+
const committed = await git.run(dir, ['show', '--stat', 'HEAD'])
|
|
157
|
+
assert.match(committed, /documents\/post\.md/)
|
|
158
|
+
assert.doesNotMatch(committed, /mikser\.config\.js/)
|
|
159
|
+
assert.doesNotMatch(committed, /dep\.js/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('a brand-new directory outside paths is invisible to a scoped add, even with --untracked-files=all', async () => {
|
|
163
|
+
await mkdir(path.join(dir, 'layouts'))
|
|
164
|
+
await writeFile(path.join(dir, 'layouts', 'post.hbs'), '<html></html>')
|
|
165
|
+
|
|
166
|
+
await git.addAll(dir, ['documents']) // layouts/ deliberately not in scope
|
|
167
|
+
assert.equal(await git.hasStagedChanges(dir, ['documents']), false, 'nothing new in documents/ this round')
|
|
168
|
+
assert.equal(await git.hasStagedChanges(dir, ['layouts']), false, 'layouts/ was never staged — out of scope')
|
|
169
|
+
|
|
170
|
+
const unscoped = await git.statusPorcelain(dir)
|
|
171
|
+
assert.match(unscoped, /layouts\//) // still genuinely untracked, just untouched by the scoped add
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// The zero-config default (index.js's `paths` config omitted → resolveConfig
|
|
176
|
+
// gives `null`): with NO pathspec at all, git.js's functions operate on the
|
|
177
|
+
// whole repo. This proves the actual behavior that default implies —
|
|
178
|
+
// something outside any collection folder DOES get staged when no `paths`
|
|
179
|
+
// was configured, and .gitignore (not a pathspec) is what would keep
|
|
180
|
+
// node_modules/.env out in that case. Distinct from the scoped-paths suite
|
|
181
|
+
// above, which proves the OPPOSITE guarantee when paths IS given.
|
|
182
|
+
describe('git.js with paths=null (the no-`paths`-configured default)', () => {
|
|
183
|
+
let dir
|
|
184
|
+
|
|
185
|
+
before(async () => {
|
|
186
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-noscope-'))
|
|
187
|
+
await git.run(dir, ['init', '-b', 'main'])
|
|
188
|
+
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
189
|
+
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
190
|
+
await writeFile(path.join(dir, '.gitignore'), 'ignored-dir/\n')
|
|
191
|
+
await mkdir(path.join(dir, 'documents'))
|
|
192
|
+
await mkdir(path.join(dir, 'ignored-dir'))
|
|
193
|
+
await writeFile(path.join(dir, 'documents', 'post.md'), 'hello')
|
|
194
|
+
await writeFile(path.join(dir, 'mikser.config.js'), 'export default {}')
|
|
195
|
+
await writeFile(path.join(dir, 'ignored-dir', 'dep.js'), 'x')
|
|
196
|
+
await git.addAll(dir) // no paths arg — baseline covers everything not gitignored
|
|
197
|
+
await git.commit(dir, 'baseline')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
after(async () => {
|
|
201
|
+
await rm(dir, { recursive: true, force: true })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('with no paths arg, an edit OUTSIDE any collection folder still gets staged', async () => {
|
|
205
|
+
await writeFile(path.join(dir, 'mikser.config.js'), 'export default { changed: true }')
|
|
206
|
+
await git.addAll(dir) // no paths — the default-config behavior
|
|
207
|
+
assert.equal(await git.hasStagedChanges(dir), true)
|
|
208
|
+
const status = await git.statusPorcelain(dir)
|
|
209
|
+
assert.match(status, /^M {2}mikser\.config\.js/m) // staged, not just modified
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('.gitignore, not a pathspec, is what keeps an ignored directory out — even unscoped', async () => {
|
|
213
|
+
await writeFile(path.join(dir, 'ignored-dir', 'dep.js'), 'changed')
|
|
214
|
+
await git.addAll(dir)
|
|
215
|
+
// The gitignored file's change never shows up at all, scoped or not —
|
|
216
|
+
// this is .gitignore doing the work, since there's no pathspec here.
|
|
217
|
+
const status = await git.statusPorcelain(dir)
|
|
218
|
+
assert.doesNotMatch(status, /dep\.js/)
|
|
219
|
+
})
|
|
220
|
+
})
|