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
1 change: 1 addition & 0 deletions changelog.d/merge-policy.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added branch protection verification to auto-merge. Fledge reads the target branch protection rule, verifies required status checks and required approvals, supports squash/merge/rebase, and refuses to auto-merge when protection cannot be verified or a required gate is missing.
15 changes: 14 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,15 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
const downgraded = currentRole === 'maintainer' && !allowed.includes('maintainer')
const nextRole = downgraded ? 'developer' : repo.repo_role
const enableReviewer = nextRole === 'maintainer' && prepared.canReview && repo.reviewer_enabled === 1
const protectionBranch = repo.target_branch !== '' ? repo.target_branch : (prepared.upstream.defaultBranch || repo.default_branch)
const protection = await new GithubClient(token, config.github.apiBase)
.getBranchProtection(prepared.upstream.owner, prepared.upstream.name, protectionBranch)
.catch((error) => {
log.warn('branch protection check failed', { repo: repo.id, error: String(error) })
return null
})
const protectionState = protection === null ? 'unknown' : protection.protected ? 'protected' : 'unprotected'
const protectionCheckedAt = nowIso()
store.updateRepo(repo.id, {
upstream_owner: prepared.upstream.owner,
upstream_name: prepared.upstream.name,
Expand All @@ -1523,6 +1532,8 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
repo_role: nextRole,
auto_merge: downgraded ? 0 : repo.auto_merge,
role_checked_at: nowIso(),
protection_state: protectionState,
protection_checked_at: protectionCheckedAt,
can_review: prepared.canReview ? 1 : 0,
can_create_issue: prepared.canCreateIssue ? 1 : 0,
reviewer_enabled: enableReviewer ? 1 : 0,
Expand Down Expand Up @@ -1630,6 +1641,8 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
plan_required: body.planRequired === '1' ? 1 : 0,
allow_paths: (body.allowPaths ?? '').trim().slice(0, 2000),
deny_paths: (body.denyPaths ?? '').trim().slice(0, 2000),
merge_method: body.mergeMethod === 'merge' || body.mergeMethod === 'rebase' ? body.mergeMethod : 'squash',
require_protection: body.requireProtection === '1' ? 1 : 0,
auto_iterate: body.auto === '1' ? 1 : 0,
enabled: body.enabled === '1' ? 1 : 0,
interval_minutes: interval,
Expand All @@ -1644,7 +1657,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
log.warn('team playbook not updated after goal change', { repo: repo.id, error: String(error) })
})
}
store.audit(viewer.user.id, 'repo.settings', `${repo.owner}/${repo.name} role=${repoRole}${roleChanged ? ' role=changed' : ''} autoMerge=${canMaintain && body.autoMerge === '1' ? 'on' : 'off'} planRequired=${body.planRequired === '1' ? 'on' : 'off'} reviewer=${canMaintain && repo.can_review === 1 && body.review === '1' ? 'on' : 'off'}${goalChanged ? ' goal=changed' : ''} base=${targetBranch}`)
store.audit(viewer.user.id, 'repo.settings', `${repo.owner}/${repo.name} role=${repoRole}${roleChanged ? ' role=changed' : ''} autoMerge=${canMaintain && body.autoMerge === '1' ? 'on' : 'off'} planRequired=${body.planRequired === '1' ? 'on' : 'off'} merge=${body.mergeMethod ?? 'squash'} protection=${body.requireProtection === '1' ? 'required' : 'optional'} reviewer=${canMaintain && repo.can_review === 1 && body.review === '1' ? 'on' : 'off'}${goalChanged ? ' goal=changed' : ''} base=${targetBranch}`)
return reply.redirect(`/repos/${repo.id}?notice=${encodeURIComponent(goalChanged ? '设置已保存;团队规则已按新目标更新。' : '设置已保存。')}#repo-settings`)
})

