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
79 changes: 79 additions & 0 deletions .github/scripts/pr-ai-description.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,55 @@ async function fetchPullRequest(githubRequest, owner, repo, number) {
return githubRequest('GET', `/repos/${owner}/${repo}/pulls/${number}`);
}

async function fetchPrCommitMessages(githubRequest, owner, repo, number) {
const commits = [];
let page = 1;

while (true) {
const response = await githubRequest(
'GET',
`/repos/${owner}/${repo}/pulls/${number}/commits?per_page=100&page=${page}`,
);

if (!Array.isArray(response) || response.length === 0) {
break;
}

for (const item of response) {
const sha = typeof item?.sha === 'string' ? item.sha.slice(0, 7) : '?';
const message =
typeof item?.commit?.message === 'string'
? item.commit.message.split('\n')[0]
: '';

if (message.length > 0) {
commits.push(`${sha} ${message}`);
}
}

if (response.length < 100) {
break;
}

page += 1;
}

return commits;
}

function collectCommitMessagesFromGit(baseSha, headSha) {
const output = runGitCommand([
'log',
'--format=%h %s',
`${baseSha}...${headSha}`,
]);

return output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}

export async function loadCurrentPullRequest({
githubRequest,
owner,
Expand Down Expand Up @@ -412,6 +461,7 @@ export function buildOpenAiPrompt({
pr,
repositoryLabels,
diffText,
commitMessages = [],
}) {
const prMeta = {
number: pr.number,
Expand Down Expand Up @@ -440,11 +490,14 @@ export function buildOpenAiPrompt({
'- dependencies는 추가/제거/업데이트된 패키지 의존성을 작성하세요. 없으면 빈 배열로 두세요.',
'- labels는 아래 제공된 레포 라벨 목록에서만 선택하세요. 최대 3개까지 선택할 수 있으나, 꼭 필요한 경우가 아니면 1-2개로 제한하는 것을 권장합니다.',
'- 코드 식별자/파일 경로/에러 메시지는 원문을 유지하고, 마크다운 백틱(`)으로 감싸세요. 예: `src/features/auth/auth.service.ts`, `package.json`, `PrismaService`',
'- 커밋 메시지를 참고하여 변경 의도를 파악하되, 실제 변경 내용은 diff를 기준으로 작성하세요.',
'',
`PR Meta:\n${JSON.stringify(prMeta, null, 2)}`,
'',
`Repository Labels:\n${JSON.stringify(repositoryLabels, null, 2)}`,
'',
`Commits:\n${commitMessages.length > 0 ? commitMessages.join('\n') : '(no commits available)'}`,
'',
`Diff:\n${diffText}`,
].join('\n');
}
Expand Down Expand Up @@ -887,6 +940,31 @@ async function run() {
throw new Error('no-diff-entries-for-ai');
}

let commitMessages = [];

try {
commitMessages = await fetchPrCommitMessages(
githubRequest,
owner,
repo,
prNumber,
);
logInfo(`fetched ${commitMessages.length} commit messages from API.`);
} catch (error) {
logWarn('commit messages API failed. fallback to git log.', {
message: error?.message ?? 'unknown-error',
});

try {
commitMessages = collectCommitMessagesFromGit(baseSha, headSha);
logInfo(`collected ${commitMessages.length} commit messages from git.`);
} catch (gitError) {
logWarn('git log fallback also failed. proceeding without commit messages.', {
message: gitError?.message ?? 'unknown-error',
});
}
}

const maskedEntries = diffEntries.map((entry) => ({
...entry,
patch: maskSensitiveContent(entry.patch),
Expand All @@ -903,6 +981,7 @@ async function run() {
pr: pullRequest,
repositoryLabels,
diffText: maskedDiff,
commitMessages,
});

const openAiMessages = [
Expand Down
9 changes: 9 additions & 0 deletions src/common/utils/id-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BadRequestException } from '@nestjs/common';

export function parseId(raw: string): bigint {
try {
return BigInt(raw);
} catch {
throw new BadRequestException('Invalid id.');
}
}
9 changes: 9 additions & 0 deletions src/features/conversation/conversation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';

import { ConversationRepository } from './repositories/conversation.repository';

@Module({
providers: [ConversationRepository],
exports: [ConversationRepository],
})
export class ConversationModule {}
6 changes: 6 additions & 0 deletions src/features/conversation/conversation.types.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""공용 대화 발신자 타입(향후 user/seller 공용으로 사용)"""
enum ConversationSenderType {
USER
STORE
SYSTEM
}
2 changes: 2 additions & 0 deletions src/features/conversation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ConversationModule } from './conversation.module';
export { ConversationRepository } from './repositories/conversation.repository';
84 changes: 84 additions & 0 deletions src/features/conversation/repositories/conversation.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { ConversationBodyFormat, ConversationSenderType } from '@prisma/client';

import { PrismaService } from '../../../prisma';

@Injectable()
export class ConversationRepository {
constructor(private readonly prisma: PrismaService) {}

async listConversationsByStore(args: {
storeId: bigint;
limit: number;
cursor?: bigint;
}) {
return this.prisma.storeConversation.findMany({
where: {
store_id: args.storeId,
...(args.cursor ? { id: { lt: args.cursor } } : {}),
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
take: args.limit + 1,
});
}

async findConversationByIdAndStore(args: {
conversationId: bigint;
storeId: bigint;
}) {
return this.prisma.storeConversation.findFirst({
where: {
id: args.conversationId,
store_id: args.storeId,
},
});
}

async listConversationMessages(args: {
conversationId: bigint;
limit: number;
cursor?: bigint;
}) {
return this.prisma.storeConversationMessage.findMany({
where: {
conversation_id: args.conversationId,
...(args.cursor ? { id: { lt: args.cursor } } : {}),
},
orderBy: { id: 'desc' },
take: args.limit + 1,
});
}

async createSellerConversationMessage(args: {
conversationId: bigint;
sellerAccountId: bigint;
bodyFormat: ConversationBodyFormat;
bodyText: string | null;
bodyHtml: string | null;
now: Date;
}) {
return this.prisma.$transaction(async (tx) => {
const message = await tx.storeConversationMessage.create({
data: {
conversation_id: args.conversationId,
sender_type: ConversationSenderType.STORE,
sender_account_id: args.sellerAccountId,
body_format: args.bodyFormat,
body_text: args.bodyText,
body_html: args.bodyHtml,
created_at: args.now,
},
});

await tx.storeConversation.update({
where: { id: args.conversationId },
data: {
last_message_at: args.now,
updated_at: args.now,
},
});

return message;
});
}
}
14 changes: 14 additions & 0 deletions src/features/core/root.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
ISO 8601 DateTime 스칼라
"""
scalar DateTime

type Query {
"""스키마 루트 유지를 위한 no-op 필드"""
_queryNoop: Boolean
}

type Mutation {
"""스키마 루트 유지를 위한 no-op 필드"""
_noop: Boolean
}
4 changes: 4 additions & 0 deletions src/features/order/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { OrderModule } from './order.module';
export { OrderRepository } from './repositories/order.repository';
export { OrderDomainService } from './services/order-domain.service';
export { OrderStatusTransitionPolicy } from './policies/order-status-transition.policy';
11 changes: 11 additions & 0 deletions src/features/order/order.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';

import { OrderStatusTransitionPolicy } from './policies/order-status-transition.policy';
import { OrderRepository } from './repositories/order.repository';
import { OrderDomainService } from './services/order-domain.service';

@Module({
providers: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService],
exports: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService],
})
export class OrderModule {}
8 changes: 8 additions & 0 deletions src/features/order/order.types.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""공용 주문 상태 타입(향후 user/seller 공용으로 사용)"""
enum OrderStatusType {
SUBMITTED
CONFIRMED
MADE
PICKED_UP
CANCELED
}
48 changes: 48 additions & 0 deletions src/features/order/policies/order-status-transition.policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OrderStatus } from '@prisma/client';

@Injectable()
export class OrderStatusTransitionPolicy {
parse(raw: string): OrderStatus {
if (raw === 'SUBMITTED') return OrderStatus.SUBMITTED;
if (raw === 'CONFIRMED') return OrderStatus.CONFIRMED;
if (raw === 'MADE') return OrderStatus.MADE;
if (raw === 'PICKED_UP') return OrderStatus.PICKED_UP;
if (raw === 'CANCELED') return OrderStatus.CANCELED;
throw new BadRequestException('Invalid order status.');
}

assertSellerTransition(from: OrderStatus, to: OrderStatus): void {
if (from === to) {
throw new BadRequestException('Order status is already set to target.');
}

if (to === OrderStatus.CONFIRMED && from !== OrderStatus.SUBMITTED) {
throw new BadRequestException('Invalid order status transition.');
}

if (to === OrderStatus.MADE && from !== OrderStatus.CONFIRMED) {
throw new BadRequestException('Invalid order status transition.');
}

if (to === OrderStatus.PICKED_UP && from !== OrderStatus.MADE) {
throw new BadRequestException('Invalid order status transition.');
}

if (to === OrderStatus.CANCELED) {
const cancellable =
from === OrderStatus.SUBMITTED ||
from === OrderStatus.CONFIRMED ||
from === OrderStatus.MADE;
if (!cancellable) {
throw new BadRequestException(
'Order cannot be canceled from current status.',
);
}
}
}

requiresCancellationNote(to: OrderStatus): boolean {
return to === OrderStatus.CANCELED;
}
}
Loading
Loading