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
38 changes: 33 additions & 5 deletions .github/scripts/reconcile-issue-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- issue-metadata-guard:v1 -->";

// #1407: bug 이슈는 조직 이슈 필드 Priority 로 심각도를 지정해야 한다. Issue Form 은 필드 값을
Expand Down Expand Up @@ -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) {
Expand All @@ -144,7 +172,7 @@ export function expectedMetadata(issue) {
moduleKey,
expectedLabel,
expectedAreaLabel,
expectedAssignee,
expectedAssignee: assigneeForIssue(moduleKey, issue.number),
};
}

Expand Down
63 changes: 62 additions & 1 deletion .github/scripts/reconcile-issue-metadata.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,16 +91,75 @@ 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);
assert.equal(inspection.expectedAreaLabel, AREA_LABEL_BY_MODULE[module], module);
}
});

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") }));
Expand Down
42 changes: 42 additions & 0 deletions .github/scripts/review-debt-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "자동 요청 대상이 비었다");
});
28 changes: 28 additions & 0 deletions .github/workflows/review-debt-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 을 닫지도 않는다 — 아래에서 명시적으로 나눈다.
Expand Down Expand Up @@ -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 의 리뷰는 끝난 것이다.
Expand Down Expand Up @@ -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 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/review-request-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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건과 필수 체크는 그대로 요구된다.
2 changes: 1 addition & 1 deletion docs/convention/presentation-package-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 을 따른다.
Expand Down
6 changes: 3 additions & 3 deletions docs/qa/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 기능별 커버리지 — 어디가 안 봐졌나

Expand Down
Loading