// Copyright 2016 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo import ( "errors" "fmt" "math" "net/http" "strconv" "strings" "time" "code.gitea.io/gitea/models" activities_model "code.gitea.io/gitea/models/activities" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/gitdiff" issue_service "code.gitea.io/gitea/services/issue" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" ) // ListPullRequests returns a list of all PRs func ListPullRequests(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls repository repoListPullRequests // --- // summary: List a repo's pull requests // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: state // in: query // description: "State of pull request: open or closed (optional)" // type: string // enum: [closed, open, all] // - name: sort // in: query // description: "Type of sort" // type: string // enum: [oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority] // - name: milestone // in: query // description: "ID of the milestone" // type: integer // format: int64 // - name: labels // in: query // description: "Label IDs" // type: array // collectionFormat: multi // items: // type: integer // format: int64 // - name: page // in: query // description: page number of results to return (1-based) // type: integer // - name: limit // in: query // description: page size of results // type: integer // responses: // "200": // "$ref": "#/responses/PullRequestList" // "404": // "$ref": "#/responses/notFound" labelIDs, err := base.StringsToInt64s(ctx.FormStrings("labels")) if err != nil { ctx.Error(http.StatusInternalServerError, "PullRequests", err) return } listOptions := utils.GetListOptions(ctx) prs, maxResults, err := issues_model.PullRequests(ctx, ctx.Repo.Repository.ID, &issues_model.PullRequestsOptions{ ListOptions: listOptions, State: ctx.FormTrim("state"), SortType: ctx.FormTrim("sort"), Labels: labelIDs, MilestoneID: ctx.FormInt64("milestone"), }) if err != nil { ctx.Error(http.StatusInternalServerError, "PullRequests", err) return } apiPrs := make([]*api.PullRequest, len(prs)) // NOTE: load repository first, so that issue.Repo will be filled with pr.BaseRepo if err := prs.LoadRepositories(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) return } issueList, err := prs.LoadIssues(ctx) if err != nil { ctx.Error(http.StatusInternalServerError, "LoadIssues", err) return } if err := issueList.LoadLabels(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadLabels", err) return } if err := issueList.LoadPosters(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadPoster", err) return } if err := issueList.LoadAttachments(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) return } if err := issueList.LoadMilestones(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadMilestones", err) return } if err := issueList.LoadAssignees(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAssignees", err) return } for i := range prs { apiPrs[i] = convert.ToAPIPullRequest(ctx, prs[i], ctx.Doer) } ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) ctx.SetTotalCountHeader(maxResults) ctx.JSON(http.StatusOK, &apiPrs) } // GetPullRequest returns a single PR based on index func GetPullRequest(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index} repository repoGetPullRequest // --- // summary: Get a pull request // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to get // type: integer // format: int64 // required: true // responses: // "200": // "$ref": "#/responses/PullRequest" // "404": // "$ref": "#/responses/notFound" pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if err = pr.LoadBaseRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) return } if err = pr.LoadHeadRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) return } ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) } // GetPullRequest returns a single PR based on index func GetPullRequestByBaseHead(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{base}/{head} repository repoGetPullRequestByBaseHead // --- // summary: Get a pull request by base and head // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: base // in: path // description: base of the pull request to get // type: string // required: true // - name: head // in: path // description: head of the pull request to get // type: string // required: true // responses: // "200": // "$ref": "#/responses/PullRequest" // "404": // "$ref": "#/responses/notFound" var headRepoID int64 var headBranch string head := ctx.Params("*") if strings.Contains(head, ":") { split := strings.SplitN(head, ":", 2) headBranch = split[1] var owner, name string if strings.Contains(split[0], "/") { split = strings.Split(split[0], "/") owner = split[0] name = split[1] } else { owner = split[0] name = ctx.Repo.Repository.Name } repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, name) if err != nil { if repo_model.IsErrRepoNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerName", err) } return } headRepoID = repo.ID } else { headRepoID = ctx.Repo.Repository.ID headBranch = head } pr, err := issues_model.GetPullRequestByBaseHeadInfo(ctx, ctx.Repo.Repository.ID, headRepoID, ctx.Params(":base"), headBranch) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByBaseHeadInfo", err) } return } if err = pr.LoadBaseRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) return } if err = pr.LoadHeadRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) return } ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) } // DownloadPullDiffOrPatch render a pull's raw diff or patch func DownloadPullDiffOrPatch(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}.{diffType} repository repoDownloadPullDiffOrPatch // --- // summary: Get a pull request diff or patch // produces: // - text/plain // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to get // type: integer // format: int64 // required: true // - name: diffType // in: path // description: whether the output is diff or patch // type: string // enum: [diff, patch] // required: true // - name: binary // in: query // description: whether to include binary file changes. if true, the diff is applicable with `git apply` // type: boolean // responses: // "200": // "$ref": "#/responses/string" // "404": // "$ref": "#/responses/notFound" pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.InternalServerError(err) } return } var patch bool if ctx.Params(":diffType") == "diff" { patch = false } else { patch = true } binary := ctx.FormBool("binary") if err := pull_service.DownloadDiffOrPatch(ctx, pr, ctx, patch, binary); err != nil { ctx.InternalServerError(err) return } } // CreatePullRequest does what it says func CreatePullRequest(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/pulls repository repoCreatePullRequest // --- // summary: Create a pull request // consumes: // - application/json // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: body // in: body // schema: // "$ref": "#/definitions/CreatePullRequestOption" // responses: // "201": // "$ref": "#/responses/PullRequest" // "404": // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" // "413": // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" form := *web.GetForm(ctx).(*api.CreatePullRequestOption) if form.Head == form.Base { ctx.Error(http.StatusUnprocessableEntity, "BaseHeadSame", "Invalid PullRequest: There are no changes between the head and the base") return } var ( repo = ctx.Repo.Repository labelIDs []int64 milestoneID int64 ) // Get repo/branch information headRepo, headGitRepo, compareInfo, baseBranch, headBranch := parseCompareInfo(ctx, form) if ctx.Written() { return } defer headGitRepo.Close() // Check if another PR exists with the same targets existingPr, err := issues_model.GetUnmergedPullRequest(ctx, headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch, issues_model.PullRequestFlowGithub) if err != nil { if !issues_model.IsErrPullRequestNotExist(err) { ctx.Error(http.StatusInternalServerError, "GetUnmergedPullRequest", err) return } } else { err = issues_model.ErrPullRequestAlreadyExists{ ID: existingPr.ID, IssueID: existingPr.Index, HeadRepoID: existingPr.HeadRepoID, BaseRepoID: existingPr.BaseRepoID, HeadBranch: existingPr.HeadBranch, BaseBranch: existingPr.BaseBranch, } ctx.Error(http.StatusConflict, "GetUnmergedPullRequest", err) return } if len(form.Labels) > 0 { labels, err := issues_model.GetLabelsInRepoByIDs(ctx, ctx.Repo.Repository.ID, form.Labels) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsInRepoByIDs", err) return } labelIDs = make([]int64, 0, len(labels)) for _, label := range labels { labelIDs = append(labelIDs, label.ID) } if ctx.Repo.Owner.IsOrganization() { orgLabels, err := issues_model.GetLabelsInOrgByIDs(ctx, ctx.Repo.Owner.ID, form.Labels) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsInOrgByIDs", err) return } orgLabelIDs := make([]int64, 0, len(orgLabels)) for _, orgLabel := range orgLabels { orgLabelIDs = append(orgLabelIDs, orgLabel.ID) } labelIDs = append(labelIDs, orgLabelIDs...) } } if form.Milestone > 0 { milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, form.Milestone) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) } return } milestoneID = milestone.ID } var deadlineUnix timeutil.TimeStamp if form.Deadline != nil { deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) } prIssue := &issues_model.Issue{ RepoID: repo.ID, Title: form.Title, PosterID: ctx.Doer.ID, Poster: ctx.Doer, MilestoneID: milestoneID, IsPull: true, Content: form.Body, DeadlineUnix: deadlineUnix, } pr := &issues_model.PullRequest{ HeadRepoID: headRepo.ID, BaseRepoID: repo.ID, HeadBranch: headBranch, BaseBranch: baseBranch, HeadRepo: headRepo, BaseRepo: repo, MergeBase: compareInfo.MergeBase, Type: issues_model.PullRequestGitea, } // Get all assignee IDs assigneeIDs, err := issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees) if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) } else { ctx.Error(http.StatusInternalServerError, "AddAssigneeByName", err) } return } // Check if the passed assignees is assignable for _, aID := range assigneeIDs { assignee, err := user_model.GetUserByID(ctx, aID) if err != nil { ctx.Error(http.StatusInternalServerError, "GetUserByID", err) return } valid, err := access_model.CanBeAssigned(ctx, assignee, repo, true) if err != nil { ctx.Error(http.StatusInternalServerError, "canBeAssigned", err) return } if !valid { ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) return } } if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { if errors.Is(err, user_model.ErrBlockedByUser) { ctx.Error(http.StatusForbidden, "BlockedByUser", err) return } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) return } ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) return } log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID) ctx.JSON(http.StatusCreated, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) } // EditPullRequest does what it says func EditPullRequest(ctx *context.APIContext) { // swagger:operation PATCH /repos/{owner}/{repo}/pulls/{index} repository repoEditPullRequest // --- // summary: Update a pull request. If using deadline only the date will be taken into account, and time of day ignored. // consumes: // - application/json // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to edit // type: integer // format: int64 // required: true // - name: body // in: body // schema: // "$ref": "#/definitions/EditPullRequestOption" // responses: // "201": // "$ref": "#/responses/PullRequest" // "403": // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" // "412": // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.EditPullRequestOption) pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } err = pr.LoadIssue(ctx) if err != nil { ctx.Error(http.StatusInternalServerError, "LoadIssue", err) return } issue := pr.Issue issue.Repo = ctx.Repo.Repository if err := issue.LoadAttributes(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) return } if !issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWrite(unit.TypePullRequests) { ctx.Status(http.StatusForbidden) return } if len(form.Title) > 0 { err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) if err != nil { ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) return } } if form.Body != nil { err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion) if err != nil { if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { ctx.Error(http.StatusBadRequest, "ChangeContent", err) return } ctx.Error(http.StatusInternalServerError, "ChangeContent", err) return } } // Update or remove deadline if set if form.Deadline != nil || form.RemoveDeadline != nil { var deadlineUnix timeutil.TimeStamp if (form.RemoveDeadline == nil || !*form.RemoveDeadline) && !form.Deadline.IsZero() { deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), 23, 59, 59, 0, form.Deadline.Location()) deadlineUnix = timeutil.TimeStamp(deadline.Unix()) } if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) return } issue.DeadlineUnix = deadlineUnix } // Add/delete assignees // Deleting is done the GitHub way (quote from their api documentation): // https://developer.github.com/v3/issues/#edit-an-issue // "assignees" (array): Logins for Users to assign to this issue. // Pass one or more user logins to replace the set of assignees on this Issue. // Send an empty array ([]) to clear all assignees from the Issue. if ctx.Repo.CanWrite(unit.TypePullRequests) && (form.Assignees != nil || len(form.Assignee) > 0) { err = issue_service.UpdateAssignees(ctx, issue, form.Assignee, form.Assignees, ctx.Doer) if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) } else { ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) } return } } if ctx.Repo.CanWrite(unit.TypePullRequests) && form.Milestone != 0 && issue.MilestoneID != form.Milestone { oldMilestoneID := issue.MilestoneID issue.MilestoneID = form.Milestone if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) return } } if ctx.Repo.CanWrite(unit.TypePullRequests) && form.Labels != nil { labels, err := issues_model.GetLabelsInRepoByIDs(ctx, ctx.Repo.Repository.ID, form.Labels) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsInRepoByIDsError", err) return } if ctx.Repo.Owner.IsOrganization() { orgLabels, err := issues_model.GetLabelsInOrgByIDs(ctx, ctx.Repo.Owner.ID, form.Labels) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsInOrgByIDs", err) return } labels = append(labels, orgLabels...) } if err = issues_model.ReplaceIssueLabels(ctx, issue, labels, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ReplaceLabelsError", err) return } } if form.State != nil { if pr.HasMerged { ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") return } isClosed := api.StateClosed == api.StateType(*form.State) if issue.IsClosed != isClosed { if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") return } ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) return } } } // change pull target branch if !pr.HasMerged && len(form.Base) != 0 && form.Base != pr.BaseBranch { if !ctx.Repo.GitRepo.IsBranchExist(form.Base) { ctx.Error(http.StatusNotFound, "NewBaseBranchNotExist", fmt.Errorf("new base '%s' not exist", form.Base)) return } if err := pull_service.ChangeTargetBranch(ctx, pr, ctx.Doer, form.Base); err != nil { if issues_model.IsErrPullRequestAlreadyExists(err) { ctx.Error(http.StatusConflict, "IsErrPullRequestAlreadyExists", err) return } else if issues_model.IsErrIssueIsClosed(err) { ctx.Error(http.StatusUnprocessableEntity, "IsErrIssueIsClosed", err) return } else if models.IsErrPullRequestHasMerged(err) { ctx.Error(http.StatusConflict, "IsErrPullRequestHasMerged", err) return } ctx.InternalServerError(err) return } notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, form.Base) } // update allow edits if form.AllowMaintainerEdit != nil { if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, *form.AllowMaintainerEdit); err != nil { if errors.Is(err, pull_service.ErrUserHasNoPermissionForAction) { ctx.Error(http.StatusForbidden, "SetAllowEdits", fmt.Sprintf("SetAllowEdits: %s", err)) return } ctx.ServerError("SetAllowEdits", err) return } } // Refetch from database pr, err = issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, pr.Index) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } // TODO this should be 200, not 201 ctx.JSON(http.StatusCreated, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) } // IsPullRequestMerged checks if a PR exists given an index func IsPullRequestMerged(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/merge repository repoPullRequestIsMerged // --- // summary: Check if a pull request has been merged // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request // type: integer // format: int64 // required: true // responses: // "204": // description: pull request has been merged // "404": // description: pull request has not been merged pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if pr.HasMerged { ctx.Status(http.StatusNoContent) } ctx.NotFound() } // MergePullRequest merges a PR given an index func MergePullRequest(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/merge repository repoMergePullRequest // --- // summary: Merge a pull request // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to merge // type: integer // format: int64 // required: true // - name: body // in: body // schema: // $ref: "#/definitions/MergePullRequestOption" // responses: // "200": // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" // "405": // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/error" // "413": // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*forms.MergePullRequestForm) pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound("GetPullRequestByIndex", err) } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if err := pr.LoadHeadRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) return } if err := pr.LoadIssue(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadIssue", err) return } pr.Issue.Repo = ctx.Repo.Repository if ctx.IsSigned { // Update issue-user. if err = activities_model.SetIssueReadBy(ctx, pr.Issue.ID, ctx.Doer.ID); err != nil { ctx.Error(http.StatusInternalServerError, "ReadBy", err) return } } manuallyMerged := repo_model.MergeStyle(form.Do) == repo_model.MergeStyleManuallyMerged mergeCheckType := pull_service.MergeCheckTypeGeneral if form.MergeWhenChecksSucceed { mergeCheckType = pull_service.MergeCheckTypeAuto } if manuallyMerged { mergeCheckType = pull_service.MergeCheckTypeManually } // start with merging by checking if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { if errors.Is(err, pull_service.ErrIsClosed) { ctx.NotFound() } else if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) { ctx.Error(http.StatusMethodNotAllowed, "Merge", "User not allowed to merge PR") } else if errors.Is(err, pull_service.ErrHasMerged) { ctx.Error(http.StatusMethodNotAllowed, "PR already merged", "") } else if errors.Is(err, pull_service.ErrIsWorkInProgress) { ctx.Error(http.StatusMethodNotAllowed, "PR is a work in progress", "Work in progress PRs cannot be merged") } else if errors.Is(err, pull_service.ErrNotMergeableState) { ctx.Error(http.StatusMethodNotAllowed, "PR not in mergeable state", "Please try again later") } else if models.IsErrDisallowedToMerge(err) { ctx.Error(http.StatusMethodNotAllowed, "PR is not ready to be merged", err) } else if asymkey_service.IsErrWontSign(err) { ctx.Error(http.StatusMethodNotAllowed, fmt.Sprintf("Protected branch %s requires signed commits but this merge would not be signed", pr.BaseBranch), err) } else { ctx.InternalServerError(err) } return } // handle manually-merged mark if manuallyMerged { if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { if models.IsErrInvalidMergeStyle(err) { ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) return } if strings.Contains(err.Error(), "Wrong commit ID") { ctx.JSON(http.StatusConflict, err) return } ctx.Error(http.StatusInternalServerError, "Manually-Merged", err) return } ctx.Status(http.StatusOK) return } if len(form.Do) == 0 { form.Do = string(repo_model.MergeStyleMerge) } message := strings.TrimSpace(form.MergeTitleField) if len(message) == 0 { message, _, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pr, repo_model.MergeStyle(form.Do)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetDefaultMergeMessage", err) return } } form.MergeMessageField = strings.TrimSpace(form.MergeMessageField) if len(form.MergeMessageField) > 0 { message += "\n\n" + form.MergeMessageField } if form.MergeWhenChecksSucceed { scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message) if err != nil { if pull_model.IsErrAlreadyScheduledToAutoMerge(err) { ctx.Error(http.StatusConflict, "ScheduleAutoMerge", err) return } ctx.Error(http.StatusInternalServerError, "ScheduleAutoMerge", err) return } else if scheduled { // nothing more to do ... ctx.Status(http.StatusCreated) return } } if err := pull_service.Merge(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if models.IsErrInvalidMergeStyle(err) { ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) } else if models.IsErrMergeConflicts(err) { conflictError := err.(models.ErrMergeConflicts) ctx.JSON(http.StatusConflict, conflictError) } else if models.IsErrRebaseConflicts(err) { conflictError := err.(models.ErrRebaseConflicts) ctx.JSON(http.StatusConflict, conflictError) } else if models.IsErrMergeUnrelatedHistories(err) { conflictError := err.(models.ErrMergeUnrelatedHistories) ctx.JSON(http.StatusConflict, conflictError) } else if git.IsErrPushOutOfDate(err) { ctx.Error(http.StatusConflict, "Merge", "merge push out of date") } else if models.IsErrSHADoesNotMatch(err) { ctx.Error(http.StatusConflict, "Merge", "head out of date") } else if git.IsErrPushRejected(err) { errPushRej := err.(*git.ErrPushRejected) if len(errPushRej.Message) == 0 { ctx.Error(http.StatusConflict, "Merge", "PushRejected without remote error message") } else { ctx.Error(http.StatusConflict, "Merge", "PushRejected with remote message: "+errPushRej.Message) } } else { ctx.Error(http.StatusInternalServerError, "Merge", err) } return } log.Trace("Pull request merged: %d", pr.ID) if form.DeleteBranchAfterMerge { // Don't cleanup when there are other PR's that use this branch as head branch. exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch) if err != nil { ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err) return } if exist { ctx.Status(http.StatusOK) return } var headRepo *git.Repository if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil { headRepo = ctx.Repo.GitRepo } else { headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) return } defer headRepo.Close() } if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { ctx.Error(http.StatusInternalServerError, "RetargetChildrenOnMerge", err) return } if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch); err != nil { switch { case git.IsErrBranchNotExist(err): ctx.NotFound(err) case errors.Is(err, repo_service.ErrBranchIsDefault): ctx.Error(http.StatusForbidden, "DefaultBranch", fmt.Errorf("can not delete default branch")) case errors.Is(err, git_model.ErrBranchIsProtected): ctx.Error(http.StatusForbidden, "IsProtectedBranch", fmt.Errorf("branch protected")) default: ctx.Error(http.StatusInternalServerError, "DeleteBranch", err) } return } if err := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil { // Do not fail here as branch has already been deleted log.Error("DeleteBranch: %v", err) } } ctx.Status(http.StatusOK) } func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) (*repo_model.Repository, *git.Repository, *git.CompareInfo, string, string) { baseRepo := ctx.Repo.Repository // Get compared branches information // format: ...[:] // base<-head: master...head:feature // same repo: master...feature // TODO: Validate form first? baseBranch := form.Base var ( headUser *user_model.User headBranch string isSameRepo bool err error ) // If there is no head repository, it means pull request between same repository. headInfos := strings.Split(form.Head, ":") if len(headInfos) == 1 { isSameRepo = true headUser = ctx.Repo.Owner headBranch = headInfos[0] } else if len(headInfos) == 2 { headUser, err = user_model.GetUserByName(ctx, headInfos[0]) if err != nil { if user_model.IsErrUserNotExist(err) { ctx.NotFound("GetUserByName") } else { ctx.Error(http.StatusInternalServerError, "GetUserByName", err) } return nil, nil, nil, "", "" } headBranch = headInfos[1] // The head repository can also point to the same repo isSameRepo = ctx.Repo.Owner.ID == headUser.ID } else { ctx.NotFound() return nil, nil, nil, "", "" } ctx.Repo.PullRequest.SameRepo = isSameRepo log.Trace("Repo path: %q, base branch: %q, head branch: %q", ctx.Repo.GitRepo.Path, baseBranch, headBranch) // Check if base branch is valid. if !ctx.Repo.GitRepo.IsBranchExist(baseBranch) && !ctx.Repo.GitRepo.IsTagExist(baseBranch) { ctx.NotFound("BaseNotExist") return nil, nil, nil, "", "" } // Check if current user has fork of repository or in the same repository. headRepo := repo_model.GetForkedRepo(ctx, headUser.ID, baseRepo.ID) if headRepo == nil && !isSameRepo { log.Trace("parseCompareInfo[%d]: does not have fork or in same repository", baseRepo.ID) ctx.NotFound("GetForkedRepo") return nil, nil, nil, "", "" } var headGitRepo *git.Repository if isSameRepo { headRepo = ctx.Repo.Repository headGitRepo = ctx.Repo.GitRepo } else { headGitRepo, err = gitrepo.OpenRepository(ctx, headRepo) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return nil, nil, nil, "", "" } } // user should have permission to read baseRepo's codes and pulls, NOT headRepo's permBase, err := access_model.GetUserRepoPermission(ctx, baseRepo, ctx.Doer) if err != nil { headGitRepo.Close() ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) return nil, nil, nil, "", "" } if !permBase.CanReadIssuesOrPulls(true) || !permBase.CanRead(unit.TypeCode) { if log.IsTrace() { log.Trace("Permission Denied: User %-v cannot create/read pull requests or cannot read code in Repo %-v\nUser in baseRepo has Permissions: %-+v", ctx.Doer, baseRepo, permBase) } headGitRepo.Close() ctx.NotFound("Can't read pulls or can't read UnitTypeCode") return nil, nil, nil, "", "" } // user should have permission to read headrepo's codes permHead, err := access_model.GetUserRepoPermission(ctx, headRepo, ctx.Doer) if err != nil { headGitRepo.Close() ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) return nil, nil, nil, "", "" } if !permHead.CanRead(unit.TypeCode) { if log.IsTrace() { log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v", ctx.Doer, headRepo, permHead) } headGitRepo.Close() ctx.NotFound("Can't read headRepo UnitTypeCode") return nil, nil, nil, "", "" } // Check if head branch is valid. if !headGitRepo.IsBranchExist(headBranch) && !headGitRepo.IsTagExist(headBranch) { headGitRepo.Close() ctx.NotFound() return nil, nil, nil, "", "" } compareInfo, err := headGitRepo.GetCompareInfo(repo_model.RepoPath(baseRepo.Owner.Name, baseRepo.Name), baseBranch, headBranch, false, false) if err != nil { headGitRepo.Close() ctx.Error(http.StatusInternalServerError, "GetCompareInfo", err) return nil, nil, nil, "", "" } return headRepo, headGitRepo, compareInfo, baseBranch, headBranch } // UpdatePullRequest merge PR's baseBranch into headBranch func UpdatePullRequest(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/update repository repoUpdatePullRequest // --- // summary: Merge PR's baseBranch into headBranch // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to get // type: integer // format: int64 // required: true // - name: style // in: query // description: how to update pull request // type: string // enum: [merge, rebase] // responses: // "200": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" // "413": // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if pr.HasMerged { ctx.Error(http.StatusUnprocessableEntity, "UpdatePullRequest", err) return } if err = pr.LoadIssue(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadIssue", err) return } if pr.Issue.IsClosed { ctx.Error(http.StatusUnprocessableEntity, "UpdatePullRequest", err) return } if err = pr.LoadBaseRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) return } if err = pr.LoadHeadRepo(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) return } rebase := ctx.FormString("style") == "rebase" allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, pr, ctx.Doer) if err != nil { ctx.Error(http.StatusInternalServerError, "IsUserAllowedToMerge", err) return } if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) { ctx.Status(http.StatusForbidden) return } // default merge commit message message := fmt.Sprintf("Merge branch '%s' into %s", pr.BaseBranch, pr.HeadBranch) if err = pull_service.Update(ctx, pr, ctx.Doer, message, rebase); err != nil { if models.IsErrMergeConflicts(err) { ctx.Error(http.StatusConflict, "Update", "merge failed because of conflict") return } else if models.IsErrRebaseConflicts(err) { ctx.Error(http.StatusConflict, "Update", "rebase failed because of conflict") return } ctx.Error(http.StatusInternalServerError, "pull_service.Update", err) return } ctx.Status(http.StatusOK) } // MergePullRequest cancel an auto merge scheduled for a given PullRequest by index func CancelScheduledAutoMerge(ctx *context.APIContext) { // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/merge repository repoCancelScheduledAutoMerge // --- // summary: Cancel the scheduled auto merge for the given pull request // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to merge // type: integer // format: int64 // required: true // responses: // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "423": // "$ref": "#/responses/repoArchivedError" pullIndex := ctx.ParamsInt64(":index") pull, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, pullIndex) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() return } ctx.InternalServerError(err) return } exist, autoMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pull.ID) if err != nil { ctx.InternalServerError(err) return } if !exist { ctx.NotFound() return } if ctx.Doer.ID != autoMerge.DoerID { allowed, err := access_model.IsUserRepoAdmin(ctx, ctx.Repo.Repository, ctx.Doer) if err != nil { ctx.InternalServerError(err) return } if !allowed { ctx.Error(http.StatusForbidden, "No permission to cancel", "user has no permission to cancel the scheduled auto merge") return } } if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, pull); err != nil { ctx.InternalServerError(err) } else { ctx.Status(http.StatusNoContent) } } // GetPullRequestCommits gets all commits associated with a given PR func GetPullRequestCommits(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/commits repository repoGetPullRequestCommits // --- // summary: Get commits for a pull request // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to get // type: integer // format: int64 // required: true // - name: page // in: query // description: page number of results to return (1-based) // type: integer // - name: limit // in: query // description: page size of results // type: integer // - name: verification // in: query // description: include verification for every commit (disable for speedup, default 'true') // type: boolean // - name: files // in: query // description: include a list of affected files for every commit (disable for speedup, default 'true') // type: boolean // responses: // "200": // "$ref": "#/responses/CommitList" // "404": // "$ref": "#/responses/notFound" pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if err := pr.LoadBaseRepo(ctx); err != nil { ctx.InternalServerError(err) return } var prInfo *git.CompareInfo baseGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) if err != nil { ctx.ServerError("OpenRepository", err) return } defer closer.Close() if pr.HasMerged { prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.MergeBase, pr.GetGitRefName(), false, false) } else { prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.GetGitRefName(), false, false) } if err != nil { ctx.ServerError("GetCompareInfo", err) return } commits := prInfo.Commits listOptions := utils.GetListOptions(ctx) totalNumberOfCommits := len(commits) totalNumberOfPages := int(math.Ceil(float64(totalNumberOfCommits) / float64(listOptions.PageSize))) userCache := make(map[string]*user_model.User) start, limit := listOptions.GetSkipTake() limit = min(limit, totalNumberOfCommits-start) limit = max(limit, 0) verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") files := ctx.FormString("files") == "" || ctx.FormBool("files") apiCommits := make([]*api.Commit, 0, limit) for i := start; i < start+limit; i++ { apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, baseGitRepo, commits[i], userCache, convert.ToCommitOptions{ Stat: true, Verification: verification, Files: files, }) if err != nil { ctx.ServerError("toCommit", err) return } apiCommits = append(apiCommits, apiCommit) } ctx.SetLinkHeader(totalNumberOfCommits, listOptions.PageSize) ctx.SetTotalCountHeader(int64(totalNumberOfCommits)) ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page)) ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize)) ctx.RespHeader().Set("X-PageCount", strconv.Itoa(totalNumberOfPages)) ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < totalNumberOfPages)) ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-PageCount", "X-HasMore") ctx.JSON(http.StatusOK, &apiCommits) } // GetPullRequestFiles gets all changed files associated with a given PR func GetPullRequestFiles(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/files repository repoGetPullRequestFiles // --- // summary: Get changed files for a pull request // produces: // - application/json // parameters: // - name: owner // in: path // description: owner of the repo // type: string // required: true // - name: repo // in: path // description: name of the repo // type: string // required: true // - name: index // in: path // description: index of the pull request to get // type: integer // format: int64 // required: true // - name: skip-to // in: query // description: skip to given file // type: string // - name: whitespace // in: query // description: whitespace behavior // type: string // enum: [ignore-all, ignore-change, ignore-eol, show-all] // - name: page // in: query // description: page number of results to return (1-based) // type: integer // - name: limit // in: query // description: page size of results // type: integer // responses: // "200": // "$ref": "#/responses/ChangedFileList" // "404": // "$ref": "#/responses/notFound" pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) } return } if err := pr.LoadBaseRepo(ctx); err != nil { ctx.InternalServerError(err) return } if err := pr.LoadHeadRepo(ctx); err != nil { ctx.InternalServerError(err) return } baseGitRepo := ctx.Repo.GitRepo var prInfo *git.CompareInfo if pr.HasMerged { prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.MergeBase, pr.GetGitRefName(), true, false) } else { prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.GetGitRefName(), true, false) } if err != nil { ctx.ServerError("GetCompareInfo", err) return } headCommitID, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) if err != nil { ctx.ServerError("GetRefCommitID", err) return } startCommitID := prInfo.MergeBase endCommitID := headCommitID maxLines := setting.Git.MaxGitDiffLines // FIXME: If there are too many files in the repo, may cause some unpredictable issues. diff, err := gitdiff.GetDiff(ctx, baseGitRepo, &gitdiff.DiffOptions{ BeforeCommitID: startCommitID, AfterCommitID: endCommitID, SkipTo: ctx.FormString("skip-to"), MaxLines: maxLines, MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, MaxFiles: -1, // GetDiff() will return all files WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.FormString("whitespace")), }) if err != nil { ctx.ServerError("GetDiff", err) return } listOptions := utils.GetListOptions(ctx) totalNumberOfFiles := diff.NumFiles totalNumberOfPages := int(math.Ceil(float64(totalNumberOfFiles) / float64(listOptions.PageSize))) start, limit := listOptions.GetSkipTake() limit = min(limit, totalNumberOfFiles-start) limit = max(limit, 0) apiFiles := make([]*api.ChangedFile, 0, limit) for i := start; i < start+limit; i++ { apiFiles = append(apiFiles, convert.ToChangedFile(diff.Files[i], pr.HeadRepo, endCommitID)) } ctx.SetLinkHeader(totalNumberOfFiles, listOptions.PageSize) ctx.SetTotalCountHeader(int64(totalNumberOfFiles)) ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page)) ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize)) ctx.RespHeader().Set("X-PageCount", strconv.Itoa(totalNumberOfPages)) ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < totalNumberOfPages)) ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-PageCount", "X-HasMore") ctx.JSON(http.StatusOK, &apiFiles) }