axmap-cli 0.0.1
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/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 승격 봇 — 정해진 주기로 브랜치를 한 칸씩 올리는 실행기.
|
|
4
|
+
*
|
|
5
|
+
* node axmap/tools/promote.mjs --step dev-to-part [--dry-run] 하루 한 번
|
|
6
|
+
* node axmap/tools/promote.mjs --step part-to-main [--dry-run] 주 한 번
|
|
7
|
+
*
|
|
8
|
+
* 판정은 전부 순수 함수에 있다. 여기는 **GitLab API 와 git 과 출력**만 담당한다.
|
|
9
|
+
*
|
|
10
|
+
* 짝 만들기 `src/promote.mjs` 의 planPromotions (그 안에서 checkTarget)
|
|
11
|
+
* 정족수 `governance/gate.mjs` 을 자식 프로세스로 부르고 종료 코드를 본다
|
|
12
|
+
*
|
|
13
|
+
* ── 🔴 이 파일의 핵심 요구: 머지에 sha 를 반드시 싣는다 ──────────────────────
|
|
14
|
+
*
|
|
15
|
+
* 표(찬성표)는 **커밋 하나에 묶여 있다** (governance/GOVERNANCE.md 의 G3).
|
|
16
|
+
* 정족수를 판정한 뒤 머지하기까지는 아무리 짧아도 창(window)이 열려 있고, 그
|
|
17
|
+
* 사이에 누가 소스 브랜치에 커밋을 하나 더 올리면 **표가 없는 커밋이 봇의 손으로
|
|
18
|
+
* 머지된다.**
|
|
19
|
+
*
|
|
20
|
+
* 그래서 머지 호출에 `sha`(= 정족수를 판정할 때 쓴 그 커밋)를 함께 보낸다. GitLab
|
|
21
|
+
* 은 그 값이 지금 MR 헤드와 다르면 **409 로 거부한다.** 이것이 CAS(**Compare-And-
|
|
22
|
+
* Swap** — "내가 읽은 뒤로 바뀐 게 없을 때만 쓴다". git push 가 원래 이렇게
|
|
23
|
+
* 동작한다)다.
|
|
24
|
+
*
|
|
25
|
+
* 🔴 409 를 받으면 **재시도하지 않는다.** 409 는 고장이 아니라 "내용이 바뀌었다"
|
|
26
|
+
* 는 뜻이고, 바뀐 내용은 새로 표를 받아야 한다. 다시 걸면 봇이 새 커밋을
|
|
27
|
+
* 옛 표로 머지하게 되고, 그게 정확히 sha 로 막으려던 사고다.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 같은 이유로 merge_when_pipeline_succeeds(**파이프라인이 초록이 되면 나중에
|
|
30
|
+
* 자동으로 머지**) 를 쓰지 않는다. 그건 판정 시점과 머지 시점을 벌려 놓는
|
|
31
|
+
* 장치이고, 벌어진 그 사이가 정확히 위 사고가 나는 자리다.
|
|
32
|
+
*
|
|
33
|
+
* ── 토큰이 없으면 ────────────────────────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* `.gitlab-ci.yml` 의 version 잡과 같은 태도다. 조용히 건너뛰지 않고, 무엇을
|
|
36
|
+
* 했을지 계산해서 찍고, 설정하는 법을 안내하고 종료 코드 0 으로 끝낸다.
|
|
37
|
+
* **승격이 안 되고 있는 것을 아무도 모르는 상태가 제일 나쁘다.**
|
|
38
|
+
*
|
|
39
|
+
* ── 종료 코드 ────────────────────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* 0 할 일을 다 했다 (아무것도 안 한 것, 표 부족으로 건너뛴 것 포함)
|
|
42
|
+
* 1 판정 불가 · API 오류 · 설정 오류
|
|
43
|
+
* 4 정책 자체가 깨짐 (게이트에서 그대로 올라온다)
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { spawnSync } from 'node:child_process'
|
|
47
|
+
import path from 'node:path'
|
|
48
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
49
|
+
import { planPromotions, formatPlan, describeStep, STEPS } from '../src/promote.mjs'
|
|
50
|
+
|
|
51
|
+
/** 이 도구의 종료 코드. */
|
|
52
|
+
export const EXIT = { OK: 0, ERROR: 1, POLICY_BROKEN: 4 }
|
|
53
|
+
|
|
54
|
+
/** 게이트의 종료 코드. `src/governance.mjs` 의 EXIT 과 같은 값이다. */
|
|
55
|
+
const GATE = { OK: 0, UNDECIDABLE: 1, SHORT: 2, POLICY_BROKEN: 4 }
|
|
56
|
+
|
|
57
|
+
/** `axmap/` 폴더. 호출 위치에 기대지 않는다 (tools/version.mjs 와 같은 방식). */
|
|
58
|
+
const AXMAP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// 인자 — `--k v` · `--k=v` · `--k`(=true). gate.mjs 의 parseArgs 와 같은 규칙.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const flags = {}
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i]
|
|
68
|
+
if (!a.startsWith('--')) continue
|
|
69
|
+
const eq = a.indexOf('=')
|
|
70
|
+
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1)
|
|
71
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[a.slice(2)] = argv[++i]
|
|
72
|
+
else flags[a.slice(2)] = true
|
|
73
|
+
}
|
|
74
|
+
return flags
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 플래그 값이 **문자열일 때만** 쓴다. `--step` 만 주면 값은 true 다. */
|
|
78
|
+
const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null)
|
|
79
|
+
|
|
80
|
+
const HELP = `승격 봇 — 정해진 주기로 브랜치를 한 칸씩 올린다
|
|
81
|
+
|
|
82
|
+
node axmap/tools/promote.mjs --step dev-to-part [--dry-run]
|
|
83
|
+
node axmap/tools/promote.mjs --step part-to-main [--dry-run]
|
|
84
|
+
|
|
85
|
+
--step dev-to-part = <파트>/dev -> <파트>/main (하루 한 번)
|
|
86
|
+
part-to-main = <파트>/main -> 최상위 main (주 한 번)
|
|
87
|
+
--dry-run 아무것도 만들지도 머지하지도 않는다. 무엇을 할지만 찍는다
|
|
88
|
+
--project GitLab 프로젝트 ID 또는 경로. 없으면 CI_PROJECT_ID
|
|
89
|
+
--api GitLab API 주소. 없으면 CI_API_V4_URL, 그것도 없으면 CI_SERVER_URL/api/v4
|
|
90
|
+
|
|
91
|
+
🔴 플래그가 환경변수를 이긴다.
|
|
92
|
+
🔴 토큰은 플래그로 받지 않는다. 명령줄에 적으면 셸 기록과 프로세스 목록에 남는다.
|
|
93
|
+
AXMAP_BOT_TOKEN 환경변수에 넣는다.
|
|
94
|
+
|
|
95
|
+
종료 코드
|
|
96
|
+
0 할 일을 다 했다 (아무것도 안 한 것 · 표 부족으로 건너뛴 것 포함)
|
|
97
|
+
1 판정 불가 · API 오류 · 설정 오류
|
|
98
|
+
4 정책 자체가 깨짐
|
|
99
|
+
`
|
|
100
|
+
|
|
101
|
+
const TOKEN_HELP = [
|
|
102
|
+
'설정하려면: Settings -> Access Tokens 에서 Role=Maintainer, scope=api 로 토큰을',
|
|
103
|
+
'만들고 Settings -> CI/CD -> Variables 의 AXMAP_BOT_TOKEN (Masked + Protected)',
|
|
104
|
+
'에 붙여넣습니다.',
|
|
105
|
+
'',
|
|
106
|
+
'version 잡은 태그를 push 하는 것뿐이라 write_repository 로 충분했지만, 여기는',
|
|
107
|
+
'MR 을 만들고 머지하므로 api 가 필요합니다.',
|
|
108
|
+
'',
|
|
109
|
+
'🔴 AXMAP_BOT_TOKEN 을 Protected 로 두면 보호 브랜치에서 도는 잡에만 값이 갑니다.',
|
|
110
|
+
' 이 봇이 도는 스케줄 파이프라인의 브랜치가 보호 브랜치가 아니면 토큰이 빈 채로',
|
|
111
|
+
' 여기까지 와서 초록으로 끝납니다 — 지금 보고 있는 이 메시지가 그 상태일 수 있습니다.',
|
|
112
|
+
].join('\n')
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// 바깥 세계 — 기본 구현. 테스트는 전부 주입해서 갈아 끼운다.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* git 한 번. 훅이 심어 놓는 `GIT_*` 를 지우는 것은 `governance/gate.mjs` 와 같은
|
|
120
|
+
* 이유다 — 남아 있으면 다른 저장소·다른 인덱스를 가리킨 채로 읽는다.
|
|
121
|
+
*/
|
|
122
|
+
function defaultGit(args) {
|
|
123
|
+
const env = { ...process.env }
|
|
124
|
+
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE',
|
|
125
|
+
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_PREFIX']) delete env[k]
|
|
126
|
+
const r = spawnSync('git', args, { cwd: AXMAP, env, encoding: 'utf8', windowsHide: true })
|
|
127
|
+
return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 정족수 게이트를 **자식 프로세스로** 부른다.
|
|
132
|
+
*
|
|
133
|
+
* 🔴 judge() 를 직접 부르지 않는 이유: 게이트는 정책을 **타깃 브랜치의 커밋에서**
|
|
134
|
+
* 읽고(G2) 표를 `axmap/votes` 에서 모은다. 그 재료 수집을 여기서 다시 구현하면
|
|
135
|
+
* CI 의 게이트와 봇의 게이트가 서로 다르게 판정할 수 있다. 부르는 쪽은 종료
|
|
136
|
+
* 코드만 본다 — 게이트의 계약이 곧 종료 코드다.
|
|
137
|
+
*
|
|
138
|
+
* `--json` 을 붙이면 판정문은 stderr, 기계가 읽을 판정은 stdout 으로 나온다.
|
|
139
|
+
* 우리가 stdout 에서 꺼내는 것은 **판정에 쓴 커밋(sha)** 하나다.
|
|
140
|
+
*/
|
|
141
|
+
function defaultGate({ source, target }) {
|
|
142
|
+
const r = spawnSync(
|
|
143
|
+
process.execPath,
|
|
144
|
+
[path.join(AXMAP, 'governance', 'gate.mjs'), '--source', source, '--target', target, '--json'],
|
|
145
|
+
{ cwd: AXMAP, encoding: 'utf8', windowsHide: true },
|
|
146
|
+
)
|
|
147
|
+
let verdict = null
|
|
148
|
+
try { verdict = JSON.parse(r.stdout ?? '') } catch { verdict = null }
|
|
149
|
+
return {
|
|
150
|
+
exit: r.status ?? GATE.UNDECIDABLE,
|
|
151
|
+
sha: verdict && typeof verdict.sha === 'string' ? verdict.sha : null,
|
|
152
|
+
text: (r.stderr ?? '').trim(),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// GitLab API
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
class ApiError extends Error {}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* PRIVATE-TOKEN 헤더로 부르는 GitLab REST 호출 하나.
|
|
164
|
+
*
|
|
165
|
+
* 4xx·5xx 를 던지지 않고 그대로 돌려준다 — 409 는 **정상적인 답**(내용이 바뀌었다)
|
|
166
|
+
* 이라 부르는 쪽이 상태 코드를 보고 갈라야 하기 때문이다. 네트워크가 아예 안 되는
|
|
167
|
+
* 것만 던진다.
|
|
168
|
+
*/
|
|
169
|
+
function makeGitlab({ fetchImpl, api, project, token }) {
|
|
170
|
+
const base = `${String(api).replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}`
|
|
171
|
+
return async function call(method, pathname, { query, body } = {}) {
|
|
172
|
+
const url = new URL(base + pathname)
|
|
173
|
+
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, String(v))
|
|
174
|
+
const headers = { 'PRIVATE-TOKEN': token, Accept: 'application/json' }
|
|
175
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
176
|
+
let res
|
|
177
|
+
try {
|
|
178
|
+
res = await fetchImpl(url.toString(), {
|
|
179
|
+
method,
|
|
180
|
+
headers,
|
|
181
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
182
|
+
})
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw new ApiError(`${method} ${url.pathname} — GitLab 에 닿지 못했습니다: ${e.message}`)
|
|
185
|
+
}
|
|
186
|
+
const text = typeof res.text === 'function' ? await res.text() : ''
|
|
187
|
+
let json = null
|
|
188
|
+
try { json = text ? JSON.parse(text) : null } catch { json = null }
|
|
189
|
+
const status = Number(res.status)
|
|
190
|
+
return { status, ok: status >= 200 && status < 300, json, text }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 실패한 응답을 사람이 읽는 한 줄로. GitLab 은 대개 {"message": ...} 를 준다. */
|
|
195
|
+
function apiWhy(r) {
|
|
196
|
+
const m = r.json && (r.json.message ?? r.json.error)
|
|
197
|
+
const s = m ? (typeof m === 'string' ? m : JSON.stringify(m)) : (r.text || '').slice(0, 200)
|
|
198
|
+
return `HTTP ${r.status}${s ? ` — ${s}` : ''}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 프로젝트의 브랜치 이름 전부. 한 쪽(page)이 꽉 차면 다음 쪽을 더 읽는다.
|
|
203
|
+
*
|
|
204
|
+
* 못 읽고 넘어가는 브랜치가 있으면 **승격이 덜 일어나는 쪽**으로 틀린다. 그건
|
|
205
|
+
* 안전한 방향이라 멈추지 않는다 — 게이트가 표를 덜 세는 쪽으로 기우는 것과 같은
|
|
206
|
+
* 판단이다.
|
|
207
|
+
*/
|
|
208
|
+
async function listBranches(gl, { perPage = 100, maxPages = 20 } = {}) {
|
|
209
|
+
const names = []
|
|
210
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
211
|
+
const r = await gl('GET', '/repository/branches', { query: { per_page: perPage, page } })
|
|
212
|
+
if (!r.ok) throw new ApiError(apiWhy(r))
|
|
213
|
+
if (!Array.isArray(r.json)) throw new ApiError('브랜치 목록이 배열이 아닙니다')
|
|
214
|
+
for (const b of r.json) if (b && typeof b.name === 'string') names.push(b.name)
|
|
215
|
+
if (r.json.length < perPage) break
|
|
216
|
+
}
|
|
217
|
+
return names
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// 토큰이 없을 때 쓰는 로컬 재료 (git)
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** bin/axmap.mjs · governance/gate.mjs 와 같은 순서로 원격을 고른다. */
|
|
225
|
+
function resolveRemote(git) {
|
|
226
|
+
const all = git(['remote']).out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
227
|
+
const cfg = git(['config', '--get', 'axmap.remote']).out
|
|
228
|
+
if (cfg && all.includes(cfg)) return cfg
|
|
229
|
+
if (all.length === 1) return all[0]
|
|
230
|
+
if (all.includes('origin')) return 'origin'
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 로컬에서 본 브랜치 목록과, 이름 하나를 ref 로 바꾸는 함수.
|
|
236
|
+
*
|
|
237
|
+
* 원격 추적 ref(**원격에서 마지막으로 받아온 상태를 가리키는 ref**)를 먼저 본다.
|
|
238
|
+
* 봇이 올리는 것은 언제나 원격의 브랜치이지 내 작업 트리가 아니다.
|
|
239
|
+
*/
|
|
240
|
+
function localRefs(git) {
|
|
241
|
+
const remote = resolveRemote(git)
|
|
242
|
+
if (remote) {
|
|
243
|
+
const prefix = `refs/remotes/${remote}/`
|
|
244
|
+
const r = git(['for-each-ref', '--format=%(refname)', prefix])
|
|
245
|
+
if (r.code === 0 && r.out) {
|
|
246
|
+
const names = r.out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
247
|
+
.map((s) => s.slice(prefix.length))
|
|
248
|
+
// origin/HEAD 는 브랜치가 아니라 기본 브랜치를 가리키는 심볼릭 ref 다.
|
|
249
|
+
.filter((n) => n && n !== 'HEAD')
|
|
250
|
+
if (names.length) return { label: `${remote}/* (원격 추적 ref)`, names, ref: (n) => `${remote}/${n}` }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const l = git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'])
|
|
254
|
+
const names = l.code === 0 && l.out ? l.out.split('\n').map((s) => s.trim()).filter(Boolean) : []
|
|
255
|
+
return { label: '로컬 브랜치 (원격 추적 ref 가 없습니다)', names, ref: (n) => n }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 타깃에 없는 커밋 수. 셀 수 없으면 null — 0 으로 치지 않는다. */
|
|
259
|
+
function aheadLocal(git, refOf, source, target) {
|
|
260
|
+
const r = git(['rev-list', '--count', `${refOf(target)}..${refOf(source)}`])
|
|
261
|
+
if (r.code !== 0) return null
|
|
262
|
+
const n = Number(r.out)
|
|
263
|
+
return Number.isInteger(n) ? n : null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// 본체
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/** 커밋 해시처럼 생겼는가. 표는 커밋 하나에 묶이므로 이게 없으면 머지하지 않는다. */
|
|
271
|
+
const looksLikeSha = (s) => typeof s === 'string' && /^[0-9a-f]{7,64}$/i.test(s)
|
|
272
|
+
|
|
273
|
+
/** 표가 몇 장 모자란지 한 줄로. 게이트의 판정문에서 그 줄만 꺼낸다. */
|
|
274
|
+
function shortLine(verdict) {
|
|
275
|
+
const line = (verdict.text ?? '').split('\n').find((l) => l.includes('합의 미달'))
|
|
276
|
+
return line ? line.trim() : '유효 찬성이 문턱에 못 미칩니다'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 봇이 연 MR 의 본문. 사람이 이 MR 을 왜 봐야 하는지가 첫 줄에 있어야 한다. */
|
|
280
|
+
function promotionBody({ source, target, step, ahead }) {
|
|
281
|
+
return [
|
|
282
|
+
`승격 봇이 연 MR 입니다 — ${describeStep(step)}`,
|
|
283
|
+
'',
|
|
284
|
+
`- 올리는 것: \`${source}\` -> \`${target}\` (커밋 ${ahead}개)`,
|
|
285
|
+
'- 봇은 **정족수(통과에 필요한 최소 찬성 수)가 찰 때까지 머지하지 않습니다.**',
|
|
286
|
+
' 표를 주려면: `node axmap/governance/vote.mjs` (근거는 axmap/governance/GOVERNANCE.md)',
|
|
287
|
+
'',
|
|
288
|
+
'머지할 때 봇은 정족수를 판정한 그 커밋의 sha 를 함께 보냅니다. 그 사이에 새 커밋이',
|
|
289
|
+
'올라오면 GitLab 이 머지를 거부하고, 봇은 재시도하지 않습니다 — 새 커밋은 새로 표를',
|
|
290
|
+
'받아야 하기 때문입니다.',
|
|
291
|
+
].join('\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {string[]} argv 플래그들 (`process.argv.slice(2)`)
|
|
296
|
+
* @param {object} io 바깥 세계. 테스트는 전부 갈아 끼운다
|
|
297
|
+
* @returns {Promise<number>} 종료 코드. **여기서 process.exit 을 부르지 않는다** —
|
|
298
|
+
* 부르면 테스트가 자기 프로세스를 죽인다.
|
|
299
|
+
*/
|
|
300
|
+
export async function run(argv, io = {}) {
|
|
301
|
+
const env = io.env ?? process.env
|
|
302
|
+
const fetchImpl = io.fetch ?? globalThis.fetch
|
|
303
|
+
const git = io.git ?? defaultGit
|
|
304
|
+
const gate = io.gate ?? defaultGate
|
|
305
|
+
const log = io.log ?? ((s) => console.log(s))
|
|
306
|
+
const warn = io.warn ?? ((s) => console.error(s))
|
|
307
|
+
|
|
308
|
+
const flags = parseArgs(argv)
|
|
309
|
+
if (flags.help || flags.h) { log(HELP); return EXIT.OK }
|
|
310
|
+
|
|
311
|
+
const step = str(flags.step)
|
|
312
|
+
if (!step || !STEPS.includes(step)) {
|
|
313
|
+
warn(`--step 을 주세요: ${STEPS.join(' | ')}`)
|
|
314
|
+
warn('')
|
|
315
|
+
warn(' 추측하지 않는 이유: 주기를 잘못 고르면 하루치 검토밖에 안 지난 것이')
|
|
316
|
+
warn(' 최상위 main 으로 올라갑니다.')
|
|
317
|
+
return EXIT.ERROR
|
|
318
|
+
}
|
|
319
|
+
const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true'
|
|
320
|
+
|
|
321
|
+
const token = str(env.AXMAP_BOT_TOKEN)
|
|
322
|
+
const project = str(flags.project) ?? str(env.CI_PROJECT_ID)
|
|
323
|
+
const api = str(flags.api) ?? str(env.CI_API_V4_URL)
|
|
324
|
+
?? (str(env.CI_SERVER_URL) ? `${str(env.CI_SERVER_URL).replace(/\/+$/, '')}/api/v4` : null)
|
|
325
|
+
|
|
326
|
+
// ── 토큰이 없다 — 계산만 하고 안내한다 (version 잡과 같은 태도) ──────────────
|
|
327
|
+
if (!token) {
|
|
328
|
+
if (!dryRun) {
|
|
329
|
+
// 🔴 맨 앞에 온다. 이 줄이 뒤로 밀리면 로그 끝만 보는 사람은 승격이 된 줄 안다.
|
|
330
|
+
log('🔴 AXMAP_BOT_TOKEN 이 없습니다 — 이번 주기에 아무것도 승격되지 않았습니다.')
|
|
331
|
+
log(' 아래는 토큰이 있었다면 무엇을 했을지 계산한 것입니다.')
|
|
332
|
+
} else {
|
|
333
|
+
log('AXMAP_BOT_TOKEN 이 없습니다. GitLab 을 부르지 않고 계산만 합니다.')
|
|
334
|
+
}
|
|
335
|
+
log('')
|
|
336
|
+
const refs = localRefs(git)
|
|
337
|
+
log(`브랜치 목록 출처: ${refs.label}`)
|
|
338
|
+
let plan
|
|
339
|
+
try { plan = planPromotions(refs.names, { step }) } catch (e) { warn(e.message); return EXIT.ERROR }
|
|
340
|
+
log('')
|
|
341
|
+
log(formatPlan(plan))
|
|
342
|
+
log('')
|
|
343
|
+
for (const p of plan.pairs) {
|
|
344
|
+
const n = aheadLocal(git, refs.ref, p.source, p.target)
|
|
345
|
+
if (n === null) log(` ? ${p.source} -> ${p.target} 올릴 것이 몇 개인지 셀 수 없습니다 (ref 를 못 찾았습니다)`)
|
|
346
|
+
else if (n === 0) log(` - ${p.source} -> ${p.target} 올릴 것이 없습니다. MR 을 만들지 않습니다`)
|
|
347
|
+
else log(` o ${p.source} -> ${p.target} 커밋 ${n}개를 올렸을 것입니다 (MR 확인 -> 정족수 판정 -> sha 를 실어 머지)`)
|
|
348
|
+
}
|
|
349
|
+
log('')
|
|
350
|
+
log('정족수 판정과 머지는 토큰이 있을 때만 합니다.')
|
|
351
|
+
log('')
|
|
352
|
+
log(TOKEN_HELP)
|
|
353
|
+
return EXIT.OK
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── 토큰은 있는데 어디에 걸어야 할지를 모른다 — 이건 설정 오류다 ─────────────
|
|
357
|
+
if (!api || !project) {
|
|
358
|
+
warn('AXMAP_BOT_TOKEN 은 있는데 어느 프로젝트의 어느 API 로 걸어야 할지 모릅니다.')
|
|
359
|
+
warn(` API : ${api ?? '(없음)'} <- --api 또는 CI_API_V4_URL / CI_SERVER_URL`)
|
|
360
|
+
warn(` 프로젝트 : ${project ?? '(없음)'} <- --project 또는 CI_PROJECT_ID`)
|
|
361
|
+
warn('')
|
|
362
|
+
warn('추측하지 않습니다 — 엉뚱한 프로젝트에 MR 을 만들고 머지하게 됩니다.')
|
|
363
|
+
return EXIT.ERROR
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const gl = makeGitlab({ fetchImpl, api, project, token })
|
|
367
|
+
|
|
368
|
+
log(`승격 봇 — ${describeStep(step)}`)
|
|
369
|
+
log(` 프로젝트 : ${project} (${api})`)
|
|
370
|
+
if (dryRun) log(' --dry-run : 아무것도 만들지도 머지하지도 않습니다')
|
|
371
|
+
log('')
|
|
372
|
+
|
|
373
|
+
let worst = EXIT.OK
|
|
374
|
+
/** 판정 불가(1)보다 정책 깨짐(4)을 먼저 낸다 — 저쪽이 무엇을 고쳐야 하는지 더 구체적이다. */
|
|
375
|
+
const fail = (code) => { if (code === EXIT.POLICY_BROKEN || worst === EXIT.OK) worst = code }
|
|
376
|
+
|
|
377
|
+
let branches
|
|
378
|
+
try {
|
|
379
|
+
branches = await listBranches(gl)
|
|
380
|
+
} catch (e) {
|
|
381
|
+
if (!(e instanceof ApiError)) throw e
|
|
382
|
+
warn(`브랜치 목록을 읽지 못했습니다: ${e.message}`)
|
|
383
|
+
return EXIT.ERROR
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let plan
|
|
387
|
+
try { plan = planPromotions(branches, { step }) } catch (e) { warn(e.message); return EXIT.ERROR }
|
|
388
|
+
log(formatPlan(plan))
|
|
389
|
+
log('')
|
|
390
|
+
|
|
391
|
+
const tally = { nothing: 0, created: 0, short: 0, merged: 0, conflict: 0, blocked: 0, failed: 0, planned: 0 }
|
|
392
|
+
|
|
393
|
+
for (const pair of plan.pairs) {
|
|
394
|
+
const { source, target } = pair
|
|
395
|
+
const say = (mark, msg) => log(` ${mark} ${source} -> ${target} ${msg}`)
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
// 1. 올릴 것이 있나. 없으면 건너뛴다 — 아무 일도 안 한 주에 MR 을 만들지 않는다.
|
|
399
|
+
const cmp = await gl('GET', '/repository/compare', { query: { from: target, to: source } })
|
|
400
|
+
if (!cmp.ok) { say('x', `비교 실패 — ${apiWhy(cmp)}`); tally.failed++; fail(EXIT.ERROR); continue }
|
|
401
|
+
const ahead = Array.isArray(cmp.json?.commits) ? cmp.json.commits.length : null
|
|
402
|
+
if (ahead === null) { say('x', '비교 응답에서 커밋 목록을 찾지 못했습니다'); tally.failed++; fail(EXIT.ERROR); continue }
|
|
403
|
+
if (ahead === 0) { say('-', '올릴 것이 없습니다. MR 을 만들지 않습니다'); tally.nothing++; continue }
|
|
404
|
+
|
|
405
|
+
// 2. 열려 있는 MR 이 있나. 없으면 만든다.
|
|
406
|
+
const found = await gl('GET', '/merge_requests', {
|
|
407
|
+
query: { state: 'opened', source_branch: source, target_branch: target, per_page: 100 },
|
|
408
|
+
})
|
|
409
|
+
if (!found.ok) { say('x', `열린 MR 을 찾지 못했습니다 — ${apiWhy(found)}`); tally.failed++; fail(EXIT.ERROR); continue }
|
|
410
|
+
let mr = Array.isArray(found.json) && found.json.length ? found.json[0] : null
|
|
411
|
+
|
|
412
|
+
if (!mr) {
|
|
413
|
+
if (dryRun) {
|
|
414
|
+
say('o', `커밋 ${ahead}개 — MR 을 새로 만들었을 것입니다`)
|
|
415
|
+
} else {
|
|
416
|
+
const made = await gl('POST', '/merge_requests', {
|
|
417
|
+
body: {
|
|
418
|
+
source_branch: source,
|
|
419
|
+
target_branch: target,
|
|
420
|
+
title: `승격: ${source} -> ${target}`,
|
|
421
|
+
description: promotionBody({ source, target, step, ahead }),
|
|
422
|
+
// 🔴 소스 브랜치를 지우지 않는다. front/dev 가 사라지면 그 파트의
|
|
423
|
+
// 작업 흐름이 통째로 끊긴다. 승격은 옮기는 것이 아니라 올리는 것이다.
|
|
424
|
+
remove_source_branch: false,
|
|
425
|
+
squash: false,
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
if (!made.ok) { say('x', `MR 을 만들지 못했습니다 — ${apiWhy(made)}`); tally.failed++; fail(EXIT.ERROR); continue }
|
|
429
|
+
mr = made.json
|
|
430
|
+
say('o', `커밋 ${ahead}개 — MR !${mr?.iid ?? '?'} 를 새로 만들었습니다`)
|
|
431
|
+
tally.created++
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
say('o', `커밋 ${ahead}개 — 이미 열려 있는 MR !${mr.iid} 를 씁니다`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 3. 정족수 판정. 게이트의 종료 코드를 그대로 읽는다.
|
|
438
|
+
const verdict = await gate({ source, target })
|
|
439
|
+
const label = mr?.iid ? `MR !${mr.iid}` : '(아직 안 만든 MR)'
|
|
440
|
+
|
|
441
|
+
if (verdict.exit === GATE.SHORT) {
|
|
442
|
+
// 실패가 아니다. 사람이 투표하면 다음 주기에 풀린다.
|
|
443
|
+
say('-', `${label} 표가 모자랍니다 — ${shortLine(verdict)}. 머지하지 않고 넘어갑니다`)
|
|
444
|
+
tally.short++
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
if (verdict.exit !== GATE.OK) {
|
|
448
|
+
// 🔴 판정 불가는 통과가 아니다. 여기서 잡을 실패시킨다.
|
|
449
|
+
say('x', `${label} 정족수를 판정하지 못했습니다 (게이트 종료 코드 ${verdict.exit})`)
|
|
450
|
+
if (verdict.text) for (const line of String(verdict.text).split('\n')) log(` | ${line}`)
|
|
451
|
+
tally.failed++
|
|
452
|
+
fail(verdict.exit === GATE.POLICY_BROKEN ? EXIT.POLICY_BROKEN : EXIT.ERROR)
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 🔴 판정에 쓴 커밋이 없으면 머지하지 않는다. sha 없는 머지는 표가 없는
|
|
457
|
+
// 커밋을 머지할 수 있는 창을 그대로 열어 두는 것이다.
|
|
458
|
+
if (!looksLikeSha(verdict.sha)) {
|
|
459
|
+
say('x', `${label} 정족수는 충족인데 판정에 쓴 커밋(sha)을 못 받았습니다. 머지하지 않습니다`)
|
|
460
|
+
tally.failed++
|
|
461
|
+
fail(EXIT.ERROR)
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (dryRun) {
|
|
466
|
+
say('o', `${label} 정족수 충족 — sha ${verdict.sha.slice(0, 12)} 를 실어 머지했을 것입니다`)
|
|
467
|
+
tally.planned++
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 4. 머지. 🔴 sha 를 반드시 싣는다 (CAS).
|
|
472
|
+
const merged = await gl('PUT', `/merge_requests/${mr.iid}/merge`, {
|
|
473
|
+
body: {
|
|
474
|
+
// 판정할 때 본 그 커밋. 지금 MR 헤드가 다르면 GitLab 이 409 로 거부한다.
|
|
475
|
+
sha: verdict.sha,
|
|
476
|
+
// 🔴 판정 시점과 머지 시점을 벌리지 않는다. 벌어진 사이에 들어온 커밋이
|
|
477
|
+
// 옛 표로 머지되는 것이 이 파일이 막으려는 사고 전부다.
|
|
478
|
+
merge_when_pipeline_succeeds: false,
|
|
479
|
+
squash: false,
|
|
480
|
+
should_remove_source_branch: false,
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
if (merged.status === 409) {
|
|
485
|
+
// 🔴 재시도하지 않는다. 내용이 바뀌었다는 뜻이고, 바뀐 내용은 새로 표를 받는다.
|
|
486
|
+
say('!', `MR !${mr.iid} 머지 거부(409) — 판정 뒤에 소스가 움직였습니다. `
|
|
487
|
+
+ '재시도하지 않습니다. 새 커밋은 새로 표를 받아야 합니다')
|
|
488
|
+
tally.conflict++
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
if (merged.status === 405) {
|
|
492
|
+
/**
|
|
493
|
+
* 🔴 405 는 "지금은 머지할 수 없다" 이지 "고장" 이 아니다.
|
|
494
|
+
*
|
|
495
|
+
* 제일 흔한 원인은 **파이프라인이 아직 도는 중**이다. `Pipelines must
|
|
496
|
+
* succeed` 가 켜져 있으면 초록이 되기 전에는 머지 버튼이 안 열리는데,
|
|
497
|
+
* 봇은 바로 위에서 MR 을 **방금 만들었다.** 그 주기에는 파이프라인이
|
|
498
|
+
* 이제 막 시작한 참이라 거의 언제나 405 다.
|
|
499
|
+
*
|
|
500
|
+
* 이걸 실패로 세면 **MR 을 만드는 밤마다 스케줄 잡이 빨개진다.**
|
|
501
|
+
* 늘 빨간 잡은 아무도 안 읽고, 안 읽는 검사는 없는 검사다 —
|
|
502
|
+
* 이 저장소가 "개수를 문서에 적지 않는다" 로 막으려는 것과 같은 종류의
|
|
503
|
+
* 낡음이다.
|
|
504
|
+
*
|
|
505
|
+
* 그래서 넘어가되 **조용히 넘어가지 않는다.** 다음 주기에 다시 온다.
|
|
506
|
+
* 충돌·드래프트처럼 사람이 손대야 풀리는 것도 405 로 오는데, 그건
|
|
507
|
+
* 같은 줄이 며칠 연속 찍히는 것으로 드러난다.
|
|
508
|
+
*/
|
|
509
|
+
say('!', `MR !${mr.iid} 아직 머지할 수 없습니다(405) — ${apiWhy(merged)}. `
|
|
510
|
+
+ '파이프라인이 도는 중이거나, 충돌·드래프트입니다. 다음 주기에 다시 봅니다')
|
|
511
|
+
tally.blocked++
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
if (!merged.ok) {
|
|
515
|
+
say('x', `MR !${mr.iid} 머지 실패 — ${apiWhy(merged)}`)
|
|
516
|
+
tally.failed++
|
|
517
|
+
fail(EXIT.ERROR)
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
say('o', `MR !${mr.iid} 머지 완료 — sha ${verdict.sha.slice(0, 12)}`)
|
|
521
|
+
tally.merged++
|
|
522
|
+
} catch (e) {
|
|
523
|
+
if (!(e instanceof ApiError)) throw e // 우리 버그는 스택과 함께 터뜨린다
|
|
524
|
+
say('x', e.message)
|
|
525
|
+
tally.failed++
|
|
526
|
+
fail(EXIT.ERROR)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
log('')
|
|
531
|
+
log('요약')
|
|
532
|
+
log(` 짝 : ${plan.pairs.length}개 (계획에서 뺀 것 ${plan.skipped.length}개)`)
|
|
533
|
+
log(` 올릴 것 없음 : ${tally.nothing}개`)
|
|
534
|
+
log(` MR 새로 만듦 : ${tally.created}개`)
|
|
535
|
+
log(` 표 부족 : ${tally.short}개`)
|
|
536
|
+
log(dryRun ? ` 머지했을 것 : ${tally.planned}개` : ` 머지 : ${tally.merged}개`)
|
|
537
|
+
log(` 409 로 넘김 : ${tally.conflict}개`)
|
|
538
|
+
log(` 405 로 넘김 : ${tally.blocked}개 (아직 머지 불가 — 다음 주기에 다시 봅니다)`)
|
|
539
|
+
log(` 실패 : ${tally.failed}개`)
|
|
540
|
+
if (worst !== EXIT.OK) log(` -> 종료 코드 ${worst}`)
|
|
541
|
+
return worst
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 직접 실행할 때만 돈다. import 하면 아무것도 안 한다 (테스트가 run 을 부른다).
|
|
545
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
546
|
+
run(process.argv.slice(2)).then(
|
|
547
|
+
(code) => process.exit(code),
|
|
548
|
+
(e) => { console.error(e?.stack ?? String(e)); process.exit(EXIT.ERROR) },
|
|
549
|
+
)
|
|
550
|
+
}
|