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
6 changes: 4 additions & 2 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ GitLab 저장소 설정의 `mattermostWebhookEnv: MATTERMOST_WEBHOOK_URL`로 알

- 첫 줄은 이름과 핵심 동작을 굵게 강조하고, 둘째 줄은 굵은 대상 링크로 표시한다.
MR 대상은 `MR !번호 · MR 제목`이며 브랜치 이동 방향과 질문 개수는 표시하지 않는다.
- Push는 `**이름**님의 **커밋 N개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에
커밋 제목을 오래된 시각부터 한 줄씩 나열한다. 시간과 해시는 화면에 표시하지 않는다.
- Push는 `**이름**님의 **신규/변경 커밋 N개**가 올라왔어요. 🚀`와 브랜치 비교 링크 다음에
병합 커밋 수는 표시하지 않으며 신규/변경 커밋 제목만 오래된 시각부터 한 줄씩 나열한다. 병합 여부는 제목이 아닌 부모 커밋 수(2개 이상)로 판단하며, 부모 정보가 없는 Push·이전 대기 알림은 GitLab compare/commit 상세 조회로 보완한다. 시간과 해시는 화면에 표시하지 않는다.
각 제목은 해당 커밋으로 연결하며 `*feat:* **변경 내용**`처럼 유형은 기울임,
내용은 굵게 표시한다. 일반 커밋 제목은 전체를 굵게 표시한다. 본문은 생략한다.
웹훅에 전체 커밋이 없으면 [GitLab 비교 API](https://docs.gitlab.com/api/repositories/#compare-branches-tags-or-commits)로 보완한다.
Expand Down Expand Up @@ -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)를 참고한다.
28 changes: 28 additions & 0 deletions docs/plans/S15P31E102-255.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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은 이번 구현 단계에서 실행하지 않았다.

## 후속 문구 변경 (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 단계는 진행 중을 유지한다.
5 changes: 5 additions & 0 deletions docs/specs/S15P31E102-255.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Mattermost Push 커밋 구분

일반 Push 알림은 `신규/변경 커밋 N개`로 표시하고 병합 커밋 수는 표시하지 않는다. 신규/변경 커밋만 시간순 링크 목록에 표시한다. 새 브랜치 안내는 유지한다. 부모가 2개 이상이면 병합, 0~1개이면 변경으로 분류한다. 제목은 판단 근거가 아니다.

GitLab Push 목록이 잘렸거나 부모 정보가 없으면 straight compare로 정확한 before..after 범위를 보완하고 필요한 커밋 상세를 조회한다. 누락·중복·잘린 비교 결과를 정상 집계로 보내지 않는다. 조회 실패는 기존 제한된 재시도 정책을 따른다. 이전 큐 payload도 전송 직전에 보완한다. 기존 MR·질문·댓글 안내, 목적지와 비밀값은 변경하지 않는다.
180 changes: 180 additions & 0 deletions docs/verification/push-count-ranges-2026-10-29.json
Original file line number Diff line number Diff line change
@@ -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)"
}
]
}
]
30 changes: 21 additions & 9 deletions src/mattermost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,26 @@ 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 }
| { kind: 'push'; branch: string; count: number; author: string; before: string; after: string; commits?: PushCommit[] };
interface Pending { key: string; target: Target; payload: Notification; owner: string; attempts: number }
const repositoryKey = (t: Pick<Target, 'platform' | 'instance' | 'repository'>) => 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 {
Expand All @@ -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}개` : '신규/변경 커밋 수 확인 필요';
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);
Expand Down Expand Up @@ -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; }
}
Expand Down
Loading
Loading