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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ type(scope): message
普通 PR 打开后,FlowPilot 通过 `changelog` output 提供一条带提示行的待确认评论。可以通过以下任一方式确认日志:

1. 编辑待确认评论,检查日志内容并删除第一行提示,使评论以 `### 📝 更新日志` 开头。
2. 由白名单成员在 PR 下创建内容为 `/changelog` 的评论。指令允许首尾空白,但不能包含其他内容。
2. 由白名单成员在 PR 下创建内容为 `/changelog` 的评论。指令允许首尾空白,但不能包含其他内容;PR 已合并时会从目标分支创建补日志 PR
3. 由白名单成员提交状态为 `approved` 的 PR Review。

通过 `/changelog` 或 Review 确认时,FlowPilot 从最新 PR 描述提取日志;编辑待确认评论时,以修改后的评论内容为准。随后 FlowPilot 向 PR 分支提交:
Expand All @@ -226,6 +226,8 @@ type(scope): message

暂存文件包含 PR 编号、贡献者、日志内容和 PR 链接,提交信息为 `chore: stash changelog [ci skip]`。重复确认同一 PR 会更新对应文件,不会创建多份日志。

如果原 PR 已合并,FlowPilot 不再向原 head 分支推送,而是从原 PR 的 base 分支创建 `changelog/pr-<PR number>` 分支并提交补日志 PR。重复执行 `/changelog` 时,如果该补日志 PR 仍处于打开状态,则更新现有分支,不会重复创建 PR。该流程要求 token 具有 Contents 写权限和 Pull requests 写权限。

### `/changelog` 指令限制

- 只响应 PR 评论,不响应普通 Issue 评论。
Expand Down Expand Up @@ -337,7 +339,7 @@ FlowPilot 会递归查找 `package.json` 和 `pubspec.yaml`,包发现本身不
| `pull_request: opened` | 非 fork 的 `release/*` PR | 生成 release Changelog 确认评论 |
| `pull_request: closed` | 已合并的 `release/*` PR | 发布 Node 包并创建符合条件的 GitHub Release/tag |
| `pull_request_review: submitted` | approved、白名单成员、普通 PR | 从 PR 描述确认并暂存日志 |
| `issue_comment: created` | PR 评论为 `/changelog`、白名单成员 | 从 PR 描述确认并暂存日志 |
| `issue_comment: created` | PR 评论为 `/changelog`、白名单成员 | 从 PR 描述确认并暂存日志;原 PR 已合并时创建补日志 PR |
| `issue_comment: edited` | 白名单成员 | 确认普通 PR 或 release Changelog 评论 |
| `workflow_run: completed` | 来源为成功的 `pull_request_review` workflow | 使用 `pr_number` 确认日志的兼容入口 |

