From 52c03b208eb86dd1f5908b666f68214940d3af50 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 12 Jun 2026 10:47:52 +0900 Subject: [PATCH] =?UTF-8?q?chore(git):=20Gitea=20PR=20=ED=97=AC=ED=8D=BC?= =?UTF-8?q?=20=E2=80=94=20UTF-8=20=EC=95=88=EC=A0=84=20=EC=83=9D=EC=84=B1/?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EB=A8=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tools/git/gitea-pr.mjs | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tools/git/gitea-pr.mjs diff --git a/tools/git/gitea-pr.mjs b/tools/git/gitea-pr.mjs new file mode 100644 index 0000000..a52aa77 --- /dev/null +++ b/tools/git/gitea-pr.mjs @@ -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: { head, base?, title, body } +// node tools/git/gitea-pr.mjs edit <번호> # 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 | edit <번호> | merge <번호> | show <번호>>'); + process.exit(1); +}