package handlers import ( "fmt" "net/url" "path" "strconv" "strings" "polar/internal/git" "polar/internal/i18n" "polar/internal/repo" "polar/internal/web/middleware" "polar/internal/web/views" "github.com/gofiber/fiber/v2" ) type BreadcrumbItem struct { Name string Path string IsLast bool } func buildBreadcrumbs(subPath string) []BreadcrumbItem { subPath = strings.Trim(subPath, "/") if subPath == "" { return nil } parts := strings.Split(subPath, "/") items := make([]BreadcrumbItem, 0, len(parts)) var acc []string for idx, p := range parts { acc = append(acc, p) items = append(items, BreadcrumbItem{ Name: p, Path: strings.Join(acc, "/"), IsLast: idx == len(parts)-1, }) } return items } func (h *Handler) NewRepoPage(c *fiber.Ctx) error { lang, _ := c.Locals("lang").(i18n.Lang) data := h.baseData(c, i18n.T(lang, "repo.new.title")) return h.render(c, fiber.StatusOK, "repo_new", data) } func (h *Handler) NewRepoSubmit(c *fiber.Ctx) error { u := middleware.CurrentUser(c) lang, _ := c.Locals("lang").(i18n.Lang) name := strings.TrimSpace(c.FormValue("name")) desc := strings.TrimSpace(c.FormValue("description")) isPriv := c.FormValue("visibility") == "private" r, err := h.repoSvc.Create(c.UserContext(), u, name, desc, isPriv) if err != nil { data := h.baseData(c, i18n.T(lang, "repo.new.title")) data["FormName"] = name data["FormDescription"] = desc data["Error"] = err.Error() return h.render(c, fiber.StatusBadRequest, "repo_new", data) } return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) loadAuthorizedRepo(c *fiber.Ctx, requireWrite bool) (*repo.Repository, string, error) { owner := c.Params("owner") repoName := strings.TrimSuffix(c.Params("repo"), ".git") viewer := middleware.CurrentUser(c) if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { _ = c.Redirect("/login", fiber.StatusSeeOther) return nil, "", fiber.ErrUnauthorized } r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName) if err != nil { _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_not_found_desc") return nil, "", err } if requireWrite { if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) { _ = h.renderErrorT(c, fiber.StatusForbidden, "err.access_denied", "err.repo_write_denied") return nil, "", fiber.ErrForbidden } } else { if !h.repoSvc.CanRead(c.UserContext(), r, viewer) { _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_private_desc") return nil, "", fiber.ErrNotFound } } diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name) if err != nil { _ = h.renderError(c, fiber.StatusBadRequest, i18n.T(h.langOf(c), "err.invalid_repo_path"), err.Error()) return nil, "", err } return r, diskPath, nil } func (h *Handler) RepoHomePage(c *fiber.Ctx) error { return h.renderRepoTree(c, "", "") } func (h *Handler) RepoTreePage(c *fiber.Ctx) error { ref, _ := url.PathUnescape(c.Params("ref")) subPath, _ := url.PathUnescape(c.Params("*")) return h.renderRepoTree(c, ref, subPath) } func (h *Handler) renderRepoTree(c *fiber.Ctx, ref, subPath string) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } viewer := middleware.CurrentUser(c) headBranch, isEmpty := git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) if ref == "" { ref = headBranch } cloneURL := h.cloneURL(c, r.OwnerUsername, r.Name) data := h.baseData(c, r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = ref data["SubPath"] = strings.Trim(subPath, "/") data["ActiveTab"] = "code" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = cloneURL data["IsEmpty"] = isEmpty if isEmpty { data["QuickStartNew"] = fmt.Sprintf( "echo \"# %s\" >> README.md\ngit init -b %s\ngit add README.md\ngit commit -m \"Initial commit\"\ngit remote add origin %s\ngit push -u origin %s", r.Name, r.DefaultBranch, cloneURL, r.DefaultBranch, ) data["QuickStartExisting"] = fmt.Sprintf( "git remote add origin %s\ngit branch -M %s\ngit push -u origin %s", cloneURL, r.DefaultBranch, r.DefaultBranch, ) return h.render(c, fiber.StatusOK, "repo", data) } branches, _ := git.ListBranches(c.UserContext(), diskPath) tags, _ := git.ListTags(c.UserContext(), diskPath) entries, err := git.ListTree(c.UserContext(), diskPath, ref, subPath) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.path_not_found", "err.path_not_found_desc", ref) } latestCommit, _ := git.GetLatestCommit(c.UserContext(), diskPath, ref, subPath) allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref) data["Branches"] = branches data["Tags"] = tags data["Entries"] = entries data["LatestCommit"] = latestCommit data["PaletteFiles"] = allFiles data["Breadcrumbs"] = buildBreadcrumbs(subPath) if strings.Trim(subPath, "/") != "" { data["ParentPath"] = path.Dir(strings.Trim(subPath, "/")) } if readmeBlob, err := git.FindReadme(c.UserContext(), diskPath, ref, subPath); err == nil && readmeBlob != nil { data["ReadmeName"] = readmeBlob.Name data["ReadmeHTML"] = views.RenderMarkdown(readmeBlob.Data) } return h.render(c, fiber.StatusOK, "repo", data) } func (h *Handler) RepoBlobPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } viewer := middleware.CurrentUser(c) ref, _ := url.PathUnescape(c.Params("ref")) subPath, _ := url.PathUnescape(c.Params("*")) blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.file_not_found", "err.file_not_found_desc", ref) } branches, _ := git.ListBranches(c.UserContext(), diskPath) tags, _ := git.ListTags(c.UserContext(), diskPath) allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref) data := h.baseData(c, blob.Name+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = ref data["SubPath"] = blob.Path data["ActiveTab"] = "code" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Branches"] = branches data["Tags"] = tags data["Blob"] = blob data["Breadcrumbs"] = buildBreadcrumbs(blob.Path) data["PaletteFiles"] = allFiles if !blob.IsBinary { rawText := string(blob.Data) data["RawText"] = rawText data["HighlightedLines"] = views.HighlightCode(blob.Name, rawText) } return h.render(c, fiber.StatusOK, "blob", data) } func (h *Handler) RepoRawBlob(c *fiber.Ctx) error { _, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } ref, _ := url.PathUnescape(c.Params("ref")) subPath, _ := url.PathUnescape(c.Params("*")) blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath) if err != nil { return c.Status(fiber.StatusNotFound).SendString("File not found") } if blob.IsBinary { c.Set(fiber.HeaderContentType, "application/octet-stream") } else { c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8") } return c.Send(blob.Data) } func (h *Handler) RepoCommitsPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } viewer := middleware.CurrentUser(c) ref, _ := url.PathUnescape(c.Params("ref")) if ref == "" { ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) } page, _ := strconv.Atoi(c.Query("page", "1")) if page < 1 { page = 1 } const perPage = 30 offset := (page - 1) * perPage commits, _ := git.ListCommits(c.UserContext(), diskPath, ref, perPage+1, offset) hasMore := false if len(commits) > perPage { hasMore = true commits = commits[:perPage] } branches, _ := git.ListBranches(c.UserContext(), diskPath) data := h.baseData(c, h.tr(c, "repo.tab.commits")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = ref data["ActiveTab"] = "commits" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Branches"] = branches data["Commits"] = commits data["Page"] = page data["HasMore"] = hasMore return h.render(c, fiber.StatusOK, "commits", data) } func (h *Handler) RepoCommitDetailPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } viewer := middleware.CurrentUser(c) hash := c.Params("hash") detail, err := git.GetCommitDiff(c.UserContext(), diskPath, hash) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.commit_not_found", "err.commit_not_found_desc", hash) } data := h.baseData(c, detail.Commit.ShortHash+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "commits" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Detail"] = detail return h.render(c, fiber.StatusOK, "commit", data) } func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } branches, _ := git.ListBranches(c.UserContext(), diskPath) data := h.baseData(c, h.tr(c, "repo.tab.settings")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "settings" data["CanWrite"] = true data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Branches"] = branches viewer := middleware.CurrentUser(c) collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID) data["Collaborators"] = collaborators data["CanManageCollab"] = viewer != nil && (viewer.ID == r.OwnerID || viewer.IsAdmin) return h.render(c, fiber.StatusOK, "repo_settings", data) } func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } desc := c.FormValue("description") defaultBranch := c.FormValue("default_branch") isPriv := c.FormValue("visibility") == "private" enableIssues := c.FormValue("enable_issues") == "true" || c.FormValue("enable_issues") == "on" || c.FormValue("enable_issues") == "1" enablePulls := c.FormValue("enable_pulls") == "true" || c.FormValue("enable_pulls") == "on" || c.FormValue("enable_pulls") == "1" enableReleases := c.FormValue("enable_releases") == "true" || c.FormValue("enable_releases") == "on" || c.FormValue("enable_releases") == "1" enableActivity := c.FormValue("enable_activity") == "true" || c.FormValue("enable_activity") == "on" || c.FormValue("enable_activity") == "1" if err := h.repoSvc.Update(c.UserContext(), r, desc, isPriv, defaultBranch, enableIssues, enablePulls, enableReleases, enableActivity); err != nil { h.setFlashT(c, "flash.error", err.Error()) return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } h.setFlashT(c, "flash.repo_settings_saved") return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoRenameSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } newName := strings.TrimSpace(c.FormValue("new_name")) if err := h.repoSvc.Rename(c.UserContext(), r, newName); err != nil { h.setFlashT(c, "flash.repo_rename_failed", err.Error()) return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } h.setFlashT(c, "flash.repo_renamed", r.Name) return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoDeleteSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } confirmName := strings.TrimSpace(c.FormValue("confirm_name")) if !strings.EqualFold(confirmName, r.Name) { h.setFlashT(c, "flash.repo_confirm_mismatch") return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } if err := h.repoSvc.Delete(c.UserContext(), r); err != nil { h.logger.Error("delete repo failed", "err", err) return h.renderErrorT(c, fiber.StatusInternalServerError, "err.delete_error", "err.repo_delete_failed") } h.setFlashT(c, "flash.repo_deleted", r.Name) return c.Redirect("/repos", fiber.StatusSeeOther) } func (h *Handler) UserProfilePage(c *fiber.Ctx) error { username := c.Params("username") profileUser, err := h.userSvc.GetByUsername(c.UserContext(), username) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.user_not_found", username) } viewer := middleware.CurrentUser(c) if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { return c.Redirect("/login", fiber.StatusSeeOther) } includePrivate := viewer != nil && (viewer.ID == profileUser.ID || viewer.IsAdmin) repos, _ := h.repoSvc.ListByOwner(c.UserContext(), profileUser.ID, includePrivate) publicCount := 0 privateCount := 0 for _, rp := range repos { if rp.IsPrivate { privateCount++ } else { publicCount++ } } lang, _ := c.Locals("lang").(i18n.Lang) title := "@" + profileUser.Username if profileUser.FullName != "" { title = profileUser.FullName + " (@" + profileUser.Username + ")" } data := h.baseData(c, title) data["ProfileUser"] = profileUser data["PageHeading"] = i18n.T(lang, "nav.repositories") data["Repos"] = repos data["PublicRepoCount"] = publicCount data["PrivateRepoCount"] = privateCount data["ShowPrivateCount"] = includePrivate return h.render(c, fiber.StatusOK, "profile", data) } func (h *Handler) RepoBranchesPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } viewer := middleware.CurrentUser(c) branches, _ := git.ListBranchDetails(c.UserContext(), diskPath, r.DefaultBranch) tags, _ := git.ListTagDetails(c.UserContext(), diskPath) data := h.baseData(c, h.tr(c, "repo.tab.branches")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "branches" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["BranchDetails"] = branches data["TagDetails"] = tags data["IsEmpty"] = len(branches) == 0 return h.render(c, fiber.StatusOK, "branches", data) } func (h *Handler) RepoCreateBranchSubmit(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } branchName := strings.TrimSpace(c.FormValue("branch_name")) fromRef := strings.TrimSpace(c.FormValue("from_ref")) if fromRef == "" { fromRef = r.DefaultBranch } if err := git.CreateBranch(c.UserContext(), diskPath, branchName, fromRef); err != nil { h.setFlashT(c, "flash.branch_create_failed", err.Error()) } else { h.setFlashT(c, "flash.branch_created", branchName) } return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoDeleteBranchSubmit(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } branchName := strings.TrimSpace(c.FormValue("branch_name")) if strings.EqualFold(branchName, r.DefaultBranch) { h.setFlashT(c, "flash.branch_default_delete") return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } if err := git.DeleteBranch(c.UserContext(), diskPath, branchName); err != nil { h.setFlashT(c, "flash.branch_delete_failed", err.Error()) } else { h.setFlashT(c, "flash.branch_deleted", branchName) } return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoCreateTagSubmit(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } tagName := strings.TrimSpace(c.FormValue("tag_name")) fromRef := strings.TrimSpace(c.FormValue("from_ref")) if fromRef == "" { fromRef = r.DefaultBranch } message := strings.TrimSpace(c.FormValue("message")) if err := git.CreateTag(c.UserContext(), diskPath, tagName, fromRef, message); err != nil { h.setFlashT(c, "flash.tag_create_failed", err.Error()) } else { h.setFlashT(c, "flash.tag_created", tagName) } return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoDeleteTagSubmit(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } tagName := strings.TrimSpace(c.FormValue("tag_name")) if err := git.DeleteTag(c.UserContext(), diskPath, tagName); err != nil { h.setFlashT(c, "flash.tag_delete_failed", err.Error()) } else { h.setFlashT(c, "flash.tag_deleted", tagName) } return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } func (h *Handler) RepoArchiveZip(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } filename := c.Params("*") isTarGz := strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") ref := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filename, ".tar.gz"), ".tgz"), ".zip") ref, _ = url.PathUnescape(ref) if ref == "" { ref = r.DefaultBranch } prefix := r.Name + "-" + strings.ReplaceAll(ref, "/", "-") if isTarGz { tarBytes, err := git.ArchiveTarGz(c.UserContext(), diskPath, ref, prefix) if err != nil { h.logger.Error("archive tar.gz failed", "ref", ref, "err", err) return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_tar_failed", ref) } c.Set(fiber.HeaderContentType, "application/gzip") c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.tar.gz"`, prefix)) return c.Send(tarBytes) } zipBytes, err := git.ArchiveZip(c.UserContext(), diskPath, ref, prefix) if err != nil { h.logger.Error("archive zip failed", "ref", ref, "err", err) return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_zip_failed", ref) } c.Set(fiber.HeaderContentType, "application/zip") c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.zip"`, prefix)) return c.Send(zipBytes) } func (h *Handler) RepoIssuesPage(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } state := c.Query("state", "open") isClosed := state == "closed" issues, _ := h.repoSvc.ListIssues(c.UserContext(), r.ID, isClosed) openCount, closedCount, _ := h.repoSvc.CountIssues(c.UserContext(), r.ID) viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "repo.tab.issues")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "issues" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Issues"] = issues data["OpenCount"] = openCount data["ClosedCount"] = closedCount data["ShowClosed"] = isClosed return h.render(c, fiber.StatusOK, "issues", data) } func (h *Handler) RepoNewIssuePage(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "issue.new")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "issues" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) return h.render(c, fiber.StatusOK, "issue_new", data) } func (h *Handler) RepoNewIssueSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } u := middleware.CurrentUser(c) title := c.FormValue("title") body := c.FormValue("body") iss, err := h.repoSvc.CreateIssue(c.UserContext(), r.ID, u.ID, title, body) if err != nil { h.setFlashT(c, "flash.error", err.Error()) return c.Redirect(fmt.Sprintf("/%s/%s/issues/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) } func (h *Handler) RepoIssueDetailPage(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } num, _ := strconv.Atoi(c.Params("number")) iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") } comments, _ := h.repoSvc.ListIssueComments(c.UserContext(), iss.ID) viewer := middleware.CurrentUser(c) canToggle := viewer != nil && (viewer.ID == iss.AuthorID || h.repoSvc.CanWrite(c.UserContext(), r, viewer)) data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", iss.Number, iss.Title, r.OwnerUsername, r.Name)) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "issues" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Issue"] = iss data["Comments"] = comments data["CanToggleIssue"] = canToggle return h.render(c, fiber.StatusOK, "issue_detail", data) } func (h *Handler) RepoIssueCommentSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } num, _ := strconv.Atoi(c.Params("number")) iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") } u := middleware.CurrentUser(c) body := c.FormValue("body") if err := h.repoSvc.CreateIssueComment(c.UserContext(), iss.ID, u.ID, body); err != nil { h.setFlashT(c, "flash.comment_error", err.Error()) } return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) } func (h *Handler) RepoIssueStateSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableIssues { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") } num, _ := strconv.Atoi(c.Params("number")) iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") } viewer := middleware.CurrentUser(c) if viewer == nil || (viewer.ID != iss.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) { return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.issue_state_forbidden") } targetState := c.FormValue("state") isClosed := targetState == "closed" _ = h.repoSvc.SetIssueClosed(c.UserContext(), iss.ID, isClosed) if isClosed { h.setFlashT(c, "flash.issue_closed", iss.Number) } else { h.setFlashT(c, "flash.issue_reopened", iss.Number) } return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) }