Expand Down
24 changes: 23 additions & 1 deletion src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,29 @@ export class Jobs {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: changes requested`)
return
}
const merged = await client.mergePullRequest(owner, name, number, { sha: pr.headSha, mergeMethod: 'squash' })
if (repo.require_protection === 1) {
const protection = await client.getBranchProtection(owner, name, pr.base)
if (protection === null || !protection.protected) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: branch protection unavailable`)
return
}
if (protection.requiredChecks.length > 0) {
const passing = new Set(checks.filter((run) => run.status === 'completed' && ['success', 'neutral', 'skipped'].includes(run.conclusion ?? '')).map((run) => run.name))
if (!protection.requiredChecks.every((context) => passing.has(context))) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: required checks missing`)
return
}
}
if (protection.requiredApprovals > 0) {
const approvals = [...latest.values()].filter((state) => state === 'APPROVED').length
if (approvals < protection.requiredApprovals) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: required approvals missing`)
return
}
}
}
const requestedMethod = repo.merge_method === 'merge' || repo.merge_method === 'rebase' ? repo.merge_method : 'squash'
const merged = await client.mergePullRequest(owner, name, number, { sha: pr.headSha, mergeMethod: requestedMethod })
if (merged.merged) {
const workItem = job.work_item_id === null ? undefined : this.store.getWorkItem(job.work_item_id)
if (workItem !== undefined) {
Expand Down
18 changes: 18 additions & 0 deletions src/repos/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,24 @@ export class GithubClient {
: []
}

async getBranchProtection(owner: string, name: string, branch: string): Promise<{ protected: boolean; requiredChecks: string[]; requiredApprovals: number } | null> {
try {
const result = await this.request<{
required_status_checks?: { contexts?: string[] } | null
required_pull_request_reviews?: { required_approving_review_count?: number } | null
}>('GET', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}/protection`)
return {
protected: true,
requiredChecks: Array.isArray(result.required_status_checks?.contexts) ? result.required_status_checks.contexts : [],
requiredApprovals: Number(result.required_pull_request_reviews?.required_approving_review_count ?? 0),
}
} catch (error) {
if (error instanceof GithubError && error.status === 404) return { protected: false, requiredChecks: [], requiredApprovals: 0 }
if (error instanceof GithubError && error.status === 403) return null
throw error
}
}

async mergePullRequest(owner: string, name: string, number: number, input: { sha: string; mergeMethod?: 'merge' | 'squash' | 'rebase' }): Promise<{ merged: boolean; sha?: string; message?: string }> {
const result = await this.request<{ merged?: boolean; sha?: string; message?: string }>(
'PUT',
Expand Down
5 changes: 5 additions & 0 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,11 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<label style="flex:1">${escapeHtml(t('允许修改路径(可选)', 'Allowed paths (optional)'))}${infoTip(t('逗号或换行分隔。批准计划时会填入执行 prompt;不是硬性沙箱。', 'Comma or newline separated. Included in the execution prompt at plan approval; not a hard sandbox.'))} <input name="allowPaths" value="${escapeHtml(repo.allow_paths)}" placeholder="src/, docs/" /></label>
<label style="flex:1">${escapeHtml(t('禁止修改路径(可选)', 'Forbidden paths (optional)'))} <input name="denyPaths" value="${escapeHtml(repo.deny_paths)}" placeholder=".github/, deploy/" /></label>
</div>
<div class="row">
<label>${escapeHtml(t('合并方式', 'Merge method'))} <select name="mergeMethod">${['squash', 'merge', 'rebase'].map((method) => `<option value="${method}"${(repo.merge_method || 'squash') === method ? ' selected' : ''}>${method}</option>`).join('')}</select></label>
<label class="check"><input type="checkbox" name="requireProtection" value="1" ${repo.require_protection === 1 ? 'checked' : ''} /> ${escapeHtml(t('自动合并前要求 branch protection', 'Require branch protection before auto-merge'))}${infoTip(t('开启后 Fledge 会检查保护规则、required checks 和 required approvals;读不到保护规则时不会自动合并。', 'When on, Fledge checks the protection rule, required checks and required approvals; if the rule cannot be read, it will not auto-merge.'))}</label>
</div>
<p class="fine">${escapeHtml(t('Branch protection', 'Branch protection'))}: <code>${escapeHtml(repo.protection_state ?? 'unknown')}</code>${repo.protection_checked_at === null ? '' : ` · ${escapeHtml(humanTimeShort(repo.protection_checked_at))}`}</p>
${roleMissing ? `<p class="fine">${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}</p>` : !githubCanReview ? `<p class="fine">${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}</p>` : !canMaintain ? `<p class="fine">${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}</p>` : ''}
${advanced ? `<details class="fine advanced-help"><summary>${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))}</summary>
<p>${escapeHtml(t('保存目标后,团队 playbook 会按新目标更新:总控 → 负责人 → 产品/测试/安全/审核/开发。审核角色只有勾选“启用 PR 审核”后才会参与。', 'Saving the goal updates the team playbook: Chief -> Lead -> Product/Test/Security/Reviewer/Dev. The Reviewer takes part only when "Enable PR reviewer" is checked.'))}</p>
Expand Down
58 changes: 58 additions & 0 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,10 @@ test('maintainer auto-merge merges only after checks pass and no changes are req
json(response, 200, reviews)
return
}
if (method === 'GET' && url === '/repos/o/r/branches/main/protection') {
json(response, 200, { required_status_checks: { contexts: ['ci'] }, required_pull_request_reviews: { required_approving_review_count: 0 } })
return
}
if (method === 'PUT' && url === '/repos/o/r/pulls/1/merge') {
merges.push('merge')
json(response, 200, { merged: true, sha: 'def' })
Expand Down Expand Up @@ -759,3 +763,57 @@ test('a running job can be cancelled and a paused job can be resumed', async ()
store.close()
}
})

test('auto-merge is skipped when branch protection is unavailable', async () => {
const merges: string[] = []
const github = await startFakeServer((request, response) => {
const method = request.method ?? 'GET'
const url = request.url ?? ''
if (method === 'GET' && url === '/repos/o/r/pulls/4') {
json(response, 200, { number: 4, html_url: 'https://github.com/o/r/pull/4', title: 'fix', state: 'open', head: { ref: 'fledge/fix', sha: 'abc' }, base: { ref: 'main' }, mergeable: true, mergeable_state: 'clean', draft: false, merged: false })
return
}
if (method === 'GET' && url === '/repos/o/r/commits/abc/check-runs?per_page=100') {
json(response, 200, { check_runs: [{ name: 'ci', status: 'completed', conclusion: 'success' }] })
return
}
if (method === 'GET' && url === '/repos/o/r/commits/abc/status') {
json(response, 200, { state: 'success', statuses: [] })
return
}
if (method === 'GET' && url === '/repos/o/r/pulls/4/reviews?per_page=100') {
json(response, 200, [])
return
}
if (method === 'GET' && url === '/repos/o/r/branches/main/protection') {
json(response, 404, { message: 'Not Found' })
return
}
if (method === 'PUT' && url === '/repos/o/r/pulls/4/merge') {
merges.push('merge')
json(response, 200, { merged: true })
return
}
if (method === 'POST' && url === '/repos/o/r/issues/4/comments') {
json(response, 200, {})
return
}
json(response, 404, { message: 'Not Found' })
})
const base = testConfig()
const config = testConfig({ github: { ...base.github, apiBase: github.url } })
const store = new Store(':memory:')
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer())
try {
const user = makeUser(store, { login: 'auto-merge-protection-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_auto_merge_protection', userId: user.id, owner: 'o', name: 'r', defaultBranch: 'main', tokenEnc: encryptSecret('gh-token', config.tokenEncryptionKey), permission: 'admin', repoRole: 'maintainer', autoMerge: true, requireProtection: true, goal: '', autoIterate: false, intervalMinutes: 1440 })
const secret = 'auto-merge-protection-secret'
store.createJob({ id: 'job_auto_merge_protection', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: sha256Hex(secret), platformTokenHash: '', kind: 'iterate', baseBranch: 'main' })
store.updateJob('job_auto_merge_protection', { status: 'running', started_at: new Date().toISOString() })
await jobs.handleCallback('job_auto_merge_protection', secret, { status: 'succeeded', prUrl: 'https://github.com/o/r/pull/4', summary: 'done' })
assert.equal(merges.length, 0)
} finally {
store.close()
await github.close()
}
})
Loading