// 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); }