diff --git a/docs/pr-reviews/PR-14.md b/docs/pr-reviews/PR-14.md new file mode 100644 index 0000000..96f5de8 --- /dev/null +++ b/docs/pr-reviews/PR-14.md @@ -0,0 +1,35 @@ +# PR-14 AI 리뷰 기록 + +- PR: https://github.com/WhyLog-App/WhyLog/pull/14 +- 제목: feat(web, server): 적용사항 대시보드 개선 코드반영 탭 구현 +- 브랜치: `develop` ← `feat/application-tabs` +- HEAD: `ccf27d61531b05b5857697b0adfb939c9cc3f328` +- 입력 digest: `d265c5336a64157f5800049bd15bf96cd2b226bd37ef019dca7027c29198f678` +- 모델: Google `gemini-3.6-flash` +- 상태: **PASS** +- 생성 시각(UTC): 2026-08-15T08:51:38+00:00 + +## 요약 + +적용사항 대시보드의 '코드 반영' 탭 구현 및 커밋 상세 정보(작성자, 추가/삭제 라인 수) 제공을 위한 백엔드 API/DTO 확장 작업입니다. 백엔드와 프론트엔드 변경사항 모두 정해진 규칙과 구조를 잘 준수하고 있습니다. + +## 이번 실행에서 새로 발견됨 + +없음 + +## 이전 실행부터 계속 남아있음 + +없음 + +## 현재까지 사라짐(자동 추정) + +없음 + +## 실행 이력 + +|HEAD|상태|모델|전체|신규|계속|해결|시각| +|---|---|---|---:|---:|---:|---:|---| +|ccf27d61531b|PASS|Google gemini-3.6-flash|||||2026-08-15T08:51:38+00:00| + + + diff --git a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java index 9f0dd8f..b1da227 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java +++ b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java @@ -1,17 +1,15 @@ package com.whylog.server.domain.decision.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; -import java.util.List; - public class ApplicationResponse { - @Getter @NoArgsConstructor @AllArgsConstructor @@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO { @Schema(description = "타임라인 내용", example = "장애 이슈 제기") private String content; - } @Getter @@ -89,14 +86,16 @@ public static class DecisionContextItemDTO { @Schema(description = "발화자 이름", example = "김주뇽", nullable = true) private String memberName; - @Schema(description = "발화자 프로필 사진", example = "https://example.com/profile.jpg", nullable = true) + @Schema( + description = "발화자 프로필 사진", + example = "https://example.com/profile.jpg", + nullable = true) private String profileImage; @Schema(description = "대화 내용", example = "아니 우리 이거 버그난다니까?!?@??@") private String dialogueContent; } - @Getter @NoArgsConstructor @AllArgsConstructor @@ -109,7 +108,6 @@ public static class DecisionReasonItemDTO { @Schema(description = "근거 내용", example = "운영복잡 우려로 보류") private String title; - } @Getter @@ -159,6 +157,15 @@ public static class RecommendedCommitDTO { @Schema(description = "커밋 메시지", example = "feat: API 구현") private String message; + @Schema(description = "작성자 이름", example = "홍길동") + private String authorName; + + @Schema(description = "추가된 라인 수", example = "120") + private Integer addedLines; + + @Schema(description = "삭제된 라인 수", example = "30") + private Integer deletedLines; + @Schema(description = "추천 사유", example = "이 커밋은 관련된 이슈를 해결하는 커밋입니다.") private String reason; @@ -199,6 +206,15 @@ public static class ConnectedCommitDTO { @Schema(description = "커밋 메시지", example = "feat: API 구현") private String message; + @Schema(description = "작성자 이름", example = "홍길동") + private String authorName; + + @Schema(description = "추가된 라인 수", example = "120") + private Integer addedLines; + + @Schema(description = "삭제된 라인 수", example = "30") + private Integer deletedLines; + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") private LocalDateTime committedDate; } @@ -216,5 +232,4 @@ public static class CommitConnectionResponseDTO { @Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]") private List commitIds; } - } diff --git a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java index a138b26..a45def2 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java +++ b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java @@ -41,12 +41,16 @@ public class ApplicationQueryService { // 적용사항 상세 조회에 필요한 제목, 타임라인, 원문 맥락, 결정근거를 조회 public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applicationId) { - Application application = applicationRepository.findById(applicationId) - .orElseThrow(ApplicationNotFoundException::new); + Application application = + applicationRepository + .findById(applicationId) + .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 연결된 근거/타임라인 원본 엔티티를 각각 조회 - List applicationBases = applicationBaseRepository.findByApplicationId(applicationId); - List applicationTimelines = applicationTimelineRepository.findByApplicationId(applicationId); + List applicationBases = + applicationBaseRepository.findByApplicationId(applicationId); + List applicationTimelines = + applicationTimelineRepository.findByApplicationId(applicationId); Map membersById = findMembersById(applicationTimelines); return ApplicationResponse.ApplicationDetailDTO.builder() @@ -63,22 +67,44 @@ public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applic // 적용사항에 연결된 커밋 목록을 조회 public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long applicationId) { // 적용사항 존재 여부검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 엔티티를 응답 DTO로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ConnectedCommitDTO.builder() - .commitId(commitConnection.getCommit().getId()) - .repositoryName(commitConnection.getCommit().getRepository().getName()) - .commitHash(commitConnection.getCommit().getHash()) - .message(commitConnection.getCommit().getMessage()) - .committedDate(commitConnection.getCommit().getDateTime()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ConnectedCommitDTO.builder() + .commitId(commitConnection.getCommit().getId()) + .repositoryName( + commitConnection + .getCommit() + .getRepository() + .getName()) + .commitHash(commitConnection.getCommit().getHash()) + .message(commitConnection.getCommit().getMessage()) + .authorName( + commitConnection + .getCommit() + .getAuthorName()) + .addedLines( + commitConnection + .getCommit() + .getAddedLines()) + .deletedLines( + commitConnection + .getCommit() + .getDeletedLines()) + .committedDate( + commitConnection.getCommit().getDateTime()) + .build()) + .toList(); return ApplicationResponse.ConnectedCommitListDTO.builder() .commitCount(commits.size()) @@ -89,19 +115,25 @@ public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long appli // 적용사항의 적용현황 요약 정보를 조회 public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 목록을 적용현황 응답 형식으로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ApplicationBaseItemDTO.builder() - .commitHash(commitConnection.getCommit().getHash()) - .commitMessage(commitConnection.getCommit().getMessage()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ApplicationBaseItemDTO.builder() + .commitHash(commitConnection.getCommit().getHash()) + .commitMessage( + commitConnection.getCommit().getMessage()) + .build()) + .toList(); return ApplicationResponse.ApplicationStatusDTO.builder() .commitCount(commits.size()) @@ -110,20 +142,26 @@ public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applic } // 적용사항에 추천된 커밋 목록을 조회 - public List getRecommendedCommits(Long applicationId) { + public List getRecommendedCommits( + Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항과 연결된 추천 커밋 원본 정보를 조회 - List applicationCommits = applicationCommitsRepository.findByApplicationId(applicationId); + List applicationCommits = + applicationCommitsRepository.findByApplicationId(applicationId); // 추천 원본이 들고 있는 commitId 목록으로 실제 커밋 정보를 조회 Map commitsById = findCommitsById(applicationCommits); // 추천 원본과 커밋 정보를 합쳐 응답 DTO로 변환 return applicationCommits.stream() - .filter(applicationCommit -> commitsById.containsKey(applicationCommit.getDecisionCommits().getCommitId())) + .filter( + applicationCommit -> + commitsById.containsKey( + applicationCommit.getDecisionCommits().getCommitId())) .map(applicationCommit -> toRecommendedCommitDTO(applicationCommit, commitsById)) .toList(); } @@ -131,17 +169,21 @@ public List getRecommendedCommits(Long // 추천 커밋 ID 목록에 해당하는 커밋 정보를 조회 private Map findCommitsById(List applicationCommits) { // 추천 커밋 원본에서 커밋 ID 목록을 추출 - List commitIds = applicationCommits.stream() - .map(applicationCommit -> applicationCommit.getDecisionCommits().getCommitId()) - .toList(); + List commitIds = + applicationCommits.stream() + .map( + applicationCommit -> + applicationCommit.getDecisionCommits().getCommitId()) + .toList(); if (commitIds.isEmpty()) { return Map.of(); } - //응답에 필요한 커밋 정보와 레포 이름을 함께 조회 - Map commitsById = commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() - .collect(Collectors.toMap(Commit::getId, Function.identity())); + // 응답에 필요한 커밋 정보와 레포 이름을 함께 조회 + Map commitsById = + commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() + .collect(Collectors.toMap(Commit::getId, Function.identity())); // 추천 원본이 존재하지 않는 커밋을 참조하는 경우 if (commitsById.size() != commitIds.size()) { @@ -152,8 +194,8 @@ private Map findCommitsById(List applicationCo } // 추천 커밋 연결 정보를 응답 DTO로 변환 - private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(ApplicationCommits applicationCommit, - Map commitsById) { + private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO( + ApplicationCommits applicationCommit, Map commitsById) { Commit commit = commitsById.get(applicationCommit.getDecisionCommits().getCommitId()); return ApplicationResponse.RecommendedCommitDTO.builder() @@ -161,58 +203,82 @@ private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(Applicat .commitId(String.valueOf(commit.getId())) .commitHash(commit.getHash()) .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .addedLines(commit.getAddedLines()) + .deletedLines(commit.getDeletedLines()) .reason(applicationCommit.getReason()) .confidence(applicationCommit.getConfidence()) .build(); } // 연결 테이블을 따라 적용사항에 속한 결정근거 목록을 응답 DTO로 변환 - private List toDecisionReasonItems(List applicationBases) { + private List toDecisionReasonItems( + List applicationBases) { return applicationBases.stream() - .map(applicationBase -> ApplicationResponse.DecisionReasonItemDTO.builder() - .reasonId(String.valueOf(applicationBase.getDecisionBase().getId())) - .title(applicationBase.getDecisionBase().getContent()) - .build()) + .map( + applicationBase -> + ApplicationResponse.DecisionReasonItemDTO.builder() + .reasonId( + String.valueOf( + applicationBase.getDecisionBase().getId())) + .title(applicationBase.getDecisionBase().getContent()) + .build()) .toList(); } // 타임라인 요약 정보 - private List toDecisionTimelineItems(List applicationTimelines) { + private List toDecisionTimelineItems( + List applicationTimelines) { return applicationTimelines.stream() - .map(applicationTimeline -> ApplicationResponse.DecisionTimelineItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .step(applicationTimeline.getDecisionTimeline().getStep()) - .content(applicationTimeline.getDecisionTimeline().getContent()) - .build()) + .map( + applicationTimeline -> + ApplicationResponse.DecisionTimelineItemDTO.builder() + .time( + applicationTimeline + .getDecisionTimeline() + .getTimestamp()) + .step(applicationTimeline.getDecisionTimeline().getStep()) + .content( + applicationTimeline + .getDecisionTimeline() + .getContent()) + .build()) .toList(); } // 원문 맥락은 발화자 정보와 원문 발화를 함께 내려줌 - private List toDecisionContextItems(List applicationTimelines, - Map membersById) { + private List toDecisionContextItems( + List applicationTimelines, Map membersById) { return applicationTimelines.stream() - .map(applicationTimeline -> { - Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); - Member member = memberId != null ? membersById.get(memberId) : null; - - return ApplicationResponse.DecisionContextItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .memberId(memberId) - .memberName(member != null ? member.getName() : null) - .profileImage(memberUseCase.getProfileImageUrl(member) ) - .dialogueContent(applicationTimeline.getDecisionTimeline().getUtterance()) - .build(); - }) + .map( + applicationTimeline -> { + Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); + Member member = memberId != null ? membersById.get(memberId) : null; + + return ApplicationResponse.DecisionContextItemDTO.builder() + .time(applicationTimeline.getDecisionTimeline().getTimestamp()) + .memberId(memberId) + .memberName(member != null ? member.getName() : null) + .profileImage(memberUseCase.getProfileImageUrl(member)) + .dialogueContent( + applicationTimeline + .getDecisionTimeline() + .getUtterance()) + .build(); + }) .toList(); } // 타임라인에 포함된 발화자들을 한 번에 조회해 memberId 기준 맵으로 구성한다. private Map findMembersById(List applicationTimelines) { - List memberIds = applicationTimelines.stream() - .map(applicationTimeline -> applicationTimeline.getDecisionTimeline().getMemberId()) - .filter(memberId -> memberId != null) - .distinct() - .toList(); + List memberIds = + applicationTimelines.stream() + .map( + applicationTimeline -> + applicationTimeline.getDecisionTimeline().getMemberId()) + .filter(memberId -> memberId != null) + .distinct() + .toList(); return memberUseCase.findMembersByIds(memberIds).stream() .collect(Collectors.toMap(Member::getId, Function.identity())); diff --git a/server/src/main/resources/db/mock/mock.sql b/server/src/main/resources/db/mock/mock.sql index 5f41dd1..479ad28 100644 --- a/server/src/main/resources/db/mock/mock.sql +++ b/server/src/main/resources/db/mock/mock.sql @@ -46,7 +46,7 @@ INSERT INTO team_member (team_id, member_id, created_at, updated_at, is_active, (1, 3, NOW(), NOW(), 1, 'MEMBER'), (1, 4, NOW(), NOW(), 1, 'MEMBER'); --- repository & commits (10건) +-- repository & commits (16건) INSERT INTO repository (repository_id, created_at, updated_at, name, url, last_synced_at, team_id) VALUES (1, NOW(), NOW(), 'whylog-server', 'https://github.com/WhyLog-App/whylog-server', NOW(), 1); @@ -60,7 +60,13 @@ INSERT INTO commits (commit_id, created_at, updated_at, repository_id, hash, mes (7, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000007', 'perf: 회의 요약 캐싱 적용', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 2 DAY), 70, 15), (8, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000008', 'fix: GitHub 웹훅 서명 검증 버그 수정', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 1 DAY), 25, 8), (9, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000009', 'chore: Flyway 마이그레이션 도입', '목데이터1', 'mock1@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 1 DAY), 350, 0), - (10, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000010', 'docs: PR 리뷰 기록 문서화', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', NOW(), 80, 2); + (10, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000010', 'docs: PR 리뷰 기록 문서화', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', NOW(), 80, 2), + (11, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000011', 'feat: 적용사항 직접 연결 목록 조회 추가', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 11 DAY), 110, 8), + (12, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000012', 'fix: 커밋 목록 페이징 중복 조회 수정', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 10 DAY), 24, 14), + (13, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000013', 'refactor: Git 조회 응답 변환 로직 정리', '목데이터1', 'mock1@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 9 DAY), 75, 53), + (14, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000014', 'chore: GitHub 동기화 로그 레벨 조정', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 8 DAY), 18, 6), + (15, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000015', 'test: 커밋 직접 연결 목록 조회 테스트 추가', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 7 DAY), 96, 4), + (16, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000016', 'docs: 커밋 연결 API 사용 예시 보완', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 6 DAY), 42, 1); INSERT INTO changed_file (changed_file_id, created_at, updated_at, commit_id, file_name) VALUES (1, NOW(), NOW(), 1, 'src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java'), @@ -74,7 +80,16 @@ INSERT INTO changed_file (changed_file_id, created_at, updated_at, commit_id, fi (9, NOW(), NOW(), 7, 'src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java'), (10, NOW(), NOW(), 8, 'src/main/java/com/whylog/server/global/external/github/GithubWebhookVerifier.java'), (11, NOW(), NOW(), 9, 'src/main/resources/db/migration/V1__init_schema.sql'), - (12, NOW(), NOW(), 10, 'docs/pr-reviews/2026-08.md'); + (12, NOW(), NOW(), 10, 'docs/pr-reviews/2026-08.md'), + (13, NOW(), NOW(), 11, 'src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java'), + (14, NOW(), NOW(), 11, 'src/main/java/com/whylog/server/domain/git/dto/GitResponse.java'), + (15, NOW(), NOW(), 12, 'src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java'), + (16, NOW(), NOW(), 13, 'src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java'), + (17, NOW(), NOW(), 13, 'src/main/java/com/whylog/server/domain/git/entity/Commit.java'), + (18, NOW(), NOW(), 14, 'src/main/java/com/whylog/server/global/external/github/GithubClient.java'), + (19, NOW(), NOW(), 15, 'src/test/java/com/whylog/server/domain/git/service/GitQueryServiceImplTest.java'), + (20, NOW(), NOW(), 15, 'src/main/java/com/whylog/server/domain/git/controller/GitController.java'), + (21, NOW(), NOW(), 16, 'src/main/java/com/whylog/server/domain/git/controller/GitController.java'); INSERT INTO commit_analysis (commit_Analysis_id, created_at, updated_at, commit_id, summary, embedding_ready) VALUES (1, NOW(), NOW(), 1, '로그인 API에 이메일/비밀번호 검증 로직을 추가했다.', 1), diff --git a/web/src/pages/decisions/DecisionDetailPage.tsx b/web/src/pages/decisions/DecisionDetailPage.tsx index 83567b4..78ed2c3 100644 --- a/web/src/pages/decisions/DecisionDetailPage.tsx +++ b/web/src/pages/decisions/DecisionDetailPage.tsx @@ -1,49 +1,24 @@ import type { DecisionDetailViewModel } from "@/types/decision"; -import ApplicationStatusCard from "./components/ApplicationStatusCard"; -import CommitTableCard from "./components/CommitTableCard"; -import ContextCard from "./components/ContextCard"; +import ApplicationDetailTabs from "./components/ApplicationDetailTabs"; +import CodeApplicationTab from "./components/CodeApplicationTab"; import DecisionHeader from "./components/DecisionHeader"; -import ReasonsCard from "./components/ReasonsCard"; -import TimelineCard from "./components/TimelineCard"; interface DecisionDetailPageProps { vm: DecisionDetailViewModel; - decisionId: number; } -const DecisionDetailPage = ({ vm, decisionId }: DecisionDetailPageProps) => { +const DecisionDetailPage = ({ vm }: DecisionDetailPageProps) => { return ( -
- + + + reason.title)} + recommendedCommits={vm.recommended_commits} + linkedCommits={vm.linked_commits} /> - -
-
- - - -
- -
- - -
-
); }; diff --git a/web/src/pages/decisions/DecisionsRoutePage.tsx b/web/src/pages/decisions/DecisionsRoutePage.tsx index 22abde7..84cbcaf 100644 --- a/web/src/pages/decisions/DecisionsRoutePage.tsx +++ b/web/src/pages/decisions/DecisionsRoutePage.tsx @@ -81,7 +81,7 @@ const DecisionsRoutePage = () => { linked_commits: connectedQuery.data?.commits ?? [], }; - return ; + return ; }; export default DecisionsRoutePage; diff --git a/web/src/pages/decisions/components/ApplicationDetailTabs.tsx b/web/src/pages/decisions/components/ApplicationDetailTabs.tsx new file mode 100644 index 0000000..962d318 --- /dev/null +++ b/web/src/pages/decisions/components/ApplicationDetailTabs.tsx @@ -0,0 +1,44 @@ +export type ApplicationDetailTab = "overview" | "relation" | "context" | "code"; + +const TABS: { id: ApplicationDetailTab; label: string }[] = [ + { id: "overview", label: "개요" }, + { id: "relation", label: "관계도" }, + { id: "context", label: "원문 맥락" }, + { id: "code", label: "코드 반영" }, +]; + +interface ApplicationDetailTabsProps { + activeTab: ApplicationDetailTab; + onTabChange?: (tab: ApplicationDetailTab) => void; +} + +const ApplicationDetailTabs = ({ + activeTab, + onTabChange, +}: ApplicationDetailTabsProps) => ( + +); + +export default ApplicationDetailTabs; diff --git a/web/src/pages/decisions/components/CodeApplicationTab.tsx b/web/src/pages/decisions/components/CodeApplicationTab.tsx new file mode 100644 index 0000000..929a261 --- /dev/null +++ b/web/src/pages/decisions/components/CodeApplicationTab.tsx @@ -0,0 +1,376 @@ +import { useEffect, useMemo, useState } from "react"; +import { useCurrentTeam } from "@/hooks/useCurrentTeam"; +import type { + ApplicationConnectedCommit, + ApplicationRecommendedCommit, +} from "@/types/application"; +import { useLinkCommit } from "../hooks/useLinkCommit"; +import { useRepositories } from "../hooks/useRepositories"; +import { useRepositoryCommitsInfinite } from "../hooks/useRepositoryCommitsInfinite"; +import { useUnlinkCommit } from "../hooks/useUnlinkCommit"; +import CommitCard from "./CommitCard"; +import CommitDetailPanel, { + type CommitDetailCommit, +} from "./CommitDetailPanel"; +import DirectCommitList from "./DirectCommitList"; + +interface CodeApplicationTabProps { + applicationId: number; + applicationName: string; + keywords: string[]; + recommendedCommits: ApplicationRecommendedCommit[]; + linkedCommits: ApplicationConnectedCommit[]; +} + +interface DetailCommitInput { + repositoryName: string; + repositoryId?: number; + hash: string; + message: string; + commitId?: number; + isConnected: boolean; + reason?: string; + authorName?: string; + committedDate?: string; +} + +const toDetailCommit = ({ + repositoryName, + repositoryId, + hash, + message, + commitId, + isConnected, + reason, + authorName, + committedDate, +}: DetailCommitInput): CommitDetailCommit => ({ + repositoryName, + repositoryId, + hash, + message, + commitId, + isConnected, + reason, + authorName, + committedDate, +}); + +const CodeApplicationTab = ({ + applicationId, + applicationName, + keywords, + recommendedCommits, + linkedCommits, +}: CodeApplicationTabProps) => { + const [sourceTab, setSourceTab] = useState<"recommended" | "direct">( + "recommended", + ); + const [selectedCommit, setSelectedCommit] = + useState(null); + const [detailCollapsed, setDetailCollapsed] = useState(true); + const [isDraggingCommit, setIsDraggingCommit] = useState(false); + const [draggedCommitId, setDraggedCommitId] = useState(null); + const [selectedRepositoryId, setSelectedRepositoryId] = useState< + number | null + >(null); + const { teamId } = useCurrentTeam(); + const { data: repositories = [] } = useRepositories(teamId); + const { + commits: directCommits, + hasNextPage: hasNextDirectPage, + isFetchingNextPage: isFetchingNextDirectPage, + fetchNextPage: fetchNextDirectPage, + } = useRepositoryCommitsInfinite(selectedRepositoryId, { + enabled: selectedRepositoryId != null, + }); + const repositoryNameById = useMemo( + () => + new Map( + repositories.map((repository) => [ + repository.repository_id, + repository.name, + ]), + ), + [repositories], + ); + const repositoryIdByName = useMemo( + () => + new Map( + repositories.map((repository) => [ + repository.name, + repository.repository_id, + ]), + ), + [repositories], + ); + const linkedCommitHashes = useMemo( + () => new Set(linkedCommits.map((commit) => commit.commit_hash)), + [linkedCommits], + ); + const { + linkCommits, + errorMessage: linkErrorMessage, + isPending: isLinkPending, + } = useLinkCommit(applicationId, { + onSuccess: (commitIds) => + setSelectedCommit((commit) => + commit && commit.commitId != null && commitIds.includes(commit.commitId) + ? { ...commit, isConnected: true } + : commit, + ), + }); + const { + unlinkCommit, + errorMessage: unlinkErrorMessage, + isPending: isUnlinkPending, + } = useUnlinkCommit(applicationId, { + onSuccess: (commitId) => + setSelectedCommit((commit) => + commit?.commitId === commitId + ? { ...commit, isConnected: false } + : commit, + ), + }); + + useEffect(() => { + if (selectedRepositoryId == null && repositories[0]) { + setSelectedRepositoryId(repositories[0].repository_id); + } + }, [repositories, selectedRepositoryId]); + + const showDetail = (commit: CommitDetailCommit) => { + setSelectedCommit(commit); + setDetailCollapsed(false); + }; + const startDragging = (commitId: string | number) => { + const numericId = Number(commitId); + setDraggedCommitId(Number.isInteger(numericId) ? numericId : null); + setIsDraggingCommit(true); + }; + const finishDragging = () => { + setDraggedCommitId(null); + setIsDraggingCommit(false); + }; + const handleDrop = () => { + if (draggedCommitId != null) linkCommits([draggedCommitId]); + finishDragging(); + }; + return ( +
+
+ + + 추천{" "} + + {recommendedCommits.length} + + + + + + 연결됨{" "} + + {linkedCommits.length} + + + + 전체 {recommendedCommits.length + linkedCommits.length} + +
+
+
+
+
+
+ +

+ 커밋 가져오기 +

+
+
+ + +
+
+
+ {sourceTab === "recommended" ? ( + recommendedCommits.map((commit) => { + return ( + + showDetail( + toDetailCommit({ + repositoryName: commit.repository_name, + repositoryId: repositoryIdByName.get( + commit.repository_name, + ), + hash: commit.commit_hash, + message: commit.message, + commitId: Number(commit.commit_id), + isConnected: linkedCommitHashes.has( + commit.commit_hash, + ), + reason: commit.reason, + authorName: commit.author_name, + }), + ) + } + onDragStart={() => startDragging(commit.commit_id)} + onDragEnd={finishDragging} + /> + ); + }) + ) : ( + { + void fetchNextDirectPage(); + }} + onSelectCommit={(commit) => + showDetail( + toDetailCommit({ + repositoryName: + selectedRepositoryId == null + ? "" + : (repositoryNameById.get(selectedRepositoryId) ?? + ""), + repositoryId: selectedRepositoryId ?? undefined, + hash: commit.hash, + message: commit.message, + commitId: commit.commit_id, + isConnected: linkedCommitHashes.has(commit.hash), + authorName: commit.author_name, + committedDate: commit.date_time, + }), + ) + } + onDragStart={startDragging} + onDragEnd={finishDragging} + /> + )} +
+
+
+
+
+ +

