chore(git): Gitea PR 헬퍼 — UTF-8 안전 생성/수정/머지

Windows 셸 인라인 curl -d 본문이 CP949로 전송되어 PR #34~41 한글이
깨진 사고 재발 방지. 제목/본문은 UTF-8 spec JSON 파일로만 받고
Node fetch가 전송. git credential(GCM)에서 토큰 자동 취득·401 재시도.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:47:52 +09:00
parent 76aaafcf1b
commit 52c03b208e

75
tools/git/gitea-pr.mjs Normal file
View File

@@ -0,0 +1,75 @@
// Gitea PR 생성·수정·머지 헬퍼 — UTF-8 안전.
//
// ⚠️ 배경: Windows에서 bash 인라인 curl -d '{"title":"한글..."}' 은 셸 경계에서
// CP949로 인코딩되어 Gitea에 한글이 깨져 올라간다(PR #34~41 사고).
// 제목/본문은 반드시 UTF-8 파일(spec JSON)로 받고, Node fetch가 UTF-8로 전송한다.
//
// 사용법 (저장소 루트에서):
// node tools/git/gitea-pr.mjs create <spec.json> # spec: { head, base?, title, body }
// node tools/git/gitea-pr.mjs edit <번호> <spec.json> # spec: { title?, body? }
// node tools/git/gitea-pr.mjs merge <번호>
// node tools/git/gitea-pr.mjs show <번호>
//
// 토큰은 git credential fill(GCM)에서 읽는다. 인증 실패 시 한 번 재시도(토큰 갱신 직후 캐시 이슈).
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
const HOST = 'gitea.gahusb.synology.me';
const REPO = 'gahusb/maplecontest';
const API = `https://${HOST}/api/v1/repos/${REPO}`;
function token() {
const out = execSync('git credential fill', { input: `protocol=https\nhost=${HOST}\n\n`, encoding: 'utf8' });
const m = out.match(/^password=(.+)$/m);
if (!m) throw new Error('git credential에서 토큰을 찾지 못함');
return m[1].trim();
}
async function call(method, path, body) {
for (let attempt = 1; attempt <= 2; attempt++) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: body == null ? undefined : JSON.stringify(body),
});
if (res.status === 401 && attempt === 1) continue; // GCM 토큰 갱신 재시도
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : null;
}
throw new Error('unreachable');
}
const [cmd, a1, a2] = process.argv.slice(2);
if (cmd === 'create') {
const spec = JSON.parse(readFileSync(a1, 'utf8'));
if (!spec.head || !spec.title) throw new Error('spec에 head/title 필수');
const pr = await call('POST', '/pulls', {
head: spec.head,
base: spec.base || 'main',
title: spec.title,
body: spec.body || '',
});
console.log(`PR #${pr.number} 생성: ${pr.html_url || pr.url}`);
} else if (cmd === 'edit') {
const spec = JSON.parse(readFileSync(a2, 'utf8'));
const pr = await call('PATCH', `/pulls/${a1}`, {
...(spec.title != null ? { title: spec.title } : {}),
...(spec.body != null ? { body: spec.body } : {}),
});
console.log(`PR #${pr.number} 수정: ${pr.title}`);
} else if (cmd === 'merge') {
await call('POST', `/pulls/${a1}/merge`, { Do: 'merge' });
console.log(`PR #${a1} 머지 완료`);
} else if (cmd === 'show') {
const pr = await call('GET', `/pulls/${a1}`);
console.log(`#${pr.number} [${pr.state}${pr.merged ? '·merged' : ''}] ${pr.title}`);
console.log(pr.body || '(본문 없음)');
} else {
console.error('사용법: node tools/git/gitea-pr.mjs <create <spec.json> | edit <번호> <spec.json> | merge <번호> | show <번호>>');
process.exit(1);
}