Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
---

## 📎 관련 이슈(선택)
- Close #이슈번호
- Closes #이슈번호
182 changes: 182 additions & 0 deletions .github/scripts/close-develop-issues.js
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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 `<!-- ${MARKER_PREFIX}:${pullRequestNumber} -->`;
}

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,
};
208 changes: 208 additions & 0 deletions .github/scripts/close-develop-issues.test.js
Original file line number Diff line number Diff line change
@@ -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,
'<!-- Closes #8 -->',
'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 = '<!-- auto-close-develop-pr:35 -->';
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],
);
});
Loading
Loading