diff --git a/scripts/pr-create.js b/scripts/pr-create.js new file mode 100644 index 0000000..25de2f0 --- /dev/null +++ b/scripts/pr-create.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * 開立 PR,然後停錶。 + * + * 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 + * + * 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序 + * 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。 + * + * 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」 + * 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。 + * + * 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段 + * 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。 + * + * 用法: + * node scripts/pr-create.js --repo owner/name --head <分支> --body-file <描述檔> + * [--base master] [--index 13] [--host <網址>] [--dry-run] + */ +import { existsSync, readFileSync } from 'node:fs'; +import { + ScriptError, + expectOk, + giteaRequest, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +/** 描述的固定段落,順序即 reviewer 閱讀的順序 */ +const SECTIONS = [ + '摘要', + '需求議題', + '工作包議題', + '變更內容', + '設計重點', + '解決的問題', + '影響的功能', + '測試結果', +]; + +/** + * 「測試結果」裡等於沒寫的那幾句。 + * 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。 + */ +const EMPTY_TALK = new Set([ + '無', + '沒有', + 'N/A', + 'n/a', + '已測試', + '已測試通過', + '測試通過', + '測試皆通過', + '測試皆已通過', + '全部通過', + '全數通過', + '皆通過', + 'ok', + 'OK', +]); + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'head', 'body-file'], + optional: ['base', 'index', 'host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const head = flags.head; + const base = flags.base ?? 'master'; + const index = flags.index === undefined ? null : parseIndex(flags.index); + const body = readBody(flags['body-file']); + + // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 + checkSections(body); + checkTestResult(body); + + const pullsPath = `/repos/${repo}/pulls`; + const stopPath = index === null ? null : `/repos/${repo}/issues/${index}/stopwatch/stop`; + const payload = { title: head, head, base, body }; + + if (flags['dry-run']) { + const requests = [{ method: 'POST', path: pullsPath, body: payload }]; + if (stopPath) requests.push({ method: 'POST', path: stopPath, body: {} }); + return { dryRun: true, repo, head, base, title: head, requests }; + } + + const login = resolveLogin({ host: flags.host }); + await preflight(login, repo); + + const pull = expectOk( + await giteaRequest(login, 'POST', pullsPath, { body: payload }), + `POST ${pullsPath}`, + ); + + // 錶只在 PR 真的開出去之後才停 + const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath); + + return { + repo, + index, + title: pull.title, + url: pull.html_url, + number: pull.number, + head, + base, + 碼錶已停: stopped, + ...(stopped || stopPath === null + ? {} + : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經開出去了,這一步略過。' }), + }; +}); + + +function readBody(path) { + if (!existsSync(path)) { + throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); + } + return readFileSync(path, 'utf8'); +} + +/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */ +function checkSections(body) { + const found = [...body.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]); + + const missing = SECTIONS.filter((section) => !found.includes(section)); + if (missing.length > 0) { + throw new ScriptError( + 'MISSING_SECTION', + `PR 描述缺少這幾段:${missing.join('、')};` + + `固定的段落順序為 ${SECTIONS.join('/')},reviewer 每次都在同一個位置找同一件事`, + ); + } + + const order = found.filter((section) => SECTIONS.includes(section)); + if (order.join('\n') !== SECTIONS.join('\n')) { + throw new ScriptError( + 'SECTION_ORDER', + `PR 描述的段落順序不對:收到的是 ${order.join('/')},應為 ${SECTIONS.join('/')}`, + ); + } +} + +/** + * 「測試結果」不能是空話。 + * 判斷很窄——只看「整段只有一行,而那一行是已知的偷懶寫法」。窄是刻意的: + * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 + */ +function checkTestResult(body) { + const section = body.slice(body.indexOf('## 測試結果')); + const content = section + .split('\n') + .slice(1) + .join('\n') + .trim(); + + const lines = content.split('\n').filter((line) => line.trim() !== ''); + const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null; + + if (content === '' || (onlyLine !== null && EMPTY_TALK.has(onlyLine))) { + throw new ScriptError( + 'EMPTY_TEST_RESULT', + '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + + '手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫', + ); + } +} + +/** + * 停錶。錶沒在跑時 Gitea 回 500,那不算失敗——PR 已經開出去了, + * 把整件事報成失敗只會讓人以為 PR 沒開成而重跑一次。 + */ +async function stopStopwatch(login, path) { + const response = await giteaRequest(login, 'POST', path, { body: {} }); + if (response.status >= 200 && response.status < 300) return true; + if (response.status === 500 || response.status === 409) return false; + + return expectOk(response, `POST ${path}`) !== undefined; +}