mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
650 lines
18 KiB
Go
650 lines
18 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/git/models/db"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
access_model "github.com/hanzoai/git/models/perm/access"
|
|
project_model "github.com/hanzoai/git/models/project"
|
|
"github.com/hanzoai/git/models/renderhelper"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
"github.com/hanzoai/git/models/unit"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/htmlutil"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/markup/markdown"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
api "github.com/hanzoai/git/modules/structs"
|
|
"github.com/hanzoai/git/modules/templates"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/modules/web"
|
|
"github.com/hanzoai/git/routers/common"
|
|
"github.com/hanzoai/git/services/context"
|
|
"github.com/hanzoai/git/services/convert"
|
|
"github.com/hanzoai/git/services/forms"
|
|
issue_service "github.com/hanzoai/git/services/issue"
|
|
)
|
|
|
|
const (
|
|
tplAttachment templates.TplName = "repo/issue/view_content/attachments"
|
|
|
|
tplIssues templates.TplName = "repo/issue/list"
|
|
tplIssueNew templates.TplName = "repo/issue/new"
|
|
tplIssueChoose templates.TplName = "repo/issue/choose"
|
|
tplIssueView templates.TplName = "repo/issue/view"
|
|
|
|
tplPullMergeBox templates.TplName = "repo/issue/view_content/pull_merge_box"
|
|
tplReactions templates.TplName = "repo/issue/view_content/reactions"
|
|
|
|
issueTemplateKey = "IssueTemplate"
|
|
issueTemplateTitleKey = "IssueTemplateTitle"
|
|
)
|
|
|
|
// IssueTemplateCandidates issue templates
|
|
var IssueTemplateCandidates = []string{
|
|
"ISSUE_TEMPLATE.md",
|
|
"ISSUE_TEMPLATE.yaml",
|
|
"ISSUE_TEMPLATE.yml",
|
|
"issue_template.md",
|
|
"issue_template.yaml",
|
|
"issue_template.yml",
|
|
".hanzo/ISSUE_TEMPLATE.md",
|
|
".hanzo/ISSUE_TEMPLATE.yaml",
|
|
".hanzo/ISSUE_TEMPLATE.yml",
|
|
".hanzo/issue_template.md",
|
|
".hanzo/issue_template.yaml",
|
|
".hanzo/issue_template.yml",
|
|
".github/ISSUE_TEMPLATE.md",
|
|
".github/ISSUE_TEMPLATE.yaml",
|
|
".github/ISSUE_TEMPLATE.yml",
|
|
".github/issue_template.md",
|
|
".github/issue_template.yaml",
|
|
".github/issue_template.yml",
|
|
}
|
|
|
|
// MustAllowUserComment checks to make sure if an issue is locked.
|
|
// If locked and user has permissions to write to the repository,
|
|
// then the comment is allowed, else it is blocked
|
|
func MustAllowUserComment(ctx *context.Context) {
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if issue.IsLocked && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
|
|
ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked"))
|
|
ctx.Redirect(issue.Link())
|
|
return
|
|
}
|
|
}
|
|
|
|
// MustEnableIssues check if repository enable internal issues
|
|
func MustEnableIssues(ctx *context.Context) {
|
|
if !ctx.Repo.Permission.CanRead(unit.TypeIssues) &&
|
|
!ctx.Repo.Permission.CanRead(unit.TypeExternalTracker) {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
|
|
unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
|
|
if err == nil {
|
|
ctx.Redirect(unit.ExternalTrackerConfig().ExternalTrackerURL)
|
|
return
|
|
}
|
|
}
|
|
|
|
// MustAllowPulls check if repository enable pull requests and user have right to do that
|
|
func MustAllowPulls(ctx *context.Context) {
|
|
if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
func retrieveProjectsInternal(ctx *context.Context, repo *repo_model.Repository) (open, closed []*project_model.Project) {
|
|
// Distinguish whether the owner of the repository
|
|
// is an individual or an organization
|
|
repoOwnerType := project_model.TypeIndividual
|
|
if repo.Owner.IsOrganization() {
|
|
repoOwnerType = project_model.TypeOrganization
|
|
}
|
|
|
|
projectsUnit := repo.MustGetUnit(ctx, unit.TypeProjects)
|
|
|
|
var openProjects []*project_model.Project
|
|
var closedProjects []*project_model.Project
|
|
var err error
|
|
|
|
if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
|
|
openProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: db.ListOptionsAll,
|
|
RepoID: repo.ID,
|
|
IsClosed: optional.Some(false),
|
|
Type: project_model.TypeRepository,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetProjects", err)
|
|
return nil, nil
|
|
}
|
|
closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: db.ListOptionsAll,
|
|
RepoID: repo.ID,
|
|
IsClosed: optional.Some(true),
|
|
Type: project_model.TypeRepository,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetProjects", err)
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeOwner) {
|
|
openProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: db.ListOptionsAll,
|
|
OwnerID: repo.OwnerID,
|
|
IsClosed: optional.Some(false),
|
|
Type: repoOwnerType,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetProjects", err)
|
|
return nil, nil
|
|
}
|
|
openProjects = append(openProjects, openProjects2...)
|
|
closedProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: db.ListOptionsAll,
|
|
OwnerID: repo.OwnerID,
|
|
IsClosed: optional.Some(true),
|
|
Type: repoOwnerType,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetProjects", err)
|
|
return nil, nil
|
|
}
|
|
closedProjects = append(closedProjects, closedProjects2...)
|
|
}
|
|
return openProjects, closedProjects
|
|
}
|
|
|
|
// GetActionIssue will return the issue which is used in the context.
|
|
func GetActionIssue(ctx *context.Context) *issues_model.Issue {
|
|
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
|
if err != nil {
|
|
ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err)
|
|
return nil
|
|
}
|
|
issue.Repo = ctx.Repo.Repository
|
|
checkIssueRights(ctx, issue)
|
|
if ctx.Written() {
|
|
return nil
|
|
}
|
|
if err = issue.LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("LoadAttributes", err)
|
|
return nil
|
|
}
|
|
return issue
|
|
}
|
|
|
|
func checkIssueRights(ctx *context.Context, issue *issues_model.Issue) {
|
|
if issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypePullRequests) ||
|
|
!issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypeIssues) {
|
|
ctx.NotFound(nil)
|
|
}
|
|
}
|
|
|
|
func getActionIssues(ctx *context.Context) issues_model.IssueList {
|
|
commaSeparatedIssueIDs := ctx.FormString("issue_ids")
|
|
if len(commaSeparatedIssueIDs) == 0 {
|
|
return nil
|
|
}
|
|
issueIDs := make([]int64, 0, 10)
|
|
for stringIssueID := range strings.SplitSeq(commaSeparatedIssueIDs, ",") {
|
|
issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
|
|
if err != nil {
|
|
ctx.ServerError("ParseInt", err)
|
|
return nil
|
|
}
|
|
issueIDs = append(issueIDs, issueID)
|
|
}
|
|
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
|
if err != nil {
|
|
ctx.ServerError("GetIssuesByIDs", err)
|
|
return nil
|
|
}
|
|
// Check access rights for all issues
|
|
issueUnitEnabled := ctx.Repo.Permission.CanRead(unit.TypeIssues)
|
|
prUnitEnabled := ctx.Repo.Permission.CanRead(unit.TypePullRequests)
|
|
for _, issue := range issues {
|
|
if issue.RepoID != ctx.Repo.Repository.ID {
|
|
ctx.NotFound(errors.New("some issue's RepoID is incorrect"))
|
|
return nil
|
|
}
|
|
if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled {
|
|
ctx.NotFound(nil)
|
|
return nil
|
|
}
|
|
if err = issue.LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("LoadAttributes", err)
|
|
return nil
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
// GetIssueInfo get an issue of a repository
|
|
func GetIssueInfo(ctx *context.Context) {
|
|
issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
|
if err != nil {
|
|
if issues_model.IsErrIssueNotExist(err) {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
} else {
|
|
ctx.HTTPError(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
if issue.IsPull {
|
|
// Need to check if Pulls are enabled and we can read Pulls
|
|
if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
} else {
|
|
// Need to check if Issues are enabled and we can read Issues
|
|
if !ctx.Repo.Permission.CanRead(unit.TypeIssues) {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"convertedIssue": convert.ToIssue(ctx, ctx.Doer, issue),
|
|
"renderedLabels": templates.NewRenderUtils(ctx).RenderLabels(issue.Labels, ctx.Repo.RepoLink, issue),
|
|
})
|
|
}
|
|
|
|
// UpdateIssueTitle change issue's title
|
|
func UpdateIssueTitle(ctx *context.Context) {
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
title := ctx.FormTrim("title")
|
|
if len(title) == 0 {
|
|
ctx.HTTPError(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
if err := issue_service.ChangeTitle(ctx, issue, ctx.Doer, title); err != nil {
|
|
ctx.ServerError("ChangeTitle", err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"title": issue.Title,
|
|
})
|
|
}
|
|
|
|
// UpdateIssueRef change issue's ref (branch)
|
|
func UpdateIssueRef(ctx *context.Context) {
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
ref := ctx.FormTrim("ref")
|
|
|
|
if err := issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, ref); err != nil {
|
|
ctx.ServerError("ChangeRef", err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"ref": ref,
|
|
})
|
|
}
|
|
|
|
// UpdateIssueContent change issue's content
|
|
func UpdateIssueContent(ctx *context.Context) {
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content"), ctx.FormInt("content_version")); err != nil {
|
|
if errors.Is(err, user_model.ErrBlockedUser) {
|
|
ctx.JSONError(ctx.Tr("repo.issues.edit.blocked_user"))
|
|
} else if errors.Is(err, issues_model.ErrIssueAlreadyChanged) {
|
|
if issue.IsPull {
|
|
ctx.JSONError(ctx.Tr("repo.pulls.edit.already_changed"))
|
|
} else {
|
|
ctx.JSONError(ctx.Tr("repo.issues.edit.already_changed"))
|
|
}
|
|
} else {
|
|
ctx.ServerError("ChangeContent", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// when update the request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates
|
|
if !ctx.FormBool("ignore_attachments") {
|
|
if err := updateAttachments(ctx, issue, ctx.FormStrings("files[]")); err != nil {
|
|
ctx.ServerError("UpdateAttachments", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
|
|
FootnoteContextID: "0",
|
|
})
|
|
content, err := markdown.RenderString(rctx, issue.Content)
|
|
if err != nil {
|
|
ctx.ServerError("RenderString", err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"content": commentContentHTML(ctx, content),
|
|
"contentVersion": issue.ContentVersion,
|
|
"attachments": attachmentsHTML(ctx, issue.Attachments, issue.Content),
|
|
})
|
|
}
|
|
|
|
// UpdateIssueDeadline updates an issue deadline
|
|
func UpdateIssueDeadline(ctx *context.Context) {
|
|
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
|
if err != nil {
|
|
if issues_model.IsErrIssueNotExist(err) {
|
|
ctx.NotFound(err)
|
|
} else {
|
|
ctx.HTTPError(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
|
ctx.HTTPError(http.StatusForbidden, "", "Not repo writer")
|
|
return
|
|
}
|
|
|
|
deadlineUnix, _ := common.ParseDeadlineDateToEndOfDay(ctx.FormString("deadline"))
|
|
if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
|
|
ctx.HTTPError(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error())
|
|
return
|
|
}
|
|
|
|
ctx.JSONRedirect("")
|
|
}
|
|
|
|
// UpdateIssueMilestone change issue's milestone
|
|
func UpdateIssueMilestone(ctx *context.Context) {
|
|
issues := getActionIssues(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
milestoneID := ctx.FormInt64("id")
|
|
for _, issue := range issues {
|
|
oldMilestoneID := issue.MilestoneID
|
|
if oldMilestoneID == milestoneID {
|
|
continue
|
|
}
|
|
issue.MilestoneID = milestoneID
|
|
if milestoneID > 0 {
|
|
var err error
|
|
issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
|
|
if err != nil {
|
|
ctx.ServerError("GetMilestoneByRepoID", err)
|
|
return
|
|
}
|
|
} else {
|
|
issue.Milestone = nil
|
|
}
|
|
if err := issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil {
|
|
ctx.ServerError("ChangeMilestoneAssign", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
ctx.JSONOK()
|
|
}
|
|
|
|
// UpdateIssueAssignee change issue's or pull's assignee
|
|
func UpdateIssueAssignee(ctx *context.Context) {
|
|
issues := getActionIssues(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
assigneeID := ctx.FormInt64("id")
|
|
action := ctx.FormString("action")
|
|
|
|
for _, issue := range issues {
|
|
switch action {
|
|
case "clear":
|
|
if err := issue_service.DeleteNotPassedAssignee(ctx, issue, ctx.Doer, []*user_model.User{}); err != nil {
|
|
ctx.ServerError("ClearAssignees", err)
|
|
return
|
|
}
|
|
default:
|
|
assignee, err := user_model.GetUserByID(ctx, assigneeID)
|
|
if err != nil {
|
|
ctx.ServerError("GetUserByID", err)
|
|
return
|
|
}
|
|
|
|
valid, err := access_model.CanBeAssigned(ctx, assignee, issue.Repo)
|
|
if err != nil {
|
|
ctx.ServerError("canBeAssigned", err)
|
|
return
|
|
}
|
|
if !valid {
|
|
ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: issue.Repo.Name})
|
|
return
|
|
}
|
|
|
|
_, _, err = issue_service.ToggleAssigneeWithNotify(ctx, issue, ctx.Doer, assigneeID)
|
|
if err != nil {
|
|
ctx.ServerError("ToggleAssignee", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
ctx.JSONOK()
|
|
}
|
|
|
|
// ChangeIssueReaction create a reaction for issue
|
|
func ChangeIssueReaction(ctx *context.Context) {
|
|
form := web.GetForm(ctx).(*forms.ReactionForm)
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull)) {
|
|
if log.IsTrace() {
|
|
if ctx.IsSigned {
|
|
issueType := "issues"
|
|
if issue.IsPull {
|
|
issueType = "pulls"
|
|
}
|
|
log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+
|
|
"User in Repo has Permissions: %-+v",
|
|
ctx.Doer,
|
|
issue.PosterID,
|
|
issueType,
|
|
ctx.Repo.Repository,
|
|
ctx.Repo.Permission)
|
|
} else {
|
|
log.Trace("Permission Denied: Not logged in")
|
|
}
|
|
}
|
|
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if ctx.HasError() {
|
|
ctx.ServerError("ChangeIssueReaction", errors.New(ctx.GetErrMsg()))
|
|
return
|
|
}
|
|
|
|
switch ctx.PathParam("action") {
|
|
case "react":
|
|
reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content)
|
|
if err != nil {
|
|
if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) {
|
|
ctx.ServerError("ChangeIssueReaction", err)
|
|
return
|
|
}
|
|
log.Info("CreateIssueReaction: %s", err)
|
|
break
|
|
}
|
|
// Reload new reactions
|
|
issue.Reactions = nil
|
|
if err = issue.LoadAttributes(ctx); err != nil {
|
|
log.Info("issue.LoadAttributes: %s", err)
|
|
break
|
|
}
|
|
|
|
log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID)
|
|
case "unreact":
|
|
if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil {
|
|
ctx.ServerError("DeleteIssueReaction", err)
|
|
return
|
|
}
|
|
|
|
// Reload new reactions
|
|
issue.Reactions = nil
|
|
if err := issue.LoadAttributes(ctx); err != nil {
|
|
log.Info("issue.LoadAttributes: %s", err)
|
|
break
|
|
}
|
|
|
|
log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID)
|
|
default:
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
|
|
if len(issue.Reactions) == 0 {
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"empty": true,
|
|
"html": "",
|
|
})
|
|
return
|
|
}
|
|
|
|
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
|
|
"ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index),
|
|
"Reactions": issue.Reactions.GroupByType(),
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("ChangeIssueReaction.HTMLString", err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"html": html,
|
|
})
|
|
}
|
|
|
|
// GetIssueAttachments returns attachments for the issue
|
|
func GetIssueAttachments(ctx *context.Context) {
|
|
issue := GetActionIssue(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
attachments := make([]*api.Attachment, len(issue.Attachments))
|
|
for i := 0; i < len(issue.Attachments); i++ {
|
|
attachments[i] = convert.ToAttachment(ctx.Repo.Repository, issue.Attachments[i])
|
|
}
|
|
ctx.JSON(http.StatusOK, attachments)
|
|
}
|
|
|
|
func updateAttachments(ctx *context.Context, item any, files []string) error {
|
|
var attachments []*repo_model.Attachment
|
|
switch content := item.(type) {
|
|
case *issues_model.Issue:
|
|
attachments = content.Attachments
|
|
case *issues_model.Comment:
|
|
attachments = content.Attachments
|
|
default:
|
|
return fmt.Errorf("unknown Type: %T", content)
|
|
}
|
|
for i := 0; i < len(attachments); i++ {
|
|
if util.SliceContainsString(files, attachments[i].UUID) {
|
|
continue
|
|
}
|
|
if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var err error
|
|
if len(files) > 0 {
|
|
switch content := item.(type) {
|
|
case *issues_model.Issue:
|
|
err = issues_model.UpdateIssueAttachments(ctx, content.ID, files)
|
|
case *issues_model.Comment:
|
|
err = issues_model.UpdateCommentAttachments(ctx, content, files)
|
|
default:
|
|
return fmt.Errorf("unknown Type: %T", content)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
switch content := item.(type) {
|
|
case *issues_model.Issue:
|
|
content.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, content.ID)
|
|
case *issues_model.Comment:
|
|
content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID)
|
|
default:
|
|
return fmt.Errorf("unknown Type: %T", content)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func commentContentHTML(ctx *context.Context, content template.HTML) template.HTML {
|
|
if strings.TrimSpace(string(content)) == "" {
|
|
return htmlutil.HTMLFormat(`<span class="no-content">%s</span>`, ctx.Tr("repo.issues.no_content"))
|
|
}
|
|
return content
|
|
}
|
|
|
|
func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment, content string) template.HTML {
|
|
attachHTML, err := ctx.RenderToHTML(tplAttachment, map[string]any{
|
|
"ctxData": ctx.Data,
|
|
"Attachments": attachments,
|
|
"Content": content,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("attachmentsHTML.HTMLString", err)
|
|
return ""
|
|
}
|
|
return attachHTML
|
|
}
|