diff --git a/.github/scripts/reconcile-issue-metadata.mjs b/.github/scripts/reconcile-issue-metadata.mjs index 2df52d3e04..0839fb8eb8 100644 --- a/.github/scripts/reconcile-issue-metadata.mjs +++ b/.github/scripts/reconcile-issue-metadata.mjs @@ -45,16 +45,44 @@ export const AREA_LABEL_BY_MODULE = Object.freeze({ export const ASSIGNEE_BY_MODULE = Object.freeze({ afternote: "1hyok", - onboarding: "1hyok", + onboarding: "Sadturtleman", core: "1hyok", receiver: "1hyok", - setting: "koongmai", + setting: "1hyok", timeletter: "koongmai", mindrecord: "Sadturtleman", home: "Sadturtleman", platform: "1hyok", }); +// #1910: 담당 이관은 모듈마다 소급 범위가 다르다. 설정은 이미 열려 있는 이슈까지 곧바로 옮기고, +// 온보딩은 이 결정 전에 열린 이슈를 옮기지 않는다. +// +// 소급 여부가 왜 선택지인가. issue-metadata-guard 의 스케줄 실행은 열린 이슈 전체를 훑으므로, +// 지도만 바꾸면 진행 중인 이슈까지 전부 새 담당자에게 넘어간다. 그러면 그 이슈로 열어 둔 PR 이 +// validate-pr-issue-link 에서 "작성자가 대표 이슈의 담당자가 아니다" 로 빨개지고, 옮겨 받은 +// 사람은 남이 절반쯤 짜 둔 브랜치를 떠안는다. 그래서 소급은 기본값이 아니라 결정 사항이다. +// +// fromIssue 미만의 이슈는 before 가 계속 맡는다. 경계는 결정을 적은 이슈 번호이고, 그보다 큰 +// 번호는 전부 결정 이후에 열린 이슈다. +export const HANDOVER_BY_MODULE = Object.freeze({ + onboarding: Object.freeze({ before: "1hyok", fromIssue: 1910 }), +}); + +/** 이관 경계를 반영한 담당자. 경계가 없는 모듈은 지도 그대로다. */ +export function assigneeForIssue(moduleKey, issueNumber) { + const handover = HANDOVER_BY_MODULE[moduleKey]; + if (!handover) { + return ASSIGNEE_BY_MODULE[moduleKey]; + } + const number = Number(issueNumber); + if (!Number.isSafeInteger(number)) { + // 번호를 모르면 경계를 판정할 수 없다. 어느 쪽으로도 접지 않고 실행을 실패시킨다. + throw new Error(`이관 경계를 판정할 이슈 번호가 없습니다: ${moduleKey} / ${issueNumber}`); + } + return number < handover.fromIssue ? handover.before : ASSIGNEE_BY_MODULE[moduleKey]; +} + export const GUARD_COMMENT_MARKER = ""; // #1407: bug 이슈는 조직 이슈 필드 Priority 로 심각도를 지정해야 한다. Issue Form 은 필드 값을 @@ -126,13 +154,13 @@ export function expectedMetadata(issue) { const moduleKey = optionKey(moduleValue); const expectedLabel = TYPE_LABEL_BY_KEY[typeKey]; const expectedAreaLabel = AREA_LABEL_BY_MODULE[moduleKey]; - const expectedAssignee = ASSIGNEE_BY_MODULE[moduleKey]; + const mappedAssignee = ASSIGNEE_BY_MODULE[moduleKey]; const reasons = []; if (!expectedLabel) { reasons.push("`작업 유형`이 없거나 허용된 값이 아닙니다."); } - if (!expectedAssignee || !expectedAreaLabel) { + if (!mappedAssignee || !expectedAreaLabel) { reasons.push("`주 담당 모듈`이 없거나 허용된 값이 아닙니다."); } if (reasons.length > 0) { @@ -144,7 +172,7 @@ export function expectedMetadata(issue) { moduleKey, expectedLabel, expectedAreaLabel, - expectedAssignee, + expectedAssignee: assigneeForIssue(moduleKey, issue.number), }; } diff --git a/.github/scripts/reconcile-issue-metadata.test.mjs b/.github/scripts/reconcile-issue-metadata.test.mjs index 5040944f76..5e4f7461a9 100644 --- a/.github/scripts/reconcile-issue-metadata.test.mjs +++ b/.github/scripts/reconcile-issue-metadata.test.mjs @@ -6,11 +6,13 @@ import { AREA_LABEL_BY_MODULE, ASSIGNEE_BY_MODULE, GUARD_COMMENT_MARKER, + HANDOVER_BY_MODULE, LEGACY_ISSUE_MAX, PRIORITY_COMMENT_MARKER, PRIORITY_FIELD_NAME, PRIORITY_OPTION_GUIDE, TYPE_LABELS, + assigneeForIssue, inspectIssue, priorityFieldState, readFormSection, @@ -89,9 +91,18 @@ test("reads exact issue-form sections with CRLF input", () => { assert.equal(readFormSection(body, "주 담당 모듈"), "setting — 선택한 주 담당 모듈"); }); +// 이관 경계 위의 번호. 지도는 앞으로의 담당을 말하므로 경계를 넘긴 이슈로 대조한다. +const AFTER_HANDOVER = Math.max( + LEGACY_ISSUE_MAX, + ...Object.values(HANDOVER_BY_MODULE).map((handover) => handover.fromIssue), +) + 1; + test("maps every primary module to the repository owner table", () => { for (const [module, assignee] of Object.entries(ASSIGNEE_BY_MODULE)) { - const inspection = inspectIssue(issue({ body: formBody("enhancement", module) })); + const inspection = inspectIssue(issue({ + number: AFTER_HANDOVER, + body: formBody("enhancement", module), + })); assert.equal(inspection.status, "valid", module); assert.equal(inspection.expectedAssignee, assignee, module); assert.equal(inspection.expectedLabel, "enhancement", module); @@ -99,6 +110,56 @@ test("maps every primary module to the repository owner table", () => { } }); +test("a module handover leaves issues opened before the decision with the previous assignee", () => { + // #1910: 온보딩은 이 결정 전에 열린 이슈를 옮기지 않는다. 옮기면 그 이슈로 열어 둔 PR 이 + // validate-pr-issue-link 에서 작성자와 담당자가 다르다는 이유로 빨개진다. + for (const [module, handover] of Object.entries(HANDOVER_BY_MODULE)) { + assert.ok(ASSIGNEE_BY_MODULE[module], `${module} 이 지도에 없다`); + assert.notEqual(handover.before, ASSIGNEE_BY_MODULE[module], module); + assert.ok(handover.fromIssue > LEGACY_ISSUE_MAX, `${module} 경계가 legacy 스킵 안에 있다`); + + assert.equal( + assigneeForIssue(module, handover.fromIssue - 1), + handover.before, + module, + ); + assert.equal( + assigneeForIssue(module, handover.fromIssue), + ASSIGNEE_BY_MODULE[module], + module, + ); + // 경계는 이슈 하나가 아니라 판정 전체를 통과해야 한다. + assert.equal( + inspectIssue(issue({ + number: handover.fromIssue - 1, + body: formBody("enhancement", module), + })).expectedAssignee, + handover.before, + module, + ); + } +}); + +test("a module without a handover entry moves its open issues right away", () => { + // 설정은 경계를 두지 않는다. 이미 열려 있는 이슈도 다음 리컨사일에서 새 담당자로 옮겨진다. + assert.equal(HANDOVER_BY_MODULE.setting, undefined); + assert.equal(assigneeForIssue("setting", LEGACY_ISSUE_MAX + 1), ASSIGNEE_BY_MODULE.setting); + const inspection = inspectIssue(issue({ + number: LEGACY_ISSUE_MAX + 1, + body: formBody("enhancement", "setting"), + assignees: [{ login: "koongmai" }], + })); + assert.equal(inspection.needsUpdate, true); + assert.deepEqual(inspection.assignees, [ASSIGNEE_BY_MODULE.setting]); +}); + +test("an unknown issue number fails the handover judgement instead of guessing", () => { + // 경계 판정을 못 하면 어느 쪽으로도 접지 않는다. 접으면 옛 이슈가 조용히 새 담당자에게 간다. + const module = Object.keys(HANDOVER_BY_MODULE)[0]; + assert.throws(() => assigneeForIssue(module, undefined), /이슈 번호/); + assert.throws(() => assigneeForIssue(module, "1910번"), /이슈 번호/); +}); + test("maps every work type to exactly one classification label", () => { for (const label of TYPE_LABELS) { const inspection = inspectIssue(issue({ body: formBody(label, "core") })); diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs index 9449395e49..fe3b3912a1 100644 --- a/.github/scripts/review-debt-policy.test.mjs +++ b/.github/scripts/review-debt-policy.test.mjs @@ -184,3 +184,45 @@ test("the rerequest job and the guard judge by the same latest decision", () => // 봇·fork 는 토큰이 read-only 라 요청을 걸 수 없다. assert.match(requestAll, /\[ "\$HEAD_REPO" != "\$REPO" \]/); }); + +function spaceSeparatedEnv(workflow, name) { + const match = new RegExp(`^\\s*${name}: ([^\\n#]+)$`, "m").exec(workflow); + assert.ok(match, `${name} 환경변수를 찾지 못했다`); + return match[1].trim().split(/\s+/).filter(Boolean).map((login) => login.toLowerCase()); +} + +test("the gate exemption is judged before any debt is counted", () => { + // 면제를 빚 계산 뒤에 두면, 면제받은 사람의 PR 때문에 조회가 실패했을 때 가드가 그를 + // 대신해 죽는다. 봇·fork·권한 판정과 같은 자리, 열린 PR 을 훑기 전에 둔다 (#1910). + const exemptAt = guard.indexOf("리뷰 게이트 면제 작성자"); + const sweepAt = guard.indexOf("if ! open_prs=$(gh api"); + assert.ok(exemptAt > 0, "면제 경로가 없다"); + assert.ok(sweepAt > exemptAt, "면제가 빚 계산 뒤에 있다"); + assert.match(guard, /리뷰 게이트 면제 작성자\(\$AUTHOR\)[^\n]*exit 0/); +}); + +test("exempt logins are matched case-insensitively", () => { + // GitHub 로그인은 대소문자를 가리지 않는다. 그대로 비교하면 웹훅이 Koongmai 를 내리는 + // 순간 면제가 조용히 풀리고, 그 사람의 PR 이 닫힌다. + assert.match(guard, /author_lc=\$\(printf '%s' "\$AUTHOR" \| tr '\[:upper:\]' '\[:lower:\]'\)/); + assert.match(guard, /exempt_lc=\$\(printf '%s' "\$REVIEW_GATE_EXEMPT_AUTHORS" \| tr '\[:upper:\]' '\[:lower:\]'\)/); +}); + +test("a change request from an exempt reviewer is not charged to the rest of the team", () => { + // 면제된 사람은 리뷰할 의무가 없다. 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 + // 대가를 나머지 팀원이 "새 PR 을 못 연다" 로 치른다. 그래도 그 PR 이 그냥 머지되지는 + // 않는다. 승인 1건은 required-approval 룰셋이 계속 요구한다. + assert.match(guard, /case " \$exempt_lc " in\n\s*\*" \$blocked_by "\*\)/); + assert.match(guard, /면제 리뷰어 @\$blocked_by/); +}); + +test("nobody is exempt from the gate while still on the automatic review roster", () => { + // 리뷰 의무가 없는 사람에게 요청만 계속 걸면, 그 요청은 아무도 응답하지 않는 알림으로 + // 쌓이고 두 워크플로가 서로 다른 팀 명단을 갖게 된다. + const exempt = spaceSeparatedEnv(guard, "REVIEW_GATE_EXEMPT_AUTHORS"); + const team = spaceSeparatedEnv(requestAll, "TEAM"); + for (const login of exempt) { + assert.ok(!team.includes(login), `${login} 이 면제이면서 자동 요청 대상이다`); + } + assert.ok(team.length > 0, "자동 요청 대상이 비었다"); +}); diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 5b9993f270..d4ee5c5426 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -62,6 +62,8 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + # PR 을 여는 시점의 제약을 두지 않기로 한 작성자 (#1910). 공백으로 구분한다. + REVIEW_GATE_EXEMPT_AUTHORS: koongmai run: | # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 # 얻지 못한 상태로 남의 PR 을 닫지도 않는다 — 아래에서 명시적으로 나눈다. @@ -97,6 +99,20 @@ jobs: *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; esac + # 이 가드 자체를 면제받은 작성자 (#1910). 팀이 koongmai 에게는 PR 을 여는 시점의 + # 제약을 두지 않고 머지 시점 제약만 남기기로 했다. 리뷰 지적을 반영했는지와 무관하게 + # 새 PR 을 열 수 있고, 남의 PR 을 리뷰할 의무도 없어 review-request-all 의 TEAM 에서도 + # 빠져 있다. 머지 게이트는 그대로다. develop 은 여전히 승인 1건과 필수 체크를 요구하고, + # 그 요건은 이 가드가 아니라 required-approval·required-checks 룰셋이 건다. + # + # 면제는 "빚 계산" 앞에 둔다. 뒤에 두면 면제받은 사람의 PR 때문에 조회가 실패했을 때 + # 가드가 그를 대신해 죽는다. GitHub 로그인은 대소문자를 가리지 않으므로 낮춰 비교한다. + author_lc=$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]') + exempt_lc=$(printf '%s' "$REVIEW_GATE_EXEMPT_AUTHORS" | tr '[:upper:]' '[:lower:]') + case " $exempt_lc " in + *" $author_lc "*) echo "리뷰 게이트 면제 작성자($AUTHOR) — 건너뜀"; exit 0 ;; + esac + # 빚의 기준은 «나에게 요청이 걸렸는가» 가 아니라 «아무도 응하지 않은 PR 이 # 남아 있는가» 다. 리뷰 요청은 알림 수단이지 담당 배정이 아니다 — # 누구에게 걸렸든 팀에서 한 사람이 보면 그 PR 의 리뷰는 끝난 것이다. @@ -189,6 +205,18 @@ jobs: fi IFS=$'\t' read -r blocked_by blocked_at <<< "$blocked" + # 면제 리뷰어가 낸 변경요청은 팀의 빚으로 돌아오지 않는다 (#1910). 면제된 사람은 + # 리뷰할 의무가 없으므로, 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 대가를 + # 나머지 팀원이 "새 PR 을 못 연다" 로 치르게 된다. 여기서 세지 않아도 그 PR 이 + # 그냥 머지되지는 않는다. 승인 1건이 머지 게이트에 그대로 남아 있다. + # jq 의 최신 판정은 이미 소문자로 내려온다. + case " $exempt_lc " in + *" $blocked_by "*) + echo "#$pn — 면제 리뷰어 @$blocked_by 의 변경요청, 팀 빚 아님" + continue + ;; + esac + # 반영을 비병합 커밋으로만 세면 병합으로 반영한 PR 을 놓친다. base 를 # 끌어와 충돌을 풀며 지적을 반영하면 그 뒤 커밋이 전부 parents 2개라 # «미반영» 이 된다 — #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 diff --git a/.github/workflows/review-request-all.yml b/.github/workflows/review-request-all.yml index d2c393851d..c0fb7d9921 100644 --- a/.github/workflows/review-request-all.yml +++ b/.github/workflows/review-request-all.yml @@ -47,7 +47,10 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} ASSOCIATION: ${{ github.event.pull_request.author_association }} - TEAM: 1hyok koongmai Sadturtleman + # 리뷰 의무가 있는 팀원만 적는다. koongmai 는 남의 PR 을 리뷰할 의무가 없어 + # 빠져 있다 (#1910). 자기 PR 에는 나머지 둘에게 그대로 요청이 걸리고, + # 스스로 리뷰하는 것도 막히지 않는다. 빠진 것은 자동 요청뿐이다. + TEAM: 1hyok Sadturtleman run: | set -euo pipefail diff --git a/README.md b/README.md index 8a8900db49..0157ae16ad 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,10 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 ## 코드 리뷰 -- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 작성자를 제외한 팀원에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. +- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 작성자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. -- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 작성자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. +- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 작성자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. - `awaiting-author` 라벨은 변경요청 뒤 작성자 무조치 상태를 보여 주는 표식이다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. - 쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다. 가장 늦은 판정이 승인이면 더 오래된 변경 요청은 자동 해제되고, 승인 뒤에 새 변경 요청이 오면 다시 차단된다. -- 긴급 PR은 근거가 있을 때만 `review-debt-exempt` 라벨로 적체 가드를 우회한다. +- 적체 가드에는 라벨 우회가 없다. 면제는 `review-debt-guard.yml`의 `REVIEW_GATE_EXEMPT_AUTHORS`에 적힌 작성자 하나뿐이고, 지금은 `koongmai`가 거기에 있다. 면제받은 사람은 리뷰 지적 반영 여부와 무관하게 새 PR을 열 수 있고 남의 PR을 리뷰할 의무가 없다. 그래서 자동 요청 대상에서도 빠지고, 그가 낸 변경요청은 다른 팀원의 빚으로 세지 않는다. 머지 게이트는 면제와 무관하다. `develop` 승인 1건과 필수 체크는 그대로 요구된다. diff --git a/docs/convention/presentation-package-structure.md b/docs/convention/presentation-package-structure.md index 57e7910e98..cf769c8002 100644 --- a/docs/convention/presentation-package-structure.md +++ b/docs/convention/presentation-package-structure.md @@ -129,7 +129,7 @@ git grep -lw 'PasskeyListItem' -- "$M" | grep -oE '[A-Za-z]+Screen\.kt' | sort - - [x] 규칙 문서화 — 이 문서 - [ ] [#1086](https://github.com/Afternote/Afternote-FE/issues/1086) — `feature/timeletter/presentation` (38, 가장 작음 → 파일럿) — @koongmai - [ ] [#1087](https://github.com/Afternote/Afternote-FE/issues/1087) — `feature/mindrecord/presentation` (80) — @Sadturtleman -- [ ] [#1088](https://github.com/Afternote/Afternote-FE/issues/1088) — `feature/setting/presentation` (64) — @koongmai +- [ ] [#1088](https://github.com/Afternote/Afternote-FE/issues/1088) — `feature/setting/presentation` (64) — @1hyok - [ ] `feature/afternote/presentation` 깊이 평탄화 검토 (117, 이미 기능별이므로 이관 아님) — @1hyok `receiver` · `onboarding` 은 이미 규칙을 만족해 대상이 아니다. `home` 은 파일 5개로 아직 폴더를 나눌 규모가 아니며, 화면이 늘면 R1 을 따른다. diff --git a/docs/qa/status.md b/docs/qa/status.md index 6e0822c21b..2d8b4023df 100644 --- a/docs/qa/status.md +++ b/docs/qa/status.md @@ -135,14 +135,14 @@ | 기능 | 잔여 | 담당 | | --- | --- | --- | | 애프터노트 | 14 | 정일혁 | -| 설정 | 9 | 박경민 | +| 설정 | 9 | 정일혁 | | 타임레터 | 5 | 박경민 | | app (수신자 라우팅 등) | 3 | 정일혁·이준혁 | | 마음의 기록 | 2 | 이준혁 | | 홈 · 코어 | 2 | 이준혁·정일혁 | -| 타임레터+설정 공통 | 1 | 박경민 | +| 타임레터+설정 공통 | 1 | 박경민·정일혁 | -담당자별로는 박경민 16 · 정일혁 16 · 이준혁 4건이다. +담당자별로는 박경민 7 · 정일혁 25 · 이준혁 4건이다. 설정 9건은 2026-09-06 담당 이관으로 박경민에서 정일혁으로 옮겼다. ## 5. 기능별 커버리지 — 어디가 안 봐졌나