diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cf48061..00979a6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,4 +20,4 @@ --- ## ๐Ÿ“Ž ๊ด€๋ จ ์ด์Šˆ(์„ ํƒ) -- Close #์ด์Šˆ๋ฒˆํ˜ธ +- Closes #์ด์Šˆ๋ฒˆํ˜ธ diff --git a/.github/scripts/close-develop-issues.js b/.github/scripts/close-develop-issues.js new file mode 100644 index 0000000..ee8132e --- /dev/null +++ b/.github/scripts/close-develop-issues.js @@ -0,0 +1,182 @@ +'use strict'; + +const EXPECTED_OWNER = 'CKLOB'; +const EXPECTED_REPOSITORY = 'MUDDA-Server'; +const EXPECTED_FULL_NAME = `${EXPECTED_OWNER}/${EXPECTED_REPOSITORY}`; +const MARKER_PREFIX = 'auto-close-develop-pr'; +const CLOSING_KEYWORDS = + 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; + +function stripNonContent(body) { + let content = typeof body === 'string' ? body : ''; + + content = content.replace(//g, ' '); + content = content.replace(/```[\s\S]*?```/g, ' '); + content = content.replace(/~~~[\s\S]*?~~~/g, ' '); + content = content.replace(/^(?: {4}|\t).+$/gm, ' '); + content = content.replace(/`[^`\r\n]*`/g, ' '); + content = content.replace(/https?:\/\/[^\s<>()]+/gi, ' '); + + return content; +} + +function extractLinkedIssueNumbers(body) { + const content = stripNonContent(body); + const pattern = new RegExp( + `(?:^|[^\\p{L}\\p{N}_-])(?:${CLOSING_KEYWORDS})(?![\\p{L}\\p{N}_-])(?:\\s+|\\s*:\\s*)#([1-9]\\d*)(?!\\d)`, + 'giu', + ); + const issueNumbers = new Set(); + + for (const match of content.matchAll(pattern)) { + const issueNumber = Number(match[1]); + if (Number.isSafeInteger(issueNumber) && issueNumber > 0) { + issueNumbers.add(issueNumber); + } + } + + return [...issueNumbers]; +} + +function isTargetPullRequest(repository, pullRequest) { + return ( + repository?.full_name === EXPECTED_FULL_NAME && + pullRequest?.base?.ref === 'develop' && + pullRequest?.merged === true && + pullRequest?.head?.repo?.full_name === EXPECTED_FULL_NAME + ); +} + +function getMarker(pullRequestNumber) { + return ``; +} + +function getPullRequestUrl(pullRequestNumber) { + return `https://github.com/${EXPECTED_FULL_NAME}/pull/${pullRequestNumber}`; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function closeLinkedIssues({ github, repository, pullRequest }) { + const result = { + skipped: [], + closed: [], + failures: [], + }; + + if (!isTargetPullRequest(repository, pullRequest)) { + result.skipped.push({ reason: 'event does not target a merged develop PR' }); + return result; + } + + const pullRequestNumber = pullRequest.number; + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber < 1) { + result.failures.push({ reason: 'invalid pull request number' }); + return result; + } + + const issueNumbers = extractLinkedIssueNumbers(pullRequest.body); + result.issueNumbers = issueNumbers; + + for (const issueNumber of issueNumbers) { + try { + const issueResponse = await github.rest.issues.get({ + owner: EXPECTED_OWNER, + repo: EXPECTED_REPOSITORY, + issue_number: issueNumber, + }); + const issue = issueResponse.data; + + if (issue.pull_request) { + result.skipped.push({ issueNumber, reason: 'target is a pull request' }); + continue; + } + + if (issue.state !== 'open') { + result.skipped.push({ issueNumber, reason: 'issue is already closed' }); + continue; + } + + const marker = getMarker(pullRequestNumber); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: EXPECTED_OWNER, + repo: EXPECTED_REPOSITORY, + issue_number: issueNumber, + per_page: 100, + }); + const alreadyAudited = comments.some((comment) => + typeof comment.body === 'string' && comment.body.includes(marker), + ); + + if (!alreadyAudited) { + await github.rest.issues.createComment({ + owner: EXPECTED_OWNER, + repo: EXPECTED_REPOSITORY, + issue_number: issueNumber, + body: `PR #${pullRequestNumber}๊ฐ€ develop์— ๋ณ‘ํ•ฉ๋˜์–ด ์ž๋™์œผ๋กœ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n${marker}\n${getPullRequestUrl(pullRequestNumber)}`, + }); + } + + await github.rest.issues.update({ + owner: EXPECTED_OWNER, + repo: EXPECTED_REPOSITORY, + issue_number: issueNumber, + state: 'closed', + state_reason: 'completed', + }); + result.closed.push(issueNumber); + } catch (error) { + if (error?.status === 404) { + result.skipped.push({ issueNumber, reason: 'issue does not exist' }); + } else { + result.failures.push({ issueNumber, reason: errorMessage(error) }); + } + } + } + + return result; +} + +async function writeSummary(core, result) { + if (!core?.summary) { + return; + } + + const summary = core.summary + .addHeading('Develop PR Issue Automation') + .addRaw(`Closed: ${result.closed.join(', ') || 'none'}\n`) + .addRaw(`Skipped: ${result.skipped.length}\n`) + .addRaw(`Failures: ${result.failures.length}\n`); + + if (result.skipped.length > 0) { + summary.addHeading('Skipped', 2).addTable([ + [{ data: 'Issue', header: true }, { data: 'Reason', header: true }], + ...result.skipped.map((entry) => [ + String(entry.issueNumber ?? '-'), + entry.reason, + ]), + ]); + } + + if (result.failures.length > 0) { + summary.addHeading('Failures', 2).addTable([ + [{ data: 'Issue', header: true }, { data: 'Reason', header: true }], + ...result.failures.map((entry) => [ + String(entry.issueNumber ?? '-'), + entry.reason, + ]), + ]); + } + + await summary.write(); +} + +module.exports = { + closeLinkedIssues, + extractLinkedIssueNumbers, + isTargetPullRequest, + stripNonContent, + writeSummary, +}; diff --git a/.github/scripts/close-develop-issues.test.js b/.github/scripts/close-develop-issues.test.js new file mode 100644 index 0000000..1c1cc78 --- /dev/null +++ b/.github/scripts/close-develop-issues.test.js @@ -0,0 +1,208 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + closeLinkedIssues, + extractLinkedIssueNumbers, + isTargetPullRequest, +} = require('./close-develop-issues'); + +const repository = { full_name: 'CKLOB/MUDDA-Server' }; +const sameRepositoryHead = { repo: { full_name: 'CKLOB/MUDDA-Server' } }; + +function createGithub(issues, comments = new Map()) { + const createdComments = []; + const updatedIssues = []; + const github = { + rest: { + issues: { + async get({ issue_number: issueNumber }) { + const issue = issues[issueNumber]; + if (issue instanceof Error) { + throw issue; + } + if (!issue) { + const error = new Error('Not Found'); + error.status = 404; + throw error; + } + return { data: issue }; + }, + async listComments() { + return { data: [] }; + }, + async createComment(params) { + createdComments.push(params); + return { data: params }; + }, + async update(params) { + updatedIssues.push(params); + return { data: params }; + }, + }, + }, + async paginate(_method, { issue_number: issueNumber }) { + return comments.get(issueNumber) ?? []; + }, + }; + + return { github, createdComments, updatedIssues }; +} + +test('extracts official keywords, colon variants, and deduplicates issue numbers', () => { + assert.deepEqual( + extractLinkedIssueNumbers( + 'Closes #32, closes #34\nFIXED: #35\nResolves: #32\nclosed #36', + ), + [32, 34, 35, 36], + ); +}); + +test('ignores non-closing references and masked markdown content', () => { + const fencedCodeBlock = ['```markdown', 'Closes #7', '```'].join('\n'); + + assert.deepEqual( + extractLinkedIssueNumbers( + [ + 'Refs #1', + 'Related #2', + '#3', + 'Closes #4, #5', + '`Closes #6`', + fencedCodeBlock, + '', + 'https://example.com/Closes%20%239', + 'Closes CKLOB/other-repository#10', + ' Closes #12', + 'Closes #0', + 'Closes#11', + ].join('\n'), + ), + [4], + ); +}); + +test('only accepts a merged PR targeting develop in this repository', () => { + const mergedDevelopPr = { + base: { ref: 'develop' }, + head: sameRepositoryHead, + merged: true, + }; + assert.equal(isTargetPullRequest(repository, mergedDevelopPr), true); + assert.equal( + isTargetPullRequest(repository, { + base: { ref: 'develop' }, + head: sameRepositoryHead, + merged: false, + }), + false, + ); + assert.equal( + isTargetPullRequest(repository, { + base: { ref: 'main' }, + head: sameRepositoryHead, + merged: true, + }), + false, + ); + assert.equal( + isTargetPullRequest( + { full_name: 'someone/another-repository' }, + mergedDevelopPr, + ), + false, + ); + assert.equal( + isTargetPullRequest(repository, { + base: { ref: 'develop' }, + head: { repo: { full_name: 'someone/another-repository' } }, + merged: true, + }), + false, + ); +}); + +test('does not use a title reference when the body is empty or null', async () => { + const { github, updatedIssues } = createGithub({ + 1: { state: 'open' }, + }); + + const result = await closeLinkedIssues({ + github, + repository, + pullRequest: { + number: 35, + title: 'Closes #1', + body: null, + base: { ref: 'develop' }, + head: sameRepositoryHead, + merged: true, + }, + }); + + assert.deepEqual(result.issueNumbers, []); + assert.deepEqual(updatedIssues, []); +}); + +test('closes open issues once and does not duplicate an existing audit comment', async () => { + const existingMarker = ''; + const comments = new Map([[2, [{ body: `already audited ${existingMarker}` }]]]); + const { github, createdComments, updatedIssues } = createGithub( + { + 1: { state: 'open' }, + 2: { state: 'open' }, + 3: { state: 'closed' }, + 4: { state: 'open', pull_request: {} }, + }, + comments, + ); + + const result = await closeLinkedIssues({ + github, + repository, + pullRequest: { + number: 35, + body: 'Closes #1, fixes #2, resolves #1, closes #3, closes #4, closes #5', + base: { ref: 'develop' }, + head: sameRepositoryHead, + merged: true, + }, + }); + + assert.deepEqual(result.closed, [1, 2]); + assert.equal(createdComments.length, 1); + assert.equal(createdComments[0].issue_number, 1); + assert.equal(createdComments[0].body.includes('auto-close-develop-pr:35'), true); + assert.deepEqual( + updatedIssues.map((issue) => issue.issue_number), + [1, 2], + ); +}); + +test('continues after an API failure and reports the failed issue', async () => { + const { github, updatedIssues } = createGithub({ + 1: Object.assign(new Error('rate limited'), { status: 429 }), + 2: { state: 'open' }, + }); + + const result = await closeLinkedIssues({ + github, + repository, + pullRequest: { + number: 35, + body: 'Closes #1, fixes #2', + base: { ref: 'develop' }, + head: sameRepositoryHead, + merged: true, + }, + }); + + assert.deepEqual(result.closed, [2]); + assert.deepEqual(result.failures, [{ issueNumber: 1, reason: 'rate limited' }]); + assert.deepEqual( + updatedIssues.map((issue) => issue.issue_number), + [2], + ); +}); diff --git a/.github/workflows/close-develop-issues.yml b/.github/workflows/close-develop-issues.yml new file mode 100644 index 0000000..a2c062b --- /dev/null +++ b/.github/workflows/close-develop-issues.yml @@ -0,0 +1,67 @@ +name: Close Issues Linked from Develop PRs + +on: + pull_request: + types: [closed] + +permissions: + contents: read + pull-requests: read + issues: write + +concurrency: + group: close-develop-issues-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + close-linked-issues: + if: >- + github.repository == 'CKLOB/MUDDA-Server' && + github.event.pull_request.base.ref == 'develop' && + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: write + + steps: + # Always execute the trusted automation code from the default branch. + # Never checkout or execute the closed PR's head branch. + - name: Checkout automation code + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Close linked issues + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const path = require('node:path'); + const { + closeLinkedIssues, + writeSummary, + } = require( + path.join( + process.env.GITHUB_WORKSPACE, + '.github/scripts/close-develop-issues.js', + ), + ); + + const result = await closeLinkedIssues({ + github, + repository: context.payload.repository, + pullRequest: context.payload.pull_request, + }); + + await writeSummary(core, result); + + if (result.failures.length > 0) { + core.setFailed( + `Failed to process ${result.failures.length} linked issue(s).`, + ); + } diff --git a/docs/GIT_FLOW.md b/docs/GIT_FLOW.md new file mode 100644 index 0000000..dc6cf56 --- /dev/null +++ b/docs/GIT_FLOW.md @@ -0,0 +1,31 @@ +# Git Flow + +## ๋ธŒ๋žœ์น˜ ์—ญํ•  + +- `main`: ์šด์˜ยท๋ฐฐํฌ ์•ˆ์ • ๋ธŒ๋žœ์น˜์ด์ž GitHub ๊ธฐ๋ณธ ๋ธŒ๋žœ์น˜ +- `develop`: ๊ธฐ๋Šฅ ํ†ตํ•ฉ ๋ธŒ๋žœ์น˜ +- `feature/*`, `fix/*`, `chore/*`: ์ž‘์—… ๋ธŒ๋žœ์น˜ + +๊ธฐ๋Šฅยท์ˆ˜์ •ยท์„ค์ • ๋ณ€๊ฒฝ PR์€ ๊ธฐ๋ณธ์ ์œผ๋กœ `develop`์„ ๋Œ€์ƒ์œผ๋กœ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. `develop โ†’ main`์€ ๋ณ„๋„์˜ ๋ฆด๋ฆฌ์Šค ๊ณผ์ •์œผ๋กœ ์ง„ํ–‰ํ•˜๋ฉฐ, ๊ธฐ๋Šฅ PR์„ `main`์— ์ง์ ‘ ๋ณ‘ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +## Issue ์—ฐ๊ฒฐ + +์™„๋ฃŒํ•œ Issue๋Š” `develop` ๋Œ€์ƒ PR ๋ณธ๋ฌธ์—์„œ ๊ณต์‹ closing keyword๋กœ ๋ช…์‹œํ•ฉ๋‹ˆ๋‹ค. + +์ง€์›ํ•˜๋Š” keyword๋Š” `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`์ด๋ฉฐ ๋Œ€์†Œ๋ฌธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. `Closes #32`, `CLOSES: #32`, `Resolves: #34`์ฒ˜๋Ÿผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```text +Closes #32 +Fixes #33 +Resolves: #34 +``` + +์—ฌ๋Ÿฌ Issue๋ฅผ ์ข…๋ฃŒํ•˜๋ ค๋ฉด ๊ฐ ๋ฒˆํ˜ธ์— keyword๋ฅผ ๋ฐ˜๋ณตํ•ด์„œ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. `Closes #32, #33`์ฒ˜๋Ÿผ ๋‘ ๋ฒˆ์งธ ๋ฒˆํ˜ธ์— keyword๊ฐ€ ์—†๋Š” ์ถ•์•ฝํ˜•์€ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. `Related #N`, `Refs #N`, ๋‹จ์ˆœ `#N`, PR ์ œ๋ชฉยท๋ธŒ๋žœ์น˜ยท์ปค๋ฐ‹ ๋ฉ”์‹œ์ง€์˜ ๋ฒˆํ˜ธ๋Š” ์ž๋™ ์ข…๋ฃŒ ๋Œ€์ƒ์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +## ์ž๋™ ์ข…๋ฃŒ Workflow + +`.github/workflows/close-develop-issues.yml`์€ PR์ด ์‹ค์ œ๋กœ ๋ณ‘ํ•ฉ๋˜์–ด ๋‹ซํžŒ ๊ฒฝ์šฐ์—๋งŒ ์‹คํ–‰๋˜๋ฉฐ, base๊ฐ€ ์ •ํ™•ํžˆ `develop`์ธ ๊ฐ™์€ ์ €์žฅ์†Œ PR ๋ณธ๋ฌธ์„ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ๋œ ๊ฐ™์€ ์ €์žฅ์†Œ์˜ ์—ด๋ฆฐ Issue๋งŒ ๊ฒ€์ฆํ•œ ๋’ค ๊ฐ์‚ฌ Comment๋ฅผ ๋‚จ๊ธฐ๊ณ  `completed` ์‚ฌ์œ ๋กœ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค. `main` ๋Œ€์ƒ ๋ฆด๋ฆฌ์Šค PR์ด๋‚˜ ๋‹ซํžˆ๊ธฐ๋งŒ ํ•œ ๋ฏธ๋ณ‘ํ•ฉ PR์€ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +Workflow๋Š” PR Head ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๊ณ  ๊ธฐ๋ณธ ๋ธŒ๋žœ์น˜ `main`์˜ ์ž๋™ํ™” ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์ด ์ž๋™ํ™” PR์ด `main`์— ๋ณ‘ํ•ฉ๋œ ์ดํ›„๋ถ€ํ„ฐ ๋™์ž‘ํ•˜๋ฉฐ, ๋ณ‘ํ•ฉ ํ›„ `main โ†’ develop` ๋™๊ธฐํ™”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. + +๋ณด์•ˆ์ƒ ์™ธ๋ถ€ Fork์—์„œ ์ƒ์„ฑ๋œ PR์€ ์ž๋™ ์ข…๋ฃŒ ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•ฉ๋‹ˆ๋‹ค. Fork PR์˜ `GITHUB_TOKEN`์€ Issue ์“ฐ๊ธฐ ๊ถŒํ•œ์ด ์ œํ•œ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ๊ถŒํ•œ์„ ํ™•๋Œ€ํ•˜๊ฑฐ๋‚˜ `pull_request_target`์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ๊ฐ™์€ ์ €์žฅ์†Œ ๋ธŒ๋žœ์น˜์˜ PR๋งŒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.