feat: enable hybrid local and remote module execution

This commit is contained in:
2026-08-06 16:17:41 +09:00
parent e06b853bcb
commit f8ccd8b0a3
18 changed files with 1312 additions and 123 deletions
+472 -53
View File
@@ -36,6 +36,8 @@ type SubmissionCommitFile = {
path: string;
status: string;
patch: string;
originalRef?: string | null;
modifiedRef?: string | null;
};
type SubmissionCommit = {
@@ -245,7 +247,7 @@ export class InstructorService {
const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt);
const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week;
const isCompletedByGroup = completedWeekNumbers.includes(week.week);
return week.requiresSourceSubmission && !isSubmitted && (isPastByProgress || isCompletedByGroup);
return !isSubmitted && (isPastByProgress || isCompletedByGroup);
})
.forEach((week) => {
if (issues.some((issue) => issue.week === week.week && issue.type === 'COMPLETED_WITHOUT_SUBMISSION')) return;
@@ -419,6 +421,50 @@ export class InstructorService {
}
const weekIds = courses.flatMap((course) => course.weeks.map((week) => week.id));
const moduleIds = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.map((module) => module.id)));
const courseIds = courses.map((course) => course.id);
const teamWeekOverrides = organizationName && courseIds.length
? await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: { in: courseIds },
isTeamProject: true,
},
select: {
courseId: true,
week: true,
},
})
: [];
const teamWeekKeys = new Set(teamWeekOverrides.map((override) => `${override.courseId}:${override.week}`));
const isTeamProjectWeek = (courseId: string, week: number) => teamWeekKeys.has(`${courseId}:${week}`);
const studentGroupByStudentId = new Map(
enrollments
.map((enrollment) => [enrollment.user.id, enrollment.user.groupMemberships[0]?.group ?? null] as const)
.filter(([, group]) => group),
);
const teamGroupsById = new Map<string, {
id: string;
name: string;
members: Array<{ id: string; name: string; email: string }>;
}>();
students.forEach((student) => {
const group = studentGroupByStudentId.get(student.id);
if (!group) return;
const current = teamGroupsById.get(group.id) ?? { id: group.id, name: group.name, members: [] };
if (!current.members.some((member) => member.id === student.id)) {
current.members.push({ id: student.id, name: student.name, email: student.email });
}
teamGroupsById.set(group.id, current);
});
const teamGroups = [...teamGroupsById.values()];
const teamRepositories = teamGroups.length && moduleIds.length
? await this.prisma.teamStudioRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
moduleId: { in: moduleIds },
},
})
: [];
const repositories = studentIds.length && moduleIds.length
? await this.prisma.studioRepository.findMany({
@@ -427,9 +473,31 @@ export class InstructorService {
},
})
: [];
const customRepositories = studentIds.length && courseIds.length
? await this.prisma.studentCustomModuleRepository.findMany({
where: {
userId: { in: studentIds },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const teamCustomRepositories = teamGroups.length && courseIds.length
? await this.prisma.teamCustomModuleRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const repositoryByStudentModule = new Map(repositories.map((repository) => [`${repository.userId}:${repository.moduleId}`, repository]));
const repositoryByStudentRepoName = new Map(repositories.map((repository) => [`${repository.userId}:${repository.repoName}`, repository]));
const teamRepositoryByGroupModule = new Map(teamRepositories.map((repository) => [`${repository.groupId}:${repository.moduleId}`, repository]));
const progressByRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamRepositoryId = new Map<string, SubmissionProgress>();
const progressByCustomRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamCustomRepositoryId = new Map<string, SubmissionProgress>();
const scores = studentIds.length && weekIds.length
? await this.prisma.submissionTotalScore.findMany({
where: {
@@ -452,8 +520,37 @@ export class InstructorService {
}));
progressByRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamRepositoryId.set(repository.id, progress);
}));
await Promise.all(customRepositories.map(async (repository) => {
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByCustomRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamCustomRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamCustomRepositoryId.set(repository.id, progress);
}));
const items = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.flatMap((module) => students.map((student) => {
const items = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => students.map((student) => {
const expectedRepoName = this.buildStudioRepoName(student.email, week.week, module.name);
const repository = repositoryByStudentModule.get(`${student.id}:${module.id}`)
?? repositoryByStudentRepoName.get(`${student.id}:${expectedRepoName}`);
@@ -479,6 +576,7 @@ export class InstructorService {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: false,
},
module: {
id: module.id,
@@ -498,7 +596,152 @@ export class InstructorService {
commits,
finalFiles: progress?.finalFiles ?? [],
};
}))));
}));
}));
const customItems = customRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const student = students.find((candidate) => candidate.id === repository.userId);
if (!course || !week || !student || isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const status = commits.length > 0 ? 'SUBMITTED' : 'PENDING';
return [{
id: `custom:${repository.userId}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
score: scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: false },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
student,
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const teamItems = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (!isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => teamGroups.map((group) => {
const repository = teamRepositoryByGroupModule.get(`${group.id}:${module.id}`);
const progress = repository ? progressByTeamRepositoryId.get(repository.id) : null;
const commits = progress?.commits ?? [];
const submittedAt = commits[0]?.date ?? null;
const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING';
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return {
id: `team:${group.id}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt,
commitCount: commits.length,
course: {
id: course.id,
title: course.title,
slug: course.slug,
},
week: {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: true,
},
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
},
team: {
id: group.id,
name: group.name,
members,
},
student: {
id: group.id,
name: group.name,
email: '',
group: group.name,
},
repository: repository
? {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
}
: null,
commits,
finalFiles: progress?.finalFiles ?? [],
};
}));
}));
const teamCustomItems = teamCustomRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const group = teamGroupsById.get(repository.groupId);
if (!course || !week || !group || !isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByTeamCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return [{
id: `team-custom:${group.id}:${module.id}`,
status: commits.length > 0 ? 'SUBMITTED' : 'PENDING',
submitted: commits.length > 0,
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: true },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
team: { id: group.id, name: group.name, members },
student: { id: group.id, name: group.name, email: '', group: group.name },
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const allItems = [...items, ...customItems, ...teamItems, ...teamCustomItems];
return {
organization: organizationName
@@ -508,20 +751,21 @@ export class InstructorService {
}
: null,
summary: {
totalItems: items.length,
submittedItems: items.filter((item) => item.status === 'SUBMITTED').length,
pendingItems: items.filter((item) => item.status === 'PENDING').length,
notInstalledItems: items.filter((item) => item.status === 'NOT_INSTALLED').length,
totalCommits: items.reduce((count, item) => count + item.commitCount, 0),
totalItems: allItems.length,
submittedItems: allItems.filter((item) => item.status === 'SUBMITTED').length,
pendingItems: allItems.filter((item) => item.status === 'PENDING').length,
notInstalledItems: allItems.filter((item) => item.status === 'NOT_INSTALLED').length,
totalCommits: allItems.reduce((count, item) => count + item.commitCount, 0),
},
courses: courses.map((course) => ({
id: course.id,
title: course.title,
slug: course.slug,
weeks: course.weeks.map((week) => ({
id: week.id,
week: week.week,
title: week.title,
id: course.id,
title: course.title,
slug: course.slug,
weeks: course.weeks.map((week) => ({
id: week.id,
week: week.week,
title: week.title,
isTeamProject: isTeamProjectWeek(course.id, week.week),
modules: week.modules.map((module) => ({
id: module.id,
name: module.name,
@@ -531,7 +775,48 @@ export class InstructorService {
})),
})),
students,
items,
items: [...items, ...customItems],
teamItems: [...teamItems, ...teamCustomItems],
};
}
async getSubmissionFile(
accessToken: string | undefined,
input: {
owner?: string;
repoName?: string;
path?: string;
baseRef?: string;
headRef?: string;
},
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
const owner = input.owner?.trim();
const repoName = input.repoName?.trim();
const path = input.path?.trim();
const baseRef = this.normalizeGitRef(input.baseRef);
const headRef = this.normalizeGitRef(input.headRef);
if (!owner || !repoName || !path || (!baseRef && !headRef)) {
throw new BadRequestException('저장소와 파일 정보를 확인할 수 없습니다.');
}
if (path.split('/').some((part) => !part || part === '.' || part === '..')) {
throw new BadRequestException('파일 경로가 올바르지 않습니다.');
}
await this.assertInstructorRepositoryAccess(organizationId, organizationName, owner, repoName);
const [original, modified] = await Promise.all([
baseRef ? this.readGiteaFileAtRef(owner, repoName, path, baseRef) : null,
headRef ? this.readGiteaFileAtRef(owner, repoName, path, headRef) : null,
]);
return {
original: original ?? '',
modified: modified ?? '',
originalAvailable: original !== null,
modifiedAvailable: modified !== null,
};
}
@@ -1009,6 +1294,16 @@ export class InstructorService {
const agents = boundAgentValues.length
? await this.platformGatewayClient.listAgents(authUser.email).catch(() => [])
: [];
const agentConnectionByPlatform = new Map(await Promise.all(platforms.map(async (platform) => {
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId')
?? agentHostname;
const connection = gatewayPlatformId
? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null)
: null;
return [platform.id, connection] as const;
})));
const responsePlatforms = platforms.map((platform) => {
const assignedGroup = groupByPlatform.get(platform.id);
@@ -1021,6 +1316,7 @@ export class InstructorService {
hostname: agentHostname,
ownerEmail: agentOwnerEmail,
});
const agentConnection = agentConnectionByPlatform.get(platform.id);
return {
id: platform.id,
masterPlatformId: platform.platformId,
@@ -1028,7 +1324,9 @@ export class InstructorService {
type: this.toPlatformType(platform.platform.displayName),
status: activeControl ? '활성' : '대기',
battery: platform.battery,
uptime: this.formatUptime(platform.uptimeMinutes),
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
: this.formatUptime(platform.uptimeMinutes),
speed: `${Number(platform.speedMeters).toFixed(1)} m/s`,
errors: platform.errorCount,
group: assignedGroup?.name ?? null,
@@ -1146,9 +1444,6 @@ export class InstructorService {
where: { courseId: course.id, enabled: true },
orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }],
});
const environmentProfileByWeekPlatformKey = new Map(
environmentProfiles.map((profile) => [`${profile.week}:${profile.platformKey}`, profile]),
);
const overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
const baseWeeks = course.weeks.map((week) => {
@@ -1188,8 +1483,8 @@ export class InstructorService {
references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)),
exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)),
deliverable: override?.deliverable ?? week.deliverable,
requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission,
},
isTeamProject: override?.isTeamProject ?? false,
modules: week.modules.map((module) => ({
id: module.id,
sortOrder: module.sortOrder,
@@ -1201,16 +1496,15 @@ export class InstructorService {
defaultParameters: this.stringArray(module.defaultParameters) ?? [],
enabled: !disabledModuleIdSet.has(module.id),
})),
environmentProfiles: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => {
const profile = environmentProfileByWeekPlatformKey.get(`${week.week}:${selection.platformKey}`);
return {
id: profile?.id ?? `${week.week}:${selection.platformKey}`,
platformKey: selection.platformKey,
profileType: profile?.profileType ?? selection.platform.type,
name: profile?.name ?? selection.platform.displayName,
config: profile?.config ?? selection.platform.defaultConfig,
};
}),
environmentProfiles: environmentProfiles
.filter((profile) => profile.week === week.week)
.map((profile) => ({
id: profile.id,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
})),
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
key: selection.platform.key,
type: selection.platform.type,
@@ -1249,8 +1543,8 @@ export class InstructorService {
practiceContents: this.practiceContentsArray(override.practiceContents) ?? [],
references: [],
deliverable: override.deliverable ?? '',
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
},
isTeamProject: override.isTeamProject ?? false,
modules: [],
visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({
key: selection.platform.key,
@@ -1421,32 +1715,55 @@ export class InstructorService {
courseId: course.id,
week: input.activeWeek,
enabled: true,
platformKey: { in: requestedSets.map((item) => item.platformKey) },
},
include: { platform: true },
});
const selectionsByPlatformKey = new Map(selections.map((selection) => [selection.platformKey, selection]));
if (requestedSets.some((item) => !selectionsByPlatformKey.has(item.platformKey))) {
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
}
const profiles = await this.prisma.courseEnvironmentProfile.findMany({
where: { courseId: course.id, week: input.activeWeek },
});
const profilesByPlatformKey = new Map(profiles.map((profile) => [profile.platformKey, profile]));
await this.prisma.$transaction(requestedSets.map((item) => {
const selection = selectionsByPlatformKey.get(item.platformKey)!;
const existingProfile = profilesByPlatformKey.get(item.platformKey);
const logicalEnvironment = item.platformKey.toLowerCase();
const isLogicalEnvironment = logicalEnvironment === 'local' || logicalEnvironment === 'platform';
const isPlatformProfile = (profile: { profileType: string; config: Prisma.JsonValue | null }) => (
profile.profileType.toUpperCase() === 'PLATFORM'
|| (profile.config && typeof profile.config === 'object' && !Array.isArray(profile.config)
&& (profile.config as Prisma.JsonObject).target === 'remote-jetson')
);
const wantsPlatform = logicalEnvironment === 'platform';
const selection = isLogicalEnvironment
? selections.find((candidate) => wantsPlatform === isPlatformProfile({
profileType: candidate.platform.type,
config: candidate.platform.defaultConfig,
}))
: selectionsByPlatformKey.get(item.platformKey);
const existingProfile = isLogicalEnvironment
? profiles.find((profile) => wantsPlatform === isPlatformProfile(profile))
: profilesByPlatformKey.get(item.platformKey);
if (!isLogicalEnvironment && !selection && !existingProfile) {
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
}
const resolvedPlatformKey = existingProfile?.platformKey
?? selection?.platformKey
?? (wantsPlatform ? 'futuredrive-platform' : 'futuredrive-local-pc');
const profileType = existingProfile?.profileType
?? selection?.platform.type
?? (wantsPlatform ? 'PLATFORM' : 'LOCAL');
const profileName = existingProfile?.name
?? selection?.platform.displayName
?? (wantsPlatform ? '플랫폼' : '로컬');
const existingConfig = existingProfile?.config && typeof existingProfile.config === 'object' && !Array.isArray(existingProfile.config)
? existingProfile.config as Prisma.JsonObject
: {};
const defaultConfig = selection.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig)
const defaultConfig = selection?.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig)
? selection.platform.defaultConfig as Prisma.JsonObject
: {};
: wantsPlatform ? { target: 'remote-jetson' } : { target: 'local' };
const config = {
...defaultConfig,
...existingConfig,
moduleSet: {
target: defaultConfig.target === 'remote-jetson' ? 'platform' : 'local',
target: wantsPlatform ? 'platform' : 'local',
includeUnlisted: false,
modules: item.modules.map((module) => ({
id: module.id,
@@ -1460,21 +1777,21 @@ export class InstructorService {
courseId_week_platformKey: {
courseId: course.id,
week: input.activeWeek,
platformKey: item.platformKey,
platformKey: resolvedPlatformKey,
},
},
create: {
courseId: course.id,
week: input.activeWeek,
platformKey: item.platformKey,
profileType: selection.platform.type,
name: selection.platform.displayName,
platformKey: resolvedPlatformKey,
profileType,
name: profileName,
config,
enabled: true,
},
update: {
profileType: selection.platform.type,
name: selection.platform.displayName,
profileType,
name: profileName,
config,
enabled: true,
},
@@ -1490,7 +1807,7 @@ export class InstructorService {
selectedTagLabel = tagOption.label;
}
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.requiresSourceSubmission !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
await this.prisma.instructorCurriculumWeekOverride.upsert({
where: {
organizationName_courseId_week: {
@@ -1506,7 +1823,7 @@ export class InstructorService {
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? existingOverride?.deliverable ?? targetWeek?.deliverable ?? '',
requiresSourceSubmission: input.requiresSourceSubmission ?? existingOverride?.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
isTeamProject: input.isTeamProject ?? existingOverride?.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
@@ -1527,7 +1844,7 @@ export class InstructorService {
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? targetWeek?.deliverable ?? '',
requiresSourceSubmission: input.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
isTeamProject: input.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
@@ -3320,6 +3637,10 @@ export class InstructorService {
return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`;
}
private formatUptimeSeconds(seconds: number) {
return this.formatUptime(Math.floor(Math.max(0, seconds) / 60));
}
private toBoundedInt(
value: number | undefined,
fallback: number,
@@ -3415,6 +3736,20 @@ export class InstructorService {
};
}
private async readTeamSubmissionProgress(
owner: string,
repoName: string,
memberEmails: Set<string>,
): Promise<SubmissionProgress> {
const commits = await this.readSubmissionCommits(owner, repoName, (commit) => (
memberEmails.has(commit.authorEmail.trim().toLowerCase()) || commit.message.includes('팀 반영')
));
return {
commits,
finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits),
};
}
private buildStudioRepoName(studentEmail: string, week: number, moduleName: string) {
return `${this.slug(studentEmail.split('@')[0] || studentEmail)}-week-${week}-${this.slug(moduleName)}`;
}
@@ -3427,7 +3762,11 @@ export class InstructorService {
.replace(/^-+|-+$/g, '') || 'workspace';
}
private async readSubmissionCommits(owner: string, repoName: string): Promise<SubmissionCommit[]> {
private async readSubmissionCommits(
owner: string,
repoName: string,
includeCommit: (commit: Omit<SubmissionCommit, 'files'>) => boolean = (commit) => this.isSubmissionCommit(commit),
): Promise<SubmissionCommit[]> {
const commits = await this.fetchGiteaJson<unknown[]>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`,
).catch(() => []);
@@ -3435,7 +3774,7 @@ export class InstructorService {
const submissionCommits = commits
.map((commit) => this.normalizeGiteaCommit(commit))
.filter((commit): commit is Omit<SubmissionCommit, 'files'> => Boolean(commit))
.filter((commit) => this.isSubmissionCommit(commit))
.filter(includeCommit)
.slice(0, 20);
return Promise.all(submissionCommits.map(async (commit) => ({
@@ -3456,7 +3795,13 @@ export class InstructorService {
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText).filter((file) => !this.isIgnoredSubmissionFile(file.path));
const diffFiles = this.parseDiffFiles(diffText)
.filter((file) => !this.isIgnoredSubmissionFile(file.path))
.map((file) => ({
...file,
originalRef: baseHash,
modifiedRef: latest.hash,
}));
if (diffFiles.length) return diffFiles;
}
@@ -3488,6 +3833,10 @@ export class InstructorService {
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]).catch(() => null);
const files = Array.isArray(detail?.files) ? detail.files : [];
const parent = Array.isArray(detail?.parents) ? detail.parents[0] : null;
const parentHash = parent && typeof parent === 'object'
? String((parent as Record<string, unknown>).sha ?? (parent as Record<string, unknown>).id ?? '') || null
: null;
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`,
@@ -3506,6 +3855,8 @@ export class InstructorService {
path,
status: String(item.status ?? diffFile?.status ?? 'modified'),
patch: String(item.patch ?? '') || diffFile?.patch || '',
originalRef: parentHash,
modifiedRef: hash,
};
}).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path));
}
@@ -3570,6 +3921,74 @@ export class InstructorService {
return '';
}
private normalizeGitRef(value: string | undefined): string | null {
const ref = value?.trim() ?? '';
return /^[a-f0-9]{7,64}$/i.test(ref) ? ref : null;
}
private async assertInstructorRepositoryAccess(
organizationId: string | undefined,
organizationName: string | undefined,
repoOwner: string,
repoName: string,
) {
if (!organizationId && !organizationName) {
throw new ForbiddenException('소속 기관 정보를 확인할 수 없습니다.');
}
const organizationFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[];
const groupFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[];
const repositoryFilter = { repoOwner, repoName };
const [studentRepository, studentCustomRepository, teamRepository, teamCustomRepository] = await Promise.all([
this.prisma.studioRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.studentCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.teamStudioRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
this.prisma.teamCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
]);
if (!studentRepository && !studentCustomRepository && !teamRepository && !teamCustomRepository) {
throw new ForbiddenException('제출 저장소에 접근할 수 없습니다.');
}
}
private async readGiteaFileAtRef(owner: string, repoName: string, path: string, ref: string): Promise<string | null> {
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await this.fetchGitea(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/raw/${encodedPath}?ref=${encodeURIComponent(ref)}`,
).catch(() => null);
return response ? response.text() : null;
}
private async fetchGitea(path: string): Promise<Response> {
const giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')