From 840ddcdce97e5f7e71515ef8183ae3b43d7be3ea Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Tue, 30 Jun 2026 15:20:26 +0700 Subject: [PATCH] add github --- management/src/api.ts | 14 +- .../handlers/handlers_exam_submissions.go | 19 +- .../internal/handlers/handlers_github_git.go | 45 +++- .../handlers/handlers_github_oauth.go | 210 +++++++++++++++--- 4 files changed, 234 insertions(+), 54 deletions(-) diff --git a/management/src/api.ts b/management/src/api.ts index 723c7ab..6de93cc 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -838,12 +838,22 @@ export const apiExam = { }, publishSubmissionsGit: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' }); - if (!res.ok) await parseError(res, 'Đẩy lên Git thất bại'); + if (!res.ok) { + const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] })); + const msg = errData.error || 'Đẩy lên Git thất bại'; + if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`); + throw new Error(msg); + } return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>; }, publishSubmissionsGitStudents: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' }); - if (!res.ok) await parseError(res, 'Đẩy repo từng SV thất bại'); + if (!res.ok) { + const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] })); + const msg = errData.error || 'Đẩy repo từng SV thất bại'; + if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`); + throw new Error(msg); + } return res.json() as Promise<{ ok: boolean; published: number; diff --git a/server/internal/handlers/handlers_exam_submissions.go b/server/internal/handlers/handlers_exam_submissions.go index ff9255b..332c6f5 100644 --- a/server/internal/handlers/handlers_exam_submissions.go +++ b/server/internal/handlers/handlers_exam_submissions.go @@ -440,7 +440,7 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { return c.Status(500).JSON(fiber.Map{"error": err.Error(), "warnings": unpackWarn}) } - owner, repo, branch, repoHTML, err := resolvePublishRepo(token, ghLogin, &room) + _, repo, branch, repoHTML, err := resolvePublishRepo(db, staffID, token, ghLogin, &room) if err != nil { return c.Status(502).JSON(fiber.Map{"error": err.Error()}) } @@ -448,9 +448,10 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { room.GitRepoURL = repoHTML room.GitBranch = branch } + pushOwner, pushRepo := resolveGitPushTarget(token, ghLogin, repo, repoHTML) commitMsg := fmt.Sprintf("Simple Care: bài nộp phòng thi %s (#%d)", room.Name, roomID) - viewURL, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, commitMsg) + viewURL, pushWarn, err := githubPushFiles(token, pushOwner, pushRepo, branch, files, commitMsg) allWarn := append(unpackWarn, pushWarn...) if err != nil { return c.Status(502).JSON(fiber.Map{ @@ -462,7 +463,7 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { room.GitPublishURL = viewURL _ = db.Save(&room).Error roomIDCopy := roomID - recordStaffGitHubRepo(db, staffID, owner, repo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil) + recordStaffGitHubRepo(db, staffID, pushOwner, pushRepo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil) msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows)) if len(allWarn) > 0 { @@ -517,7 +518,6 @@ func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler { return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"}) } - branch := "main" published := 0 var allWarn []string var repoURLs []string @@ -536,14 +536,19 @@ func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler { allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName))) continue } - repoName, repoHTML, err := githubEnsureRepoForStudent(token, ghLogin, &room, &row) + repoName, repoHTML, err := githubEnsureRepoForStudent(db, staffID, token, ghLogin, &room, &row) if err != nil { allWarn = append(allWarn, row.StudentCode+": "+err.Error()) continue } + pushOwner, pushRepo := resolveGitPushTarget(token, ghLogin, repoName, repoHTML) + branch := "main" + if meta, ok := githubFetchRepo(token, pushOwner, pushRepo); ok && meta.DefaultBranch != "" { + branch = meta.DefaultBranch + } label := studentSubmissionFolder(row.StudentCode, row.FullName) commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name) - viewURL, pushWarn, err := githubPushFiles(token, ghLogin, repoName, branch, files, commitMsg) + viewURL, pushWarn, err := githubPushFiles(token, pushOwner, pushRepo, branch, files, commitMsg) allWarn = append(allWarn, pushWarn...) if err != nil { allWarn = append(allWarn, label+": "+err.Error()) @@ -553,7 +558,7 @@ func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler { Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error subID := row.ID roomIDCopy := roomID - recordStaffGitHubRepo(db, staffID, ghLogin, repoName, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID) + recordStaffGitHubRepo(db, staffID, pushOwner, pushRepo, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID) published++ repoURLs = append(repoURLs, viewURL) studentRepos = append(studentRepos, studentRepo{ diff --git a/server/internal/handlers/handlers_github_git.go b/server/internal/handlers/handlers_github_git.go index bfbe461..6ff6ce9 100644 --- a/server/internal/handlers/handlers_github_git.go +++ b/server/internal/handlers/handlers_github_git.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -65,8 +66,27 @@ type gitPathFile struct { content []byte } +func githubRepoAPI(owner, repo, suffix string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/%s%s", + url.PathEscape(strings.TrimSpace(owner)), + url.PathEscape(strings.TrimSpace(repo)), + suffix, + ) +} + +func resolveGitPushTarget(token, fallbackOwner, repoName, repoHTML string) (owner, repo string) { + if o, r, err := parseGitHubRepo(repoHTML); err == nil && o != "" && r != "" { + return o, r + } + owner = strings.TrimSpace(fallbackOwner) + if live, err := githubGetUser(token); err == nil && live != "" { + owner = live + } + return owner, trimGitHubRepoName(repoName) +} + func githubCreateBlob(token, owner, repo string, content []byte) (string, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/blobs", owner, repo) + apiURL := githubRepoAPI(owner, repo, "/git/blobs") body := map[string]string{ "content": base64.StdEncoding.EncodeToString(content), "encoding": "base64", @@ -84,7 +104,7 @@ func githubCreateBlob(token, owner, repo string, content []byte) (string, error) } func githubCreateTree(token, owner, repo, baseTreeSHA string, entries []map[string]string) (string, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/trees", owner, repo) + apiURL := githubRepoAPI(owner, repo, "/git/trees") body := map[string]any{"tree": entries} if baseTreeSHA != "" { body["base_tree"] = baseTreeSHA @@ -102,7 +122,7 @@ func githubCreateTree(token, owner, repo, baseTreeSHA string, entries []map[stri } func githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message string) (string, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits", owner, repo) + apiURL := githubRepoAPI(owner, repo, "/git/commits") body := map[string]any{ "message": message, "tree": treeSHA, @@ -123,22 +143,30 @@ func githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message string) } func githubUpdateBranchRef(token, owner, repo, branch, commitSHA string) error { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs/heads/%s", owner, repo, branch) + apiURL := githubRepoAPI(owner, repo, "/git/refs/heads/"+url.PathEscape(branch)) body := map[string]any{"sha": commitSHA, "force": false} err := githubJSON(token, http.MethodPatch, apiURL, body, nil) if err == nil { return nil } - createURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs", owner, repo) + // Branch chưa tồn tại — tạo mới + createURL := githubRepoAPI(owner, repo, "/git/refs") createBody := map[string]string{ "ref": "refs/heads/" + branch, "sha": commitSHA, } - return githubJSON(token, http.MethodPost, createURL, createBody, nil) + if createErr := githubJSON(token, http.MethodPost, createURL, createBody, nil); createErr == nil { + return nil + } else if !strings.Contains(strings.ToLower(createErr.Error()), "already exists") { + return createErr + } + // Ref đã tồn tại nhưng PATCH thất bại (non-fast-forward) — cập nhật force + forceBody := map[string]any{"sha": commitSHA, "force": true} + return githubJSON(token, http.MethodPatch, apiURL, forceBody, nil) } func githubBranchTip(token, owner, repo, branch string) (commitSHA, treeSHA string, err error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/ref/heads/%s", owner, repo, branch) + apiURL := githubRepoAPI(owner, repo, "/git/ref/heads/"+url.PathEscape(branch)) var refResp struct { Object struct { SHA string `json:"sha"` @@ -151,7 +179,7 @@ func githubBranchTip(token, owner, repo, branch string) (commitSHA, treeSHA stri if commitSHA == "" { return "", "", errors.New("không lấy được commit hiện tại") } - commitURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits/%s", owner, repo, commitSHA) + commitURL := githubRepoAPI(owner, repo, "/git/commits/"+url.PathEscape(commitSHA)) var commitResp struct { Tree struct { SHA string `json:"sha"` @@ -178,6 +206,7 @@ func githubJSON(token, method, apiURL string, body any, out any) error { } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/server/internal/handlers/handlers_github_oauth.go b/server/internal/handlers/handlers_github_oauth.go index d2894cc..1491ca0 100644 --- a/server/internal/handlers/handlers_github_oauth.go +++ b/server/internal/handlers/handlers_github_oauth.go @@ -247,29 +247,87 @@ func githubGetUser(token string) (login string, err error) { } func githubGetRepo(token, owner, repoName string) (htmlURL string, found bool) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repoName) + meta, ok := githubFetchRepo(token, owner, repoName) + if !ok { + return "", false + } + return meta.HTMLURL, true +} + +func githubFetchRepo(token, owner, repoName string) (meta struct { + HTMLURL string + DefaultBranch string +}, ok bool) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repoName)) req, _ := http.NewRequest(http.MethodGet, apiURL, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := http.DefaultClient.Do(req) if err != nil { - return "", false + return meta, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", false + return meta, false } var repo struct { - HTMLURL string `json:"html_url"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` } _ = json.NewDecoder(resp.Body).Decode(&repo) if repo.HTMLURL == "" { - return "", false + return meta, false } - return repo.HTMLURL, true + meta.HTMLURL = repo.HTMLURL + meta.DefaultBranch = strings.TrimSpace(repo.DefaultBranch) + if meta.DefaultBranch == "" { + meta.DefaultBranch = "main" + } + return meta, true } -func githubCreateRepo(token, owner, repoName string, private bool) (htmlURL string, err error) { +func clearStaleExamRoomGitURLs(db *gorm.DB, room *models.ExamRoom) { + room.GitRepoURL = "" + room.GitPublishURL = "" + _ = db.Model(room).Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error +} + +func clearStaleSubmissionGitURLs(db *gorm.DB, row *submissionRow) { + row.GitRepoURL = "" + row.GitPublishURL = "" + _ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID). + Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error +} + +func githubRepoTrackedForRoom(db *gorm.DB, staffID uint, owner, repoName string, roomID uint) bool { + var count int64 + _ = db.Model(&models.StaffGitHubRepo{}). + Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND exam_room_id = ? AND publish_mode = ?", + staffID, owner, repoName, roomID, models.GitPublishModeRoom). + Count(&count).Error + return count > 0 +} + +func githubRepoTrackedForSubmission(db *gorm.DB, staffID uint, owner, repoName string, submissionID uint) bool { + var count int64 + _ = db.Model(&models.StaffGitHubRepo{}). + Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND submission_id = ? AND publish_mode = ?", + staffID, owner, repoName, submissionID, models.GitPublishModeStudent). + Count(&count).Error + return count > 0 +} + +func repoNameMatchesSavedURL(savedURL, owner, repoName string) bool { + if strings.TrimSpace(savedURL) == "" { + return false + } + o, r, err := parseGitHubRepo(savedURL) + return err == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, repoName) +} + +func githubCreateRepo(token, owner, repoName string, private bool) (actualName, htmlURL string, err error) { + repoName = trimGitHubRepoName(repoName) body := map[string]any{ "name": repoName, "private": private, @@ -279,31 +337,46 @@ func githubCreateRepo(token, owner, repoName string, private bool) (htmlURL stri payload, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, "https://api.github.com/user/repos", bytes.NewReader(payload)) if err != nil { - return "", err + return "", "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - return "", err + return "", "", err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { - return "", fmt.Errorf("tạo repo thất bại: %s", strings.TrimSpace(string(raw))) + return "", "", fmt.Errorf("tạo repo thất bại: %s", strings.TrimSpace(string(raw))) } var created struct { HTMLURL string `json:"html_url"` Name string `json:"name"` + FullName string `json:"full_name"` } if err := json.Unmarshal(raw, &created); err != nil { - return "", err + return "", "", err } if created.HTMLURL == "" { - return "", errors.New("GitHub không trả URL repo") + return "", "", errors.New("GitHub không trả URL repo") } - return created.HTMLURL, nil + actualName = strings.TrimSpace(created.Name) + if actualName == "" { + if _, r, perr := parseGitHubRepo(created.HTMLURL); perr == nil { + actualName = r + } else { + actualName = repoName + } + } + return actualName, created.HTMLURL, nil +} + +func githubOwnerRepoFromURL(htmlURL string) (owner, repo string, ok bool) { + o, r, err := parseGitHubRepo(htmlURL) + return o, r, err == nil } func isGitHubRepoNameTaken(err error) bool { @@ -316,6 +389,20 @@ func isGitHubRepoNameTaken(err error) bool { func trimGitHubRepoName(name string) string { name = strings.ToLower(strings.TrimSpace(name)) + name = strings.NewReplacer(" ", "-", "_", "-").Replace(name) + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.': + b.WriteRune(r) + case r == '_': + b.WriteRune('-') + } + } + name = b.String() + for strings.Contains(name, "--") { + name = strings.ReplaceAll(name, "--", "-") + } name = strings.Trim(name, "-.") if len(name) > 90 { name = strings.Trim(name[:90], "-.") @@ -365,36 +452,58 @@ func examGitRepoNameCandidates(room *models.ExamRoom) []string { } // githubEnsureRepoForRoom — dùng repo đã gắn phòng thi, hoặc tạo mới; trùng tên thì tự đổi tên. -func githubEnsureRepoForRoom(token, owner string, room *models.ExamRoom) (repoName, htmlURL string, err error) { +func githubEnsureRepoForRoom(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom) (repoName, htmlURL string, err error) { if u := strings.TrimSpace(room.GitRepoURL); u != "" { o, r, parseErr := parseGitHubRepo(u) if parseErr == nil && strings.EqualFold(o, owner) { - if url, ok := githubGetRepo(token, owner, r); ok { - return r, url, nil + if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok { + if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { + return name, url, nil + } + return trimGitHubRepoName(r), url, nil + } + clearStaleExamRoomGitURLs(db, room) + } + } + + var tracked models.StaffGitHubRepo + if err := db.Where("staff_id = ? AND exam_room_id = ? AND publish_mode = ?", staffID, room.ID, models.GitPublishModeRoom). + Order("created_at desc").First(&tracked).Error; err == nil { + if strings.EqualFold(tracked.OwnerLogin, owner) { + trackedName := trimGitHubRepoName(tracked.RepoName) + if url, ok := githubGetRepo(token, owner, trackedName); ok { + if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { + return name, url, nil + } + return trackedName, url, nil } } } for _, candidate := range examGitRepoNameCandidates(room) { if url, ok := githubGetRepo(token, owner, candidate); ok { - // Repo đã tồn tại — chỉ dùng lại nếu đúng phòng thi này đã lưu URL - if u := strings.TrimSpace(room.GitRepoURL); u != "" { - if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) { - return candidate, url, nil - } + if repoNameMatchesSavedURL(room.GitRepoURL, owner, candidate) || + githubRepoTrackedForRoom(db, staffID, owner, candidate, room.ID) { + return candidate, url, nil } continue } - url, createErr := githubCreateRepo(token, owner, candidate, true) + actualName, url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { - return candidate, url, nil + return actualName, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } - return "", "", errors.New("không tạo được repo GitHub — đã thử nhiều tên khác nhau") + + fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", examGitRepoBaseName(room), room.ID, time.Now().Unix())) + actualName, url, createErr := githubCreateRepo(token, owner, fallback, true) + if createErr != nil { + return "", "", fmt.Errorf("không tạo được repo GitHub — đã thử nhiều tên khác nhau: %w", createErr) + } + return actualName, url, nil } func examGitRepoName(room *models.ExamRoom) string { @@ -406,13 +515,16 @@ func examGitRepoName(room *models.ExamRoom) string { return examGitRepoBaseName(room) } -func resolvePublishRepo(token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) { - branch = "main" +func resolvePublishRepo(db *gorm.DB, staffID uint, token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) { owner = ghLogin - repo, htmlURL, err := githubEnsureRepoForRoom(token, owner, room) + repo, htmlURL, err := githubEnsureRepoForRoom(db, staffID, token, owner, room) if err != nil { return "", "", "", "", err } + branch = "main" + if meta, ok := githubFetchRepo(token, owner, repo); ok && meta.DefaultBranch != "" { + branch = meta.DefaultBranch + } return owner, repo, branch, htmlURL, nil } @@ -444,32 +556,56 @@ func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []st return out } -func githubEnsureRepoForStudent(token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) { +func githubEnsureRepoForStudent(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) { if u := strings.TrimSpace(row.GitRepoURL); u != "" { o, r, parseErr := parseGitHubRepo(u) if parseErr == nil && strings.EqualFold(o, owner) { - if url, ok := githubGetRepo(token, owner, r); ok { - return r, url, nil + if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok { + if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { + return name, url, nil + } + return trimGitHubRepoName(r), url, nil + } + clearStaleSubmissionGitURLs(db, row) + } + } + + var tracked models.StaffGitHubRepo + if err := db.Where("staff_id = ? AND submission_id = ? AND publish_mode = ?", staffID, row.ID, models.GitPublishModeStudent). + Order("created_at desc").First(&tracked).Error; err == nil { + if strings.EqualFold(tracked.OwnerLogin, owner) { + trackedName := trimGitHubRepoName(tracked.RepoName) + if url, ok := githubGetRepo(token, owner, trackedName); ok { + if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { + return name, url, nil + } + return trackedName, url, nil } } } + for _, candidate := range studentGitRepoNameCandidates(*row, room) { if url, ok := githubGetRepo(token, owner, candidate); ok { - if u := strings.TrimSpace(row.GitRepoURL); u != "" { - if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) { - return candidate, url, nil - } + if repoNameMatchesSavedURL(row.GitRepoURL, owner, candidate) || + githubRepoTrackedForSubmission(db, staffID, owner, candidate, row.ID) { + return candidate, url, nil } continue } - url, createErr := githubCreateRepo(token, owner, candidate, true) + actualName, url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { - return candidate, url, nil + return actualName, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } - return "", "", errors.New("không tạo được repo GitHub cho sinh viên") + + fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", studentSubmissionFolder(row.StudentCode, row.FullName), row.StudentRkID, time.Now().Unix())) + actualName, url, createErr := githubCreateRepo(token, owner, fallback, true) + if createErr != nil { + return "", "", fmt.Errorf("không tạo được repo GitHub cho sinh viên: %w", createErr) + } + return actualName, url, nil }