Skip to content

Commit 3faf359

Browse files
committed
Add bulk approval and merge functionality for open PRs; enhance request handling and validation
1 parent bd0cc0e commit 3faf359

8 files changed

Lines changed: 646 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ export async function newFeature(request, env) {
6868
}
6969

7070
// index.js에 라우팅 추가
71-
import { newFeature } from './handlers/new-feature.js';
71+
import { newFeature } from "./handlers/new-feature.js";
7272

73-
if (url.pathname === '/new-feature') {
73+
if (url.pathname === "/new-feature") {
7474
return newFeature(request, env);
7575
}
7676
```
@@ -151,9 +151,10 @@ GitHub Organization webhook 수신용 엔드포인트
151151

152152
**Request:**
153153

154+
`repo_owner` 생략 시 기본값으로 `DaleStudy`가 사용됩니다.
155+
154156
```json
155157
{
156-
"repo_owner": "DaleStudy",
157158
"repo_name": "leetcode-study"
158159
}
159160
```
@@ -174,6 +175,67 @@ GitHub Organization webhook 수신용 엔드포인트
174175
}
175176
```
176177

178+
#### `POST /approve-prs`
179+
180+
열려있는 답안 제출 PR을 일괄 승인합니다. `excludes` 배열로 특정 PR을 제외합니다. 이미 승인된 PR, `maintenance` 라벨, Draft 상태의 PR은 자동으로 스킵됩니다.
181+
182+
**Request:**
183+
184+
```json
185+
{ "repo_name": "leetcode-study", "excludes": [1972] }
186+
```
187+
188+
**Response:**
189+
190+
```json
191+
{
192+
"success": true,
193+
"action": "approve",
194+
"repo": "DaleStudy/leetcode-study",
195+
"total_open_prs": 5,
196+
"processed": 2,
197+
"approved": 2,
198+
"skipped": 0,
199+
"results": [
200+
{ "pr": 1970, "title": "week8 solutions", "approved": true },
201+
{ "pr": 1971, "title": "week8 extras", "approved": true }
202+
]
203+
}
204+
```
205+
206+
#### `POST /merge-prs`
207+
208+
열려있는 PR을 일괄 병합합니다. 기본 병합 방식은 `merge`이며 `merge_method` 값으로 `merge | squash | rebase` 중 선택할 수 있습니다. `excludes`로 특정 PR을 제외할 수 있습니다. 승인 리뷰가 없거나 `maintenance` 라벨이 붙은 PR, Draft PR, GitHub `mergeable_state !== "clean"` PR은 스킵되며 `unknown`/`behind` 상태는 최대 1초 후 한 번 더 확인합니다.
209+
210+
**Request:**
211+
212+
```json
213+
{
214+
"repo_name": "leetcode-study",
215+
"merge_method": "squash",
216+
"excludes": [1972]
217+
}
218+
```
219+
220+
**Response:**
221+
222+
```json
223+
{
224+
"success": true,
225+
"action": "merge",
226+
"repo": "DaleStudy/leetcode-study",
227+
"merge_method": "squash",
228+
"total_open_prs": 5,
229+
"processed": 2,
230+
"merged": 2,
231+
"skipped": 0,
232+
"results": [
233+
{ "pr": 1970, "title": "week8 solutions", "merged": true, "sha": "abc123" },
234+
{ "pr": 1971, "title": "week8 extras", "merged": true, "sha": "def456" }
235+
]
236+
}
237+
```
238+
177239
### 3. 워크플로우
178240

179241
1. Open PR 목록 조회 (GitHub REST API)
@@ -192,7 +254,7 @@ GitHub Organization webhook 수신용 엔드포인트
192254

193255
- `contents: read`: PR 정보 조회
194256
- `issues: write`: 댓글 작성 및 삭제
195-
- `pull_requests: read`: PR 목록상태 조회
257+
- `pull_requests: read & write`: PR 목록/상태 조회, 리뷰 생성, 병합 수행
196258
- `organization_projects: read`: Projects v2의 Week 필드 접근 (GraphQL API)
197259

198260
### Secrets 관리
@@ -245,13 +307,15 @@ https://github.com/settings/apps/dalestudy
245307
### 3. Permissions & events 탭 - 권한 설정
246308

247309
**Repository permissions:**
310+
248311
- **Contents**: Read
249312
- **Issues**: Read & write (issue_comment 이벤트용)
250313
- **Metadata**: Read
251314
- **Pull requests**: Read & write
252315
- **Projects**: Read & write (Projects V2)
253316

254317
**Subscribe to events:**
318+
255319
- ☑️ **Issue comments** (`issue_comment` - AI 코드 리뷰)
256320
- ☑️ **Projects v2 item** (`projects_v2_item` - Week 체크)
257321
- ☑️ **Pull requests** (`pull_request` - Week 체크)
@@ -269,6 +333,7 @@ wrangler secret put WEBHOOK_SECRET
269333
### 5. GitHub App 설치
270334

271335
저장소에 App이 설치되어 있는지 확인:
336+
272337
```
273338
https://github.com/apps/dalestudy/installations
274339
```

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ https://github.dalestudy.com
7777

7878
**Request:**
7979

80+
요청 바디에서 `repo_owner`를 생략하면 자동으로 `DaleStudy`가 사용됩니다.
81+
8082
```json
8183
{
82-
"repo_owner": "DaleStudy",
8384
"repo_name": "leetcode-study"
8485
}
8586
```
@@ -100,6 +101,67 @@ https://github.dalestudy.com
100101
}
101102
```
102103

104+
### `POST /approve-prs`
105+
106+
열려있는 답안 제출 PR을 일괄 승인합니다. `excludes` 배열을 사용해 특정 PR 번호를 제외할 수 있습니다. 이미 승인되었거나 Draft/`maintenance` 라벨이 붙은 PR은 자동으로 스킵됩니다.
107+
108+
**Request:**
109+
110+
```json
111+
{ "repo_name": "leetcode-study", "excludes": [1972] }
112+
```
113+
114+
**Response:**
115+
116+
```json
117+
{
118+
"success": true,
119+
"action": "approve",
120+
"repo": "DaleStudy/leetcode-study",
121+
"total_open_prs": 5,
122+
"processed": 2,
123+
"approved": 2,
124+
"skipped": 0,
125+
"results": [
126+
{ "pr": 1970, "title": "week8 solutions", "approved": true },
127+
{ "pr": 1971, "title": "week8 extras", "approved": true }
128+
]
129+
}
130+
```
131+
132+
### `POST /merge-prs`
133+
134+
열려있는 PR을 일괄 병합합니다. 기본 병합 방식은 `merge`이며, `merge_method``merge | squash | rebase` 중 선택할 수 있습니다. `excludes` 배열로 특정 PR을 제외할 수 있습니다. 최소 1개의 승인 리뷰가 없거나 Draft/`maintenance` 라벨이 붙은 PR은 스킵되며, GitHub에서 `mergeable_state === "clean"`인 PR만 병합됩니다(`behind`, `dirty`, `unknown` 등은 스킵). `unknown`/`behind` 상태는 최대 1초 후 한 차례 재확인합니다.
135+
136+
**Request:**
137+
138+
```json
139+
{
140+
"repo_name": "leetcode-study",
141+
"excludes": [1972],
142+
"merge_method": "squash"
143+
}
144+
```
145+
146+
**Response:**
147+
148+
```json
149+
{
150+
"success": true,
151+
"action": "merge",
152+
"repo": "DaleStudy/leetcode-study",
153+
"merge_method": "squash",
154+
"total_open_prs": 5,
155+
"processed": 2,
156+
"merged": 2,
157+
"skipped": 0,
158+
"results": [
159+
{ "pr": 1970, "title": "week8 solutions", "merged": true, "sha": "abc123" },
160+
{ "pr": 1971, "title": "week8 extras", "merged": true, "sha": "def456" }
161+
]
162+
}
163+
```
164+
103165
## 개발 및 테스트
104166

105167
### 로컬 개발

handlers/approve_prs.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { generateGitHubAppToken, getGitHubHeaders } from "../utils/github.js";
2+
import { corsResponse, errorResponse } from "../utils/cors.js";
3+
import {
4+
parsePrActionPayload,
5+
fetchOpenPullRequests,
6+
filterTargetPrs,
7+
getSkipReason,
8+
formatResult,
9+
safeJson,
10+
hasApprovedReview,
11+
} from "../utils/prActions.js";
12+
13+
const APPROVAL_COMMENT = `현재 주차가 종료되어 자동으로 승인되었습니다. PR을 병합해주세요!`;
14+
15+
/**
16+
* Bulk approve handler
17+
*
18+
* @param {Request} request - Worker request object
19+
* @param {Env} env - Worker bindings (APP_ID, PRIVATE_KEY, etc.)
20+
*/
21+
export async function approvePrs(request, env) {
22+
try {
23+
const payload = await parsePrActionPayload(request);
24+
if (!payload.valid) {
25+
return payload.response;
26+
}
27+
28+
const { repoOwner, repoName, excludes } = payload.data;
29+
const appToken = await generateGitHubAppToken(env);
30+
const openPrs = await fetchOpenPullRequests(repoOwner, repoName, appToken);
31+
const targetPrs = filterTargetPrs(openPrs, excludes);
32+
33+
const results = [];
34+
let approved = 0;
35+
let skipped = 0;
36+
37+
for (const pr of targetPrs) {
38+
const skipReason = getSkipReason(pr);
39+
if (skipReason) {
40+
skipped++;
41+
results.push(formatResult(pr, { skipped: true, reason: skipReason }));
42+
continue;
43+
}
44+
45+
const alreadyApproved = await hasApprovedReview(
46+
repoOwner,
47+
repoName,
48+
pr.number,
49+
appToken
50+
);
51+
if (alreadyApproved) {
52+
skipped++;
53+
results.push(
54+
formatResult(pr, { skipped: true, reason: "already approved" })
55+
);
56+
continue;
57+
}
58+
59+
const reviewResult = await approvePullRequest(
60+
repoOwner,
61+
repoName,
62+
pr.number,
63+
appToken
64+
);
65+
66+
if (reviewResult.approved) {
67+
approved++;
68+
}
69+
70+
results.push(formatResult(pr, reviewResult));
71+
}
72+
73+
return corsResponse({
74+
success: true,
75+
action: "approve",
76+
repo: `${repoOwner}/${repoName}`,
77+
total_open_prs: openPrs.length,
78+
processed: targetPrs.length,
79+
approved,
80+
skipped,
81+
results,
82+
});
83+
} catch (error) {
84+
console.error("approvePrs error:", error);
85+
return errorResponse(`Internal server error: ${error.message}`, 500);
86+
}
87+
}
88+
89+
async function approvePullRequest(owner, repo, prNumber, token) {
90+
const response = await fetch(
91+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
92+
{
93+
method: "POST",
94+
headers: {
95+
...getGitHubHeaders(token),
96+
"Content-Type": "application/json",
97+
},
98+
body: JSON.stringify({
99+
body: APPROVAL_COMMENT,
100+
event: "APPROVE",
101+
}),
102+
}
103+
);
104+
105+
if (response.ok) {
106+
return { approved: true };
107+
}
108+
109+
const errorData = await safeJson(response);
110+
return {
111+
approved: false,
112+
error: errorData.message || "Approval failed",
113+
};
114+
}

handlers/check-weeks.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,24 @@ import { validateOrganization, hasMaintenanceLabel } from "../utils/validation.j
1717
export async function checkWeeks(request, env) {
1818
try {
1919
const { repo_owner, repo_name } = await request.json();
20+
const repoOwner = repo_owner || "DaleStudy";
2021

2122
// Validation
22-
if (!repo_owner || !repo_name) {
23-
return errorResponse(
24-
"Missing required fields: repo_owner, repo_name",
25-
400
26-
);
23+
if (!repo_name) {
24+
return errorResponse("Missing required field: repo_name", 400);
2725
}
2826

2927
// DaleStudy organization만 허용
30-
if (!validateOrganization(repo_owner)) {
31-
return errorResponse(`Unauthorized organization: ${repo_owner}`, 403);
28+
if (!validateOrganization(repoOwner)) {
29+
return errorResponse(`Unauthorized organization: ${repoOwner}`, 403);
3230
}
3331

3432
// GitHub App Token 생성
3533
const appToken = await generateGitHubAppToken(env);
3634

3735
// Open PR 목록 조회
3836
const prsResponse = await fetch(
39-
`https://api.github.com/repos/${repo_owner}/${repo_name}/pulls?state=open&per_page=100`,
37+
`https://api.github.com/repos/${repoOwner}/${repo_name}/pulls?state=open&per_page=100`,
4038
{ headers: getGitHubHeaders(appToken) }
4139
);
4240

@@ -63,7 +61,7 @@ export async function checkWeeks(request, env) {
6361

6462
// Week 값 확인 및 댓글 처리
6563
const weekValue = await handleWeekComment(
66-
repo_owner,
64+
repoOwner,
6765
repo_name,
6866
prNumber,
6967
env,

0 commit comments

Comments
 (0)