Expand Down
41 changes: 37 additions & 4 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31202,6 +31202,26 @@ function useGithub(token) {
per_page: 100
});
}
async function getOpenPullRequestByHead(head) {
const { data } = await octokit.rest.pulls.list({
owner,
repo,
head: `${owner}:${head}`,
state: "open"
});
return data[0];
}
async function createPullRequest(title, head, base, body) {
const { data } = await octokit.rest.pulls.create({
owner,
repo,
title,
head,
base,
body
});
return data;
}
async function getCommentList(pr_number) {
const { data } = await octokit.rest.issues.listComments({
owner,
Expand Down Expand Up @@ -31255,6 +31275,8 @@ function useGithub(token) {
return {
getPullRequestData,
getPullRequestFiles,
getOpenPullRequestByHead,
createPullRequest,
addPullRequestLabels,
addComment,
updateComment,
Expand Down Expand Up @@ -31302,12 +31324,20 @@ async function confirmChangelog(prNumber, log, token) {
if (!log.startsWith("### 📝 更新日志")) return false;
const changelog = extractChangelog(log || "", getInputPkgs());
info(`stash_changelog: ${JSON.stringify(changelog, null, 2)}`);
const { getPullRequestData } = useGithub(token);
const { createPullRequest, getOpenPullRequestByHead, getPullRequestData } = useGithub(token);
const prData = await getPullRequestData(prNumber);
const { cloneRepo, addRemote, checkoutPr, checkoutBranch, isNeedCommit } = useGit(token);
const { cloneRepo, addRemote, checkoutPr, checkoutBranch, createBranch, gitPush, isNeedCommit } = useGit(token);
const isMerged = prData.merged === true;
const changelogBranch = `changelog/pr-${prNumber}`;
const openChangelogPr = isMerged ? await getOpenPullRequestByHead(changelogBranch) : void 0;
await cloneRepo();
const isForkPr = checkIsForkPr(prData);
if (isForkPr) {
if (isMerged) if (openChangelogPr) await checkoutBranch(changelogBranch);
else {
await checkoutBranch(prData.base.ref);
await createBranch(changelogBranch);
}
else if (isForkPr) {
await addRemote(prData.head.user.login, prData.head?.repo?.clone_url || "");
await checkoutPr(prNumber);
await exec("git", [
Expand All @@ -31331,7 +31361,10 @@ async function confirmChangelog(prNumber, log, token) {
"-m",
"chore: stash changelog [ci skip]"
]);
if (isForkPr) await exec("git", [
if (isMerged) {
await gitPush(changelogBranch);
if (!openChangelogPr) info(`Created changelog pull request: ${(await createPullRequest(`chore: 补充 #${prNumber} 的 Changelog`, changelogBranch, prData.base.ref, `补充已合并 PR #${prNumber} 的 Changelog。`)).html_url}`);
} else if (isForkPr) await exec("git", [
"push",
prData.head.user.login,
`HEAD:${prData.head.ref}`
Expand Down
32 changes: 28 additions & 4 deletions src/github-event/issue-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,25 @@ export async function confirmChangelog(prNumber: number, log: string, token: str

core.info(`stash_changelog: ${JSON.stringify(changelog, null, 2)}`)

const { getPullRequestData } = useGithub(token)
const { createPullRequest, getOpenPullRequestByHead, getPullRequestData } = useGithub(token)
const prData = await getPullRequestData(prNumber) as PullRequestData

const { cloneRepo, addRemote, checkoutPr, checkoutBranch, isNeedCommit } = useGit(token)
const { cloneRepo, addRemote, checkoutPr, checkoutBranch, createBranch, gitPush, isNeedCommit } = useGit(token)
const isMerged = prData.merged === true
const changelogBranch = `changelog/pr-${prNumber}`
const openChangelogPr = isMerged ? await getOpenPullRequestByHead(changelogBranch) : undefined
await cloneRepo()
const isForkPr = checkIsForkPr(prData)
if (isForkPr) {
if (isMerged) {
if (openChangelogPr) {
await checkoutBranch(changelogBranch)
}
else {
await checkoutBranch(prData.base.ref)
await createBranch(changelogBranch)
}
}
else if (isForkPr) {
await addRemote(prData.head.user.login, prData.head?.repo?.clone_url || '')
await checkoutPr(prNumber)
await exec('git', [
Expand All @@ -108,7 +120,19 @@ export async function confirmChangelog(prNumber: number, log: string, token: str
return true
}
await exec('git', ['commit', '-m', 'chore: stash changelog [ci skip]'])
if (isForkPr) {
if (isMerged) {
await gitPush(changelogBranch)
if (!openChangelogPr) {
const pullRequest = await createPullRequest(
`chore: 补充 #${prNumber} 的 Changelog`,
changelogBranch,
prData.base.ref,
`补充已合并 PR #${prNumber} 的 Changelog。`,
)
core.info(`Created changelog pull request: ${pullRequest.html_url}`)
}
}
else if (isForkPr) {
await exec('git', ['push', prData.head.user.login, `HEAD:${prData.head.ref}`])
}
else {
Expand Down
22 changes: 21 additions & 1 deletion src/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ export default function useGithub(token: string) {
per_page: 100,
})
}
async function getOpenPullRequestByHead(head: string) {
const { data } = await octokit.rest.pulls.list({
owner,
repo,
head: `${owner}:${head}`,
state: 'open',
})
return data[0]
}
async function createPullRequest(title: string, head: string, base: string, body: string) {
const { data } = await octokit.rest.pulls.create({
owner,
repo,
title,
head,
base,
body,
})
return data
}
async function getCommentList(pr_number: number) {
const { data } = await octokit.rest.issues.listComments({
owner,
Expand Down Expand Up @@ -72,5 +92,5 @@ export default function useGithub(token: string) {
target_commitish,
})
}
return { getPullRequestData, getPullRequestFiles, addPullRequestLabels, addComment, updateComment, getCommentList, getRequestedReviewers, createRelease }
return { getPullRequestData, getPullRequestFiles, getOpenPullRequestByHead, createPullRequest, addPullRequestLabels, addComment, updateComment, getCommentList, getRequestedReviewers, createRelease }
}
46 changes: 45 additions & 1 deletion test/issue-comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
checkoutBranch: vi.fn(),
checkoutPr: vi.fn(),
cloneRepo: vi.fn(),
createBranch: vi.fn(),
createPullRequest: vi.fn(),
context: {
actor: 'maintainer',
eventName: 'issue_comment',
Expand All @@ -27,7 +29,9 @@ const mocks = vi.hoisted(() => ({
exec: vi.fn(),
extractChangelog: vi.fn(),
getPrCommentWhitelist: vi.fn(),
getOpenPullRequestByHead: vi.fn(),
getPullRequestData: vi.fn(),
gitPush: vi.fn(),
isNeedCommit: vi.fn(),
stashPackageChangelog: vi.fn(),
}))
Expand All @@ -52,15 +56,22 @@ vi.mock('../src/utils/git', () => ({
checkoutBranch: mocks.checkoutBranch,
checkoutPr: mocks.checkoutPr,
cloneRepo: mocks.cloneRepo,
createBranch: mocks.createBranch,
gitPush: mocks.gitPush,
isNeedCommit: mocks.isNeedCommit,
}),
}))
vi.mock('../src/utils/github', () => ({
default: () => ({ getPullRequestData: mocks.getPullRequestData }),
default: () => ({
createPullRequest: mocks.createPullRequest,
getOpenPullRequestByHead: mocks.getOpenPullRequestByHead,
getPullRequestData: mocks.getPullRequestData,
}),
}))

const prData = {
body: '### 📝 更新日志\n\n#### pkg-a\n- feat(Button): add loading state',
base: { ref: 'develop' },
head: {
ref: 'feat/loading',
repo: { clone_url: 'https://github.com/owner/repo.git' },
Expand All @@ -83,6 +94,8 @@ describe('issue_comment', () => {
mocks.getPullRequestData.mockResolvedValue(prData)
mocks.extractChangelog.mockReturnValue({ 'pkg-a': ['feat(Button): add loading state'] })
mocks.isNeedCommit.mockResolvedValue(false)
mocks.getOpenPullRequestByHead.mockResolvedValue(undefined)
mocks.createPullRequest.mockResolvedValue({ html_url: 'https://github.com/owner/repo/pull/43' })
})

it('submits the PR body changelog when /changelog is created', async () => {
Expand Down Expand Up @@ -143,6 +156,37 @@ describe('issue_comment', () => {
expect(mocks.cloneRepo).not.toHaveBeenCalled()
})

it('creates a changelog pull request when the original PR is already merged', async () => {
mocks.getPullRequestData.mockResolvedValue({ ...prData, merged: true })
mocks.isNeedCommit.mockResolvedValue(true)

await expect(issue_comment('token')).resolves.toBe(true)

expect(mocks.getOpenPullRequestByHead).toHaveBeenCalledWith('changelog/pr-42')
expect(mocks.checkoutBranch).toHaveBeenCalledWith('develop')
expect(mocks.createBranch).toHaveBeenCalledWith('changelog/pr-42')
expect(mocks.gitPush).toHaveBeenCalledWith('changelog/pr-42')
expect(mocks.createPullRequest).toHaveBeenCalledWith(
'chore: 补充 #42 的 Changelog',
'changelog/pr-42',
'develop',
'补充已合并 PR #42 的 Changelog。',
)
})

it('updates an existing changelog pull request for a merged PR', async () => {
mocks.getPullRequestData.mockResolvedValue({ ...prData, merged: true })
mocks.getOpenPullRequestByHead.mockResolvedValue({ number: 43 })
mocks.isNeedCommit.mockResolvedValue(true)

await expect(issue_comment('token')).resolves.toBe(true)

expect(mocks.checkoutBranch).toHaveBeenCalledWith('changelog/pr-42')
expect(mocks.createBranch).not.toHaveBeenCalled()
expect(mocks.gitPush).toHaveBeenCalledWith('changelog/pr-42')
expect(mocks.createPullRequest).not.toHaveBeenCalled()
})

it('keeps processing edited changelog confirmations', async () => {
mocks.context.payload = {
action: 'edited',
Expand Down