From 09dedf03ada64d23af26cf2b55ab0d6ef3e27031 Mon Sep 17 00:00:00 2001 From: thisis-joe Date: Thu, 29 Oct 2026 11:48:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(infra):=20Mattermost=20Push=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=98=20=EB=B3=80=EA=B2=BD=C2=B7=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20=EC=BB=A4=EB=B0=8B=20=EC=A7=91=EA=B3=84=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=20(S15P31E102-255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/operations.md | 6 +- docs/plans/S15P31E102-255.md | 18 ++ docs/specs/S15P31E102-255.md | 5 + .../push-count-ranges-2026-10-29.json | 180 ++++++++++++++++++ src/mattermost.ts | 30 ++- test/integration/mattermost.test.mjs | 4 +- test/mattermost.test.mjs | 74 ++++++- 7 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 docs/plans/S15P31E102-255.md create mode 100644 docs/specs/S15P31E102-255.md create mode 100644 docs/verification/push-count-ranges-2026-10-29.json diff --git a/docs/operations.md b/docs/operations.md index 99b48d1..e1bb00a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -59,8 +59,8 @@ GitLab 저장소 설정의 `mattermostWebhookEnv: MATTERMOST_WEBHOOK_URL`로 알 - 첫 줄은 이름과 핵심 동작을 굵게 강조하고, 둘째 줄은 굵은 대상 링크로 표시한다. MR 대상은 `MR !번호 · MR 제목`이며 브랜치 이동 방향과 질문 개수는 표시하지 않는다. -- Push는 `**이름**님의 **커밋 N개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에 - 커밋 제목을 오래된 시각부터 한 줄씩 나열한다. 시간과 해시는 화면에 표시하지 않는다. +- Push는 `**이름**님의 **변경 커밋 N개 · 병합 커밋 M개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에 + 변경 커밋 제목만 오래된 시각부터 한 줄씩 나열한다. 병합 여부는 제목이 아닌 부모 커밋 수(2개 이상)로 판단하며, 부모 정보가 없는 Push·이전 대기 알림은 GitLab compare/commit 상세 조회로 보완한다. 시간과 해시는 화면에 표시하지 않는다. 각 제목은 해당 커밋으로 연결하며 `*feat:* **변경 내용**`처럼 유형은 기울임, 내용은 굵게 표시한다. 일반 커밋 제목은 전체를 굵게 표시한다. 본문은 생략한다. 웹훅에 전체 커밋이 없으면 [GitLab 비교 API](https://docs.gitlab.com/api/repositories/#compare-branches-tags-or-commits)로 보완한다. @@ -199,3 +199,5 @@ MM 전송 오류와 GitLab 수신 중단을 먼저 구분한다. 해당 프로 - 빌드 전에 `node scripts/storage.mjs --check-build`로 Mac과 Colima에 각각 8GiB 이상 여유가 있는지 확인한다. 운영만 할 때는 새 이미지나 빌드 캐시를 만들지 않는다. 의존성 변경 없는 검증된 코드 교체는 현재 이미지 위에 dist만 복사하는 작은 이미지를 사용한다. - 배포 확인 후 `node scripts/storage.mjs --apply --keep-image <직전-복구용-이미지>`로 불필요한 hey 이미지를 정리한다. 실행·정지 상태를 포함한 모든 컨테이너의 참조 이미지와 명시한 복구 이미지는 보존하고, 볼륨·다른 프로젝트 태그·전역 빌드 캐시는 삭제하지 않는다. 옵션 없는 실행은 정리 계획만 출력한다. - PostgreSQL 통합 테스트는 테스트 컨테이너를 제거할 때 그 컨테이너의 익명 볼륨도 함께 제거한다. 운영 볼륨에는 적용하지 않는다. + +집계 변경의 [스펙](specs/S15P31E102-255.md), [실행 기록](plans/S15P31E102-255.md), [실제 Git 범위 재생 자료](verification/push-count-ranges-2026-10-29.json)를 참고한다. diff --git a/docs/plans/S15P31E102-255.md b/docs/plans/S15P31E102-255.md new file mode 100644 index 0000000..2c19852 --- /dev/null +++ b/docs/plans/S15P31E102-255.md @@ -0,0 +1,18 @@ +# S15P31E102-255 실행 기록 + +- 사용자 승인: 변경/병합 커밋을 분리하고 목록에서 병합 커밋 제외. +- Jira: Story S15P31E102-48 관련됨, Epic S15P31E102-1, 스프린트 3, 진행 중. +- 실제 알림 구현은 별도 GitHub `thisis-joe/codereviewbot` 저장소다. 이 저장소에는 develop이 없으므로 최신 origin/main ae49155를 기반으로 피처 브랜치를 분리했다. 기존 작업 사본은 보존하고 /private/tmp/hey-push-counts-255에 작업한다. +- 실제 사례: MR !132 전체2=변경1+병합1; !134 전체3=변경1+병합2; !44 전체15=변경7+병합8. S15P31E102 Git의 부모 개수로 확인했다. +- 순서: 부모 정보/잘린 목록 회귀 검사(red) → 최소 구현 → 단위 전체 검사 및 실제 범위 재생 → 작업 단위 커밋. +- 운영: edo1 hey controller 이미지 hey-controller:gms-reduce-6afd0d1. 운영 배포는 별도 이미지로 알림 모듈만 교체하고 종전 이미지로 복구 가능하게 한다. + +## 결과 + +- RED: 기존 코드에서 3개 부모 정보 입력(변경1·병합2)을 일반 커밋3개로 보내고 병합 제목까지 나열하는 회귀 실패를 확인했다. +- GREEN: 변경/병합 분리, 제목과 부모 수가 불일치하는 경우, octopus merge, root commit, 이전 큐, 24개 잘린 Push, 상세 조회, 중복·불완전 비교·조회 실패를 검사했다. 알림 단위 검사 13개 통과. 전체 단위 검사 200개 통과 후 실제 이력 재생 검사 1개를 추가해 해당 파일을 다시 통과했다. PostgreSQL 기반 통합 검사 71개 통과. 일회용 DB는 검사 스크립트가 종료 시 제거했다. +- 운영: 기존 모듈 해시가 origin/main과 일치함을 확인한 뒤 기존 운영 이미지 위에 mattermost.js만 교체했다. 컨트롤러만 재생성했으며 Runner·DB·n8n·터널·자격증명은 변경하지 않았다. +- 이미지: hey-controller:push-count-255. 모듈 SHA256: 6ea5c0148e81457c5ff046406a6f15c4da57ed50b7edb35c67907d68ff29e8b6. health healthy 확인. +- 운영 모듈과 실제 GitLab API로 세 범위를 재조회해 변경1/병합1, 변경1/병합2, 변경7/병합8 및 변경 목록 수를 검증했다. DB와 Mattermost 전송을 메모리 대역으로 바꾼 읽기 전용 검증이므로 실제 메시지는 게시하지 않았다. 다음 Push부터 적용된다. +- 복구: /home/ubuntu/hey/releases/push-count-255/compose.before.json을 /home/ubuntu/hey/compose.json에 복원한 뒤 `docker compose --project-name hey-edo1 --file /home/ubuntu/hey/compose.json up -d --no-deps controller`. 종전 이미지는 보존했다. 이후 다른 운영 변경이 있으면 전체 Compose 복원 대신 controller.image만 종전 값으로 복원한다. +- 자체 diff 검토: 저장소 목적지/명령/질문/댓글 서식은 보존. 부모를 확인하지 못한 항목을 변경으로 추측하지 않는다. Push 누락 정보 조회는 기존 3회 재시도 정책을 따른다. 독립 정식 리뷰는 미수행. Push·PR은 이번 구현 단계에서 실행하지 않았다. diff --git a/docs/specs/S15P31E102-255.md b/docs/specs/S15P31E102-255.md new file mode 100644 index 0000000..8995b84 --- /dev/null +++ b/docs/specs/S15P31E102-255.md @@ -0,0 +1,5 @@ +# Mattermost Push 커밋 구분 + +일반 Push 알림은 `변경 커밋 N개 · 병합 커밋 M개`로 표시하고 변경 커밋만 시간순 링크 목록에 표시한다. 새 브랜치 안내는 유지한다. 부모가 2개 이상이면 병합, 0~1개이면 변경으로 분류한다. 제목은 판단 근거가 아니다. + +GitLab Push 목록이 잘렸거나 부모 정보가 없으면 straight compare로 정확한 before..after 범위를 보완하고 필요한 커밋 상세를 조회한다. 누락·중복·잘린 비교 결과를 정상 집계로 보내지 않는다. 조회 실패는 기존 제한된 재시도 정책을 따른다. 이전 큐 payload도 전송 직전에 보완한다. 기존 MR·질문·댓글 안내, 목적지와 비밀값은 변경하지 않는다. diff --git a/docs/verification/push-count-ranges-2026-10-29.json b/docs/verification/push-count-ranges-2026-10-29.json new file mode 100644 index 0000000..c3e5e63 --- /dev/null +++ b/docs/verification/push-count-ranges-2026-10-29.json @@ -0,0 +1,180 @@ +[ + { + "mr": "132", + "before": "2373f191da5bb2161564cf8441bc8bf9d3be00d0", + "after": "f891dfaaba9e4f08c0d6edf9eba60ec7100ef621", + "changes": 1, + "merges": 1, + "commits": [ + { + "id": "f891dfaaba9e4f08c0d6edf9eba60ec7100ef621", + "parent_ids": [ + "2373f191da5bb2161564cf8441bc8bf9d3be00d0", + "5833f8cf74b6b2a1bd1ba43ce54692348e3b47c4" + ], + "message": "Merge branch 'feat(fe)/S15P31E102-192-악보-화면에-연습-시간-측정과-9분-30초-알림-연결' into 'develop'" + }, + { + "id": "5833f8cf74b6b2a1bd1ba43ce54692348e3b47c4", + "parent_ids": [ + "2373f191da5bb2161564cf8441bc8bf9d3be00d0" + ], + "message": "feat(fe): 악보 화면에 연습 시간 측정과 9분 30초 알림 연결 (S15P31E102-192)" + } + ] + }, + { + "mr": "134", + "before": "e113e34cc472d6001c386c5e955f8c8072f86c6a", + "after": "554a9ff4c4a7c2a1143dc9f1ee1db866506f14e2", + "changes": 1, + "merges": 2, + "commits": [ + { + "id": "554a9ff4c4a7c2a1143dc9f1ee1db866506f14e2", + "parent_ids": [ + "e113e34cc472d6001c386c5e955f8c8072f86c6a", + "a642b4a145f2f1761be6f7c7515a21561655dd52" + ], + "message": "Merge branch 'test(be)/S15P31E102-252-분석-다시-시도-API-실제-앱-통합-테스트-추가' into 'develop'" + }, + { + "id": "a642b4a145f2f1761be6f7c7515a21561655dd52", + "parent_ids": [ + "403af10ba89bbab82279b4b7c066d5d3e6b33cba", + "e113e34cc472d6001c386c5e955f8c8072f86c6a" + ], + "message": "Merge remote-tracking branch 'origin/develop' into test(be)/S15P31E102-252-분석-다시-시도-API-실제-앱-통합-테스트-추가" + }, + { + "id": "403af10ba89bbab82279b4b7c066d5d3e6b33cba", + "parent_ids": [ + "1519136d3c650f9991df512a5df3dd90b08c777d" + ], + "message": "test(be): 분석 다시 시도 API 실제 앱 통합 테스트 추가 (S15P31E102-252)" + } + ] + }, + { + "mr": "44", + "before": "1519136d3c650f9991df512a5df3dd90b08c777d", + "after": "e113e34cc472d6001c386c5e955f8c8072f86c6a", + "changes": 7, + "merges": 8, + "commits": [ + { + "id": "e113e34cc472d6001c386c5e955f8c8072f86c6a", + "parent_ids": [ + "1519136d3c650f9991df512a5df3dd90b08c777d", + "769efdd5d8aba4de6f5f7144c30af56881dd0a40" + ], + "message": "Merge branch 'feat(ai)/S15P31E102-108-소금-악보-해석·음원-생성-파이프라인-구축' into 'develop'" + }, + { + "id": "769efdd5d8aba4de6f5f7144c30af56881dd0a40", + "parent_ids": [ + "35479e01794d94f7aed63ede6673e10de2ce16ff" + ], + "message": "docs(ai): 소금 MR에서 로컬 작업 계획 제외 (S15P31E102-108)" + }, + { + "id": "35479e01794d94f7aed63ede6673e10de2ce16ff", + "parent_ids": [ + "a6f5ddba53814785dbfa69bab8d7d6da7e21a003", + "88a1b058ff9bdd60179d8e161e2af3fbbcc99c84" + ], + "message": "chore(ai): 소금 검증 후 최신 develop 통합 (S15P31E102-108)" + }, + { + "id": "a6f5ddba53814785dbfa69bab8d7d6da7e21a003", + "parent_ids": [ + "a8633e8bc47956d5c47c06a96564b6fb2046ae4b", + "64ce7b80b71be2e8e3966261bd4987e5d6205914" + ], + "message": "feat(ai): 소금 음열·표현과 지속음의 M4A 변환 통합 (S15P31E102-108)" + }, + { + "id": "a8633e8bc47956d5c47c06a96564b6fb2046ae4b", + "parent_ids": [ + "ef348c8b6da22729b6379914db1cb9dfb465f02b" + ], + "message": "refac(ai): 공통 단일 변환 문서·검사의 병합 충돌 해소 (S15P31E102-108)" + }, + { + "id": "ef348c8b6da22729b6379914db1cb9dfb465f02b", + "parent_ids": [ + "8f547b857dc54154358f519cd2273c60a1b5367b", + "875743ffa0ec4e79e70bb62b71e8ea342e71b768" + ], + "message": "Merge commit '875743ffa0ec4e79e70bb62b71e8ea342e71b768' into feat(ai)/S15P31E102-108-소금-악보-해석·음원-생성-파이프라인-구축" + }, + { + "id": "8f547b857dc54154358f519cd2273c60a1b5367b", + "parent_ids": [ + "f171f560029f3103b711a28d57606169f45928bc", + "c478691604fdf80cdd291d349a9312959f8875ea" + ], + "message": "refac(ai): 소금 단일 음원 생성 범위로 정리 (S15P31E102-108)" + }, + { + "id": "f171f560029f3103b711a28d57606169f45928bc", + "parent_ids": [ + "8d018a871f08bc7d6641f2be2c3d75d69987490b" + ], + "message": "fix(ai): 소금 예제 빠르기 명시와 재생 회귀 확인 (S15P31E102-108)" + }, + { + "id": "8d018a871f08bc7d6641f2be2c3d75d69987490b", + "parent_ids": [ + "87d8ba4a7021c0ec804638b6243945a85aeb625a" + ], + "message": "fix(ai): 악기 선언 충돌과 비단소 재생 설정 보존 (S15P31E102-106)" + }, + { + "id": "87d8ba4a7021c0ec804638b6243945a85aeb625a", + "parent_ids": [ + "6b9a8100a11756b71a6834a091045719e67d2863" + ], + "message": "feat(ai): 소금 JGB·M4A 생성과 실음 보존 검증 (S15P31E102-108)" + }, + { + "id": "6b9a8100a11756b71a6834a091045719e67d2863", + "parent_ids": [ + "783002f03ffceeb7ab32ccdf254a10796c5a95ee" + ], + "message": "feat(ai): 악기별 JGB 해석과 명시적 표현 정책 연결 (S15P31E102-106)" + }, + { + "id": "783002f03ffceeb7ab32ccdf254a10796c5a95ee", + "parent_ids": [ + "1a12c0c38e933ae2dfb97244cbd32ed6794c31d4", + "a9936acffc4898051d5d07332009bffe3a977831" + ], + "message": "Merge remote-tracking branch 'origin/feat(data)/S15P31E102-166-JGB-악보의-MIDI·CAT-음원-변환-연결' into feat(ai)/S15P31E102-108-소금-악보-해석·음원-생성-파이프라인-구축" + }, + { + "id": "1a12c0c38e933ae2dfb97244cbd32ed6794c31d4", + "parent_ids": [ + "c559ebc1e57ebb9d59ef7d4b31fbbdb11b7f950b", + "f1347b5f723ec0766e71f2af6dda4ff305e185c3" + ], + "message": "Merge remote-tracking branch 'origin/develop' into feat(ai)/S15P31E102-108-소금-악보-해석·음원-생성-파이프라인-구축" + }, + { + "id": "c559ebc1e57ebb9d59ef7d4b31fbbdb11b7f950b", + "parent_ids": [ + "b83049daf7f5730b56d157b3c0c32174a7a9d475", + "bbebc5f4bfb39ec720ee0b39519faac98172307d" + ], + "message": "chore(ai): 최신 develop과 악기별 게시 기록 동기화 (S15P31E102-108)" + }, + { + "id": "b83049daf7f5730b56d157b3c0c32174a7a9d475", + "parent_ids": [ + "c3dfaeb25758ff235b9eebe379202438f311ebc6" + ], + "message": "feat(ai): 소금 해석 프로필과 재생 검증 분리 (S15P31E102-108)" + } + ] + } +] diff --git a/src/mattermost.ts b/src/mattermost.ts index 576508f..64910ee 100644 --- a/src/mattermost.ts +++ b/src/mattermost.ts @@ -8,7 +8,7 @@ import { Store, targetKey } from './store.js'; import { plain, summaryMarker } from './render.js'; import { redact } from './redact.js'; -type PushCommit = { id: string; subject: string; timestamp: number | null }; +type PushCommit = { id: string; subject: string; timestamp: number | null; parentCount?: number }; type Notification = { kind: 'opened' | 'reopened' | 'merged' | 'closed'; title: string } | { kind: 'questions'; title: string; count: number; noteId: string; partial?: boolean } | { kind: 'commented'; title: string; noteId: string; author?: string; preview?: string } @@ -16,17 +16,18 @@ type Notification = { kind: 'opened' | 'reopened' | 'merged' | 'closed'; title: interface Pending { key: string; target: Target; payload: Notification; owner: string; attempts: number } const repositoryKey = (t: Pick) => JSON.stringify([t.platform, t.instance, t.repository]); -// Store only the subject and ordering metadata; never trust webhook-provided URLs. +// Store subjects, ordering and parent counts; never trust webhook-provided URLs. function pushCommits(values: unknown): PushCommit[] { if (!Array.isArray(values)) return []; - return values.flatMap(c => { + return [...new Map(values.flatMap(c => { if (!c || typeof c.id !== 'string' || !/^[a-f0-9]{40,64}$/.test(c.id)) return []; const message = typeof c.message === 'string' ? c.message : typeof c.title === 'string' ? c.title : ''; const subject = redact(message).trim().split(/[\r\n]/, 1)[0]!.trim().slice(0, 500); if (!subject) return []; const timestamp = Date.parse(c.timestamp ?? c.committed_date ?? c.created_at); - return [{ id: c.id, subject, timestamp: Number.isFinite(timestamp) ? timestamp : null }]; - }); + const parents = Array.isArray(c.parent_ids) && c.parent_ids.every((id: unknown) => typeof id === 'string' && /^[a-f0-9]{40,64}$/.test(id)) ? c.parent_ids : undefined; + return [[c.id, { id: c.id, subject, timestamp: Number.isFinite(timestamp) ? timestamp : null, ...(parents ? { parentCount: parents.length } : {}) }] as const]; + })).values()]; } export function givenName(display: string): string { @@ -44,8 +45,12 @@ export function renderNotification(target: Target, notification: Notification, a const who = plain(givenName(notification.author)), branch = plain(redact(notification.branch)); const created = /^0+$/.test(notification.before); const link = created ? `${project}/-/commits/${encodeURIComponent(notification.branch).replace(/[()]/g, c => c === '(' ? '%28' : '%29')}` : `${project}/-/compare/${notification.before}...${notification.after}`; - const headline = created ? `**${who}**님의 **새 브랜치가 생겼어요.** 🌱` : `**${who}**님의 **커밋 ${notification.count}개**가 올라왔어요. 🚀`; - const lines = created ? [] : [...(notification.commits ?? [])] + const commits = notification.commits ?? []; + const classified = commits.length === notification.count && commits.every(c => c.parentCount !== undefined); + const merges = commits.filter(c => c.parentCount !== undefined && c.parentCount > 1).length; + const counts = classified ? `변경 커밋 ${notification.count - merges}개 · 병합 커밋 ${merges}개` : `전체 커밋 ${notification.count}개 · 분류 미확인`; + const headline = created ? `**${who}**님의 **새 브랜치가 생겼어요.** 🌱` : `**${who}**님의 **${counts}**가 올라왔어요. 🚀`; + const lines = created ? [] : [...commits].filter(c => c.parentCount !== undefined && c.parentCount <= 1) .sort((a, b) => (a.timestamp ?? Infinity) - (b.timestamp ?? Infinity)) .map(c => { const match = /^([a-zA-Z]+(?:\([^\r\n]*?\))?!?:)\s+(.+)$/.exec(c.subject); @@ -148,11 +153,18 @@ export class Mattermost { const destination = this.destinations.get(repositoryKey(job.target)); if (!destination) { await settle('failed', 'destination_unavailable'); return; } let author = ''; - if (job.payload.kind === 'push' && !/^0+$/.test(job.payload.before) && (job.payload.commits?.length ?? 0) < job.payload.count) { + if (job.payload.kind === 'push' && !/^0+$/.test(job.payload.before) && ((job.payload.commits?.length ?? 0) !== job.payload.count || job.payload.commits?.some(c => c.parentCount === undefined))) { try { const comparison = await destination.platform.api.request(`${destination.platform.root}/repository/compare?from=${job.payload.before}&to=${job.payload.after}&straight=true`); const commits = pushCommits(comparison?.commits); - if (commits.length < job.payload.count) throw new Error('Incomplete push commits'); + if (comparison?.compare_timeout || comparison?.overflow || commits.length !== job.payload.count) throw new Error('Incomplete push commits'); + for (const commit of commits) { + if (commit.parentCount !== undefined) continue; + const detail = await destination.platform.api.request(`${destination.platform.root}/repository/commits/${commit.id}`); + const resolved = pushCommits([detail])[0]; + if (resolved?.id !== commit.id || resolved.parentCount === undefined) throw new Error('Missing commit parents'); + commit.parentCount = resolved.parentCount; + } job.payload.commits = commits; } catch { await settle(job.attempts < 3 ? 'queued' : 'failed', 'commits_lookup_failed'); return; } } diff --git a/test/integration/mattermost.test.mjs b/test/integration/mattermost.test.mjs index c9e63f0..9314326 100644 --- a/test/integration/mattermost.test.mjs +++ b/test/integration/mattermost.test.mjs @@ -24,7 +24,7 @@ async function fixture(t, responseStatus = 200) { api: { request: async path => { authorReads.push(path); if (path.includes('/repository/compare?')) return { commits: Array.from({ length: 24 }, (_, i) => ({ - id: (i + 1).toString(16).padStart(40, '0'), message: `feat: 변경 ${i + 1}`, + id: (i + 1).toString(16).padStart(40, '0'), message: `feat: 변경 ${i + 1}`, parent_ids: [], created_at: new Date(Date.UTC(2026, 9, 23, 0, i)).toISOString(), })).reverse() }; return { author: { name: '박찬형[부울경_1반_E102]팀원', username: 'login_must_not_appear' } }; @@ -136,7 +136,7 @@ test('push events preserve the total count, deduplicate replay and ignore branch } assert.equal((await f.pool.query('SELECT count(*) FROM mattermost_notifications')).rows[0].count, '1'); await f.flush(); assert.equal(f.received.length, 1); - assert.ok(f.received[0].text.startsWith('**찬형**님의 **커밋 24개**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/')); + assert.ok(f.received[0].text.startsWith('**찬형**님의 **변경 커밋 24개 · 병합 커밋 0개**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/')); assert.equal(f.received[0].text.split('\n').filter(line => line.startsWith('- ')).length, 24); assert.ok(f.received[0].text.indexOf('변경 1**') < f.received[0].text.indexOf('변경 24**')); assert.doesNotMatch(f.received[0].text, /must_not_appear|박찬형/); diff --git a/test/mattermost.test.mjs b/test/mattermost.test.mjs index 0e40a03..9e0b81c 100644 --- a/test/mattermost.test.mjs +++ b/test/mattermost.test.mjs @@ -40,11 +40,11 @@ test('MM destinations are opt-in per repository and refuse unsafe configured URL test('push notifications show the pusher given name, branch and full commit count with hashes only in the URL', () => { const after = 'a'.repeat(40), before = 'b'.repeat(40); const text = renderNotification({ ...target, number: 0 }, { kind: 'push', branch: 'feature/data', count: 24, author: '박찬형[소속]팀원', before, after }, ''); - assert.equal(text, `**찬형**님의 **커밋 24개**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/${before}...${after})`); + assert.equal(text, `**찬형**님의 **전체 커밋 24개 · 분류 미확인**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/${before}...${after})`); const created = renderNotification({ ...target, number: 0 }, { kind: 'push', branch: 'feature/new', count: 3, author: '우동균', before: '0'.repeat(40), after }, ''); assert.equal(created, '**동균**님의 **새 브랜치가 생겼어요.** 🌱\n[**feature/new**](https://gitlab.example/team/project/-/commits/feature%2Fnew)'); const empty = renderNotification(target, { kind: 'push', branch: 'feature/data', count: 0, author: '박찬형', before, after }, ''); - assert.ok(empty.startsWith('**찬형**님의 **커밋 0개**가 올라왔어요. 🚀\n')); + assert.ok(empty.startsWith('**찬형**님의 **변경 커밋 0개 · 병합 커밋 0개**가 올라왔어요. 🚀\n')); assert.equal(text.split('\n').length, 2); assert.doesNotMatch(text, /변경 내용 보기/); }); @@ -65,9 +65,9 @@ test('push admission sorts commit messages chronologically and renders linked on const config = { ...target, enabled: true, mattermostWebhookEnv: 'MM_URL' }; const mm = new Mattermost({ pool: { query: async (_sql, values) => { payload = values[2]; return { rowCount: 1 }; } } }, [{ config }], { MM_URL: 'https://mm.example/hooks/test' }); const commits = [ - { id: 'b'.repeat(40), timestamp: '2026-10-23T02:00:00Z', message: 'fix: 빈 배열 반환\n\n본문 제외' }, - { id: 'a'.repeat(40), timestamp: '2026-10-23T10:00:00+09:00', message: 'feat: 조회 API 추가' }, - { id: 'c'.repeat(40), timestamp: '2026-10-23T03:00:00Z', message: '일반 커밋 @channel [링크]' }, + { id: 'b'.repeat(40), timestamp: '2026-10-23T02:00:00Z', parent_ids: [], message: 'fix: 빈 배열 반환\n\n본문 제외' }, + { id: 'a'.repeat(40), timestamp: '2026-10-23T10:00:00+09:00', parent_ids: [], message: 'feat: 조회 API 추가' }, + { id: 'c'.repeat(40), timestamp: '2026-10-23T03:00:00Z', parent_ids: [], message: '일반 커밋 @channel [링크]' }, ]; await mm.capture({ repo: config, deliveryId: 'push', body: { object_kind: 'push', ref: 'refs/heads/feature/data', before: 'd'.repeat(40), after: 'c'.repeat(40), user_name: '박찬형', total_commits_count: 3, commits } }); const text = renderNotification(target, payload, ''); @@ -90,3 +90,67 @@ test('comment capture uses the commenter and an escaped italic first-line previe assert.ok(text.includes('\n\n*“확인 \\*부탁\\* @\u200bchannel”*')); assert.doesNotMatch(text, /둘째 줄 제외|@channel/); }); + +test('push counts use parents, omit merge rows and never infer merges from subjects', async () => { + let payload; + const config = { ...target, enabled: true, mattermostWebhookEnv: 'MM_URL' }; + const mm = new Mattermost({ pool: { query: async (_sql, values) => { payload = values[2]; return { rowCount: 1 }; } } }, [{ config }], { MM_URL: 'https://mm.example/hooks/test' }); + await mm.capture({ repo: config, deliveryId: 'parents', body: { object_kind: 'push', ref: 'refs/heads/develop', before: 'd'.repeat(40), after: 'c'.repeat(40), user_name: '우동균', total_commits_count: 3, commits: [ + { id: 'a'.repeat(40), message: 'Merge is an ordinary subject', parent_ids: ['d'.repeat(40)] }, + { id: 'b'.repeat(40), message: 'chore: sync develop', parent_ids: ['a'.repeat(40), 'e'.repeat(40)] }, + { id: 'c'.repeat(40), message: 'land branch', parent_ids: ['b'.repeat(40), 'f'.repeat(40), 'e'.repeat(40)] }, + ] } }); + const text = renderNotification(target, payload, ''); + assert.match(text, /변경 커밋 1개 · 병합 커밋 2개/); + assert.equal(text.split('\n').filter(x => x.startsWith('- ')).length, 1); + assert.match(text, /Merge is an ordinary subject/); + assert.doesNotMatch(text, /sync develop|land branch/); +}); + +async function dispatchPush(payload, compare, detail) { + const sent = [], reads = [], settled = []; + const job = { key: 'job', target, payload, owner: 'owner', attempts: 1 }; + const store = { transaction: async action => action({ query: async sql => ({ rows: sql.startsWith('SELECT') || sql.includes('RETURNING') ? [job] : [] }) }), pool: { query: async (_sql, args) => { settled.push(args.slice(2)); } } }; + const config = { ...target, enabled: true, mattermostWebhookEnv: 'MM_URL' }; + const api = { request: async path => { reads.push(path); return path.includes('/compare?') ? compare : detail; } }; + const mm = new Mattermost(store, [{ config, root: '/projects/test', api }], { MM_URL: 'https://mm.example/hooks/test' }, async (_url, init) => { sent.push(JSON.parse(init.body)); return new Response('ok'); }); + mm.tick(); await mm.active; + return { sent, reads, settled }; +} +const pushPayload = (count, commits = []) => ({ kind: 'push', branch: 'develop', count, author: '우동균', before: 'd'.repeat(40), after: 'c'.repeat(40), commits }); + +test('old queued payload and truncated webhook are classified from the full straight comparison', async () => { + const commits = Array.from({ length: 24 }, (_, i) => ({ id: (i + 1).toString(16).padStart(40, '0'), message: `change ${i}`, parent_ids: i < 7 ? [] : ['a'.repeat(40), 'b'.repeat(40)] })); + const result = await dispatchPush(pushPayload(24, [{ id: 'a'.repeat(40), subject: 'old queued subject', timestamp: null }]), { commits }); + assert.equal(result.reads.length, 1); + assert.match(result.reads[0], /straight=true$/); + assert.match(result.sent[0].text, /변경 커밋 7개 · 병합 커밋 17개/); + assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, 7); + assert.deepEqual(result.settled, [['sent', null]]); +}); + +test('missing parents require commit detail; incomplete comparisons never send false counts', async () => { + const commit = { id: 'a'.repeat(40), message: 'not a merge-looking title' }; + const result = await dispatchPush(pushPayload(1), { commits: [commit] }, { ...commit, parent_ids: ['b'.repeat(40), 'c'.repeat(40)] }); + assert.equal(result.reads.length, 2); + assert.match(result.sent[0].text, /변경 커밋 0개 · 병합 커밋 1개/); + assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, 0); + for (const comparison of [{ commits: [] }, { commits: [commit], compare_timeout: true }, { commits: [commit, commit] }]) { + const bad = await dispatchPush(pushPayload(2), comparison); + assert.equal(bad.sent.length, 0); + assert.deepEqual(bad.settled, [['queued', 'commits_lookup_failed']]); + } + const missing = await dispatchPush(pushPayload(1), { commits: [commit] }, commit); + assert.equal(missing.sent.length, 0); + assert.deepEqual(missing.settled, [['queued', 'commits_lookup_failed']]); +}); + +test('captured MR132, MR134 and MR44 Git parent ranges reproduce the reported counts', async () => { + const { readFile } = await import('node:fs/promises'); + const ranges = JSON.parse(await readFile(new URL('../docs/verification/push-count-ranges-2026-10-29.json', import.meta.url), 'utf8')); + for (const range of ranges) { + const result = await dispatchPush({ ...pushPayload(range.commits.length), before: range.before, after: range.after }, { commits: range.commits }); + assert.ok(result.sent[0].text.includes(`변경 커밋 ${range.changes}개 · 병합 커밋 ${range.merges}개`), range.mr); + assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, range.changes); + } +}); From 676142725b45df096b5db376a38ca52d374280a4 Mon Sep 17 00:00:00 2001 From: thisis-joe Date: Thu, 29 Oct 2026 12:32:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(infra):=20Push=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B3=91=ED=95=A9=20=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EC=88=A8=EA=B8=B0=EA=B3=A0=20=EC=8B=A0=EA=B7=9C=C2=B7=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=AC=B8=EA=B5=AC=20=EC=A0=81=EC=9A=A9=20(S15P31E1?= =?UTF-8?q?02-255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/operations.md | 4 ++-- docs/plans/S15P31E102-255.md | 10 ++++++++++ docs/specs/S15P31E102-255.md | 2 +- src/mattermost.ts | 2 +- test/integration/mattermost.test.mjs | 2 +- test/mattermost.test.mjs | 18 +++++++++++------- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index e1bb00a..ec6af99 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -59,8 +59,8 @@ GitLab 저장소 설정의 `mattermostWebhookEnv: MATTERMOST_WEBHOOK_URL`로 알 - 첫 줄은 이름과 핵심 동작을 굵게 강조하고, 둘째 줄은 굵은 대상 링크로 표시한다. MR 대상은 `MR !번호 · MR 제목`이며 브랜치 이동 방향과 질문 개수는 표시하지 않는다. -- Push는 `**이름**님의 **변경 커밋 N개 · 병합 커밋 M개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에 - 변경 커밋 제목만 오래된 시각부터 한 줄씩 나열한다. 병합 여부는 제목이 아닌 부모 커밋 수(2개 이상)로 판단하며, 부모 정보가 없는 Push·이전 대기 알림은 GitLab compare/commit 상세 조회로 보완한다. 시간과 해시는 화면에 표시하지 않는다. +- Push는 `**이름**님의 **신규/변경 커밋 N개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에 + 병합 커밋 수는 표시하지 않으며 신규/변경 커밋 제목만 오래된 시각부터 한 줄씩 나열한다. 병합 여부는 제목이 아닌 부모 커밋 수(2개 이상)로 판단하며, 부모 정보가 없는 Push·이전 대기 알림은 GitLab compare/commit 상세 조회로 보완한다. 시간과 해시는 화면에 표시하지 않는다. 각 제목은 해당 커밋으로 연결하며 `*feat:* **변경 내용**`처럼 유형은 기울임, 내용은 굵게 표시한다. 일반 커밋 제목은 전체를 굵게 표시한다. 본문은 생략한다. 웹훅에 전체 커밋이 없으면 [GitLab 비교 API](https://docs.gitlab.com/api/repositories/#compare-branches-tags-or-commits)로 보완한다. diff --git a/docs/plans/S15P31E102-255.md b/docs/plans/S15P31E102-255.md index 2c19852..f6d59c6 100644 --- a/docs/plans/S15P31E102-255.md +++ b/docs/plans/S15P31E102-255.md @@ -16,3 +16,13 @@ - 운영 모듈과 실제 GitLab API로 세 범위를 재조회해 변경1/병합1, 변경1/병합2, 변경7/병합8 및 변경 목록 수를 검증했다. DB와 Mattermost 전송을 메모리 대역으로 바꾼 읽기 전용 검증이므로 실제 메시지는 게시하지 않았다. 다음 Push부터 적용된다. - 복구: /home/ubuntu/hey/releases/push-count-255/compose.before.json을 /home/ubuntu/hey/compose.json에 복원한 뒤 `docker compose --project-name hey-edo1 --file /home/ubuntu/hey/compose.json up -d --no-deps controller`. 종전 이미지는 보존했다. 이후 다른 운영 변경이 있으면 전체 Compose 복원 대신 controller.image만 종전 값으로 복원한다. - 자체 diff 검토: 저장소 목적지/명령/질문/댓글 서식은 보존. 부모를 확인하지 못한 항목을 변경으로 추측하지 않는다. Push 누락 정보 조회는 기존 3회 재시도 정책을 따른다. 독립 정식 리뷰는 미수행. Push·PR은 이번 구현 단계에서 실행하지 않았다. + +## 후속 문구 변경 (2026-10-29) + +사용자 요청으로 종전 `변경 커밋 N개 · 병합 커밋 M개` 표시는 폐기했다. 최종 출력은 `신규/변경 커밋 N개`이며 병합 커밋 수·목록은 모두 숨긴다. 집계 기준과 GitLab 보완 조회는 그대로 유지한다. 분류가 확인되지 않은 직접 렌더링도 합계를 노출하지 않고 `신규/변경 커밋 수 확인 필요`로 표시한다. 실제 전송 경로는 여전히 조회 성공 후에만 전송한다. + +- RED: 종전 출력에서 새 문구·병합 숨김 기대 검사 5개가 실패했다. +- GREEN: 단위 201개, PostgreSQL 통합 71개 통과. 실제 운영 코드+GitLab API로 3건의 신규/변경 수와 병합 문구 부재·변경 목록 수를 재확인했다. 검증 메시지는 게시하지 않았다. +- 운영 이미지: hey-controller:push-count-255-v2, 알림 모듈 SHA256 53da595dd98eda3fc441c6a191f65a1b6c0ee4921efc9762911dfe943a087546. +- 이전 이미지 hey-controller:push-count-255와 /home/ubuntu/hey/releases/push-count-255-v2/compose.before.json 보존. 이전 복구 절차에서 이 백업을 사용하면 바로 이전 출력으로 되돌린다. 컨트롤러만 교체했다. +- 자체 diff 검토 완료. 독립 리뷰·Push·PR은 미실행이며 Jira 단계는 진행 중을 유지한다. diff --git a/docs/specs/S15P31E102-255.md b/docs/specs/S15P31E102-255.md index 8995b84..6aa47b4 100644 --- a/docs/specs/S15P31E102-255.md +++ b/docs/specs/S15P31E102-255.md @@ -1,5 +1,5 @@ # Mattermost Push 커밋 구분 -일반 Push 알림은 `변경 커밋 N개 · 병합 커밋 M개`로 표시하고 변경 커밋만 시간순 링크 목록에 표시한다. 새 브랜치 안내는 유지한다. 부모가 2개 이상이면 병합, 0~1개이면 변경으로 분류한다. 제목은 판단 근거가 아니다. +일반 Push 알림은 `신규/변경 커밋 N개`로 표시하고 병합 커밋 수는 표시하지 않는다. 신규/변경 커밋만 시간순 링크 목록에 표시한다. 새 브랜치 안내는 유지한다. 부모가 2개 이상이면 병합, 0~1개이면 변경으로 분류한다. 제목은 판단 근거가 아니다. GitLab Push 목록이 잘렸거나 부모 정보가 없으면 straight compare로 정확한 before..after 범위를 보완하고 필요한 커밋 상세를 조회한다. 누락·중복·잘린 비교 결과를 정상 집계로 보내지 않는다. 조회 실패는 기존 제한된 재시도 정책을 따른다. 이전 큐 payload도 전송 직전에 보완한다. 기존 MR·질문·댓글 안내, 목적지와 비밀값은 변경하지 않는다. diff --git a/src/mattermost.ts b/src/mattermost.ts index 64910ee..5934327 100644 --- a/src/mattermost.ts +++ b/src/mattermost.ts @@ -48,7 +48,7 @@ export function renderNotification(target: Target, notification: Notification, a const commits = notification.commits ?? []; const classified = commits.length === notification.count && commits.every(c => c.parentCount !== undefined); const merges = commits.filter(c => c.parentCount !== undefined && c.parentCount > 1).length; - const counts = classified ? `변경 커밋 ${notification.count - merges}개 · 병합 커밋 ${merges}개` : `전체 커밋 ${notification.count}개 · 분류 미확인`; + const counts = classified ? `신규/변경 커밋 ${notification.count - merges}개` : '신규/변경 커밋 수 확인 필요'; const headline = created ? `**${who}**님의 **새 브랜치가 생겼어요.** 🌱` : `**${who}**님의 **${counts}**가 올라왔어요. 🚀`; const lines = created ? [] : [...commits].filter(c => c.parentCount !== undefined && c.parentCount <= 1) .sort((a, b) => (a.timestamp ?? Infinity) - (b.timestamp ?? Infinity)) diff --git a/test/integration/mattermost.test.mjs b/test/integration/mattermost.test.mjs index 9314326..b340670 100644 --- a/test/integration/mattermost.test.mjs +++ b/test/integration/mattermost.test.mjs @@ -136,7 +136,7 @@ test('push events preserve the total count, deduplicate replay and ignore branch } assert.equal((await f.pool.query('SELECT count(*) FROM mattermost_notifications')).rows[0].count, '1'); await f.flush(); assert.equal(f.received.length, 1); - assert.ok(f.received[0].text.startsWith('**찬형**님의 **변경 커밋 24개 · 병합 커밋 0개**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/')); + assert.ok(f.received[0].text.startsWith('**찬형**님의 **신규/변경 커밋 24개**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/')); assert.equal(f.received[0].text.split('\n').filter(line => line.startsWith('- ')).length, 24); assert.ok(f.received[0].text.indexOf('변경 1**') < f.received[0].text.indexOf('변경 24**')); assert.doesNotMatch(f.received[0].text, /must_not_appear|박찬형/); diff --git a/test/mattermost.test.mjs b/test/mattermost.test.mjs index 9e0b81c..1062158 100644 --- a/test/mattermost.test.mjs +++ b/test/mattermost.test.mjs @@ -40,11 +40,11 @@ test('MM destinations are opt-in per repository and refuse unsafe configured URL test('push notifications show the pusher given name, branch and full commit count with hashes only in the URL', () => { const after = 'a'.repeat(40), before = 'b'.repeat(40); const text = renderNotification({ ...target, number: 0 }, { kind: 'push', branch: 'feature/data', count: 24, author: '박찬형[소속]팀원', before, after }, ''); - assert.equal(text, `**찬형**님의 **전체 커밋 24개 · 분류 미확인**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/${before}...${after})`); + assert.equal(text, `**찬형**님의 **신규/변경 커밋 수 확인 필요**가 올라왔어요. 🚀\n[**feature/data**](https://gitlab.example/team/project/-/compare/${before}...${after})`); const created = renderNotification({ ...target, number: 0 }, { kind: 'push', branch: 'feature/new', count: 3, author: '우동균', before: '0'.repeat(40), after }, ''); assert.equal(created, '**동균**님의 **새 브랜치가 생겼어요.** 🌱\n[**feature/new**](https://gitlab.example/team/project/-/commits/feature%2Fnew)'); const empty = renderNotification(target, { kind: 'push', branch: 'feature/data', count: 0, author: '박찬형', before, after }, ''); - assert.ok(empty.startsWith('**찬형**님의 **변경 커밋 0개 · 병합 커밋 0개**가 올라왔어요. 🚀\n')); + assert.ok(empty.startsWith('**찬형**님의 **신규/변경 커밋 0개**가 올라왔어요. 🚀\n')); assert.equal(text.split('\n').length, 2); assert.doesNotMatch(text, /변경 내용 보기/); }); @@ -101,7 +101,8 @@ test('push counts use parents, omit merge rows and never infer merges from subje { id: 'c'.repeat(40), message: 'land branch', parent_ids: ['b'.repeat(40), 'f'.repeat(40), 'e'.repeat(40)] }, ] } }); const text = renderNotification(target, payload, ''); - assert.match(text, /변경 커밋 1개 · 병합 커밋 2개/); + assert.match(text, /신규\/변경 커밋 1개/); + assert.doesNotMatch(text, /병합|커밋 3개/); assert.equal(text.split('\n').filter(x => x.startsWith('- ')).length, 1); assert.match(text, /Merge is an ordinary subject/); assert.doesNotMatch(text, /sync develop|land branch/); @@ -124,7 +125,8 @@ test('old queued payload and truncated webhook are classified from the full stra const result = await dispatchPush(pushPayload(24, [{ id: 'a'.repeat(40), subject: 'old queued subject', timestamp: null }]), { commits }); assert.equal(result.reads.length, 1); assert.match(result.reads[0], /straight=true$/); - assert.match(result.sent[0].text, /변경 커밋 7개 · 병합 커밋 17개/); + assert.match(result.sent[0].text, /신규\/변경 커밋 7개/); + assert.doesNotMatch(result.sent[0].text, /병합 커밋/); assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, 7); assert.deepEqual(result.settled, [['sent', null]]); }); @@ -133,7 +135,8 @@ test('missing parents require commit detail; incomplete comparisons never send f const commit = { id: 'a'.repeat(40), message: 'not a merge-looking title' }; const result = await dispatchPush(pushPayload(1), { commits: [commit] }, { ...commit, parent_ids: ['b'.repeat(40), 'c'.repeat(40)] }); assert.equal(result.reads.length, 2); - assert.match(result.sent[0].text, /변경 커밋 0개 · 병합 커밋 1개/); + assert.match(result.sent[0].text, /신규\/변경 커밋 0개/); + assert.doesNotMatch(result.sent[0].text, /병합 커밋/); assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, 0); for (const comparison of [{ commits: [] }, { commits: [commit], compare_timeout: true }, { commits: [commit, commit] }]) { const bad = await dispatchPush(pushPayload(2), comparison); @@ -150,7 +153,8 @@ test('captured MR132, MR134 and MR44 Git parent ranges reproduce the reported co const ranges = JSON.parse(await readFile(new URL('../docs/verification/push-count-ranges-2026-10-29.json', import.meta.url), 'utf8')); for (const range of ranges) { const result = await dispatchPush({ ...pushPayload(range.commits.length), before: range.before, after: range.after }, { commits: range.commits }); - assert.ok(result.sent[0].text.includes(`변경 커밋 ${range.changes}개 · 병합 커밋 ${range.merges}개`), range.mr); - assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, range.changes); + assert.ok(result.sent[0].text.includes(`신규/변경 커밋 ${range.changes}개`), range.mr); + assert.doesNotMatch(result.sent[0].text, /병합 커밋/); + assert.equal(result.sent[0].text.split('\n').filter(x => x.startsWith('- ')).length, range.changes); } });