+ 연결된 커밋 ({linkedCommits.length}) +

+
+
+ {linkErrorMessage ? ( +

+ {linkErrorMessage} +

+ ) : null} + {unlinkErrorMessage ? ( +

+ {unlinkErrorMessage} +

+ ) : null} +
{ + event.preventDefault(); + setIsDraggingCommit(true); + }} + onDrop={handleDrop} + className="application-scroll relative flex flex-1 flex-col overflow-y-auto" + > + {linkedCommits.map((commit) => { + return ( + + showDetail( + toDetailCommit({ + repositoryName: commit.repository_name, + hash: commit.commit_hash, + message: commit.message, + commitId: commit.commit_id, + isConnected: true, + repositoryId: repositoryIdByName.get( + commit.repository_name, + ), + authorName: commit.author_name, + committedDate: commit.committed_date, + }), + ) + } + /> + ); + })} + {isDraggingCommit ? ( +
+

+ 여기에 드래그하여 연결 +

+

+ 커밋을 이 영역에 드롭하면 연결됩니다 +

+
+ ) : null} +
+
+
+ setDetailCollapsed((value) => !value)} + onConnect={(commitId) => linkCommits([commitId])} + onUnlink={unlinkCommit} + isPending={isLinkPending || isUnlinkPending} + /> +
+
+ ); +}; + +export default CodeApplicationTab; diff --git a/web/src/pages/decisions/components/CommitCard.tsx b/web/src/pages/decisions/components/CommitCard.tsx new file mode 100644 index 0000000..de2fc58 --- /dev/null +++ b/web/src/pages/decisions/components/CommitCard.tsx @@ -0,0 +1,98 @@ +import { formatCommittedDate } from "@/utils/date"; +import CommitHashBadge from "./CommitHashBadge"; + +export type CommitCardVariant = "recommended" | "direct" | "linked"; + +interface CommitCardProps { + hash: string; + message: string; + repositoryName: string; + variant: CommitCardVariant; + reason?: string; + committedDate?: string; + authorName?: string; + addedLines?: number; + removedLines?: number; + selected?: boolean; + onClick?: () => void; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +const CommitCard = ({ + hash, + message, + repositoryName, + variant, + reason, + committedDate, + authorName, + addedLines, + removedLines, + selected = false, + onClick, + onDragStart, + onDragEnd, +}: CommitCardProps) => ( + +); + +export default CommitCard; diff --git a/web/src/pages/decisions/components/CommitDetailPanel.tsx b/web/src/pages/decisions/components/CommitDetailPanel.tsx new file mode 100644 index 0000000..3306a23 --- /dev/null +++ b/web/src/pages/decisions/components/CommitDetailPanel.tsx @@ -0,0 +1,219 @@ +import IconArrowRight from "@/assets/icons/arrow/ic_arrow_right_md.svg?react"; +import { Icon } from "@/components/common/Icon"; +import { useGetCommitDetail } from "@/pages/git/hooks/useGetCommitDetail"; +import { formatCommittedDate } from "@/utils/date"; +import CommitHashBadge from "./CommitHashBadge"; + +export interface CommitDetailCommit { + repositoryName: string; + hash: string; + message: string; + commitId?: number; + isConnected: boolean; + reason?: string; + repositoryId?: number; + authorName?: string; + committedDate?: string; +} + +interface CommitDetailPanelProps { + applicationName: string; + keywords: string[]; + commit: CommitDetailCommit | null; + collapsed: boolean; + onToggle: () => void; + onConnect: (commitId: number) => void; + onUnlink: (commitId: number) => void; + isPending: boolean; +} + +const CommitDetailPanel = ({ + applicationName, + keywords, + commit, + collapsed, + onToggle, + onConnect, + onUnlink, + isPending, +}: CommitDetailPanelProps) => { + const detailQuery = useGetCommitDetail( + collapsed ? null : (commit?.repositoryId ?? null), + commit?.hash, + ); + const detail = detailQuery.data; + + return ( + + ); +}; + +export default CommitDetailPanel; diff --git a/web/src/pages/decisions/components/CommitHashBadge.tsx b/web/src/pages/decisions/components/CommitHashBadge.tsx index 97c2c07..bdeb124 100644 --- a/web/src/pages/decisions/components/CommitHashBadge.tsx +++ b/web/src/pages/decisions/components/CommitHashBadge.tsx @@ -6,7 +6,7 @@ interface CommitHashBadgeProps { const CommitHashBadge = ({ hash, className = "" }: CommitHashBadgeProps) => ( {hash.slice(0, 6)} diff --git a/web/src/pages/decisions/components/DecisionHeader.tsx b/web/src/pages/decisions/components/DecisionHeader.tsx index 2e092b1..9fcec9d 100644 --- a/web/src/pages/decisions/components/DecisionHeader.tsx +++ b/web/src/pages/decisions/components/DecisionHeader.tsx @@ -1,11 +1,8 @@ import IconCircleUser from "@/assets/icons/user/ic_circle_user.svg?react"; import { Icon } from "@/components/common/Icon"; -import type { DecisionConfidence, DecisionMeetingMeta } from "@/types/decision"; -import ConfidenceBadge from "./ConfidenceBadge"; +import type { DecisionMeetingMeta } from "@/types/decision"; interface DecisionHeaderProps { - name: string; - confidence: DecisionConfidence; meta: DecisionMeetingMeta; } @@ -13,18 +10,15 @@ const Dot = () => ( · ); -const DecisionHeader = ({ name, confidence, meta }: DecisionHeaderProps) => { +const DecisionHeader = ({ meta }: DecisionHeaderProps) => { const visibleAvatars = meta.participants.slice(0, 5); return ( -
-
-

{name}

- -
- +
- {meta.meeting_name} + + {meta.meeting_name} + {meta.meeting_date} diff --git a/web/src/pages/decisions/components/DirectCommitList.tsx b/web/src/pages/decisions/components/DirectCommitList.tsx new file mode 100644 index 0000000..0c5b32c --- /dev/null +++ b/web/src/pages/decisions/components/DirectCommitList.tsx @@ -0,0 +1,122 @@ +import { useRef } from "react"; +import type { RepositoryCommitItem } from "@/types/git"; +import CommitCard from "./CommitCard"; + +interface DirectCommitListProps { + repositories: { repository_id: number; name: string }[]; + selectedRepositoryId: number | null; + repositoryName: string; + onSelectRepository: (repositoryId: number) => void; + commits: RepositoryCommitItem[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + onSelectCommit: (commit: RepositoryCommitItem) => void; + onDragStart: (commitId: number) => void; + onDragEnd: () => void; +} + +const RepositoryTabs = ({ + repositories, + selectedRepositoryId, + onSelectRepository, +}: Pick< + DirectCommitListProps, + "repositories" | "selectedRepositoryId" | "onSelectRepository" +>) => { + const scrollRef = useRef(null); + const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null); + + return ( +
{ + dragStartRef.current = { + x: event.clientX, + scrollLeft: scrollRef.current?.scrollLeft ?? 0, + }; + }} + onMouseMove={(event) => { + if (dragStartRef.current && scrollRef.current) { + scrollRef.current.scrollLeft = + dragStartRef.current.scrollLeft - + (event.clientX - dragStartRef.current.x); + } + }} + onMouseUp={() => { + dragStartRef.current = null; + }} + onMouseLeave={() => { + dragStartRef.current = null; + }} + className="flex min-h-10 gap-1 overflow-x-auto px-0 py-1" + > + {repositories.map((repository) => ( + + ))} +
+ ); +}; + +const DirectCommitList = ({ + repositories, + selectedRepositoryId, + repositoryName, + onSelectRepository, + commits, + hasNextPage, + isFetchingNextPage, + onLoadMore, + onSelectCommit, + onDragStart, + onDragEnd, +}: DirectCommitListProps) => ( + <> + +
{ + const element = event.currentTarget; + const remaining = + element.scrollHeight - element.scrollTop - element.clientHeight; + if (hasNextPage && !isFetchingNextPage && remaining < 80) onLoadMore(); + }} + className="flex flex-1 flex-col overflow-y-auto" + > + {commits.map((commit) => ( + onSelectCommit(commit)} + onDragStart={() => onDragStart(commit.commit_id)} + onDragEnd={onDragEnd} + /> + ))} + {isFetchingNextPage ? ( +

+ 커밋을 더 불러오는 중입니다 +

+ ) : null} +
+ +); + +export default DirectCommitList; diff --git a/web/src/pages/decisions/hooks/useLinkCommit.ts b/web/src/pages/decisions/hooks/useLinkCommit.ts index 5c44d17..0591f9e 100644 --- a/web/src/pages/decisions/hooks/useLinkCommit.ts +++ b/web/src/pages/decisions/hooks/useLinkCommit.ts @@ -7,7 +7,7 @@ import { APPLICATION_CONNECTED_COMMITS_QUERY_KEY } from "./useConnectedCommits"; import { APPLICATION_RECOMMENDED_COMMITS_QUERY_KEY } from "./useRecommendedCommits"; interface UseLinkCommitOptions { - onSuccess?: () => void; + onSuccess?: (commitIds: number[]) => void; } export const useLinkCommit = ( @@ -20,7 +20,7 @@ export const useLinkCommit = ( const mutation = useMutation({ mutationFn: (commitIds: number[]) => linkCommit(applicationId, { commit_ids: commitIds }), - onSuccess: async () => { + onSuccess: async (_, commitIds) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: [ @@ -32,7 +32,7 @@ export const useLinkCommit = ( queryKey: [...APPLICATION_CONNECTED_COMMITS_QUERY_KEY, applicationId], }), ]); - options?.onSuccess?.(); + options?.onSuccess?.(commitIds); }, onError: (error: unknown) => { if (isAxiosError>(error)) { diff --git a/web/src/pages/decisions/hooks/useUnlinkCommit.ts b/web/src/pages/decisions/hooks/useUnlinkCommit.ts index 41ed77f..426dae3 100644 --- a/web/src/pages/decisions/hooks/useUnlinkCommit.ts +++ b/web/src/pages/decisions/hooks/useUnlinkCommit.ts @@ -7,7 +7,7 @@ import { APPLICATION_CONNECTED_COMMITS_QUERY_KEY } from "./useConnectedCommits"; import { APPLICATION_RECOMMENDED_COMMITS_QUERY_KEY } from "./useRecommendedCommits"; interface UseUnlinkCommitOptions { - onSuccess?: () => void; + onSuccess?: (commitId: number) => void; } export const useUnlinkCommit = ( @@ -20,7 +20,7 @@ export const useUnlinkCommit = ( const mutation = useMutation({ mutationFn: (commitId: number) => unlinkCommit(applicationId, { commit_id: commitId }), - onSuccess: async () => { + onSuccess: async (_, commitId) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: [ @@ -32,7 +32,7 @@ export const useUnlinkCommit = ( queryKey: [...APPLICATION_CONNECTED_COMMITS_QUERY_KEY, applicationId], }), ]); - options?.onSuccess?.(); + options?.onSuccess?.(commitId); }, onError: (error: unknown) => { if (isAxiosError>(error)) { diff --git a/web/src/styles/base/base.css b/web/src/styles/base/base.css index 56f5f20..bb50b65 100644 --- a/web/src/styles/base/base.css +++ b/web/src/styles/base/base.css @@ -68,3 +68,22 @@ body { box-shadow: none; -webkit-box-shadow: none; } + +.application-scroll::-webkit-scrollbar { + display: none; +} + +.application-scroll::-webkit-scrollbar-track { + background-color: transparent; + box-shadow: none; +} + +.application-scroll::-webkit-scrollbar-thumb { + background-color: transparent; + box-shadow: none; + -webkit-box-shadow: none; +} + +.application-scroll { + scrollbar-width: none; +} diff --git a/web/src/types/application.ts b/web/src/types/application.ts index c4be060..6933783 100644 --- a/web/src/types/application.ts +++ b/web/src/types/application.ts @@ -34,6 +34,9 @@ export interface ApplicationRecommendedCommit { commit_id: string; commit_hash: string; message: string; + author_name: string; + added_lines: number; + deleted_lines: number; reason: string; } @@ -43,6 +46,9 @@ export interface ApplicationConnectedCommit { commit_id: number; commit_hash: string; message: string; + author_name: string; + added_lines: number; + deleted_lines: number; committed_date: string; }