summaryrefslogtreecommitdiffstats
path: root/routers/api/v1/repo
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /routers/api/v1/repo
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'routers/api/v1/repo')
-rw-r--r--routers/api/v1/repo/action.go653
-rw-r--r--routers/api/v1/repo/avatar.go88
-rw-r--r--routers/api/v1/repo/blob.go55
-rw-r--r--routers/api/v1/repo/branch.go1019
-rw-r--r--routers/api/v1/repo/collaborators.go370
-rw-r--r--routers/api/v1/repo/commits.go376
-rw-r--r--routers/api/v1/repo/compare.go99
-rw-r--r--routers/api/v1/repo/file.go1014
-rw-r--r--routers/api/v1/repo/flags.go245
-rw-r--r--routers/api/v1/repo/fork.go167
-rw-r--r--routers/api/v1/repo/git_hook.go196
-rw-r--r--routers/api/v1/repo/git_ref.go107
-rw-r--r--routers/api/v1/repo/hook.go308
-rw-r--r--routers/api/v1/repo/hook_test.go33
-rw-r--r--routers/api/v1/repo/issue.go1041
-rw-r--r--routers/api/v1/repo/issue_attachment.go411
-rw-r--r--routers/api/v1/repo/issue_comment.go691
-rw-r--r--routers/api/v1/repo/issue_comment_attachment.go400
-rw-r--r--routers/api/v1/repo/issue_dependency.go613
-rw-r--r--routers/api/v1/repo/issue_label.go385
-rw-r--r--routers/api/v1/repo/issue_pin.go309
-rw-r--r--routers/api/v1/repo/issue_reaction.go424
-rw-r--r--routers/api/v1/repo/issue_stopwatch.go241
-rw-r--r--routers/api/v1/repo/issue_subscription.go294
-rw-r--r--routers/api/v1/repo/issue_tracked_time.go633
-rw-r--r--routers/api/v1/repo/key.go292
-rw-r--r--routers/api/v1/repo/label.go285
-rw-r--r--routers/api/v1/repo/language.go81
-rw-r--r--routers/api/v1/repo/main_test.go21
-rw-r--r--routers/api/v1/repo/migrate.go281
-rw-r--r--routers/api/v1/repo/milestone.go309
-rw-r--r--routers/api/v1/repo/mirror.go449
-rw-r--r--routers/api/v1/repo/notes.go104
-rw-r--r--routers/api/v1/repo/patch.go114
-rw-r--r--routers/api/v1/repo/pull.go1635
-rw-r--r--routers/api/v1/repo/pull_review.go1107
-rw-r--r--routers/api/v1/repo/release.go424
-rw-r--r--routers/api/v1/repo/release_attachment.go467
-rw-r--r--routers/api/v1/repo/release_tags.go125
-rw-r--r--routers/api/v1/repo/repo.go1334
-rw-r--r--routers/api/v1/repo/repo_test.go86
-rw-r--r--routers/api/v1/repo/star.go60
-rw-r--r--routers/api/v1/repo/status.go282
-rw-r--r--routers/api/v1/repo/subscriber.go60
-rw-r--r--routers/api/v1/repo/tag.go668
-rw-r--r--routers/api/v1/repo/teams.go235
-rw-r--r--routers/api/v1/repo/topic.go305
-rw-r--r--routers/api/v1/repo/transfer.go254
-rw-r--r--routers/api/v1/repo/tree.go70
-rw-r--r--routers/api/v1/repo/wiki.go536
50 files changed, 19756 insertions, 0 deletions
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
new file mode 100644
index 0000000..0c7506b
--- /dev/null
+++ b/routers/api/v1/repo/action.go
@@ -0,0 +1,653 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "net/http"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ secret_model "code.gitea.io/gitea/models/secret"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/shared"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ actions_service "code.gitea.io/gitea/services/actions"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ secret_service "code.gitea.io/gitea/services/secrets"
+)
+
+// ListActionsSecrets list an repo's actions secrets
+func (Action) ListActionsSecrets(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/secrets repository repoListActionsSecrets
+ // ---
+ // summary: List an repo's actions secrets
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repository
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/SecretList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+
+ opts := &secret_model.FindSecretsOptions{
+ RepoID: repo.ID,
+ ListOptions: utils.GetListOptions(ctx),
+ }
+
+ secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiSecrets := make([]*api.Secret, len(secrets))
+ for k, v := range secrets {
+ apiSecrets[k] = &api.Secret{
+ Name: v.Name,
+ Created: v.CreatedUnix.AsTime(),
+ }
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, apiSecrets)
+}
+
+// create or update one secret of the repository
+func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/actions/secrets/{secretname} repository updateRepoSecret
+ // ---
+ // summary: Create or Update a secret value in a repository
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repository
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: secretname
+ // in: path
+ // description: name of the secret
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateOrUpdateSecretOption"
+ // responses:
+ // "201":
+ // description: response when creating a secret
+ // "204":
+ // description: response when updating a secret
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+
+ opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
+
+ _, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.Params("secretname"), opt.Data)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
+ } else if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+ }
+ return
+ }
+
+ if created {
+ ctx.Status(http.StatusCreated)
+ } else {
+ ctx.Status(http.StatusNoContent)
+ }
+}
+
+// DeleteSecret delete one secret of the repository
+func (Action) DeleteSecret(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/actions/secrets/{secretname} repository deleteRepoSecret
+ // ---
+ // summary: Delete a secret in a repository
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repository
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: secretname
+ // in: path
+ // description: name of the secret
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // description: delete one secret of the organization
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+
+ err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.Params("secretname"))
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
+ } else if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "DeleteSecret", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// GetVariable get a repo-level variable
+func (Action) GetVariable(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/variables/{variablename} repository getRepoVariable
+ // ---
+ // summary: Get a repo-level variable
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: name of the owner
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: variablename
+ // in: path
+ // description: name of the variable
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActionVariable"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
+ RepoID: ctx.Repo.Repository.ID,
+ Name: ctx.Params("variablename"),
+ })
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "GetVariable", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
+ }
+ return
+ }
+
+ variable := &api.ActionVariable{
+ OwnerID: v.OwnerID,
+ RepoID: v.RepoID,
+ Name: v.Name,
+ Data: v.Data,
+ }
+
+ ctx.JSON(http.StatusOK, variable)
+}
+
+// DeleteVariable delete a repo-level variable
+func (Action) DeleteVariable(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/actions/variables/{variablename} repository deleteRepoVariable
+ // ---
+ // summary: Delete a repo-level variable
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: name of the owner
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: variablename
+ // in: path
+ // description: name of the variable
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActionVariable"
+ // "201":
+ // description: response when deleting a variable
+ // "204":
+ // description: response when deleting a variable
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if err := actions_service.DeleteVariableByName(ctx, 0, ctx.Repo.Repository.ID, ctx.Params("variablename")); err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err)
+ } else if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "DeleteVariableByName", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// CreateVariable create a repo-level variable
+func (Action) CreateVariable(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/actions/variables/{variablename} repository createRepoVariable
+ // ---
+ // summary: Create a repo-level variable
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: name of the owner
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: variablename
+ // in: path
+ // description: name of the variable
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateVariableOption"
+ // responses:
+ // "201":
+ // description: response when creating a repo-level variable
+ // "204":
+ // description: response when creating a repo-level variable
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opt := web.GetForm(ctx).(*api.CreateVariableOption)
+
+ repoID := ctx.Repo.Repository.ID
+ variableName := ctx.Params("variablename")
+
+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
+ RepoID: repoID,
+ Name: variableName,
+ })
+ if err != nil && !errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
+ return
+ }
+ if v != nil && v.ID > 0 {
+ ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName))
+ return
+ }
+
+ if _, err := actions_service.CreateVariable(ctx, 0, repoID, variableName, opt.Value); err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Error(http.StatusBadRequest, "CreateVariable", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateVariable", err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// UpdateVariable update a repo-level variable
+func (Action) UpdateVariable(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/actions/variables/{variablename} repository updateRepoVariable
+ // ---
+ // summary: Update a repo-level variable
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: name of the owner
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: variablename
+ // in: path
+ // description: name of the variable
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/UpdateVariableOption"
+ // responses:
+ // "201":
+ // description: response when updating a repo-level variable
+ // "204":
+ // description: response when updating a repo-level variable
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opt := web.GetForm(ctx).(*api.UpdateVariableOption)
+
+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
+ RepoID: ctx.Repo.Repository.ID,
+ Name: ctx.Params("variablename"),
+ })
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "GetVariable", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
+ }
+ return
+ }
+
+ if opt.Name == "" {
+ opt.Name = ctx.Params("variablename")
+ }
+ if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Error(http.StatusBadRequest, "UpdateVariable", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "UpdateVariable", err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// ListVariables list repo-level variables
+func (Action) ListVariables(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/variables repository getRepoVariablesList
+ // ---
+ // summary: Get repo-level variables list
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: name of the owner
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/VariableList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
+ RepoID: ctx.Repo.Repository.ID,
+ ListOptions: utils.GetListOptions(ctx),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindVariables", err)
+ return
+ }
+
+ variables := make([]*api.ActionVariable, len(vars))
+ for i, v := range vars {
+ variables[i] = &api.ActionVariable{
+ OwnerID: v.OwnerID,
+ RepoID: v.RepoID,
+ Name: v.Name,
+ }
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, variables)
+}
+
+// GetRegistrationToken returns the token to register repo runners
+func (Action) GetRegistrationToken(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/runners/registration-token repository repoGetRunnerRegistrationToken
+ // ---
+ // summary: Get a repository's actions runner registration token
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RegistrationToken"
+
+ shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID)
+}
+
+var _ actions_service.API = new(Action)
+
+// Action implements actions_service.API
+type Action struct{}
+
+// NewAction creates a new Action service
+func NewAction() actions_service.API {
+ return Action{}
+}
+
+// ListActionTasks list all the actions of a repository
+func ListActionTasks(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/tasks repository ListActionTasks
+ // ---
+ // summary: List a repository's action tasks
+ // 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: page
+ // in: query
+ // description: page number of results to return (1-based)
+ // type: integer
+ // - name: limit
+ // in: query
+ // description: page size of results, default maximum page size is 50
+ // type: integer
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TasksList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // "$ref": "#/responses/conflict"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ListActionTasks", err)
+ return
+ }
+
+ res := new(api.ActionTaskResponse)
+ res.TotalCount = total
+
+ res.Entries = make([]*api.ActionTask, len(tasks))
+ for i := range tasks {
+ convertedTask, err := convert.ToActionTask(ctx, tasks[i])
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ToActionTask", err)
+ return
+ }
+ res.Entries[i] = convertedTask
+ }
+
+ ctx.JSON(http.StatusOK, &res)
+}
+
+// DispatchWorkflow dispatches a workflow
+func DispatchWorkflow(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches repository DispatchWorkflow
+ // ---
+ // summary: Dispatches a workflow
+ // consumes:
+ // - 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: workflowname
+ // in: path
+ // description: name of the workflow
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/DispatchWorkflowOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opt := web.GetForm(ctx).(*api.DispatchWorkflowOption)
+ name := ctx.Params("workflowname")
+
+ if len(opt.Ref) == 0 {
+ ctx.Error(http.StatusBadRequest, "ref", "ref is empty")
+ return
+ } else if len(name) == 0 {
+ ctx.Error(http.StatusBadRequest, "workflowname", "workflow name is empty")
+ return
+ }
+
+ workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, opt.Ref, name)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.Error(http.StatusNotFound, "GetWorkflowFromCommit", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetWorkflowFromCommit", err)
+ }
+ return
+ }
+
+ inputGetter := func(key string) string {
+ return opt.Inputs[key]
+ }
+
+ if err := workflow.Dispatch(ctx, inputGetter, ctx.Repo.Repository, ctx.Doer); err != nil {
+ if actions_service.IsInputRequiredErr(err) {
+ ctx.Error(http.StatusBadRequest, "workflow.Dispatch", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "workflow.Dispatch", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusNoContent, nil)
+}
diff --git a/routers/api/v1/repo/avatar.go b/routers/api/v1/repo/avatar.go
new file mode 100644
index 0000000..698337f
--- /dev/null
+++ b/routers/api/v1/repo/avatar.go
@@ -0,0 +1,88 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "encoding/base64"
+ "net/http"
+
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// UpdateVatar updates the Avatar of an Repo
+func UpdateAvatar(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/avatar repository repoUpdateAvatar
+ // ---
+ // summary: Update avatar
+ // 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/UpdateRepoAvatarOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption)
+
+ content, err := base64.StdEncoding.DecodeString(form.Image)
+ if err != nil {
+ ctx.Error(http.StatusBadRequest, "DecodeImage", err)
+ return
+ }
+
+ err = repo_service.UploadAvatar(ctx, ctx.Repo.Repository, content)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UploadAvatar", err)
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// UpdateAvatar deletes the Avatar of an Repo
+func DeleteAvatar(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/avatar repository repoDeleteAvatar
+ // ---
+ // summary: Delete avatar
+ // 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
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err)
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go
new file mode 100644
index 0000000..3b11666
--- /dev/null
+++ b/routers/api/v1/repo/blob.go
@@ -0,0 +1,55 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/services/context"
+ files_service "code.gitea.io/gitea/services/repository/files"
+)
+
+// GetBlob get the blob of a repository file.
+func GetBlob(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/blobs/{sha} repository GetBlob
+ // ---
+ // summary: Gets the blob of a repository.
+ // 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: sha
+ // in: path
+ // description: sha of the commit
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/GitBlobResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := ctx.Params("sha")
+ if len(sha) == 0 {
+ ctx.Error(http.StatusBadRequest, "", "sha not provided")
+ return
+ }
+
+ if blob, err := files_service.GetBlobBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil {
+ ctx.Error(http.StatusBadRequest, "", err)
+ } else {
+ ctx.JSON(http.StatusOK, blob)
+ }
+}
diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go
new file mode 100644
index 0000000..a468fd9
--- /dev/null
+++ b/routers/api/v1/repo/branch.go
@@ -0,0 +1,1019 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/optional"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ pull_service "code.gitea.io/gitea/services/pull"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// GetBranch get a branch of a repository
+func GetBranch(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/branches/{branch} repository repoGetBranch
+ // ---
+ // summary: Retrieve a specific branch from a repository, including its effective branch protection
+ // 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: branch
+ // in: path
+ // description: branch to get
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Branch"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ branchName := ctx.Params("*")
+
+ branch, err := ctx.Repo.GitRepo.GetBranch(branchName)
+ if err != nil {
+ if git.IsErrBranchNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetBranch", err)
+ }
+ return
+ }
+
+ c, err := branch.GetCommit()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+
+ branchProtection, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, branchName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranchProtection", err)
+ return
+ }
+
+ br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, br)
+}
+
+// DeleteBranch get a branch of a repository
+func DeleteBranch(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/branches/{branch} repository repoDeleteBranch
+ // ---
+ // summary: Delete a specific branch from a repository
+ // 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: branch
+ // in: path
+ // description: branch to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Error(http.StatusNotFound, "", "Git Repository is empty.")
+ return
+ }
+
+ if ctx.Repo.Repository.IsMirror {
+ ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.")
+ return
+ }
+
+ branchName := ctx.Params("*")
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Error(http.StatusForbidden, "", "Git Repository is empty.")
+ return
+ }
+
+ // check whether branches of this repository has been synced
+ totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{
+ RepoID: ctx.Repo.Repository.ID,
+ IsDeletedBranch: optional.Some(false),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CountBranches", err)
+ return
+ }
+ if totalNumOfBranches == 0 { // sync branches immediately because non-empty repository should have at least 1 branch
+ _, err = repo_module.SyncRepoBranches(ctx, ctx.Repo.Repository.ID, 0)
+ if err != nil {
+ ctx.ServerError("SyncRepoBranches", err)
+ return
+ }
+ }
+
+ if ctx.Repo.Repository.IsMirror {
+ ctx.Error(http.StatusForbidden, "IsMirrored", fmt.Errorf("can not delete branch of an mirror repository"))
+ return
+ }
+
+ if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); 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
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// CreateBranch creates a branch for a user's repository
+func CreateBranch(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/branches repository repoCreateBranch
+ // ---
+ // summary: Create a branch
+ // 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/CreateBranchRepoOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Branch"
+ // "403":
+ // description: The branch is archived or a mirror.
+ // "404":
+ // description: The old branch does not exist.
+ // "409":
+ // description: The branch with the same name already exists.
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Error(http.StatusNotFound, "", "Git Repository is empty.")
+ return
+ }
+
+ if ctx.Repo.Repository.IsMirror {
+ ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.")
+ return
+ }
+
+ opt := web.GetForm(ctx).(*api.CreateBranchRepoOption)
+
+ var oldCommit *git.Commit
+ var err error
+
+ if len(opt.OldRefName) > 0 {
+ oldCommit, err = ctx.Repo.GitRepo.GetCommit(opt.OldRefName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+ } else if len(opt.OldBranchName) > 0 { //nolint
+ if ctx.Repo.GitRepo.IsBranchExist(opt.OldBranchName) { //nolint
+ oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(opt.OldBranchName) //nolint
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err)
+ return
+ }
+ } else {
+ ctx.Error(http.StatusNotFound, "", "The old branch does not exist")
+ return
+ }
+ } else {
+ oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err)
+ return
+ }
+ }
+
+ err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, oldCommit.ID.String(), opt.BranchName)
+ if err != nil {
+ if git_model.IsErrBranchNotExist(err) {
+ ctx.Error(http.StatusNotFound, "", "The old branch does not exist")
+ } else if models.IsErrTagAlreadyExists(err) {
+ ctx.Error(http.StatusConflict, "", "The branch with the same tag already exists.")
+ } else if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) {
+ ctx.Error(http.StatusConflict, "", "The branch already exists.")
+ } else if git_model.IsErrBranchNameConflict(err) {
+ ctx.Error(http.StatusConflict, "", "The branch with the same name already exists.")
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateNewBranchFromCommit", err)
+ }
+ return
+ }
+
+ branch, err := ctx.Repo.GitRepo.GetBranch(opt.BranchName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranch", err)
+ return
+ }
+
+ commit, err := branch.GetCommit()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+
+ branchProtection, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, branch.Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranchProtection", err)
+ return
+ }
+
+ br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, commit, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, br)
+}
+
+// ListBranches list all the branches of a repository
+func ListBranches(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/branches repository repoListBranches
+ // ---
+ // summary: List a repository's branches
+ // 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: 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/BranchList"
+
+ var totalNumOfBranches int64
+ var apiBranches []*api.Branch
+
+ listOptions := utils.GetListOptions(ctx)
+
+ if !ctx.Repo.Repository.IsEmpty {
+ if ctx.Repo.GitRepo == nil {
+ ctx.Error(http.StatusInternalServerError, "Load git repository failed", nil)
+ return
+ }
+
+ branchOpts := git_model.FindBranchOptions{
+ ListOptions: listOptions,
+ RepoID: ctx.Repo.Repository.ID,
+ IsDeletedBranch: optional.Some(false),
+ }
+ var err error
+ totalNumOfBranches, err = db.Count[git_model.Branch](ctx, branchOpts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CountBranches", err)
+ return
+ }
+ if totalNumOfBranches == 0 { // sync branches immediately because non-empty repository should have at least 1 branch
+ totalNumOfBranches, err = repo_module.SyncRepoBranches(ctx, ctx.Repo.Repository.ID, 0)
+ if err != nil {
+ ctx.ServerError("SyncRepoBranches", err)
+ return
+ }
+ }
+
+ rules, err := git_model.FindRepoProtectedBranchRules(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindMatchedProtectedBranchRules", err)
+ return
+ }
+
+ branches, err := db.Find[git_model.Branch](ctx, branchOpts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBranches", err)
+ return
+ }
+
+ apiBranches = make([]*api.Branch, 0, len(branches))
+ for i := range branches {
+ c, err := ctx.Repo.GitRepo.GetBranchCommit(branches[i].Name)
+ if err != nil {
+ // Skip if this branch doesn't exist anymore.
+ if git.IsErrNotExist(err) {
+ totalNumOfBranches--
+ continue
+ }
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+
+ branchProtection := rules.GetFirstMatched(branches[i].Name)
+ apiBranch, err := convert.ToBranch(ctx, ctx.Repo.Repository, branches[i].Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
+ return
+ }
+ apiBranches = append(apiBranches, apiBranch)
+ }
+ }
+
+ ctx.SetLinkHeader(int(totalNumOfBranches), listOptions.PageSize)
+ ctx.SetTotalCountHeader(totalNumOfBranches)
+ ctx.JSON(http.StatusOK, apiBranches)
+}
+
+// GetBranchProtection gets a branch protection
+func GetBranchProtection(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/branch_protections/{name} repository repoGetBranchProtection
+ // ---
+ // summary: Get a specific branch protection for the repository
+ // 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: name
+ // in: path
+ // description: name of protected branch
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/BranchProtection"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+ bpName := ctx.Params(":name")
+ bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err)
+ return
+ }
+ if bp == nil || bp.RepoID != repo.ID {
+ ctx.NotFound()
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp, repo))
+}
+
+// ListBranchProtections list branch protections for a repo
+func ListBranchProtections(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/branch_protections repository repoListBranchProtection
+ // ---
+ // summary: List branch protections for a repository
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/BranchProtectionList"
+
+ repo := ctx.Repo.Repository
+ bps, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranches", err)
+ return
+ }
+ apiBps := make([]*api.BranchProtection, len(bps))
+ for i := range bps {
+ apiBps[i] = convert.ToBranchProtection(ctx, bps[i], repo)
+ }
+
+ ctx.JSON(http.StatusOK, apiBps)
+}
+
+// CreateBranchProtection creates a branch protection for a repo
+func CreateBranchProtection(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/branch_protections repository repoCreateBranchProtection
+ // ---
+ // summary: Create a branch protections for a repository
+ // 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/CreateBranchProtectionOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/BranchProtection"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ form := web.GetForm(ctx).(*api.CreateBranchProtectionOption)
+ repo := ctx.Repo.Repository
+
+ ruleName := form.RuleName
+ if ruleName == "" {
+ ruleName = form.BranchName //nolint
+ }
+ if len(ruleName) == 0 {
+ ctx.Error(http.StatusBadRequest, "both rule_name and branch_name are empty", "both rule_name and branch_name are empty")
+ return
+ }
+
+ isPlainRule := !git_model.IsRuleNameSpecial(ruleName)
+ var isBranchExist bool
+ if isPlainRule {
+ isBranchExist = git.IsBranchExist(ctx.Req.Context(), ctx.Repo.Repository.RepoPath(), ruleName)
+ }
+
+ protectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, ruleName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectBranchOfRepoByName", err)
+ return
+ } else if protectBranch != nil {
+ ctx.Error(http.StatusForbidden, "Create branch protection", "Branch protection already exist")
+ return
+ }
+
+ var requiredApprovals int64
+ if form.RequiredApprovals > 0 {
+ requiredApprovals = form.RequiredApprovals
+ }
+
+ whitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ approvalsWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
+ if repo.Owner.IsOrganization() {
+ whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ }
+
+ protectBranch = &git_model.ProtectedBranch{
+ RepoID: ctx.Repo.Repository.ID,
+ RuleName: ruleName,
+ CanPush: form.EnablePush,
+ EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
+ EnableMergeWhitelist: form.EnableMergeWhitelist,
+ WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys,
+ EnableStatusCheck: form.EnableStatusCheck,
+ StatusCheckContexts: form.StatusCheckContexts,
+ EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
+ RequiredApprovals: requiredApprovals,
+ BlockOnRejectedReviews: form.BlockOnRejectedReviews,
+ BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests,
+ DismissStaleApprovals: form.DismissStaleApprovals,
+ IgnoreStaleApprovals: form.IgnoreStaleApprovals,
+ RequireSignedCommits: form.RequireSignedCommits,
+ ProtectedFilePatterns: form.ProtectedFilePatterns,
+ UnprotectedFilePatterns: form.UnprotectedFilePatterns,
+ BlockOnOutdatedBranch: form.BlockOnOutdatedBranch,
+ ApplyToAdmins: form.ApplyToAdmins,
+ }
+
+ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
+ UserIDs: whitelistUsers,
+ TeamIDs: whitelistTeams,
+ MergeUserIDs: mergeWhitelistUsers,
+ MergeTeamIDs: mergeWhitelistTeams,
+ ApprovalsUserIDs: approvalsWhitelistUsers,
+ ApprovalsTeamIDs: approvalsWhitelistTeams,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err)
+ return
+ }
+
+ if isBranchExist {
+ if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, ruleName); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err)
+ return
+ }
+ } else {
+ if !isPlainRule {
+ if ctx.Repo.GitRepo == nil {
+ ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
+ return
+ }
+ defer func() {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }()
+ }
+ // FIXME: since we only need to recheck files protected rules, we could improve this
+ matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, ruleName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindAllMatchedBranches", err)
+ return
+ }
+
+ for _, branchName := range matchedBranches {
+ if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err)
+ return
+ }
+ }
+ }
+ }
+
+ // Reload from db to get all whitelists
+ bp, err := git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, ruleName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err)
+ return
+ }
+ if bp == nil || bp.RepoID != ctx.Repo.Repository.ID {
+ ctx.Error(http.StatusInternalServerError, "New branch protection not found", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToBranchProtection(ctx, bp, repo))
+}
+
+// EditBranchProtection edits a branch protection for a repo
+func EditBranchProtection(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/branch_protections/{name} repository repoEditBranchProtection
+ // ---
+ // summary: Edit a branch protections for a repository. Only fields that are set will be changed
+ // 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: name
+ // in: path
+ // description: name of protected branch
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditBranchProtectionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/BranchProtection"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ form := web.GetForm(ctx).(*api.EditBranchProtectionOption)
+ repo := ctx.Repo.Repository
+ bpName := ctx.Params(":name")
+ protectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err)
+ return
+ }
+ if protectBranch == nil || protectBranch.RepoID != repo.ID {
+ ctx.NotFound()
+ return
+ }
+
+ if form.EnablePush != nil {
+ if !*form.EnablePush {
+ protectBranch.CanPush = false
+ protectBranch.EnableWhitelist = false
+ protectBranch.WhitelistDeployKeys = false
+ } else {
+ protectBranch.CanPush = true
+ if form.EnablePushWhitelist != nil {
+ if !*form.EnablePushWhitelist {
+ protectBranch.EnableWhitelist = false
+ protectBranch.WhitelistDeployKeys = false
+ } else {
+ protectBranch.EnableWhitelist = true
+ if form.PushWhitelistDeployKeys != nil {
+ protectBranch.WhitelistDeployKeys = *form.PushWhitelistDeployKeys
+ }
+ }
+ }
+ }
+ }
+
+ if form.EnableMergeWhitelist != nil {
+ protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
+ }
+
+ if form.EnableStatusCheck != nil {
+ protectBranch.EnableStatusCheck = *form.EnableStatusCheck
+ }
+
+ if form.StatusCheckContexts != nil {
+ protectBranch.StatusCheckContexts = form.StatusCheckContexts
+ }
+
+ if form.RequiredApprovals != nil && *form.RequiredApprovals >= 0 {
+ protectBranch.RequiredApprovals = *form.RequiredApprovals
+ }
+
+ if form.EnableApprovalsWhitelist != nil {
+ protectBranch.EnableApprovalsWhitelist = *form.EnableApprovalsWhitelist
+ }
+
+ if form.BlockOnRejectedReviews != nil {
+ protectBranch.BlockOnRejectedReviews = *form.BlockOnRejectedReviews
+ }
+
+ if form.BlockOnOfficialReviewRequests != nil {
+ protectBranch.BlockOnOfficialReviewRequests = *form.BlockOnOfficialReviewRequests
+ }
+
+ if form.DismissStaleApprovals != nil {
+ protectBranch.DismissStaleApprovals = *form.DismissStaleApprovals
+ }
+
+ if form.IgnoreStaleApprovals != nil {
+ protectBranch.IgnoreStaleApprovals = *form.IgnoreStaleApprovals
+ }
+
+ if form.RequireSignedCommits != nil {
+ protectBranch.RequireSignedCommits = *form.RequireSignedCommits
+ }
+
+ if form.ProtectedFilePatterns != nil {
+ protectBranch.ProtectedFilePatterns = *form.ProtectedFilePatterns
+ }
+
+ if form.UnprotectedFilePatterns != nil {
+ protectBranch.UnprotectedFilePatterns = *form.UnprotectedFilePatterns
+ }
+
+ if form.BlockOnOutdatedBranch != nil {
+ protectBranch.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch
+ }
+
+ if form.ApplyToAdmins != nil {
+ protectBranch.ApplyToAdmins = *form.ApplyToAdmins
+ }
+
+ var whitelistUsers []int64
+ if form.PushWhitelistUsernames != nil {
+ whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ } else {
+ whitelistUsers = protectBranch.WhitelistUserIDs
+ }
+ var mergeWhitelistUsers []int64
+ if form.MergeWhitelistUsernames != nil {
+ mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ } else {
+ mergeWhitelistUsers = protectBranch.MergeWhitelistUserIDs
+ }
+ var approvalsWhitelistUsers []int64
+ if form.ApprovalsWhitelistUsernames != nil {
+ approvalsWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ } else {
+ approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
+ }
+
+ var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
+ if repo.Owner.IsOrganization() {
+ if form.PushWhitelistTeams != nil {
+ whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ } else {
+ whitelistTeams = protectBranch.WhitelistTeamIDs
+ }
+ if form.MergeWhitelistTeams != nil {
+ mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ } else {
+ mergeWhitelistTeams = protectBranch.MergeWhitelistTeamIDs
+ }
+ if form.ApprovalsWhitelistTeams != nil {
+ approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ } else {
+ approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs
+ }
+ }
+
+ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
+ UserIDs: whitelistUsers,
+ TeamIDs: whitelistTeams,
+ MergeUserIDs: mergeWhitelistUsers,
+ MergeTeamIDs: mergeWhitelistTeams,
+ ApprovalsUserIDs: approvalsWhitelistUsers,
+ ApprovalsTeamIDs: approvalsWhitelistTeams,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err)
+ return
+ }
+
+ isPlainRule := !git_model.IsRuleNameSpecial(bpName)
+ var isBranchExist bool
+ if isPlainRule {
+ isBranchExist = git.IsBranchExist(ctx.Req.Context(), ctx.Repo.Repository.RepoPath(), bpName)
+ }
+
+ if isBranchExist {
+ if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, bpName); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CheckPrsForBaseBranch", err)
+ return
+ }
+ } else {
+ if !isPlainRule {
+ if ctx.Repo.GitRepo == nil {
+ ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
+ return
+ }
+ defer func() {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }()
+ }
+
+ // FIXME: since we only need to recheck files protected rules, we could improve this
+ matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindAllMatchedBranches", err)
+ return
+ }
+
+ for _, branchName := range matchedBranches {
+ if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CheckPrsForBaseBranch", err)
+ return
+ }
+ }
+ }
+ }
+
+ // Reload from db to ensure get all whitelists
+ bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranchBy", err)
+ return
+ }
+ if bp == nil || bp.RepoID != ctx.Repo.Repository.ID {
+ ctx.Error(http.StatusInternalServerError, "New branch protection not found", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp, repo))
+}
+
+// DeleteBranchProtection deletes a branch protection for a repo
+func DeleteBranchProtection(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/branch_protections/{name} repository repoDeleteBranchProtection
+ // ---
+ // summary: Delete a specific branch protection for the repository
+ // 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: name
+ // in: path
+ // description: name of protected branch
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+ bpName := ctx.Params(":name")
+ bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err)
+ return
+ }
+ if bp == nil || bp.RepoID != repo.ID {
+ ctx.NotFound()
+ return
+ }
+
+ if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, bp.ID); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteProtectedBranch", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go
new file mode 100644
index 0000000..a43a21a
--- /dev/null
+++ b/routers/api/v1/repo/collaborators.go
@@ -0,0 +1,370 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// ListCollaborators list a repository's collaborators
+func ListCollaborators(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/collaborators repository repoListCollaborators
+ // ---
+ // summary: List a repository's collaborators
+ // 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: 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/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ count, err := db.Count[repo_model.Collaboration](ctx, repo_model.FindCollaborationOptions{
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ collaborators, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ListCollaborators", err)
+ return
+ }
+
+ users := make([]*api.User, len(collaborators))
+ for i, collaborator := range collaborators {
+ users[i] = convert.ToUser(ctx, collaborator.User, ctx.Doer)
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, users)
+}
+
+// IsCollaborator check if a user is a collaborator of a repository
+func IsCollaborator(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator} repository repoCheckCollaborator
+ // ---
+ // summary: Check if a user is a collaborator of a repository
+ // 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: collaborator
+ // in: path
+ // description: username of the collaborator
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ user, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+ isColab, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, user.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "IsCollaborator", err)
+ return
+ }
+ if isColab {
+ ctx.Status(http.StatusNoContent)
+ } else {
+ ctx.NotFound()
+ }
+}
+
+// AddCollaborator add a collaborator to a repository
+func AddCollaborator(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/collaborators/{collaborator} repository repoAddCollaborator
+ // ---
+ // summary: Add a collaborator to a repository
+ // 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: collaborator
+ // in: path
+ // description: username of the collaborator to add
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/AddCollaboratorOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+
+ form := web.GetForm(ctx).(*api.AddCollaboratorOption)
+
+ collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+
+ if !collaborator.IsActive {
+ ctx.Error(http.StatusInternalServerError, "InactiveCollaborator", errors.New("collaborator's account is inactive"))
+ return
+ }
+
+ if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil {
+ if errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.Error(http.StatusForbidden, "AddCollaborator", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
+ }
+ return
+ }
+
+ if form.Permission != nil {
+ if err := repo_model.ChangeCollaborationAccessMode(ctx, ctx.Repo.Repository, collaborator.ID, perm.ParseAccessMode(*form.Permission)); err != nil {
+ ctx.Error(http.StatusInternalServerError, "ChangeCollaborationAccessMode", err)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// DeleteCollaborator delete a collaborator from a repository
+func DeleteCollaborator(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/collaborators/{collaborator} repository repoDeleteCollaborator
+ // ---
+ // summary: Delete a collaborator from a repository
+ // 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: collaborator
+ // in: path
+ // description: username of the collaborator to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+
+ if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator.ID); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteCollaboration", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+// GetRepoPermissions gets repository permissions for a user
+func GetRepoPermissions(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission repository repoGetRepoPermissions
+ // ---
+ // summary: Get repository permissions for a user
+ // 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: collaborator
+ // in: path
+ // description: username of the collaborator
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RepoCollaboratorPermission"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+
+ if !ctx.Doer.IsAdmin && ctx.Doer.LoginName != ctx.Params(":collaborator") && !ctx.IsUserRepoAdmin() {
+ ctx.Error(http.StatusForbidden, "User", "Only admins can query all permissions, repo admins can query all repo permissions, collaborators can query only their own")
+ return
+ }
+
+ collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusNotFound, "GetUserByName", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+
+ permission, err := access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, collaborator)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToUserAndPermission(ctx, collaborator, ctx.ContextUser, permission.AccessMode))
+}
+
+// GetReviewers return all users that can be requested to review in this repo
+func GetReviewers(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/reviewers repository repoGetReviewers
+ // ---
+ // summary: Return all users that can be requested to review in this repo
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ reviewers, err := repo_model.GetReviewers(ctx, ctx.Repo.Repository, ctx.Doer.ID, 0)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ListCollaborators", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, reviewers))
+}
+
+// GetAssignees return all users that have write access and can be assigned to issues
+func GetAssignees(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/assignees repository repoGetAssignees
+ // ---
+ // summary: Return all users that have write access and can be assigned to issues
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ListCollaborators", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, assignees))
+}
diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go
new file mode 100644
index 0000000..c5e8cf9
--- /dev/null
+++ b/routers/api/v1/repo/commits.go
@@ -0,0 +1,376 @@
+// Copyright 2018 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "math"
+ "net/http"
+ "strconv"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// GetSingleCommit get a commit via sha
+func GetSingleCommit(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha} repository repoGetSingleCommit
+ // ---
+ // summary: Get a single commit from a repository
+ // 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: sha
+ // in: path
+ // description: a git ref or commit sha
+ // type: string
+ // required: true
+ // - name: stat
+ // in: query
+ // description: include diff stats for every commit (disable for speedup, default 'true')
+ // type: boolean
+ // - 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/Commit"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := ctx.Params(":sha")
+ if !git.IsValidRefPattern(sha) {
+ ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha))
+ return
+ }
+
+ getCommit(ctx, sha, convert.ParseCommitOptions(ctx))
+}
+
+func getCommit(ctx *context.APIContext, identifier string, toCommitOpts convert.ToCommitOptions) {
+ commit, err := ctx.Repo.GitRepo.GetCommit(identifier)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(identifier)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "gitRepo.GetCommit", err)
+ return
+ }
+
+ json, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, nil, toCommitOpts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "toCommit", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, json)
+}
+
+// GetAllCommits get all commits via
+func GetAllCommits(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/commits repository repoGetAllCommits
+ // ---
+ // summary: Get a list of all commits from a repository
+ // 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: sha
+ // in: query
+ // description: SHA or branch to start listing commits from (usually 'master')
+ // type: string
+ // - name: path
+ // in: query
+ // description: filepath of a file/dir
+ // type: string
+ // - name: stat
+ // in: query
+ // description: include diff stats for every commit (disable for speedup, default 'true')
+ // type: boolean
+ // - 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
+ // - name: page
+ // in: query
+ // description: page number of results to return (1-based)
+ // type: integer
+ // - name: limit
+ // in: query
+ // description: page size of results (ignored if used with 'path')
+ // type: integer
+ // - name: not
+ // in: query
+ // description: commits that match the given specifier will not be listed.
+ // type: string
+ // responses:
+ // "200":
+ // "$ref": "#/responses/CommitList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // "$ref": "#/responses/EmptyRepository"
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.JSON(http.StatusConflict, api.APIError{
+ Message: "Git Repository is empty.",
+ URL: setting.API.SwaggerURL,
+ })
+ return
+ }
+
+ listOptions := utils.GetListOptions(ctx)
+ if listOptions.Page <= 0 {
+ listOptions.Page = 1
+ }
+
+ if listOptions.PageSize > setting.Git.CommitsRangeSize {
+ listOptions.PageSize = setting.Git.CommitsRangeSize
+ }
+
+ sha := ctx.FormString("sha")
+ path := ctx.FormString("path")
+ not := ctx.FormString("not")
+
+ var (
+ commitsCountTotal int64
+ commits []*git.Commit
+ err error
+ )
+
+ if len(path) == 0 {
+ var baseCommit *git.Commit
+ if len(sha) == 0 {
+ // no sha supplied - use default branch
+ head, err := ctx.Repo.GitRepo.GetHEADBranch()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetHEADBranch", err)
+ return
+ }
+
+ baseCommit, err = ctx.Repo.GitRepo.GetBranchCommit(head.Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+ } else {
+ // get commit specified by sha
+ baseCommit, err = ctx.Repo.GitRepo.GetCommit(sha)
+ if err != nil {
+ ctx.NotFoundOrServerError("GetCommit", git.IsErrNotExist, err)
+ return
+ }
+ }
+
+ // Total commit count
+ commitsCountTotal, err = git.CommitsCount(ctx.Repo.GitRepo.Ctx, git.CommitsCountOptions{
+ RepoPath: ctx.Repo.GitRepo.Path,
+ Not: not,
+ Revision: []string{baseCommit.ID.String()},
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommitsCount", err)
+ return
+ }
+
+ // Query commits
+ commits, err = baseCommit.CommitsByRange(listOptions.Page, listOptions.PageSize, not)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CommitsByRange", err)
+ return
+ }
+ } else {
+ if len(sha) == 0 {
+ sha = ctx.Repo.Repository.DefaultBranch
+ }
+
+ commitsCountTotal, err = git.CommitsCount(ctx,
+ git.CommitsCountOptions{
+ RepoPath: ctx.Repo.GitRepo.Path,
+ Not: not,
+ Revision: []string{sha},
+ RelPath: []string{path},
+ })
+
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FileCommitsCount", err)
+ return
+ } else if commitsCountTotal == 0 {
+ ctx.NotFound("FileCommitsCount", nil)
+ return
+ }
+
+ commits, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
+ git.CommitsByFileAndRangeOptions{
+ Revision: sha,
+ File: path,
+ Not: not,
+ Page: listOptions.Page,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CommitsByFileAndRange", err)
+ return
+ }
+ }
+
+ pageCount := int(math.Ceil(float64(commitsCountTotal) / float64(listOptions.PageSize)))
+ userCache := make(map[string]*user_model.User)
+ apiCommits := make([]*api.Commit, len(commits))
+
+ for i, commit := range commits {
+ // Create json struct
+ apiCommits[i], err = convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, userCache, convert.ParseCommitOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "toCommit", err)
+ return
+ }
+ }
+
+ ctx.SetLinkHeader(int(commitsCountTotal), listOptions.PageSize)
+ ctx.SetTotalCountHeader(commitsCountTotal)
+
+ // kept for backwards compatibility
+ ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page))
+ ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize))
+ ctx.RespHeader().Set("X-Total", strconv.FormatInt(commitsCountTotal, 10))
+ ctx.RespHeader().Set("X-PageCount", strconv.Itoa(pageCount))
+ ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < pageCount))
+ ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-Total", "X-PageCount", "X-HasMore")
+
+ ctx.JSON(http.StatusOK, &apiCommits)
+}
+
+// DownloadCommitDiffOrPatch render a commit's raw diff or patch
+func DownloadCommitDiffOrPatch(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha}.{diffType} repository repoDownloadCommitDiffOrPatch
+ // ---
+ // summary: Get a commit's 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: sha
+ // in: path
+ // description: SHA of the commit to get
+ // type: string
+ // required: true
+ // - name: diffType
+ // in: path
+ // description: whether the output is diff or patch
+ // type: string
+ // enum: [diff, patch]
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/string"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ sha := ctx.Params(":sha")
+ diffType := git.RawDiffType(ctx.Params(":diffType"))
+
+ if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, diffType, ctx.Resp); err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(sha)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DownloadCommitDiffOrPatch", err)
+ return
+ }
+}
+
+// GetCommitPullRequest returns the pull request of the commit
+func GetCommitPullRequest(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/commits/{sha}/pull repository repoGetCommitPullRequest
+ // ---
+ // summary: Get the pull request of the commit
+ // 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: sha
+ // in: path
+ // description: SHA of the commit to get
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullRequest"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ pr, err := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, ctx.Params("ref"))
+ if err != nil {
+ if issues_model.IsErrPullRequestNotExist(err) {
+ ctx.Error(http.StatusNotFound, "GetPullRequestByMergedCommit", err)
+ } 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))
+}
diff --git a/routers/api/v1/repo/compare.go b/routers/api/v1/repo/compare.go
new file mode 100644
index 0000000..429145c
--- /dev/null
+++ b/routers/api/v1/repo/compare.go
@@ -0,0 +1,99 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// CompareDiff compare two branches or commits
+func CompareDiff(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff
+ // ---
+ // summary: Get commit comparison information
+ // 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: basehead
+ // in: path
+ // description: compare two branches or commits
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Compare"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if ctx.Repo.GitRepo == nil {
+ gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
+ return
+ }
+ ctx.Repo.GitRepo = gitRepo
+ defer gitRepo.Close()
+ }
+
+ infoPath := ctx.Params("*")
+ infos := []string{ctx.Repo.Repository.DefaultBranch, ctx.Repo.Repository.DefaultBranch}
+ if infoPath != "" {
+ infos = strings.SplitN(infoPath, "...", 2)
+ if len(infos) != 2 {
+ if infos = strings.SplitN(infoPath, "..", 2); len(infos) != 2 {
+ infos = []string{ctx.Repo.Repository.DefaultBranch, infoPath}
+ }
+ }
+ }
+
+ _, headGitRepo, ci, _, _ := parseCompareInfo(ctx, api.CreatePullRequestOption{
+ Base: infos[0],
+ Head: infos[1],
+ })
+ if ctx.Written() {
+ return
+ }
+ defer headGitRepo.Close()
+
+ verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
+ files := ctx.FormString("files") == "" || ctx.FormBool("files")
+
+ apiCommits := make([]*api.Commit, 0, len(ci.Commits))
+ userCache := make(map[string]*user_model.User)
+ for i := 0; i < len(ci.Commits); i++ {
+ apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, ci.Commits[i], userCache,
+ convert.ToCommitOptions{
+ Stat: true,
+ Verification: verification,
+ Files: files,
+ })
+ if err != nil {
+ ctx.ServerError("toCommit", err)
+ return
+ }
+ apiCommits = append(apiCommits, apiCommit)
+ }
+
+ ctx.JSON(http.StatusOK, &api.Compare{
+ TotalCommits: len(ci.Commits),
+ Commits: apiCommits,
+ })
+}
diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go
new file mode 100644
index 0000000..1fa44d5
--- /dev/null
+++ b/routers/api/v1/repo/file.go
@@ -0,0 +1,1014 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/httpcache"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/common"
+ "code.gitea.io/gitea/services/context"
+ archiver_service "code.gitea.io/gitea/services/repository/archiver"
+ files_service "code.gitea.io/gitea/services/repository/files"
+)
+
+const (
+ giteaObjectTypeHeader = "X-Gitea-Object-Type"
+ forgejoObjectTypeHeader = "X-Forgejo-Object-Type"
+)
+
+// GetRawFile get a file by path on a repository
+func GetRawFile(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/raw/{filepath} repository repoGetRawFile
+ // ---
+ // summary: Get a file from a repository
+ // produces:
+ // - application/octet-stream
+ // 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: filepath
+ // in: path
+ // description: filepath of the file to get
+ // type: string
+ // required: true
+ // - name: ref
+ // in: query
+ // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
+ // type: string
+ // required: false
+ // responses:
+ // 200:
+ // description: Returns raw file content.
+ // schema:
+ // type: file
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.NotFound()
+ return
+ }
+
+ blob, entry, lastModified := getBlobForEntry(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
+ ctx.RespHeader().Set(forgejoObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
+
+ if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil {
+ ctx.Error(http.StatusInternalServerError, "ServeBlob", err)
+ }
+}
+
+// GetRawFileOrLFS get a file by repo's path, redirecting to LFS if necessary.
+func GetRawFileOrLFS(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/media/{filepath} repository repoGetRawFileOrLFS
+ // ---
+ // summary: Get a file or it's LFS object from a repository
+ // produces:
+ // - application/octet-stream
+ // 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: filepath
+ // in: path
+ // description: filepath of the file to get
+ // type: string
+ // required: true
+ // - name: ref
+ // in: query
+ // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
+ // type: string
+ // required: false
+ // responses:
+ // 200:
+ // description: Returns raw file content.
+ // schema:
+ // type: file
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.NotFound()
+ return
+ }
+
+ blob, entry, lastModified := getBlobForEntry(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
+ ctx.RespHeader().Set(forgejoObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
+
+ // LFS Pointer files are at most 1024 bytes - so any blob greater than 1024 bytes cannot be an LFS file
+ if blob.Size() > 1024 {
+ // First handle caching for the blob
+ if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
+ return
+ }
+
+ // OK not cached - serve!
+ if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil {
+ ctx.ServerError("ServeBlob", err)
+ }
+ return
+ }
+
+ // OK, now the blob is known to have at most 1024 bytes we can simply read this in one go (This saves reading it twice)
+ dataRc, err := blob.DataAsync()
+ if err != nil {
+ ctx.ServerError("DataAsync", err)
+ return
+ }
+
+ // FIXME: code from #19689, what if the file is large ... OOM ...
+ buf, err := io.ReadAll(dataRc)
+ if err != nil {
+ _ = dataRc.Close()
+ ctx.ServerError("DataAsync", err)
+ return
+ }
+
+ if err := dataRc.Close(); err != nil {
+ log.Error("Error whilst closing blob %s reader in %-v. Error: %v", blob.ID, ctx.Repo.Repository, err)
+ }
+
+ // Check if the blob represents a pointer
+ pointer, _ := lfs.ReadPointer(bytes.NewReader(buf))
+
+ // if it's not a pointer, just serve the data directly
+ if !pointer.IsValid() {
+ // First handle caching for the blob
+ if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
+ return
+ }
+
+ // OK not cached - serve!
+ common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
+ return
+ }
+
+ // Now check if there is a MetaObject for this pointer
+ meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid)
+
+ // If there isn't one, just serve the data directly
+ if err == git_model.ErrLFSObjectNotExist {
+ // Handle caching for the blob SHA (not the LFS object OID)
+ if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
+ return
+ }
+
+ common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
+ return
+ } else if err != nil {
+ ctx.ServerError("GetLFSMetaObjectByOid", err)
+ return
+ }
+
+ // Handle caching for the LFS object OID
+ if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) {
+ return
+ }
+
+ if setting.LFS.Storage.MinioConfig.ServeDirect {
+ // If we have a signed url (S3, object storage), redirect to this directly.
+ u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name())
+ if u != nil && err == nil {
+ ctx.Redirect(u.String())
+ return
+ }
+ }
+
+ lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer)
+ if err != nil {
+ ctx.ServerError("ReadMetaObject", err)
+ return
+ }
+ defer lfsDataRc.Close()
+
+ common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc)
+}
+
+func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
+ entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetTreeEntryByPath", err)
+ }
+ return nil, nil, nil
+ }
+
+ if entry.IsDir() || entry.IsSubModule() {
+ ctx.NotFound("getBlobForEntry", nil)
+ return nil, nil, nil
+ }
+
+ info, _, err := git.Entries([]*git.TreeEntry{entry}).GetCommitsInfo(ctx, ctx.Repo.Commit, path.Dir("/" + ctx.Repo.TreePath)[1:])
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommitsInfo", err)
+ return nil, nil, nil
+ }
+
+ if len(info) == 1 {
+ // Not Modified
+ lastModified = &info[0].Commit.Committer.When
+ }
+ blob = entry.Blob()
+
+ return blob, entry, lastModified
+}
+
+// GetArchive get archive of a repository
+func GetArchive(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/archive/{archive} repository repoGetArchive
+ // ---
+ // summary: Get an archive of a repository
+ // produces:
+ // - application/octet-stream
+ // - application/zip
+ // - application/gzip
+ // 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: archive
+ // in: path
+ // description: the git reference for download with attached archive format (e.g. master.zip)
+ // type: string
+ // required: true
+ // responses:
+ // 200:
+ // description: success
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if ctx.Repo.GitRepo == nil {
+ gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
+ return
+ }
+ ctx.Repo.GitRepo = gitRepo
+ defer gitRepo.Close()
+ }
+
+ archiveDownload(ctx)
+}
+
+func archiveDownload(ctx *context.APIContext) {
+ uri := ctx.Params("*")
+ aReq, err := archiver_service.NewRequest(ctx, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri)
+ if err != nil {
+ if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
+ ctx.Error(http.StatusBadRequest, "unknown archive format", err)
+ } else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) {
+ ctx.Error(http.StatusNotFound, "unrecognized reference", err)
+ } else {
+ ctx.ServerError("archiver_service.NewRequest", err)
+ }
+ return
+ }
+
+ archiver, err := aReq.Await(ctx)
+ if err != nil {
+ ctx.ServerError("archiver.Await", err)
+ return
+ }
+
+ download(ctx, aReq.GetArchiveName(), archiver)
+}
+
+func download(ctx *context.APIContext, archiveName string, archiver *repo_model.RepoArchiver) {
+ downloadName := ctx.Repo.Repository.Name + "-" + archiveName
+
+ // Add nix format link header so tarballs lock correctly:
+ // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md
+ ctx.Resp.Header().Add("Link", fmt.Sprintf("<%s/archive/%s.tar.gz?rev=%s>; rel=\"immutable\"",
+ ctx.Repo.Repository.APIURL(),
+ archiver.CommitID, archiver.CommitID))
+
+ rPath := archiver.RelativePath()
+ if setting.RepoArchive.Storage.MinioConfig.ServeDirect {
+ // If we have a signed url (S3, object storage), redirect to this directly.
+ u, err := storage.RepoArchives.URL(rPath, downloadName)
+ if u != nil && err == nil {
+ ctx.Redirect(u.String())
+ return
+ }
+ }
+
+ // If we have matched and access to release or issue
+ fr, err := storage.RepoArchives.Open(rPath)
+ if err != nil {
+ ctx.ServerError("Open", err)
+ return
+ }
+ defer fr.Close()
+
+ contentType := ""
+ switch archiver.Type {
+ case git.ZIP:
+ contentType = "application/zip"
+ case git.TARGZ:
+ // Per RFC6713.
+ contentType = "application/gzip"
+ }
+
+ ctx.ServeContent(fr, &context.ServeHeaderOptions{
+ ContentType: contentType,
+ Filename: downloadName,
+ LastModified: archiver.CreatedUnix.AsLocalTime(),
+ })
+}
+
+// GetEditorconfig get editor config of a repository
+func GetEditorconfig(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/editorconfig/{filepath} repository repoGetEditorConfig
+ // ---
+ // summary: Get the EditorConfig definitions of a file in a repository
+ // 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: filepath
+ // in: path
+ // description: filepath of file to get
+ // type: string
+ // required: true
+ // - name: ref
+ // in: query
+ // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
+ // type: string
+ // required: false
+ // responses:
+ // 200:
+ // description: success
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ ec, _, err := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetEditorconfig", err)
+ }
+ return
+ }
+
+ fileName := ctx.Params("filename")
+ def, err := ec.GetDefinitionForFilename(fileName)
+ if def == nil {
+ ctx.NotFound(err)
+ return
+ }
+ ctx.JSON(http.StatusOK, def)
+}
+
+// canWriteFiles returns true if repository is editable and user has proper access level.
+func canWriteFiles(ctx *context.APIContext, branch string) bool {
+ return ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, branch) &&
+ !ctx.Repo.Repository.IsMirror &&
+ !ctx.Repo.Repository.IsArchived
+}
+
+// canReadFiles returns true if repository is readable and user has proper access level.
+func canReadFiles(r *context.Repository) bool {
+ return r.Permission.CanRead(unit.TypeCode)
+}
+
+func base64Reader(s string) (io.ReadSeeker, error) {
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewReader(b), nil
+}
+
+// ChangeFiles handles API call for modifying multiple files
+func ChangeFiles(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles
+ // ---
+ // summary: Modify multiple files in a repository
+ // 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
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/ChangeFilesOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/FilesResponse"
+ // "403":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/error"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions)
+
+ if apiOpts.BranchName == "" {
+ apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
+ }
+
+ var files []*files_service.ChangeRepoFile
+ for _, file := range apiOpts.Files {
+ contentReader, err := base64Reader(file.ContentBase64)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err)
+ return
+ }
+ changeRepoFile := &files_service.ChangeRepoFile{
+ Operation: file.Operation,
+ TreePath: file.Path,
+ FromTreePath: file.FromPath,
+ ContentReader: contentReader,
+ SHA: file.SHA,
+ }
+ files = append(files, changeRepoFile)
+ }
+
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: files,
+ Message: apiOpts.Message,
+ OldBranch: apiOpts.BranchName,
+ NewBranch: apiOpts.NewBranchName,
+ Committer: &files_service.IdentityOptions{
+ Name: apiOpts.Committer.Name,
+ Email: apiOpts.Committer.Email,
+ },
+ Author: &files_service.IdentityOptions{
+ Name: apiOpts.Author.Name,
+ Email: apiOpts.Author.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: apiOpts.Dates.Author,
+ Committer: apiOpts.Dates.Committer,
+ },
+ Signoff: apiOpts.Signoff,
+ }
+ if opts.Dates.Author.IsZero() {
+ opts.Dates.Author = time.Now()
+ }
+ if opts.Dates.Committer.IsZero() {
+ opts.Dates.Committer = time.Now()
+ }
+
+ if opts.Message == "" {
+ opts.Message = changeFilesCommitMessage(ctx, files)
+ }
+
+ if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
+ handleCreateOrUpdateFileError(ctx, err)
+ } else {
+ ctx.JSON(http.StatusCreated, filesResponse)
+ }
+}
+
+// CreateFile handles API call for creating a file
+func CreateFile(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile
+ // ---
+ // summary: Create a file in a repository
+ // 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: filepath
+ // in: path
+ // description: path of the file to create
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/CreateFileOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/FileResponse"
+ // "403":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/error"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ apiOpts := web.GetForm(ctx).(*api.CreateFileOptions)
+
+ if apiOpts.BranchName == "" {
+ apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
+ }
+
+ contentReader, err := base64Reader(apiOpts.ContentBase64)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err)
+ return
+ }
+
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ctx.Params("*"),
+ ContentReader: contentReader,
+ },
+ },
+ Message: apiOpts.Message,
+ OldBranch: apiOpts.BranchName,
+ NewBranch: apiOpts.NewBranchName,
+ Committer: &files_service.IdentityOptions{
+ Name: apiOpts.Committer.Name,
+ Email: apiOpts.Committer.Email,
+ },
+ Author: &files_service.IdentityOptions{
+ Name: apiOpts.Author.Name,
+ Email: apiOpts.Author.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: apiOpts.Dates.Author,
+ Committer: apiOpts.Dates.Committer,
+ },
+ Signoff: apiOpts.Signoff,
+ }
+ if opts.Dates.Author.IsZero() {
+ opts.Dates.Author = time.Now()
+ }
+ if opts.Dates.Committer.IsZero() {
+ opts.Dates.Committer = time.Now()
+ }
+
+ if opts.Message == "" {
+ opts.Message = changeFilesCommitMessage(ctx, opts.Files)
+ }
+
+ if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
+ handleCreateOrUpdateFileError(ctx, err)
+ } else {
+ fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
+ ctx.JSON(http.StatusCreated, fileResponse)
+ }
+}
+
+// UpdateFile handles API call for updating a file
+func UpdateFile(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile
+ // ---
+ // summary: Update a file in a repository
+ // 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: filepath
+ // in: path
+ // description: path of the file to update
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/UpdateFileOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/FileResponse"
+ // "403":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/error"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ apiOpts := web.GetForm(ctx).(*api.UpdateFileOptions)
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty"))
+ return
+ }
+
+ if apiOpts.BranchName == "" {
+ apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
+ }
+
+ contentReader, err := base64Reader(apiOpts.ContentBase64)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err)
+ return
+ }
+
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ ContentReader: contentReader,
+ SHA: apiOpts.SHA,
+ FromTreePath: apiOpts.FromPath,
+ TreePath: ctx.Params("*"),
+ },
+ },
+ Message: apiOpts.Message,
+ OldBranch: apiOpts.BranchName,
+ NewBranch: apiOpts.NewBranchName,
+ Committer: &files_service.IdentityOptions{
+ Name: apiOpts.Committer.Name,
+ Email: apiOpts.Committer.Email,
+ },
+ Author: &files_service.IdentityOptions{
+ Name: apiOpts.Author.Name,
+ Email: apiOpts.Author.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: apiOpts.Dates.Author,
+ Committer: apiOpts.Dates.Committer,
+ },
+ Signoff: apiOpts.Signoff,
+ }
+ if opts.Dates.Author.IsZero() {
+ opts.Dates.Author = time.Now()
+ }
+ if opts.Dates.Committer.IsZero() {
+ opts.Dates.Committer = time.Now()
+ }
+
+ if opts.Message == "" {
+ opts.Message = changeFilesCommitMessage(ctx, opts.Files)
+ }
+
+ if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
+ handleCreateOrUpdateFileError(ctx, err)
+ } else {
+ fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
+ ctx.JSON(http.StatusOK, fileResponse)
+ }
+}
+
+func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
+ if models.IsErrUserCannotCommit(err) || models.IsErrFilePathProtected(err) {
+ ctx.Error(http.StatusForbidden, "Access", err)
+ return
+ }
+ if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) ||
+ models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid", err)
+ return
+ }
+ if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) {
+ ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err)
+ return
+ }
+
+ ctx.Error(http.StatusInternalServerError, "UpdateFile", err)
+}
+
+// Called from both CreateFile or UpdateFile to handle both
+func createOrUpdateFiles(ctx *context.APIContext, opts *files_service.ChangeRepoFilesOptions) (*api.FilesResponse, error) {
+ if !canWriteFiles(ctx, opts.OldBranch) {
+ return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{
+ UserID: ctx.Doer.ID,
+ RepoName: ctx.Repo.Repository.LowerName,
+ }
+ }
+
+ return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts)
+}
+
+// format commit message if empty
+func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string {
+ var (
+ createFiles []string
+ updateFiles []string
+ deleteFiles []string
+ )
+ for _, file := range files {
+ switch file.Operation {
+ case "create":
+ createFiles = append(createFiles, file.TreePath)
+ case "update":
+ updateFiles = append(updateFiles, file.TreePath)
+ case "delete":
+ deleteFiles = append(deleteFiles, file.TreePath)
+ }
+ }
+ message := ""
+ if len(createFiles) != 0 {
+ message += ctx.Locale.TrString("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
+ }
+ if len(updateFiles) != 0 {
+ message += ctx.Locale.TrString("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
+ }
+ if len(deleteFiles) != 0 {
+ message += ctx.Locale.TrString("repo.editor.delete", strings.Join(deleteFiles, ", "))
+ }
+ return strings.Trim(message, "\n")
+}
+
+// DeleteFile Delete a file in a repository
+func DeleteFile(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/contents/{filepath} repository repoDeleteFile
+ // ---
+ // summary: Delete a file in a repository
+ // 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: filepath
+ // in: path
+ // description: path of the file to delete
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/DeleteFileOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/FileDeleteResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ apiOpts := web.GetForm(ctx).(*api.DeleteFileOptions)
+ if !canWriteFiles(ctx, apiOpts.BranchName) {
+ ctx.Error(http.StatusForbidden, "DeleteFile", repo_model.ErrUserDoesNotHaveAccessToRepo{
+ UserID: ctx.Doer.ID,
+ RepoName: ctx.Repo.Repository.LowerName,
+ })
+ return
+ }
+
+ if apiOpts.BranchName == "" {
+ apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
+ }
+
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "delete",
+ SHA: apiOpts.SHA,
+ TreePath: ctx.Params("*"),
+ },
+ },
+ Message: apiOpts.Message,
+ OldBranch: apiOpts.BranchName,
+ NewBranch: apiOpts.NewBranchName,
+ Committer: &files_service.IdentityOptions{
+ Name: apiOpts.Committer.Name,
+ Email: apiOpts.Committer.Email,
+ },
+ Author: &files_service.IdentityOptions{
+ Name: apiOpts.Author.Name,
+ Email: apiOpts.Author.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: apiOpts.Dates.Author,
+ Committer: apiOpts.Dates.Committer,
+ },
+ Signoff: apiOpts.Signoff,
+ }
+ if opts.Dates.Author.IsZero() {
+ opts.Dates.Author = time.Now()
+ }
+ if opts.Dates.Committer.IsZero() {
+ opts.Dates.Committer = time.Now()
+ }
+
+ if opts.Message == "" {
+ opts.Message = changeFilesCommitMessage(ctx, opts.Files)
+ }
+
+ if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
+ if git.IsErrBranchNotExist(err) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
+ ctx.Error(http.StatusNotFound, "DeleteFile", err)
+ return
+ } else if git_model.IsErrBranchAlreadyExists(err) ||
+ models.IsErrFilenameInvalid(err) ||
+ models.IsErrSHADoesNotMatch(err) ||
+ models.IsErrCommitIDDoesNotMatch(err) ||
+ models.IsErrSHAOrCommitIDNotProvided(err) {
+ ctx.Error(http.StatusBadRequest, "DeleteFile", err)
+ return
+ } else if models.IsErrUserCannotCommit(err) {
+ ctx.Error(http.StatusForbidden, "DeleteFile", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
+ } else {
+ fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
+ ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
+ }
+}
+
+// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir
+func GetContents(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents
+ // ---
+ // summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir
+ // 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: filepath
+ // in: path
+ // description: path of the dir, file, symlink or submodule in the repo
+ // type: string
+ // required: true
+ // - name: ref
+ // in: query
+ // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
+ // type: string
+ // required: false
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ContentsResponse"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !canReadFiles(ctx.Repo) {
+ ctx.Error(http.StatusInternalServerError, "GetContentsOrList", repo_model.ErrUserDoesNotHaveAccessToRepo{
+ UserID: ctx.Doer.ID,
+ RepoName: ctx.Repo.Repository.LowerName,
+ })
+ return
+ }
+
+ treePath := ctx.Params("*")
+ ref := ctx.FormTrim("ref")
+
+ if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref); err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound("GetContentsOrList", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetContentsOrList", err)
+ } else {
+ ctx.JSON(http.StatusOK, fileList)
+ }
+}
+
+// GetContentsList Get the metadata of all the entries of the root dir
+func GetContentsList(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/contents repository repoGetContentsList
+ // ---
+ // summary: Gets the metadata of all the entries of the root dir
+ // 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: ref
+ // in: query
+ // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
+ // type: string
+ // required: false
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ContentsListResponse"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ // same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface
+ GetContents(ctx)
+}
diff --git a/routers/api/v1/repo/flags.go b/routers/api/v1/repo/flags.go
new file mode 100644
index 0000000..ac5cb2e
--- /dev/null
+++ b/routers/api/v1/repo/flags.go
@@ -0,0 +1,245 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+)
+
+func ListFlags(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/flags repository repoListFlags
+ // ---
+ // summary: List a repository's flags
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/StringSlice"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repoFlags, err := ctx.Repo.Repository.ListFlags(ctx)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ flags := make([]string, len(repoFlags))
+ for i := range repoFlags {
+ flags[i] = repoFlags[i].Name
+ }
+
+ ctx.SetTotalCountHeader(int64(len(repoFlags)))
+ ctx.JSON(http.StatusOK, flags)
+}
+
+func ReplaceAllFlags(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/flags repository repoReplaceAllFlags
+ // ---
+ // summary: Replace all flags of a repository
+ // 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/ReplaceFlagsOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ flagsForm := web.GetForm(ctx).(*api.ReplaceFlagsOption)
+
+ if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, flagsForm.Flags); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func DeleteAllFlags(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/flags repository repoDeleteAllFlags
+ // ---
+ // summary: Remove all flags from a repository
+ // 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
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, nil); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func HasFlag(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/flags/{flag} repository repoCheckFlag
+ // ---
+ // summary: Check if a repository has a given flag
+ // 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: flag
+ // in: path
+ // description: name of the flag
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ hasFlag := ctx.Repo.Repository.HasFlag(ctx, ctx.Params(":flag"))
+ if hasFlag {
+ ctx.Status(http.StatusNoContent)
+ } else {
+ ctx.NotFound()
+ }
+}
+
+func AddFlag(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/flags/{flag} repository repoAddFlag
+ // ---
+ // summary: Add a flag to a repository
+ // 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: flag
+ // in: path
+ // description: name of the flag
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ flag := ctx.Params(":flag")
+
+ if ctx.Repo.Repository.HasFlag(ctx, flag) {
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+
+ if err := ctx.Repo.Repository.AddFlag(ctx, flag); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+func DeleteFlag(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/flags/{flag} repository repoDeleteFlag
+ // ---
+ // summary: Remove a flag from a repository
+ // 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: flag
+ // in: path
+ // description: name of the flag
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ flag := ctx.Params(":flag")
+
+ if _, err := ctx.Repo.Repository.DeleteFlag(ctx, flag); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go
new file mode 100644
index 0000000..97aaffd
--- /dev/null
+++ b/routers/api/v1/repo/fork.go
@@ -0,0 +1,167 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// ListForks list a repository's forks
+func ListForks(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/forks repository listForks
+ // ---
+ // summary: List a repository's forks
+ // 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: 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/RepositoryList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetForks", err)
+ return
+ }
+ apiForks := make([]*api.Repository, len(forks))
+ for i, fork := range forks {
+ permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+ return
+ }
+ apiForks[i] = convert.ToRepo(ctx, fork, permission)
+ }
+
+ ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks))
+ ctx.JSON(http.StatusOK, apiForks)
+}
+
+// CreateFork create a fork of a repo
+func CreateFork(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/forks repository createFork
+ // ---
+ // summary: Fork a repository
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to fork
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to fork
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateForkOption"
+ // responses:
+ // "202":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // description: The repository with the same name already exists.
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.CreateForkOption)
+ repo := ctx.Repo.Repository
+ var forker *user_model.User // user/org that will own the fork
+ if form.Organization == nil {
+ forker = ctx.Doer
+ } else {
+ org, err := organization.GetOrgByName(ctx, *form.Organization)
+ if err != nil {
+ if organization.IsErrOrgNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetOrgByName", err)
+ }
+ return
+ }
+ isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "IsOrgMember", err)
+ return
+ } else if !isMember {
+ ctx.Error(http.StatusForbidden, "isMemberNot", fmt.Sprintf("User is no Member of Organisation '%s'", org.Name))
+ return
+ }
+ forker = org.AsUser()
+ }
+
+ if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, forker.ID, forker.Name) {
+ return
+ }
+
+ var name string
+ if form.Name == nil {
+ name = repo.Name
+ } else {
+ name = *form.Name
+ }
+
+ fork, err := repo_service.ForkRepositoryAndUpdates(ctx, ctx.Doer, forker, repo_service.ForkRepoOptions{
+ BaseRepo: repo,
+ Name: name,
+ Description: repo.Description,
+ })
+ if err != nil {
+ if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) {
+ ctx.Error(http.StatusConflict, "ForkRepositoryAndUpdates", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "ForkRepositoryAndUpdates", err)
+ }
+ return
+ }
+
+ // TODO change back to 201
+ ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, fork, access_model.Permission{AccessMode: perm.AccessModeOwner}))
+}
diff --git a/routers/api/v1/repo/git_hook.go b/routers/api/v1/repo/git_hook.go
new file mode 100644
index 0000000..26ae84d
--- /dev/null
+++ b/routers/api/v1/repo/git_hook.go
@@ -0,0 +1,196 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListGitHooks list all Git hooks of a repository
+func ListGitHooks(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/hooks/git repository repoListGitHooks
+ // ---
+ // summary: List the Git hooks in a repository
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/GitHookList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ hooks, err := ctx.Repo.GitRepo.Hooks()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Hooks", err)
+ return
+ }
+
+ apiHooks := make([]*api.GitHook, len(hooks))
+ for i := range hooks {
+ apiHooks[i] = convert.ToGitHook(hooks[i])
+ }
+ ctx.JSON(http.StatusOK, &apiHooks)
+}
+
+// GetGitHook get a repo's Git hook by id
+func GetGitHook(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/hooks/git/{id} repository repoGetGitHook
+ // ---
+ // summary: Get a Git hook
+ // 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: id
+ // in: path
+ // description: id of the hook to get
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/GitHook"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ hookID := ctx.Params(":id")
+ hook, err := ctx.Repo.GitRepo.GetHook(hookID)
+ if err != nil {
+ if err == git.ErrNotValidHook {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetHook", err)
+ }
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToGitHook(hook))
+}
+
+// EditGitHook modify a Git hook of a repository
+func EditGitHook(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/hooks/git/{id} repository repoEditGitHook
+ // ---
+ // summary: Edit a Git hook in a repository
+ // 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: id
+ // in: path
+ // description: id of the hook to get
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditGitHookOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/GitHook"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.EditGitHookOption)
+ hookID := ctx.Params(":id")
+ hook, err := ctx.Repo.GitRepo.GetHook(hookID)
+ if err != nil {
+ if err == git.ErrNotValidHook {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetHook", err)
+ }
+ return
+ }
+
+ hook.Content = form.Content
+ if err = hook.Update(); err != nil {
+ ctx.Error(http.StatusInternalServerError, "hook.Update", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToGitHook(hook))
+}
+
+// DeleteGitHook delete a Git hook of a repository
+func DeleteGitHook(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/hooks/git/{id} repository repoDeleteGitHook
+ // ---
+ // summary: Delete a Git hook in a repository
+ // 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: id
+ // in: path
+ // description: id of the hook to get
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ hookID := ctx.Params(":id")
+ hook, err := ctx.Repo.GitRepo.GetHook(hookID)
+ if err != nil {
+ if err == git.ErrNotValidHook {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetHook", err)
+ }
+ return
+ }
+
+ hook.Content = ""
+ if err = hook.Update(); err != nil {
+ ctx.Error(http.StatusInternalServerError, "hook.Update", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/git_ref.go b/routers/api/v1/repo/git_ref.go
new file mode 100644
index 0000000..54da5ee
--- /dev/null
+++ b/routers/api/v1/repo/git_ref.go
@@ -0,0 +1,107 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "net/url"
+
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+)
+
+// GetGitAllRefs get ref or an list all the refs of a repository
+func GetGitAllRefs(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/refs repository repoListAllGitRefs
+ // ---
+ // summary: Get specified ref or filtered repository's refs
+ // 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
+ // responses:
+ // "200":
+ // # "$ref": "#/responses/Reference" TODO: swagger doesn't support different output formats by ref
+ // "$ref": "#/responses/ReferenceList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ getGitRefsInternal(ctx, "")
+}
+
+// GetGitRefs get ref or an filteresd list of refs of a repository
+func GetGitRefs(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/refs/{ref} repository repoListGitRefs
+ // ---
+ // summary: Get specified ref or filtered repository's refs
+ // 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: ref
+ // in: path
+ // description: part or full name of the ref
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // # "$ref": "#/responses/Reference" TODO: swagger doesn't support different output formats by ref
+ // "$ref": "#/responses/ReferenceList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ getGitRefsInternal(ctx, ctx.Params("*"))
+}
+
+func getGitRefsInternal(ctx *context.APIContext, filter string) {
+ refs, lastMethodName, err := utils.GetGitRefs(ctx, filter)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, lastMethodName, err)
+ return
+ }
+
+ if len(refs) == 0 {
+ ctx.NotFound()
+ return
+ }
+
+ apiRefs := make([]*api.Reference, len(refs))
+ for i := range refs {
+ apiRefs[i] = &api.Reference{
+ Ref: refs[i].Name,
+ URL: ctx.Repo.Repository.APIURL() + "/git/" + util.PathEscapeSegments(refs[i].Name),
+ Object: &api.GitObject{
+ SHA: refs[i].Object.String(),
+ Type: refs[i].Type,
+ URL: ctx.Repo.Repository.APIURL() + "/git/" + url.PathEscape(refs[i].Type) + "s/" + url.PathEscape(refs[i].Object.String()),
+ },
+ }
+ }
+ // If single reference is found and it matches filter exactly return it as object
+ if len(apiRefs) == 1 && apiRefs[0].Ref == filter {
+ ctx.JSON(http.StatusOK, &apiRefs[0])
+ return
+ }
+ ctx.JSON(http.StatusOK, &apiRefs)
+}
diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go
new file mode 100644
index 0000000..ffd2313
--- /dev/null
+++ b/routers/api/v1/repo/hook.go
@@ -0,0 +1,308 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+)
+
+// ListHooks list all hooks of a repository
+func ListHooks(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/hooks repository repoListHooks
+ // ---
+ // summary: List the hooks in a repository
+ // 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: 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/HookList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opts := &webhook.ListWebhookOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ }
+
+ hooks, count, err := db.FindAndCount[webhook.Webhook](ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiHooks := make([]*api.Hook, len(hooks))
+ for i := range hooks {
+ apiHooks[i], err = webhook_service.ToHook(ctx.Repo.RepoLink, hooks[i])
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, &apiHooks)
+}
+
+// GetHook get a repo's hook by id
+func GetHook(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/hooks/{id} repository repoGetHook
+ // ---
+ // summary: Get a hook
+ // 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: id
+ // in: path
+ // description: id of the hook to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Hook"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo
+ hookID := ctx.ParamsInt64(":id")
+ hook, err := utils.GetRepoHook(ctx, repo.Repository.ID, hookID)
+ if err != nil {
+ return
+ }
+ apiHook, err := webhook_service.ToHook(repo.RepoLink, hook)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.JSON(http.StatusOK, apiHook)
+}
+
+// TestHook tests a hook
+func TestHook(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/hooks/{id}/tests repository repoTestHook
+ // ---
+ // summary: Test a push webhook
+ // 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: id
+ // in: path
+ // description: id of the hook to test
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: ref
+ // in: query
+ // description: "The name of the commit/branch/tag, indicates which commit will be loaded to the webhook payload."
+ // type: string
+ // required: false
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if ctx.Repo.Commit == nil {
+ // if repo does not have any commits, then don't send a webhook
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+
+ ref := git.BranchPrefix + ctx.Repo.Repository.DefaultBranch
+ if r := ctx.FormTrim("ref"); r != "" {
+ ref = r
+ }
+
+ hookID := ctx.ParamsInt64(":id")
+ hook, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID)
+ if err != nil {
+ return
+ }
+
+ commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
+
+ commitID := ctx.Repo.Commit.ID.String()
+ if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
+ Ref: ref,
+ Before: commitID,
+ After: commitID,
+ CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
+ Commits: []*api.PayloadCommit{commit},
+ TotalCommits: 1,
+ HeadCommit: commit,
+ Repo: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}),
+ Pusher: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone),
+ Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone),
+ }); err != nil {
+ ctx.Error(http.StatusInternalServerError, "PrepareWebhook: ", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// CreateHook create a hook for a repository
+func CreateHook(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/hooks repository repoCreateHook
+ // ---
+ // summary: Create a hook
+ // 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/CreateHookOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Hook"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ utils.AddRepoHook(ctx, web.GetForm(ctx).(*api.CreateHookOption))
+}
+
+// EditHook modify a hook of a repository
+func EditHook(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/hooks/{id} repository repoEditHook
+ // ---
+ // summary: Edit a hook in a repository
+ // 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: id
+ // in: path
+ // description: index of the hook
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditHookOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Hook"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.EditHookOption)
+ hookID := ctx.ParamsInt64(":id")
+ utils.EditRepoHook(ctx, form, hookID)
+}
+
+// DeleteHook delete a hook of a repository
+func DeleteHook(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/hooks/{id} repository repoDeleteHook
+ // ---
+ // summary: Delete a hook in a repository
+ // 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: id
+ // in: path
+ // description: id of the hook to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil {
+ if webhook.IsErrWebhookNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "DeleteWebhookByRepoID", err)
+ }
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/hook_test.go b/routers/api/v1/repo/hook_test.go
new file mode 100644
index 0000000..a8065e4
--- /dev/null
+++ b/routers/api/v1/repo/hook_test.go
@@ -0,0 +1,33 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTestHook(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/wiki/_pages")
+ ctx.SetParams(":id", "1")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadRepo(t, ctx, 1)
+ contexttest.LoadGitRepo(t, ctx)
+ defer ctx.Repo.GitRepo.Close()
+ contexttest.LoadRepoCommit(t, ctx)
+ TestHook(ctx)
+ assert.EqualValues(t, http.StatusNoContent, ctx.Resp.Status())
+
+ unittest.AssertExistsAndLoadBean(t, &webhook.HookTask{
+ HookID: 1,
+ }, unittest.Cond("is_delivered=?", false))
+}
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
new file mode 100644
index 0000000..99cd980
--- /dev/null
+++ b/routers/api/v1/repo/issue.go
@@ -0,0 +1,1041 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+ "code.gitea.io/gitea/modules/optional"
+ "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"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// SearchIssues searches for issues across the repositories that the user has access to
+func SearchIssues(ctx *context.APIContext) {
+ // swagger:operation GET /repos/issues/search issue issueSearchIssues
+ // ---
+ // summary: Search for issues across the repositories that the user has access to
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: state
+ // in: query
+ // description: whether issue is open or closed
+ // type: string
+ // - name: labels
+ // in: query
+ // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded
+ // type: string
+ // - name: milestones
+ // in: query
+ // description: comma separated list of milestone names. Fetch only issues that have any of this milestones. Non existent are discarded
+ // type: string
+ // - name: q
+ // in: query
+ // description: search string
+ // type: string
+ // - name: priority_repo_id
+ // in: query
+ // description: repository to prioritize in the results
+ // type: integer
+ // format: int64
+ // - name: type
+ // in: query
+ // description: filter by type (issues / pulls) if set
+ // type: string
+ // - name: since
+ // in: query
+ // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // required: false
+ // - name: before
+ // in: query
+ // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // required: false
+ // - name: assigned
+ // in: query
+ // description: filter (issues / pulls) assigned to you, default is false
+ // type: boolean
+ // - name: created
+ // in: query
+ // description: filter (issues / pulls) created by you, default is false
+ // type: boolean
+ // - name: mentioned
+ // in: query
+ // description: filter (issues / pulls) mentioning you, default is false
+ // type: boolean
+ // - name: review_requested
+ // in: query
+ // description: filter pulls requesting your review, default is false
+ // type: boolean
+ // - name: reviewed
+ // in: query
+ // description: filter pulls reviewed by you, default is false
+ // type: boolean
+ // - name: owner
+ // in: query
+ // description: filter by owner
+ // type: string
+ // - name: team
+ // in: query
+ // description: filter by team (requires organization owner parameter to be provided)
+ // type: string
+ // - 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/IssueList"
+
+ before, since, err := context.GetQueryBeforeSince(ctx.Base)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ var isClosed optional.Option[bool]
+ switch ctx.FormString("state") {
+ case "closed":
+ isClosed = optional.Some(true)
+ case "all":
+ isClosed = optional.None[bool]()
+ default:
+ isClosed = optional.Some(false)
+ }
+
+ var (
+ repoIDs []int64
+ allPublic bool
+ )
+ {
+ // find repos user can access (for issue search)
+ opts := &repo_model.SearchRepoOptions{
+ Private: false,
+ AllPublic: true,
+ TopicOnly: false,
+ Collaborate: optional.None[bool](),
+ // This needs to be a column that is not nil in fixtures or
+ // MySQL will return different results when sorting by null in some cases
+ OrderBy: db.SearchOrderByAlphabetically,
+ Actor: ctx.Doer,
+ }
+ if ctx.IsSigned {
+ opts.Private = !ctx.PublicOnly
+ opts.AllLimited = true
+ }
+ if ctx.FormString("owner") != "" {
+ owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusBadRequest, "Owner not found", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+ opts.OwnerID = owner.ID
+ opts.AllLimited = false
+ opts.AllPublic = false
+ opts.Collaborate = optional.Some(false)
+ }
+ if ctx.FormString("team") != "" {
+ if ctx.FormString("owner") == "" {
+ ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team")
+ return
+ }
+ team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team"))
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusBadRequest, "Team not found", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+ opts.TeamID = team.ID
+ }
+
+ if opts.AllPublic {
+ allPublic = true
+ opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer
+ }
+ repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err)
+ return
+ }
+ if len(repoIDs) == 0 {
+ // no repos found, don't let the indexer return all repos
+ repoIDs = []int64{0}
+ }
+ }
+
+ keyword := ctx.FormTrim("q")
+ if strings.IndexByte(keyword, 0) >= 0 {
+ keyword = ""
+ }
+
+ var isPull optional.Option[bool]
+ switch ctx.FormString("type") {
+ case "pulls":
+ isPull = optional.Some(true)
+ case "issues":
+ isPull = optional.Some(false)
+ default:
+ isPull = optional.None[bool]()
+ }
+
+ var includedAnyLabels []int64
+ {
+ labels := ctx.FormTrim("labels")
+ var includedLabelNames []string
+ if len(labels) > 0 {
+ includedLabelNames = strings.Split(labels, ",")
+ }
+ includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err)
+ return
+ }
+ }
+
+ var includedMilestones []int64
+ {
+ milestones := ctx.FormTrim("milestones")
+ var includedMilestoneNames []string
+ if len(milestones) > 0 {
+ includedMilestoneNames = strings.Split(milestones, ",")
+ }
+ includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err)
+ return
+ }
+ }
+
+ // this api is also used in UI,
+ // so the default limit is set to fit UI needs
+ limit := ctx.FormInt("limit")
+ if limit == 0 {
+ limit = setting.UI.IssuePagingNum
+ } else if limit > setting.API.MaxResponseItems {
+ limit = setting.API.MaxResponseItems
+ }
+
+ searchOpt := &issue_indexer.SearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: limit,
+ Page: ctx.FormInt("page"),
+ },
+ Keyword: keyword,
+ RepoIDs: repoIDs,
+ AllPublic: allPublic,
+ IsPull: isPull,
+ IsClosed: isClosed,
+ IncludedAnyLabelIDs: includedAnyLabels,
+ MilestoneIDs: includedMilestones,
+ SortBy: issue_indexer.SortByCreatedDesc,
+ }
+
+ if since != 0 {
+ searchOpt.UpdatedAfterUnix = optional.Some(since)
+ }
+ if before != 0 {
+ searchOpt.UpdatedBeforeUnix = optional.Some(before)
+ }
+
+ if ctx.IsSigned {
+ ctxUserID := ctx.Doer.ID
+ if ctx.FormBool("created") {
+ searchOpt.PosterID = optional.Some(ctxUserID)
+ }
+ if ctx.FormBool("assigned") {
+ searchOpt.AssigneeID = optional.Some(ctxUserID)
+ }
+ if ctx.FormBool("mentioned") {
+ searchOpt.MentionID = optional.Some(ctxUserID)
+ }
+ if ctx.FormBool("review_requested") {
+ searchOpt.ReviewRequestedID = optional.Some(ctxUserID)
+ }
+ if ctx.FormBool("reviewed") {
+ searchOpt.ReviewedID = optional.Some(ctxUserID)
+ }
+ }
+
+ // FIXME: It's unsupported to sort by priority repo when searching by indexer,
+ // it's indeed an regression, but I think it is worth to support filtering by indexer first.
+ _ = ctx.FormInt64("priority_repo_id")
+
+ ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "SearchIssues", err)
+ return
+ }
+ issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err)
+ return
+ }
+
+ ctx.SetLinkHeader(int(total), limit)
+ ctx.SetTotalCountHeader(total)
+ ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
+}
+
+// ListIssues list the issues of a repository
+func ListIssues(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues
+ // ---
+ // summary: List a repository's issues
+ // 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: whether issue is open or closed
+ // type: string
+ // enum: [closed, open, all]
+ // - name: labels
+ // in: query
+ // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded
+ // type: string
+ // - name: q
+ // in: query
+ // description: search string
+ // type: string
+ // - name: type
+ // in: query
+ // description: filter by type (issues / pulls) if set
+ // type: string
+ // enum: [issues, pulls]
+ // - name: milestones
+ // in: query
+ // description: comma separated list of milestone names or ids. It uses names and fall back to ids. Fetch only issues that have any of this milestones. Non existent milestones are discarded
+ // type: string
+ // - name: since
+ // in: query
+ // description: Only show items updated after the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // required: false
+ // - name: before
+ // in: query
+ // description: Only show items updated before the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // required: false
+ // - name: created_by
+ // in: query
+ // description: Only show items which were created by the given user
+ // type: string
+ // - name: assigned_by
+ // in: query
+ // description: Only show items for which the given user is assigned
+ // type: string
+ // - name: mentioned_by
+ // in: query
+ // description: Only show items in which the given user was mentioned
+ // type: string
+ // - 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/IssueList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ before, since, err := context.GetQueryBeforeSince(ctx.Base)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ var isClosed optional.Option[bool]
+ switch ctx.FormString("state") {
+ case "closed":
+ isClosed = optional.Some(true)
+ case "all":
+ isClosed = optional.None[bool]()
+ default:
+ isClosed = optional.Some(false)
+ }
+
+ keyword := ctx.FormTrim("q")
+ if strings.IndexByte(keyword, 0) >= 0 {
+ keyword = ""
+ }
+
+ var labelIDs []int64
+ if split := strings.Split(ctx.FormString("labels"), ","); len(split) > 0 {
+ labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, split)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err)
+ return
+ }
+ }
+
+ var mileIDs []int64
+ if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 {
+ for i := range part {
+ // uses names and fall back to ids
+ // non existent milestones are discarded
+ mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i])
+ if err == nil {
+ mileIDs = append(mileIDs, mile.ID)
+ continue
+ }
+ if !issues_model.IsErrMilestoneNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoIDANDName", err)
+ return
+ }
+ id, err := strconv.ParseInt(part[i], 10, 64)
+ if err != nil {
+ continue
+ }
+ mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id)
+ if err == nil {
+ mileIDs = append(mileIDs, mile.ID)
+ continue
+ }
+ if issues_model.IsErrMilestoneNotExist(err) {
+ continue
+ }
+ ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err)
+ }
+ }
+
+ listOptions := utils.GetListOptions(ctx)
+
+ isPull := optional.None[bool]()
+ switch ctx.FormString("type") {
+ case "pulls":
+ isPull = optional.Some(true)
+ case "issues":
+ isPull = optional.Some(false)
+ }
+
+ if isPull.Has() && !ctx.Repo.CanReadIssuesOrPulls(isPull.Value()) {
+ ctx.NotFound()
+ return
+ }
+
+ if !isPull.Has() {
+ canReadIssues := ctx.Repo.CanRead(unit.TypeIssues)
+ canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests)
+ if !canReadIssues && !canReadPulls {
+ ctx.NotFound()
+ return
+ } else if !canReadIssues {
+ isPull = optional.Some(true)
+ } else if !canReadPulls {
+ isPull = optional.Some(false)
+ }
+ }
+
+ // FIXME: we should be more efficient here
+ createdByID := getUserIDForFilter(ctx, "created_by")
+ if ctx.Written() {
+ return
+ }
+ assignedByID := getUserIDForFilter(ctx, "assigned_by")
+ if ctx.Written() {
+ return
+ }
+ mentionedByID := getUserIDForFilter(ctx, "mentioned_by")
+ if ctx.Written() {
+ return
+ }
+
+ searchOpt := &issue_indexer.SearchOptions{
+ Paginator: &listOptions,
+ Keyword: keyword,
+ RepoIDs: []int64{ctx.Repo.Repository.ID},
+ IsPull: isPull,
+ IsClosed: isClosed,
+ SortBy: issue_indexer.SortByCreatedDesc,
+ }
+ if since != 0 {
+ searchOpt.UpdatedAfterUnix = optional.Some(since)
+ }
+ if before != 0 {
+ searchOpt.UpdatedBeforeUnix = optional.Some(before)
+ }
+ if len(labelIDs) == 1 && labelIDs[0] == 0 {
+ searchOpt.NoLabelOnly = true
+ } else {
+ for _, labelID := range labelIDs {
+ if labelID > 0 {
+ searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
+ } else {
+ searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
+ }
+ }
+ }
+
+ if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
+ searchOpt.MilestoneIDs = []int64{0}
+ } else {
+ searchOpt.MilestoneIDs = mileIDs
+ }
+
+ if createdByID > 0 {
+ searchOpt.PosterID = optional.Some(createdByID)
+ }
+ if assignedByID > 0 {
+ searchOpt.AssigneeID = optional.Some(assignedByID)
+ }
+ if mentionedByID > 0 {
+ searchOpt.MentionID = optional.Some(mentionedByID)
+ }
+
+ ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "SearchIssues", err)
+ return
+ }
+ issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err)
+ return
+ }
+
+ ctx.SetLinkHeader(int(total), listOptions.PageSize)
+ ctx.SetTotalCountHeader(total)
+ ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
+}
+
+func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 {
+ userName := ctx.FormString(queryName)
+ if len(userName) == 0 {
+ return 0
+ }
+
+ user, err := user_model.GetUserByName(ctx, userName)
+ if user_model.IsErrUserNotExist(err) {
+ ctx.NotFound(err)
+ return 0
+ }
+
+ if err != nil {
+ ctx.InternalServerError(err)
+ return 0
+ }
+
+ return user.ID
+}
+
+// GetIssue get an issue of a repository
+func GetIssue(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue
+ // ---
+ // summary: Get an issue
+ // 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 issue to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Issue"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+ if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.NotFound()
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
+}
+
+// CreateIssue create an issue of a repository
+func CreateIssue(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue
+ // ---
+ // summary: Create an issue. 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: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateIssueOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Issue"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "412":
+ // "$ref": "#/responses/error"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ form := web.GetForm(ctx).(*api.CreateIssueOption)
+ var deadlineUnix timeutil.TimeStamp
+ if form.Deadline != nil && ctx.Repo.CanWrite(unit.TypeIssues) {
+ deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
+ }
+
+ issue := &issues_model.Issue{
+ RepoID: ctx.Repo.Repository.ID,
+ Repo: ctx.Repo.Repository,
+ Title: form.Title,
+ PosterID: ctx.Doer.ID,
+ Poster: ctx.Doer,
+ Content: form.Body,
+ Ref: form.Ref,
+ DeadlineUnix: deadlineUnix,
+ }
+
+ assigneeIDs := make([]int64, 0)
+ var err error
+ if ctx.Repo.CanWrite(unit.TypeIssues) {
+ issue.MilestoneID = form.Milestone
+ 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, ctx.Repo.Repository, false)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "canBeAssigned", err)
+ return
+ }
+ if !valid {
+ ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: ctx.Repo.Repository.Name})
+ return
+ }
+ }
+ } else {
+ // setting labels is not allowed if user is not a writer
+ form.Labels = make([]int64, 0)
+ }
+
+ if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, 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, "NewIssue", err)
+ return
+ }
+
+ if form.Closed {
+ if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil {
+ if issues_model.IsErrDependenciesLeft(err) {
+ ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
+ return
+ }
+ }
+
+ // Refetch from database to assign some automatic values
+ issue, err = issues_model.GetIssueByID(ctx, issue.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByID", err)
+ return
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue))
+}
+
+// EditIssue modify an issue of a repository
+func EditIssue(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue
+ // ---
+ // summary: Edit an issue. 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 issue to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditIssueOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Issue"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "412":
+ // "$ref": "#/responses/error"
+
+ form := web.GetForm(ctx).(*api.EditIssueOption)
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+ issue.Repo = ctx.Repo.Repository
+ canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
+
+ err = issue.LoadAttributes(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ if !issue.IsPoster(ctx.Doer.ID) && !canWrite {
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ 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
+ }
+ }
+ if form.Ref != nil {
+ err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateRef", err)
+ return
+ }
+ }
+
+ // Update or remove the deadline, only if set and allowed
+ if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite {
+ var deadlineUnix timeutil.TimeStamp
+
+ if form.RemoveDeadline == nil || !*form.RemoveDeadline {
+ if form.Deadline == nil {
+ ctx.Error(http.StatusBadRequest, "", "The due_date cannot be empty")
+ return
+ }
+ if !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 canWrite && (form.Assignees != nil || form.Assignee != nil) {
+ oneAssignee := ""
+ if form.Assignee != nil {
+ oneAssignee = *form.Assignee
+ }
+
+ err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err)
+ return
+ }
+ }
+
+ if canWrite && form.Milestone != nil &&
+ 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 form.State != nil {
+ if issue.IsPull {
+ if err := issue.LoadPullRequest(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetPullRequest", err)
+ return
+ }
+ if issue.PullRequest.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 issue because it still has open dependencies")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
+ return
+ }
+ }
+ }
+
+ // Refetch from database to assign some automatic values
+ issue, err = issues_model.GetIssueByID(ctx, issue.ID)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ if err = issue.LoadMilestone(ctx); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue))
+}
+
+func DeleteIssue(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index} issue issueDelete
+ // ---
+ // summary: Delete an issue
+ // 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 issue to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByID", err)
+ }
+ return
+ }
+
+ if err = issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteIssueByID", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// UpdateIssueDeadline updates an issue deadline
+func UpdateIssueDeadline(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline
+ // ---
+ // summary: Set an issue deadline. If set to null, the deadline is deleted. 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 issue to create or update a deadline on
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditDeadlineOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/IssueDeadline"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.EditDeadlineOption)
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Error(http.StatusForbidden, "", "Not repo writer")
+ return
+ }
+
+ var deadlineUnix timeutil.TimeStamp
+ var deadline time.Time
+ if form.Deadline != nil && !form.Deadline.IsZero() {
+ deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(),
+ 23, 59, 59, 0, time.Local)
+ deadlineUnix = timeutil.TimeStamp(deadline.Unix())
+ }
+
+ if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline})
+}
diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go
new file mode 100644
index 0000000..a972ab0
--- /dev/null
+++ b/routers/api/v1/repo/issue_attachment.go
@@ -0,0 +1,411 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "time"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/attachment"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/context/upload"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// GetIssueAttachment gets a single attachment of the issue
+func GetIssueAttachment(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueGetIssueAttachment
+ // ---
+ // summary: Get an issue attachment
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/error"
+
+ issue := getIssueFromContext(ctx)
+ if issue == nil {
+ return
+ }
+
+ attach := getIssueAttachmentSafeRead(ctx, issue)
+ if attach == nil {
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+}
+
+// ListIssueAttachments lists all attachments of the issue
+func ListIssueAttachments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets issue issueListIssueAttachments
+ // ---
+ // summary: List issue's attachments
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/AttachmentList"
+ // "404":
+ // "$ref": "#/responses/error"
+
+ issue := getIssueFromContext(ctx)
+ if issue == nil {
+ return
+ }
+
+ if err := issue.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue).Attachments)
+}
+
+// CreateIssueAttachment creates an attachment and saves the given file
+func CreateIssueAttachment(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/assets issue issueCreateIssueAttachment
+ // ---
+ // summary: Create an issue attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - multipart/form-data
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: name
+ // in: query
+ // description: name of the attachment
+ // type: string
+ // required: false
+ // - name: updated_at
+ // in: query
+ // description: time of the attachment's creation. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - name: attachment
+ // in: formData
+ // description: attachment to upload
+ // type: file
+ // required: true
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ issue := getIssueFromContext(ctx)
+ if issue == nil {
+ return
+ }
+
+ if !canUserWriteIssueAttachment(ctx, issue) {
+ return
+ }
+
+ updatedAt := ctx.Req.FormValue("updated_at")
+ if len(updatedAt) != 0 {
+ updated, err := time.Parse(time.RFC3339, updatedAt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "time.Parse", err)
+ return
+ }
+ err = issue_service.SetIssueUpdateDate(ctx, issue, &updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+ }
+
+ // Get uploaded file from request
+ file, header, err := ctx.Req.FormFile("attachment")
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FormFile", err)
+ return
+ }
+ defer file.Close()
+
+ filename := header.Filename
+ if query := ctx.FormString("name"); query != "" {
+ filename = query
+ }
+
+ attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
+ Name: filename,
+ UploaderID: ctx.Doer.ID,
+ RepoID: ctx.Repo.Repository.ID,
+ IssueID: issue.ID,
+ NoAutoTime: issue.NoAutoTime,
+ CreatedUnix: issue.UpdatedUnix,
+ })
+ if err != nil {
+ if upload.IsErrFileTypeForbidden(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+ }
+ return
+ }
+
+ issue.Attachments = append(issue.Attachments, attachment)
+
+ if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, issue.Content, issue.ContentVersion); err != nil {
+ ctx.Error(http.StatusInternalServerError, "ChangeContent", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
+}
+
+// EditIssueAttachment updates the given attachment
+func EditIssueAttachment(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueEditIssueAttachment
+ // ---
+ // summary: Edit an issue attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditAttachmentOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ attachment := getIssueAttachmentSafeWrite(ctx)
+ if attachment == nil {
+ return
+ }
+
+ // do changes to attachment. only meaningful change is name.
+ form := web.GetForm(ctx).(*api.EditAttachmentOptions)
+ if form.Name != "" {
+ attachment.Name = form.Name
+ }
+
+ if err := repo_model.UpdateAttachment(ctx, attachment); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
+}
+
+// DeleteIssueAttachment delete a given attachment
+func DeleteIssueAttachment(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueDeleteIssueAttachment
+ // ---
+ // summary: Delete an issue attachment
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ attachment := getIssueAttachmentSafeWrite(ctx)
+ if attachment == nil {
+ return
+ }
+
+ if err := repo_model.DeleteAttachment(ctx, attachment, true); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue {
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64("index"))
+ if err != nil {
+ ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err)
+ return nil
+ }
+
+ issue.Repo = ctx.Repo.Repository
+
+ return issue
+}
+
+func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
+ issue := getIssueFromContext(ctx)
+ if issue == nil {
+ return nil
+ }
+
+ if !canUserWriteIssueAttachment(ctx, issue) {
+ return nil
+ }
+
+ return getIssueAttachmentSafeRead(ctx, issue)
+}
+
+func getIssueAttachmentSafeRead(ctx *context.APIContext, issue *issues_model.Issue) *repo_model.Attachment {
+ attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("attachment_id"))
+ if err != nil {
+ ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err)
+ return nil
+ }
+ if !attachmentBelongsToRepoOrIssue(ctx, attachment, issue) {
+ return nil
+ }
+ return attachment
+}
+
+func canUserWriteIssueAttachment(ctx *context.APIContext, issue *issues_model.Issue) bool {
+ canEditIssue := ctx.IsSigned && (ctx.Doer.ID == issue.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull))
+ if !canEditIssue {
+ ctx.Error(http.StatusForbidden, "", "user should have permission to write issue")
+ return false
+ }
+
+ return true
+}
+
+func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_model.Attachment, issue *issues_model.Issue) bool {
+ if attachment.RepoID != ctx.Repo.Repository.ID {
+ log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository)
+ ctx.NotFound("no such attachment in repo")
+ return false
+ }
+ if attachment.IssueID == 0 {
+ log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID)
+ ctx.NotFound("no such attachment in issue")
+ return false
+ } else if issue != nil && attachment.IssueID != issue.ID {
+ log.Debug("Requested attachment[%d] does not belong to issue[%d, #%d].", attachment.ID, issue.ID, issue.Index)
+ ctx.NotFound("no such attachment in issue")
+ return false
+ }
+ return true
+}
diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go
new file mode 100644
index 0000000..1ff755c
--- /dev/null
+++ b/routers/api/v1/repo/issue_comment.go
@@ -0,0 +1,691 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ stdCtx "context"
+ "errors"
+ "net/http"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ 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/optional"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// ListIssueComments list all the comments of an issue
+func ListIssueComments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/comments issue issueGetComments
+ // ---
+ // summary: List all comments on an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: since
+ // in: query
+ // description: if provided, only comments updated since the specified time are returned.
+ // type: string
+ // format: date-time
+ // - name: before
+ // in: query
+ // description: if provided, only comments updated before the provided time are returned.
+ // type: string
+ // format: date-time
+ // responses:
+ // "200":
+ // "$ref": "#/responses/CommentList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ before, since, err := context.GetQueryBeforeSince(ctx.Base)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err)
+ return
+ }
+ if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.NotFound()
+ return
+ }
+
+ issue.Repo = ctx.Repo.Repository
+
+ opts := &issues_model.FindCommentsOptions{
+ IssueID: issue.ID,
+ Since: since,
+ Before: before,
+ Type: issues_model.CommentTypeComment,
+ }
+
+ comments, err := issues_model.FindComments(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindComments", err)
+ return
+ }
+
+ totalCount, err := issues_model.CountComments(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ if err := comments.LoadPosters(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPosters", err)
+ return
+ }
+
+ if err := comments.LoadAttachments(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+ return
+ }
+
+ apiComments := make([]*api.Comment, len(comments))
+ for i, comment := range comments {
+ comment.Issue = issue
+ apiComments[i] = convert.ToAPIComment(ctx, ctx.Repo.Repository, comments[i])
+ }
+
+ ctx.SetTotalCountHeader(totalCount)
+ ctx.JSON(http.StatusOK, &apiComments)
+}
+
+// ListIssueCommentsAndTimeline list all the comments and events of an issue
+func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/timeline issue issueGetCommentsAndTimeline
+ // ---
+ // summary: List all comments and events on an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: since
+ // in: query
+ // description: if provided, only comments updated since the specified time are returned.
+ // type: string
+ // format: date-time
+ // - 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: before
+ // in: query
+ // description: if provided, only comments updated before the provided time are returned.
+ // type: string
+ // format: date-time
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TimelineList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ before, since, err := context.GetQueryBeforeSince(ctx.Base)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err)
+ return
+ }
+ issue.Repo = ctx.Repo.Repository
+
+ opts := &issues_model.FindCommentsOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ IssueID: issue.ID,
+ Since: since,
+ Before: before,
+ Type: issues_model.CommentTypeUndefined,
+ }
+
+ comments, err := issues_model.FindComments(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindComments", err)
+ return
+ }
+
+ if err := comments.LoadPosters(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPosters", err)
+ return
+ }
+
+ var apiComments []*api.TimelineComment
+ for _, comment := range comments {
+ if comment.Type != issues_model.CommentTypeCode && isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID) {
+ comment.Issue = issue
+ apiComments = append(apiComments, convert.ToTimelineComment(ctx, issue.Repo, comment, ctx.Doer))
+ }
+ }
+
+ ctx.SetTotalCountHeader(int64(len(apiComments)))
+ ctx.JSON(http.StatusOK, &apiComments)
+}
+
+func isXRefCommentAccessible(ctx stdCtx.Context, user *user_model.User, c *issues_model.Comment, issueRepoID int64) bool {
+ // Remove comments that the user has no permissions to see
+ if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issueRepoID && c.RefRepoID != 0 {
+ var err error
+ // Set RefRepo for description in template
+ c.RefRepo, err = repo_model.GetRepositoryByID(ctx, c.RefRepoID)
+ if err != nil {
+ return false
+ }
+ perm, err := access_model.GetUserRepoPermission(ctx, c.RefRepo, user)
+ if err != nil {
+ return false
+ }
+ if !perm.CanReadIssuesOrPulls(c.RefIsPull) {
+ return false
+ }
+ }
+ return true
+}
+
+// ListRepoIssueComments returns all issue-comments for a repo
+func ListRepoIssueComments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/comments issue issueGetRepoComments
+ // ---
+ // summary: List all comments in a repository
+ // 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: since
+ // in: query
+ // description: if provided, only comments updated since the provided time are returned.
+ // type: string
+ // format: date-time
+ // - name: before
+ // in: query
+ // description: if provided, only comments updated before the provided time are returned.
+ // type: string
+ // format: date-time
+ // - 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/CommentList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ before, since, err := context.GetQueryBeforeSince(ctx.Base)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ var isPull optional.Option[bool]
+ canReadIssue := ctx.Repo.CanRead(unit.TypeIssues)
+ canReadPull := ctx.Repo.CanRead(unit.TypePullRequests)
+ if canReadIssue && canReadPull {
+ isPull = optional.None[bool]()
+ } else if canReadIssue {
+ isPull = optional.Some(false)
+ } else if canReadPull {
+ isPull = optional.Some(true)
+ } else {
+ ctx.NotFound()
+ return
+ }
+
+ opts := &issues_model.FindCommentsOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ Type: issues_model.CommentTypeComment,
+ Since: since,
+ Before: before,
+ IsPull: isPull,
+ }
+
+ comments, err := issues_model.FindComments(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindComments", err)
+ return
+ }
+
+ totalCount, err := issues_model.CountComments(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ if err = comments.LoadPosters(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPosters", err)
+ return
+ }
+
+ apiComments := make([]*api.Comment, len(comments))
+ if err := comments.LoadIssues(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadIssues", err)
+ return
+ }
+ if err := comments.LoadAttachments(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+ return
+ }
+ if _, err := comments.Issues().LoadRepositories(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadRepositories", err)
+ return
+ }
+ for i := range comments {
+ apiComments[i] = convert.ToAPIComment(ctx, ctx.Repo.Repository, comments[i])
+ }
+
+ ctx.SetTotalCountHeader(totalCount)
+ ctx.JSON(http.StatusOK, &apiComments)
+}
+
+// CreateIssueComment create a comment for an issue
+func CreateIssueComment(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/comments issue issueCreateComment
+ // ---
+ // summary: Add a comment to an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateIssueCommentOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Comment"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ form := web.GetForm(ctx).(*api.CreateIssueCommentOption)
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ return
+ }
+
+ if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.NotFound()
+ return
+ }
+
+ if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
+ ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Locale.TrString("repo.issues.comment_on_locked")))
+ return
+ }
+
+ err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+
+ comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil)
+ if err != nil {
+ if errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.Error(http.StatusForbidden, "CreateIssueComment", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment))
+}
+
+// GetIssueComment Get a comment by ID
+func GetIssueComment(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id} issue issueGetComment
+ // ---
+ // summary: Get a comment
+ // 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Comment"
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ comment := ctx.Comment
+
+ if comment.Type != issues_model.CommentTypeComment {
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+
+ if err := comment.LoadPoster(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "comment.LoadPoster", err)
+ return
+ }
+
+ if err := comment.LoadAttachments(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment))
+}
+
+// EditIssueComment modify a comment of an issue
+func EditIssueComment(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/comments/{id} issue issueEditComment
+ // ---
+ // summary: Edit a comment
+ // 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: id
+ // in: path
+ // description: id of the comment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditIssueCommentOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Comment"
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ form := web.GetForm(ctx).(*api.EditIssueCommentOption)
+ editIssueComment(ctx, *form)
+}
+
+// EditIssueCommentDeprecated modify a comment of an issue
+func EditIssueCommentDeprecated(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/comments/{id} issue issueEditCommentDeprecated
+ // ---
+ // summary: Edit a comment
+ // deprecated: true
+ // 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: this parameter is ignored
+ // type: integer
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the comment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditIssueCommentOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Comment"
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.EditIssueCommentOption)
+ editIssueComment(ctx, *form)
+}
+
+func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) {
+ comment := ctx.Comment
+
+ if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ if !comment.Type.HasContentSupport() {
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+
+ err := comment.LoadIssue(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadIssue", err)
+ return
+ }
+ err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, form.Updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+
+ oldContent := comment.Content
+ comment.Content = form.Body
+ if err := issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, oldContent); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateComment", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment))
+}
+
+// DeleteIssueComment delete a comment from an issue
+func DeleteIssueComment(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id} issue issueDeleteComment
+ // ---
+ // summary: Delete a comment
+ // 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: id
+ // in: path
+ // description: id of comment to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ deleteIssueComment(ctx, issues_model.CommentTypeComment)
+}
+
+// DeleteIssueCommentDeprecated delete a comment from an issue
+func DeleteIssueCommentDeprecated(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/comments/{id} issue issueDeleteCommentDeprecated
+ // ---
+ // summary: Delete a comment
+ // deprecated: true
+ // 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: this parameter is ignored
+ // type: integer
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of comment to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ deleteIssueComment(ctx, issues_model.CommentTypeComment)
+}
+
+func deleteIssueComment(ctx *context.APIContext, commentType issues_model.CommentType) {
+ comment := ctx.Comment
+
+ if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
+ ctx.Status(http.StatusForbidden)
+ return
+ } else if comment.Type != commentType {
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+
+ if err := issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteCommentByID", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go
new file mode 100644
index 0000000..c45e2eb
--- /dev/null
+++ b/routers/api/v1/repo/issue_comment_attachment.go
@@ -0,0 +1,400 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "time"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/attachment"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/context/upload"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// GetIssueCommentAttachment gets a single attachment of the comment
+func GetIssueCommentAttachment(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueGetIssueCommentAttachment
+ // ---
+ // summary: Get a comment attachment
+ // 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/error"
+
+ comment := ctx.Comment
+ attachment := getIssueCommentAttachmentSafeRead(ctx)
+ if attachment == nil {
+ return
+ }
+ if attachment.CommentID != comment.ID {
+ log.Debug("User requested attachment[%d] is not in comment[%d].", attachment.ID, comment.ID)
+ ctx.NotFound("attachment not in comment")
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
+}
+
+// ListIssueCommentAttachments lists all attachments of the comment
+func ListIssueCommentAttachments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueListIssueCommentAttachments
+ // ---
+ // summary: List comment's attachments
+ // 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/AttachmentList"
+ // "404":
+ // "$ref": "#/responses/error"
+ comment := ctx.Comment
+
+ if err := comment.LoadAttachments(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIAttachments(ctx.Repo.Repository, comment.Attachments))
+}
+
+// CreateIssueCommentAttachment creates an attachment and saves the given file
+func CreateIssueCommentAttachment(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueCreateIssueCommentAttachment
+ // ---
+ // summary: Create a comment attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - multipart/form-data
+ // 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: name
+ // in: query
+ // description: name of the attachment
+ // type: string
+ // required: false
+ // - name: updated_at
+ // in: query
+ // description: time of the attachment's creation. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - name: attachment
+ // in: formData
+ // description: attachment to upload
+ // type: file
+ // required: true
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ // Check if comment exists and load comment
+
+ if !canUserWriteIssueCommentAttachment(ctx) {
+ return
+ }
+
+ comment := ctx.Comment
+
+ updatedAt := ctx.Req.FormValue("updated_at")
+ if len(updatedAt) != 0 {
+ updated, err := time.Parse(time.RFC3339, updatedAt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "time.Parse", err)
+ return
+ }
+ err = comment.LoadIssue(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadIssue", err)
+ return
+ }
+ err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, &updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+ }
+
+ // Get uploaded file from request
+ file, header, err := ctx.Req.FormFile("attachment")
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FormFile", err)
+ return
+ }
+ defer file.Close()
+
+ filename := header.Filename
+ if query := ctx.FormString("name"); query != "" {
+ filename = query
+ }
+
+ attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
+ Name: filename,
+ UploaderID: ctx.Doer.ID,
+ RepoID: ctx.Repo.Repository.ID,
+ IssueID: comment.IssueID,
+ CommentID: comment.ID,
+ NoAutoTime: comment.Issue.NoAutoTime,
+ CreatedUnix: comment.Issue.UpdatedUnix,
+ })
+ if err != nil {
+ if upload.IsErrFileTypeForbidden(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+ }
+ return
+ }
+
+ if err := comment.LoadAttachments(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+ return
+ }
+
+ if err = issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, comment.Content); err != nil {
+ ctx.ServerError("UpdateComment", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
+}
+
+// EditIssueCommentAttachment updates the given attachment
+func EditIssueCommentAttachment(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueEditIssueCommentAttachment
+ // ---
+ // summary: Edit a comment attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditAttachmentOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ attach := getIssueCommentAttachmentSafeWrite(ctx)
+ if attach == nil {
+ return
+ }
+
+ form := web.GetForm(ctx).(*api.EditAttachmentOptions)
+ if form.Name != "" {
+ attach.Name = form.Name
+ }
+
+ if err := repo_model.UpdateAttachment(ctx, attach); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+}
+
+// DeleteIssueCommentAttachment delete a given attachment
+func DeleteIssueCommentAttachment(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueDeleteIssueCommentAttachment
+ // ---
+ // summary: Delete a comment attachment
+ // 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: id
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/error"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ attach := getIssueCommentAttachmentSafeWrite(ctx)
+ if attach == nil {
+ return
+ }
+
+ if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+func getIssueCommentAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
+ if !canUserWriteIssueCommentAttachment(ctx) {
+ return nil
+ }
+ return getIssueCommentAttachmentSafeRead(ctx)
+}
+
+func canUserWriteIssueCommentAttachment(ctx *context.APIContext) bool {
+ // ctx.Comment is assumed to be set in a safe way via a middleware
+ comment := ctx.Comment
+
+ canEditComment := ctx.IsSigned && (ctx.Doer.ID == comment.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin()) && ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)
+ if !canEditComment {
+ ctx.Error(http.StatusForbidden, "", "user should have permission to edit comment")
+ return false
+ }
+
+ return true
+}
+
+func getIssueCommentAttachmentSafeRead(ctx *context.APIContext) *repo_model.Attachment {
+ // ctx.Comment is assumed to be set in a safe way via a middleware
+ comment := ctx.Comment
+
+ attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("attachment_id"))
+ if err != nil {
+ ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err)
+ return nil
+ }
+ if !attachmentBelongsToRepoOrComment(ctx, attachment, comment) {
+ return nil
+ }
+ return attachment
+}
+
+func attachmentBelongsToRepoOrComment(ctx *context.APIContext, attachment *repo_model.Attachment, comment *issues_model.Comment) bool {
+ if attachment.RepoID != ctx.Repo.Repository.ID {
+ log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository)
+ ctx.NotFound("no such attachment in repo")
+ return false
+ }
+ if attachment.IssueID == 0 || attachment.CommentID == 0 {
+ log.Debug("Requested attachment[%d] is not in a comment.", attachment.ID)
+ ctx.NotFound("no such attachment in comment")
+ return false
+ }
+ if comment != nil && attachment.CommentID != comment.ID {
+ log.Debug("Requested attachment[%d] does not belong to comment[%d].", attachment.ID, comment.ID)
+ ctx.NotFound("no such attachment in comment")
+ return false
+ }
+ return true
+}
diff --git a/routers/api/v1/repo/issue_dependency.go b/routers/api/v1/repo/issue_dependency.go
new file mode 100644
index 0000000..c40e92c
--- /dev/null
+++ b/routers/api/v1/repo/issue_dependency.go
@@ -0,0 +1,613 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// GetIssueDependencies list an issue's dependencies
+func GetIssueDependencies(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/dependencies issue issueListIssueDependencies
+ // ---
+ // summary: List an issue's dependencies, i.e all issues that block this issue.
+ // 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 issue
+ // type: string
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/IssueList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ // If this issue's repository does not enable dependencies then there can be no dependencies by default
+ if !ctx.Repo.Repository.IsDependenciesEnabled(ctx) {
+ ctx.NotFound()
+ return
+ }
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound("IsErrIssueNotExist", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ // 1. We must be able to read this issue
+ if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.NotFound()
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ limit := ctx.FormInt("limit")
+ if limit == 0 {
+ limit = setting.API.DefaultPagingNum
+ } else if limit > setting.API.MaxResponseItems {
+ limit = setting.API.MaxResponseItems
+ }
+
+ canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
+
+ blockerIssues := make([]*issues_model.Issue, 0, limit)
+
+ // 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
+ blockersInfo, err := issue.BlockedByDependencies(ctx, db.ListOptions{
+ Page: page,
+ PageSize: limit,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "BlockedByDependencies", err)
+ return
+ }
+
+ repoPerms := make(map[int64]access_model.Permission)
+ repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission
+ for _, blocker := range blockersInfo {
+ // Get the permissions for this repository
+ // If the repo ID exists in the map, return the exist permissions
+ // else get the permission and add it to the map
+ var perm access_model.Permission
+ existPerm, ok := repoPerms[blocker.RepoID]
+ if ok {
+ perm = existPerm
+ } else {
+ var err error
+ perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+ repoPerms[blocker.RepoID] = perm
+ }
+
+ // check permission
+ if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) {
+ if !canWrite {
+ hiddenBlocker := &issues_model.DependencyInfo{
+ Issue: issues_model.Issue{
+ Title: "HIDDEN",
+ },
+ }
+ blocker = hiddenBlocker
+ } else {
+ confidentialBlocker := &issues_model.DependencyInfo{
+ Issue: issues_model.Issue{
+ RepoID: blocker.Issue.RepoID,
+ Index: blocker.Index,
+ Title: blocker.Title,
+ IsClosed: blocker.IsClosed,
+ IsPull: blocker.IsPull,
+ },
+ Repository: repo_model.Repository{
+ ID: blocker.Issue.Repo.ID,
+ Name: blocker.Issue.Repo.Name,
+ OwnerName: blocker.Issue.Repo.OwnerName,
+ },
+ }
+ confidentialBlocker.Issue.Repo = &confidentialBlocker.Repository
+ blocker = confidentialBlocker
+ }
+ }
+ blockerIssues = append(blockerIssues, &blocker.Issue)
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, blockerIssues))
+}
+
+// CreateIssueDependency create a new issue dependencies
+func CreateIssueDependency(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/dependencies issue issueCreateIssueDependencies
+ // ---
+ // summary: Make the issue in the url depend on the issue in the form.
+ // 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 issue
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueMeta"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Issue"
+ // "404":
+ // description: the issue does not exist
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ // We want to make <:index> depend on <Form>, i.e. <:index> is the target
+ target := getParamsIssue(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ // and <Form> represents the dependency
+ form := web.GetForm(ctx).(*api.IssueMeta)
+ dependency := getFormIssue(ctx, form)
+ if ctx.Written() {
+ return
+ }
+
+ dependencyPerm := getPermissionForRepo(ctx, target.Repo)
+ if ctx.Written() {
+ return
+ }
+
+ createIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target))
+}
+
+// RemoveIssueDependency remove an issue dependency
+func RemoveIssueDependency(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/dependencies issue issueRemoveIssueDependencies
+ // ---
+ // summary: Remove an issue dependency
+ // 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 issue
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueMeta"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Issue"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ // We want to make <:index> depend on <Form>, i.e. <:index> is the target
+ target := getParamsIssue(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ // and <Form> represents the dependency
+ form := web.GetForm(ctx).(*api.IssueMeta)
+ dependency := getFormIssue(ctx, form)
+ if ctx.Written() {
+ return
+ }
+
+ dependencyPerm := getPermissionForRepo(ctx, target.Repo)
+ if ctx.Written() {
+ return
+ }
+
+ removeIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target))
+}
+
+// GetIssueBlocks list issues that are blocked by this issue
+func GetIssueBlocks(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/blocks issue issueListBlocks
+ // ---
+ // summary: List issues that are blocked by this issue
+ // 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 issue
+ // type: string
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/IssueList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ // We need to list the issues that DEPEND on this issue not the other way round
+ // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant.
+
+ issue := getParamsIssue(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.NotFound()
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ limit := ctx.FormInt("limit")
+ if limit <= 1 {
+ limit = setting.API.DefaultPagingNum
+ }
+
+ skip := (page - 1) * limit
+ max := page * limit
+
+ deps, err := issue.BlockingDependencies(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "BlockingDependencies", err)
+ return
+ }
+
+ var issues []*issues_model.Issue
+
+ repoPerms := make(map[int64]access_model.Permission)
+ repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission
+
+ for i, depMeta := range deps {
+ if i < skip || i >= max {
+ continue
+ }
+
+ // Get the permissions for this repository
+ // If the repo ID exists in the map, return the exist permissions
+ // else get the permission and add it to the map
+ var perm access_model.Permission
+ existPerm, ok := repoPerms[depMeta.RepoID]
+ if ok {
+ perm = existPerm
+ } else {
+ var err error
+ perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+ repoPerms[depMeta.RepoID] = perm
+ }
+
+ if !perm.CanReadIssuesOrPulls(depMeta.Issue.IsPull) {
+ continue
+ }
+
+ depMeta.Issue.Repo = &depMeta.Repository
+ issues = append(issues, &depMeta.Issue)
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
+}
+
+// CreateIssueBlocking block the issue given in the body by the issue in path
+func CreateIssueBlocking(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/blocks issue issueCreateIssueBlocking
+ // ---
+ // summary: Block the issue given in the body by the issue in path
+ // 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 issue
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueMeta"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Issue"
+ // "404":
+ // description: the issue does not exist
+
+ dependency := getParamsIssue(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ form := web.GetForm(ctx).(*api.IssueMeta)
+ target := getFormIssue(ctx, form)
+ if ctx.Written() {
+ return
+ }
+
+ targetPerm := getPermissionForRepo(ctx, target.Repo)
+ if ctx.Written() {
+ return
+ }
+
+ createIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
+}
+
+// RemoveIssueBlocking unblock the issue given in the body by the issue in path
+func RemoveIssueBlocking(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/blocks issue issueRemoveIssueBlocking
+ // ---
+ // summary: Unblock the issue given in the body by the issue in path
+ // 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 issue
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueMeta"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Issue"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ dependency := getParamsIssue(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ form := web.GetForm(ctx).(*api.IssueMeta)
+ target := getFormIssue(ctx, form)
+ if ctx.Written() {
+ return
+ }
+
+ targetPerm := getPermissionForRepo(ctx, target.Repo)
+ if ctx.Written() {
+ return
+ }
+
+ removeIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
+}
+
+func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound("IsErrIssueNotExist", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return nil
+ }
+ issue.Repo = ctx.Repo.Repository
+ return issue
+}
+
+func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue {
+ var repo *repo_model.Repository
+ if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name {
+ if !setting.Service.AllowCrossRepositoryDependencies {
+ ctx.JSON(http.StatusBadRequest, "CrossRepositoryDependencies not enabled")
+ return nil
+ }
+ var err error
+ repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ ctx.NotFound("IsErrRepoNotExist", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerAndName", err)
+ }
+ return nil
+ }
+ } else {
+ repo = ctx.Repo.Repository
+ }
+
+ issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, form.Index)
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound("IsErrIssueNotExist", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return nil
+ }
+ issue.Repo = repo
+ return issue
+}
+
+func getPermissionForRepo(ctx *context.APIContext, repo *repo_model.Repository) *access_model.Permission {
+ if repo.ID == ctx.Repo.Repository.ID {
+ return &ctx.Repo.Permission
+ }
+
+ perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+ return nil
+ }
+
+ return &perm
+}
+
+func createIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) {
+ if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) {
+ // The target's repository doesn't have dependencies enabled
+ ctx.NotFound()
+ return
+ }
+
+ if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) {
+ // We can't write to the target
+ ctx.NotFound()
+ return
+ }
+
+ if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) {
+ // We can't read the dependency
+ ctx.NotFound()
+ return
+ }
+
+ err := issues_model.CreateIssueDependency(ctx, ctx.Doer, target, dependency)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err)
+ return
+ }
+}
+
+func removeIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) {
+ if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) {
+ // The target's repository doesn't have dependencies enabled
+ ctx.NotFound()
+ return
+ }
+
+ if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) {
+ // We can't write to the target
+ ctx.NotFound()
+ return
+ }
+
+ if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) {
+ // We can't read the dependency
+ ctx.NotFound()
+ return
+ }
+
+ err := issues_model.RemoveIssueDependency(ctx, ctx.Doer, target, dependency, issues_model.DependencyTypeBlockedBy)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err)
+ return
+ }
+}
diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go
new file mode 100644
index 0000000..ae05544
--- /dev/null
+++ b/routers/api/v1/repo/issue_label.go
@@ -0,0 +1,385 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// ListIssueLabels list all the labels of an issue
+func ListIssueLabels(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/labels issue issueGetLabels
+ // ---
+ // summary: Get an issue's labels
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/LabelList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if err := issue.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToLabelList(issue.Labels, ctx.Repo.Repository, ctx.Repo.Owner))
+}
+
+// AddIssueLabels add labels for an issue
+func AddIssueLabels(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/labels issue issueAddLabel
+ // ---
+ // summary: Add a label to an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueLabelsOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/LabelList"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.IssueLabelsOption)
+ issue, labels, err := prepareForReplaceOrAdd(ctx, *form)
+ if err != nil {
+ return
+ }
+
+ if err = issue_service.AddLabels(ctx, issue, ctx.Doer, labels); err != nil {
+ ctx.Error(http.StatusInternalServerError, "AddLabels", err)
+ return
+ }
+
+ labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelsByIssueID", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner))
+}
+
+// DeleteIssueLabel delete a label for an issue
+func DeleteIssueLabel(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id} issue issueRemoveLabel
+ // ---
+ // summary: Remove a label from an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the label to remove
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/DeleteLabelsOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ form := web.GetForm(ctx).(*api.DeleteLabelsOption)
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+
+ label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if issues_model.IsErrLabelNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetLabelByID", err)
+ }
+ return
+ }
+
+ if err := issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteIssueLabel", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// ReplaceIssueLabels replace labels for an issue
+func ReplaceIssueLabels(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/labels issue issueReplaceLabels
+ // ---
+ // summary: Replace an issue's labels
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/IssueLabelsOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/LabelList"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.IssueLabelsOption)
+ issue, labels, err := prepareForReplaceOrAdd(ctx, *form)
+ if err != nil {
+ return
+ }
+
+ if err := issue_service.ReplaceLabels(ctx, issue, ctx.Doer, labels); err != nil {
+ ctx.Error(http.StatusInternalServerError, "ReplaceLabels", err)
+ return
+ }
+
+ labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelsByIssueID", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner))
+}
+
+// ClearIssueLabels delete all the labels for an issue
+func ClearIssueLabels(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels issue issueClearLabels
+ // ---
+ // summary: Remove all labels from an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/DeleteLabelsOption"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.DeleteLabelsOption)
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return
+ }
+
+ if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil {
+ ctx.Error(http.StatusInternalServerError, "ClearLabels", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return nil, nil, err
+ }
+
+ var (
+ labelIDs []int64
+ labelNames []string
+ )
+ for _, label := range form.Labels {
+ rv := reflect.ValueOf(label)
+ switch rv.Kind() {
+ case reflect.Float64:
+ labelIDs = append(labelIDs, int64(rv.Float()))
+ case reflect.String:
+ labelNames = append(labelNames, rv.String())
+ }
+ }
+ if len(labelIDs) > 0 && len(labelNames) > 0 {
+ ctx.Error(http.StatusBadRequest, "InvalidLabels", "labels should be an array of strings or integers")
+ return nil, nil, fmt.Errorf("invalid labels")
+ }
+ if len(labelNames) > 0 {
+ labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err)
+ return nil, nil, err
+ }
+ }
+
+ labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive")
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err)
+ return nil, nil, err
+ }
+
+ if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Status(http.StatusForbidden)
+ return nil, nil, nil
+ }
+
+ err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
+ return nil, nil, err
+ }
+
+ return issue, labels, err
+}
diff --git a/routers/api/v1/repo/issue_pin.go b/routers/api/v1/repo/issue_pin.go
new file mode 100644
index 0000000..af3e063
--- /dev/null
+++ b/routers/api/v1/repo/issue_pin.go
@@ -0,0 +1,309 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// PinIssue pins a issue
+func PinIssue(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/pin issue pinIssue
+ // ---
+ // summary: Pin an Issue
+ // 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 issue to pin
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else if issues_model.IsErrIssueMaxPinReached(err) {
+ ctx.Error(http.StatusBadRequest, "MaxPinReached", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ // If we don't do this, it will crash when trying to add the pin event to the comment history
+ err = issue.LoadRepo(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadRepo", err)
+ return
+ }
+
+ err = issue.Pin(ctx, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "PinIssue", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// UnpinIssue unpins a Issue
+func UnpinIssue(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/pin issue unpinIssue
+ // ---
+ // summary: Unpin an Issue
+ // 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 issue to unpin
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ // If we don't do this, it will crash when trying to add the unpin event to the comment history
+ err = issue.LoadRepo(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadRepo", err)
+ return
+ }
+
+ err = issue.Unpin(ctx, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UnpinIssue", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// MoveIssuePin moves a pinned Issue to a new Position
+func MoveIssuePin(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/pin/{position} issue moveIssuePin
+ // ---
+ // summary: Moves the Pin to the given Position
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: position
+ // in: path
+ // description: the new position
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ err = issue.MovePin(ctx, int(ctx.ParamsInt64(":position")))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "MovePin", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// ListPinnedIssues returns a list of all pinned Issues
+func ListPinnedIssues(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/pinned repository repoListPinnedIssues
+ // ---
+ // summary: List a repo's pinned issues
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/IssueList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, false)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPinnedIssues", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
+}
+
+// ListPinnedPullRequests returns a list of all pinned PRs
+func ListPinnedPullRequests(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/pulls/pinned repository repoListPinnedPullRequests
+ // ---
+ // summary: List a repo's pinned 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullRequestList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, true)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPinnedPullRequests", err)
+ return
+ }
+
+ apiPrs := make([]*api.PullRequest, len(issues))
+ if err := issues.LoadPullRequests(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadPullRequests", err)
+ return
+ }
+ for i, currentIssue := range issues {
+ pr := currentIssue.PullRequest
+ if err = pr.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", 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
+ }
+
+ apiPrs[i] = convert.ToAPIPullRequest(ctx, pr, ctx.Doer)
+ }
+
+ ctx.JSON(http.StatusOK, &apiPrs)
+}
+
+// AreNewIssuePinsAllowed returns if new issues pins are allowed
+func AreNewIssuePinsAllowed(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/new_pin_allowed repository repoNewPinAllowed
+ // ---
+ // summary: Returns if new Issue Pins are allowed
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RepoNewIssuePinsAllowed"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ pinsAllowed := api.NewIssuePinsAllowed{}
+ var err error
+
+ pinsAllowed.Issues, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, false)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "IsNewIssuePinAllowed", err)
+ return
+ }
+
+ pinsAllowed.PullRequests, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, true)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "IsNewPullRequestPinAllowed", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, pinsAllowed)
+}
diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go
new file mode 100644
index 0000000..c395255
--- /dev/null
+++ b/routers/api/v1/repo/issue_reaction.go
@@ -0,0 +1,424 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "net/http"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+)
+
+// GetIssueCommentReactions list reactions of a comment from an issue
+func GetIssueCommentReactions(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueGetCommentReactions
+ // ---
+ // summary: Get a list of reactions from a comment of an issue
+ // 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: id
+ // in: path
+ // description: id of the comment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ReactionList"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ comment := ctx.Comment
+
+ reactions, _, err := issues_model.FindCommentReactions(ctx, comment.IssueID, comment.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindCommentReactions", err)
+ return
+ }
+ _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ReactionList.LoadUsers()", err)
+ return
+ }
+
+ var result []api.Reaction
+ for _, r := range reactions {
+ result = append(result, api.Reaction{
+ User: convert.ToUser(ctx, r.User, ctx.Doer),
+ Reaction: r.Type,
+ Created: r.CreatedUnix.AsTime(),
+ })
+ }
+
+ ctx.JSON(http.StatusOK, result)
+}
+
+// PostIssueCommentReaction add a reaction to a comment of an issue
+func PostIssueCommentReaction(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issuePostCommentReaction
+ // ---
+ // summary: Add a reaction to a comment of an issue
+ // 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: id
+ // in: path
+ // description: id of the comment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: content
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditReactionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Reaction"
+ // "201":
+ // "$ref": "#/responses/Reaction"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.EditReactionOption)
+
+ changeIssueCommentReaction(ctx, *form, true)
+}
+
+// DeleteIssueCommentReaction remove a reaction from a comment of an issue
+func DeleteIssueCommentReaction(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueDeleteCommentReaction
+ // ---
+ // summary: Remove a reaction from a comment of an issue
+ // 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: id
+ // in: path
+ // description: id of the comment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: content
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditReactionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.EditReactionOption)
+
+ changeIssueCommentReaction(ctx, *form, false)
+}
+
+func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) {
+ comment := ctx.Comment
+
+ if comment.Issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull) {
+ ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction"))
+ return
+ }
+
+ if isCreateType {
+ // PostIssueCommentReaction part
+ reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction)
+ if err != nil {
+ if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.Error(http.StatusForbidden, err.Error(), err)
+ } else if issues_model.IsErrReactionAlreadyExist(err) {
+ ctx.JSON(http.StatusOK, api.Reaction{
+ User: convert.ToUser(ctx, ctx.Doer, ctx.Doer),
+ Reaction: reaction.Type,
+ Created: reaction.CreatedUnix.AsTime(),
+ })
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, api.Reaction{
+ User: convert.ToUser(ctx, ctx.Doer, ctx.Doer),
+ Reaction: reaction.Type,
+ Created: reaction.CreatedUnix.AsTime(),
+ })
+ } else {
+ // DeleteIssueCommentReaction part
+ err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteCommentReaction", err)
+ return
+ }
+ // ToDo respond 204
+ ctx.Status(http.StatusOK)
+ }
+}
+
+// GetIssueReactions list reactions of an issue
+func GetIssueReactions(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/reactions issue issueGetIssueReactions
+ // ---
+ // summary: Get a list reactions of an issue
+ // 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 issue
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ReactionList"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.Error(http.StatusForbidden, "GetIssueReactions", errors.New("no permission to get reactions"))
+ return
+ }
+
+ reactions, count, err := issues_model.FindIssueReactions(ctx, issue.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "FindIssueReactions", err)
+ return
+ }
+ _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ReactionList.LoadUsers()", err)
+ return
+ }
+
+ var result []api.Reaction
+ for _, r := range reactions {
+ result = append(result, api.Reaction{
+ User: convert.ToUser(ctx, r.User, ctx.Doer),
+ Reaction: r.Type,
+ Created: r.CreatedUnix.AsTime(),
+ })
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, result)
+}
+
+// PostIssueReaction add a reaction to an issue
+func PostIssueReaction(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/reactions issue issuePostIssueReaction
+ // ---
+ // summary: Add a reaction to an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: content
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditReactionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Reaction"
+ // "201":
+ // "$ref": "#/responses/Reaction"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.EditReactionOption)
+ changeIssueReaction(ctx, *form, true)
+}
+
+// DeleteIssueReaction remove a reaction from an issue
+func DeleteIssueReaction(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/reactions issue issueDeleteIssueReaction
+ // ---
+ // summary: Remove a reaction from an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: content
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditReactionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.EditReactionOption)
+ changeIssueReaction(ctx, *form, false)
+}
+
+func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) {
+ issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction"))
+ return
+ }
+
+ if isCreateType {
+ // PostIssueReaction part
+ reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction)
+ if err != nil {
+ if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.Error(http.StatusForbidden, err.Error(), err)
+ } else if issues_model.IsErrReactionAlreadyExist(err) {
+ ctx.JSON(http.StatusOK, api.Reaction{
+ User: convert.ToUser(ctx, ctx.Doer, ctx.Doer),
+ Reaction: reaction.Type,
+ Created: reaction.CreatedUnix.AsTime(),
+ })
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, api.Reaction{
+ User: convert.ToUser(ctx, ctx.Doer, ctx.Doer),
+ Reaction: reaction.Type,
+ Created: reaction.CreatedUnix.AsTime(),
+ })
+ } else {
+ // DeleteIssueReaction part
+ err = issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Reaction)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteIssueReaction", err)
+ return
+ }
+ // ToDo respond 204
+ ctx.Status(http.StatusOK)
+ }
+}
diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go
new file mode 100644
index 0000000..d9054e8
--- /dev/null
+++ b/routers/api/v1/repo/issue_stopwatch.go
@@ -0,0 +1,241 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "net/http"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// StartIssueStopwatch creates a stopwatch for the given issue.
+func StartIssueStopwatch(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/start issue issueStartStopWatch
+ // ---
+ // summary: Start stopwatch on an issue.
+ // 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 issue to create the stopwatch on
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "201":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // description: Not repo writer, user does not have rights to toggle stopwatch
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // description: Cannot start a stopwatch again if it already exists
+
+ issue, err := prepareIssueStopwatch(ctx, false)
+ if err != nil {
+ return
+ }
+
+ if err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// StopIssueStopwatch stops a stopwatch for the given issue.
+func StopIssueStopwatch(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/stop issue issueStopStopWatch
+ // ---
+ // summary: Stop an issue's existing stopwatch.
+ // 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 issue to stop the stopwatch on
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "201":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // description: Not repo writer, user does not have rights to toggle stopwatch
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // description: Cannot stop a non existent stopwatch
+
+ issue, err := prepareIssueStopwatch(ctx, true)
+ if err != nil {
+ return
+ }
+
+ if err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// DeleteIssueStopwatch delete a specific stopwatch
+func DeleteIssueStopwatch(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/stopwatch/delete issue issueDeleteStopWatch
+ // ---
+ // summary: Delete an issue's existing stopwatch.
+ // 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 issue to stop the stopwatch on
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // description: Not repo writer, user does not have rights to toggle stopwatch
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // description: Cannot cancel a non existent stopwatch
+
+ issue, err := prepareIssueStopwatch(ctx, true)
+ if err != nil {
+ return
+ }
+
+ if err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CancelStopwatch", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_model.Issue, error) {
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+
+ return nil, err
+ }
+
+ if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+ ctx.Status(http.StatusForbidden)
+ return nil, errors.New("Unable to write to PRs")
+ }
+
+ if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) {
+ ctx.Status(http.StatusForbidden)
+ return nil, errors.New("Cannot use time tracker")
+ }
+
+ if issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) != shouldExist {
+ if shouldExist {
+ ctx.Error(http.StatusConflict, "StopwatchExists", "cannot stop/cancel a non existent stopwatch")
+ err = errors.New("cannot stop/cancel a non existent stopwatch")
+ } else {
+ ctx.Error(http.StatusConflict, "StopwatchExists", "cannot start a stopwatch again if it already exists")
+ err = errors.New("cannot start a stopwatch again if it already exists")
+ }
+ return nil, err
+ }
+
+ return issue, nil
+}
+
+// GetStopwatches get all stopwatches
+func GetStopwatches(ctx *context.APIContext) {
+ // swagger:operation GET /user/stopwatches user userGetStopWatches
+ // ---
+ // summary: Get list of all existing stopwatches
+ // parameters:
+ // - 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
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // responses:
+ // "200":
+ // "$ref": "#/responses/StopWatchList"
+
+ sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserStopwatches", err)
+ return
+ }
+
+ count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiSWs, err := convert.ToStopWatches(ctx, sws)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "APIFormat", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, apiSWs)
+}
diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go
new file mode 100644
index 0000000..6b29218
--- /dev/null
+++ b/routers/api/v1/repo/issue_subscription.go
@@ -0,0 +1,294 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// AddIssueSubscription Subscribe user to issue
+func AddIssueSubscription(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/subscriptions/{user} issue issueAddSubscription
+ // ---
+ // summary: Subscribe user to issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: user
+ // in: path
+ // description: user to subscribe
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // description: Already subscribed
+ // "201":
+ // description: Successfully Subscribed
+ // "304":
+ // description: User can only subscribe itself if he is no admin
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ setIssueSubscription(ctx, true)
+}
+
+// DelIssueSubscription Unsubscribe user from issue
+func DelIssueSubscription(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/subscriptions/{user} issue issueDeleteSubscription
+ // ---
+ // summary: Unsubscribe user from issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: user
+ // in: path
+ // description: user witch unsubscribe
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // description: Already unsubscribed
+ // "201":
+ // description: Successfully Unsubscribed
+ // "304":
+ // description: User can only subscribe itself if he is no admin
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ setIssueSubscription(ctx, false)
+}
+
+func setIssueSubscription(ctx *context.APIContext, watch bool) {
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+
+ return
+ }
+
+ user, err := user_model.GetUserByName(ctx, ctx.Params(":user"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+
+ return
+ }
+
+ // only admin and user for itself can change subscription
+ if user.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
+ ctx.Error(http.StatusForbidden, "User", fmt.Errorf("%s is not permitted to change subscriptions for %s", ctx.Doer.Name, user.Name))
+ return
+ }
+
+ current, err := issues_model.CheckIssueWatch(ctx, user, issue)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CheckIssueWatch", err)
+ return
+ }
+
+ // If watch state won't change
+ if current == watch {
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ // Update watch state
+ if err := issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, issue.ID, watch); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// CheckIssueSubscription check if user is subscribed to an issue
+func CheckIssueSubscription(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/subscriptions/check issue issueCheckSubscription
+ // ---
+ // summary: Check if user is subscribed to an issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/WatchInfo"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+
+ return
+ }
+
+ watching, err := issues_model.CheckIssueWatch(ctx, ctx.Doer, issue)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.JSON(http.StatusOK, api.WatchInfo{
+ Subscribed: watching,
+ Ignored: !watching,
+ Reason: nil,
+ CreatedAt: issue.CreatedUnix.AsTime(),
+ URL: issue.APIURL(ctx) + "/subscriptions",
+ RepositoryURL: ctx.Repo.Repository.APIURL(),
+ })
+}
+
+// GetIssueSubscribers return subscribers of an issue
+func GetIssueSubscribers(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/subscriptions issue issueSubscriptions
+ // ---
+ // summary: Get users who subscribed on an issue.
+ // 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 issue
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+
+ return
+ }
+
+ iwl, err := issues_model.GetIssueWatchers(ctx, issue.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetIssueWatchers", err)
+ return
+ }
+
+ userIDs := make([]int64, 0, len(iwl))
+ for _, iw := range iwl {
+ userIDs = append(userIDs, iw.UserID)
+ }
+
+ users, err := user_model.GetUsersByIDs(ctx, userIDs)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUsersByIDs", err)
+ return
+ }
+ apiUsers := make([]*api.User, 0, len(users))
+ for _, v := range users {
+ apiUsers = append(apiUsers, convert.ToUser(ctx, v, ctx.Doer))
+ }
+
+ count, err := issues_model.CountIssueWatchers(ctx, issue.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CountIssueWatchers", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, apiUsers)
+}
diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go
new file mode 100644
index 0000000..f83855e
--- /dev/null
+++ b/routers/api/v1/repo/issue_tracked_time.go
@@ -0,0 +1,633 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListTrackedTimes list all the tracked times of an issue
+func ListTrackedTimes(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/times issue issueTrackedTimes
+ // ---
+ // summary: List an issue's tracked times
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: user
+ // in: query
+ // description: optional filter by user (available for issue managers)
+ // type: string
+ // - name: since
+ // in: query
+ // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - name: before
+ // in: query
+ // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - 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/TrackedTimeList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.NotFound("Timetracker is disabled")
+ return
+ }
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ opts := &issues_model.FindTrackedTimesOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepositoryID: ctx.Repo.Repository.ID,
+ IssueID: issue.ID,
+ }
+
+ qUser := ctx.FormTrim("user")
+ if qUser != "" {
+ user, err := user_model.GetUserByName(ctx, qUser)
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusNotFound, "User does not exist", err)
+ } else if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ return
+ }
+ opts.UserID = user.ID
+ }
+
+ if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ cantSetUser := !ctx.Doer.IsAdmin &&
+ opts.UserID != ctx.Doer.ID &&
+ !ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues})
+
+ if cantSetUser {
+ if opts.UserID == 0 {
+ opts.UserID = ctx.Doer.ID
+ } else {
+ ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights"))
+ return
+ }
+ }
+
+ count, err := issues_model.CountTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err)
+ return
+ }
+ if err = trackedTimes.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
+}
+
+// AddTime add time manual to the given issue
+func AddTime(ctx *context.APIContext) {
+ // swagger:operation Post /repos/{owner}/{repo}/issues/{index}/times issue issueAddTime
+ // ---
+ // summary: Add tracked time to a issue
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/AddTimeOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TrackedTime"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.AddTimeOption)
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) {
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.Error(http.StatusBadRequest, "", "time tracking disabled")
+ return
+ }
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ user := ctx.Doer
+ if form.User != "" {
+ if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
+ // allow only RepoAdmin, Admin and User to add time
+ user, err = user_model.GetUserByName(ctx, form.User)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ }
+ }
+
+ created := time.Time{}
+ if !form.Created.IsZero() {
+ created = form.Created
+ }
+
+ trackedTime, err := issues_model.AddTime(ctx, user, issue, form.Time, created)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "AddTime", err)
+ return
+ }
+ if err = trackedTime.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToTrackedTime(ctx, user, trackedTime))
+}
+
+// ResetIssueTime reset time manual to the given issue
+func ResetIssueTime(ctx *context.APIContext) {
+ // swagger:operation Delete /repos/{owner}/{repo}/issues/{index}/times issue issueResetTime
+ // ---
+ // summary: Reset a tracked time of an issue
+ // 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 issue to add tracked time to
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) {
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"})
+ return
+ }
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer)
+ if err != nil {
+ if db.IsErrNotExist(err) {
+ ctx.Error(http.StatusNotFound, "DeleteIssueUserTimes", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "DeleteIssueUserTimes", err)
+ }
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+// DeleteTime delete a specific time by id
+func DeleteTime(ctx *context.APIContext) {
+ // swagger:operation Delete /repos/{owner}/{repo}/issues/{index}/times/{id} issue issueDeleteTime
+ // ---
+ // summary: Delete specific tracked time
+ // 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 issue
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of time to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
+ }
+ return
+ }
+
+ if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) {
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"})
+ return
+ }
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ time, err := issues_model.GetTrackedTimeByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if db.IsErrNotExist(err) {
+ ctx.NotFound(err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTrackedTimeByID", err)
+ return
+ }
+ if time.Deleted {
+ ctx.NotFound(fmt.Errorf("tracked time [%d] already deleted", time.ID))
+ return
+ }
+
+ if !ctx.Doer.IsAdmin && time.UserID != ctx.Doer.ID {
+ // Only Admin and User itself can delete their time
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ err = issues_model.DeleteTime(ctx, time)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteTime", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+// ListTrackedTimesByUser lists all tracked times of the user
+func ListTrackedTimesByUser(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/times/{user} repository userTrackedTimes
+ // ---
+ // summary: List a user's tracked times in a repo
+ // deprecated: true
+ // 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: user
+ // in: path
+ // description: username of user
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TrackedTimeList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.Error(http.StatusBadRequest, "", "time tracking disabled")
+ return
+ }
+ user, err := user_model.GetUserByName(ctx, ctx.Params(":timetrackingusername"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ return
+ }
+ if user == nil {
+ ctx.NotFound()
+ return
+ }
+
+ if !ctx.IsUserRepoAdmin() && !ctx.Doer.IsAdmin && ctx.Doer.ID != user.ID {
+ ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights"))
+ return
+ }
+
+ opts := &issues_model.FindTrackedTimesOptions{
+ UserID: user.ID,
+ RepositoryID: ctx.Repo.Repository.ID,
+ }
+
+ trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err)
+ return
+ }
+ if err = trackedTimes.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
+}
+
+// ListTrackedTimesByRepository lists all tracked times of the repository
+func ListTrackedTimesByRepository(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/times repository repoTrackedTimes
+ // ---
+ // summary: List a repo's tracked times
+ // 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: user
+ // in: query
+ // description: optional filter by user (available for issue managers)
+ // type: string
+ // - name: since
+ // in: query
+ // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - name: before
+ // in: query
+ // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - 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/TrackedTimeList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
+ ctx.Error(http.StatusBadRequest, "", "time tracking disabled")
+ return
+ }
+
+ opts := &issues_model.FindTrackedTimesOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepositoryID: ctx.Repo.Repository.ID,
+ }
+
+ // Filters
+ qUser := ctx.FormTrim("user")
+ if qUser != "" {
+ user, err := user_model.GetUserByName(ctx, qUser)
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusNotFound, "User does not exist", err)
+ } else if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ return
+ }
+ opts.UserID = user.ID
+ }
+
+ var err error
+ if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ cantSetUser := !ctx.Doer.IsAdmin &&
+ opts.UserID != ctx.Doer.ID &&
+ !ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues})
+
+ if cantSetUser {
+ if opts.UserID == 0 {
+ opts.UserID = ctx.Doer.ID
+ } else {
+ ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights"))
+ return
+ }
+ }
+
+ count, err := issues_model.CountTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err)
+ return
+ }
+ if err = trackedTimes.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
+}
+
+// ListMyTrackedTimes lists all tracked times of the current user
+func ListMyTrackedTimes(ctx *context.APIContext) {
+ // swagger:operation GET /user/times user userCurrentTrackedTimes
+ // ---
+ // summary: List the current user's tracked times
+ // produces:
+ // - application/json
+ // parameters:
+ // - 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: since
+ // in: query
+ // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // - name: before
+ // in: query
+ // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format
+ // type: string
+ // format: date-time
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TrackedTimeList"
+
+ opts := &issues_model.FindTrackedTimesOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ UserID: ctx.Doer.ID,
+ }
+
+ var err error
+ if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+ return
+ }
+
+ count, err := issues_model.CountTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTrackedTimesByUser", err)
+ return
+ }
+
+ if err = trackedTimes.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
+}
diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go
new file mode 100644
index 0000000..88444a2
--- /dev/null
+++ b/routers/api/v1/repo/key.go
@@ -0,0 +1,292 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ stdCtx "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "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/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// appendPrivateInformation appends the owner and key type information to api.PublicKey
+func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
+ apiKey.ReadOnly = key.Mode == perm.AccessModeRead
+ if repository.ID == key.RepoID {
+ apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
+ } else {
+ repo, err := repo_model.GetRepositoryByID(ctx, key.RepoID)
+ if err != nil {
+ return apiKey, err
+ }
+ apiKey.Repository = convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: key.Mode})
+ }
+ return apiKey, nil
+}
+
+func composeDeployKeysAPILink(owner, name string) string {
+ return setting.AppURL + "api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/keys/"
+}
+
+// ListDeployKeys list all the deploy keys of a repository
+func ListDeployKeys(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/keys repository repoListKeys
+ // ---
+ // summary: List a repository's keys
+ // 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: key_id
+ // in: query
+ // description: the key_id to search for
+ // type: integer
+ // - name: fingerprint
+ // in: query
+ // description: fingerprint of the key
+ // type: string
+ // - 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/DeployKeyList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opts := asymkey_model.ListDeployKeysOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ KeyID: ctx.FormInt64("key_id"),
+ Fingerprint: ctx.FormString("fingerprint"),
+ }
+
+ keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
+ apiKeys := make([]*api.DeployKey, len(keys))
+ for i := range keys {
+ if err := keys[i].GetContent(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetContent", err)
+ return
+ }
+ apiKeys[i] = convert.ToDeployKey(apiLink, keys[i])
+ if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == keys[i].RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
+ apiKeys[i], _ = appendPrivateInformation(ctx, apiKeys[i], keys[i], ctx.Repo.Repository)
+ }
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, &apiKeys)
+}
+
+// GetDeployKey get a deploy key by id
+func GetDeployKey(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/keys/{id} repository repoGetKey
+ // ---
+ // summary: Get a repository's key by id
+ // 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: id
+ // in: path
+ // description: id of the key to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/DeployKey"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if asymkey_model.IsErrDeployKeyNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetDeployKeyByID", err)
+ }
+ return
+ }
+
+ // this check make it more consistent
+ if key.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound()
+ return
+ }
+
+ if err = key.GetContent(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetContent", err)
+ return
+ }
+
+ apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
+ apiKey := convert.ToDeployKey(apiLink, key)
+ if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == key.RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
+ apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Repo.Repository)
+ }
+ ctx.JSON(http.StatusOK, apiKey)
+}
+
+// HandleCheckKeyStringError handle check key error
+func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
+ if db.IsErrSSHDisabled(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", "SSH is disabled")
+ } else if asymkey_model.IsErrKeyUnableVerify(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", "Unable to verify key content")
+ } else {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid key content: %w", err))
+ }
+}
+
+// HandleAddKeyError handle add key error
+func HandleAddKeyError(ctx *context.APIContext, err error) {
+ switch {
+ case asymkey_model.IsErrDeployKeyAlreadyExist(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "This key has already been added to this repository")
+ case asymkey_model.IsErrKeyAlreadyExist(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "Key content has been used as non-deploy key")
+ case asymkey_model.IsErrKeyNameAlreadyUsed(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "Key title has been used")
+ case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "A key with the same name already exists")
+ default:
+ ctx.Error(http.StatusInternalServerError, "AddKey", err)
+ }
+}
+
+// CreateDeployKey create deploy key for a repository
+func CreateDeployKey(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/keys repository repoCreateKey
+ // ---
+ // summary: Add a key to a repository
+ // 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/CreateKeyOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/DeployKey"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.CreateKeyOption)
+ content, err := asymkey_model.CheckPublicKeyString(form.Key)
+ if err != nil {
+ HandleCheckKeyStringError(ctx, err)
+ return
+ }
+
+ key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly)
+ if err != nil {
+ HandleAddKeyError(ctx, err)
+ return
+ }
+
+ key.Content = content
+ apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
+ ctx.JSON(http.StatusCreated, convert.ToDeployKey(apiLink, key))
+}
+
+// DeleteDeploykey delete deploy key for a repository
+func DeleteDeploykey(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
+ // ---
+ // summary: Delete a key from a repository
+ // 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: id
+ // in: path
+ // description: id of the key to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.ParamsInt64(":id")); err != nil {
+ if asymkey_model.IsErrKeyAccessDenied(err) {
+ ctx.Error(http.StatusForbidden, "", "You do not have access to this key")
+ } else {
+ ctx.Error(http.StatusInternalServerError, "DeleteDeployKey", err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go
new file mode 100644
index 0000000..b6eb51f
--- /dev/null
+++ b/routers/api/v1/repo/label.go
@@ -0,0 +1,285 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "strconv"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/label"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListLabels list all the labels of a repository
+func ListLabels(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/labels issue issueListLabels
+ // ---
+ // summary: Get all of a repository's labels
+ // 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: 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/LabelList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLabelsByRepoID", err)
+ return
+ }
+
+ count, err := issues_model.CountLabelsByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, nil))
+}
+
+// GetLabel get label by repository and label id
+func GetLabel(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/labels/{id} issue issueGetLabel
+ // ---
+ // summary: Get a single label
+ // 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: id
+ // in: path
+ // description: id of the label to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Label"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ var (
+ l *issues_model.Label
+ err error
+ )
+ strID := ctx.Params(":id")
+ if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil {
+ l, err = issues_model.GetLabelInRepoByName(ctx, ctx.Repo.Repository.ID, strID)
+ } else {
+ l, err = issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, intID)
+ }
+ if err != nil {
+ if issues_model.IsErrRepoLabelNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil))
+}
+
+// CreateLabel create a label for a repository
+func CreateLabel(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/labels issue issueCreateLabel
+ // ---
+ // summary: Create a label
+ // 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/CreateLabelOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Label"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.CreateLabelOption)
+
+ color, err := label.NormalizeColor(form.Color)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err)
+ return
+ }
+ form.Color = color
+ l := &issues_model.Label{
+ Name: form.Name,
+ Exclusive: form.Exclusive,
+ Color: form.Color,
+ RepoID: ctx.Repo.Repository.ID,
+ Description: form.Description,
+ }
+ l.SetArchived(form.IsArchived)
+ if err := issues_model.NewLabel(ctx, l); err != nil {
+ ctx.Error(http.StatusInternalServerError, "NewLabel", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToLabel(l, ctx.Repo.Repository, nil))
+}
+
+// EditLabel modify a label for a repository
+func EditLabel(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/labels/{id} issue issueEditLabel
+ // ---
+ // summary: Update a label
+ // 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: id
+ // in: path
+ // description: id of the label to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditLabelOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Label"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.EditLabelOption)
+ l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if issues_model.IsErrRepoLabelNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err)
+ }
+ return
+ }
+
+ if form.Name != nil {
+ l.Name = *form.Name
+ }
+ if form.Exclusive != nil {
+ l.Exclusive = *form.Exclusive
+ }
+ if form.Color != nil {
+ color, err := label.NormalizeColor(*form.Color)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err)
+ return
+ }
+ l.Color = color
+ }
+ if form.Description != nil {
+ l.Description = *form.Description
+ }
+ l.SetArchived(form.IsArchived != nil && *form.IsArchived)
+ if err := issues_model.UpdateLabel(ctx, l); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateLabel", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil))
+}
+
+// DeleteLabel delete a label for a repository
+func DeleteLabel(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/labels/{id} issue issueDeleteLabel
+ // ---
+ // summary: Delete a label
+ // 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: id
+ // in: path
+ // description: id of the label to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteLabel", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/language.go b/routers/api/v1/repo/language.go
new file mode 100644
index 0000000..f1d5bbe
--- /dev/null
+++ b/routers/api/v1/repo/language.go
@@ -0,0 +1,81 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "bytes"
+ "net/http"
+ "strconv"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/services/context"
+)
+
+type languageResponse []*repo_model.LanguageStat
+
+func (l languageResponse) MarshalJSON() ([]byte, error) {
+ var buf bytes.Buffer
+ if _, err := buf.WriteString("{"); err != nil {
+ return nil, err
+ }
+ for i, lang := range l {
+ if i > 0 {
+ if _, err := buf.WriteString(","); err != nil {
+ return nil, err
+ }
+ }
+ if _, err := buf.WriteString(strconv.Quote(lang.Language)); err != nil {
+ return nil, err
+ }
+ if _, err := buf.WriteString(":"); err != nil {
+ return nil, err
+ }
+ if _, err := buf.WriteString(strconv.FormatInt(lang.Size, 10)); err != nil {
+ return nil, err
+ }
+ }
+ if _, err := buf.WriteString("}"); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GetLanguages returns languages and number of bytes of code written
+func GetLanguages(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/languages repository repoGetLanguages
+ // ---
+ // summary: Get languages and number of bytes of code written
+ // 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
+ // responses:
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "200":
+ // "$ref": "#/responses/LanguageStatistics"
+
+ langs, err := repo_model.GetLanguageStats(ctx, ctx.Repo.Repository)
+ if err != nil {
+ log.Error("GetLanguageStats failed: %v", err)
+ ctx.InternalServerError(err)
+ return
+ }
+
+ resp := make(languageResponse, len(langs))
+ copy(resp, langs)
+
+ ctx.JSON(http.StatusOK, resp)
+}
diff --git a/routers/api/v1/repo/main_test.go b/routers/api/v1/repo/main_test.go
new file mode 100644
index 0000000..451f34d
--- /dev/null
+++ b/routers/api/v1/repo/main_test.go
@@ -0,0 +1,21 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{
+ SetUp: func() error {
+ setting.LoadQueueSettings()
+ return webhook_service.Init()
+ },
+ })
+}
diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go
new file mode 100644
index 0000000..0991723
--- /dev/null
+++ b/routers/api/v1/repo/migrate.go
@@ -0,0 +1,281 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/log"
+ base "code.gitea.io/gitea/modules/migration"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/migrations"
+ notify_service "code.gitea.io/gitea/services/notify"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// Migrate migrate remote git repository to gitea
+func Migrate(ctx *context.APIContext) {
+ // swagger:operation POST /repos/migrate repository repoMigrate
+ // ---
+ // summary: Migrate a remote git repository
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/MigrateRepoOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "409":
+ // description: The repository with the same name already exists.
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.MigrateRepoOptions)
+
+ // get repoOwner
+ var (
+ repoOwner *user_model.User
+ err error
+ )
+ if len(form.RepoOwner) != 0 {
+ repoOwner, err = user_model.GetUserByName(ctx, form.RepoOwner)
+ } else if form.RepoOwnerID != 0 {
+ repoOwner, err = user_model.GetUserByID(ctx, form.RepoOwnerID)
+ } else {
+ repoOwner = ctx.Doer
+ }
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUser", err)
+ }
+ return
+ }
+
+ if ctx.HasAPIError() {
+ ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg())
+ return
+ }
+
+ if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, repoOwner.ID, repoOwner.Name) {
+ return
+ }
+
+ if !ctx.Doer.IsAdmin {
+ if !repoOwner.IsOrganization() && ctx.Doer.ID != repoOwner.ID {
+ ctx.Error(http.StatusForbidden, "", "Given user is not an organization.")
+ return
+ }
+
+ if repoOwner.IsOrganization() {
+ // Check ownership of organization.
+ isOwner, err := organization.OrgFromUser(repoOwner).IsOwnedBy(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "IsOwnedBy", err)
+ return
+ } else if !isOwner {
+ ctx.Error(http.StatusForbidden, "", "Given user is not owner of organization.")
+ return
+ }
+ }
+ }
+
+ remoteAddr, err := forms.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword)
+ if err == nil {
+ err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.Doer)
+ }
+ if err != nil {
+ handleRemoteAddrError(ctx, err)
+ return
+ }
+
+ gitServiceType := convert.ToGitServiceType(form.Service)
+
+ if form.Mirror && setting.Mirror.DisableNewPull {
+ ctx.Error(http.StatusForbidden, "MirrorsGlobalDisabled", fmt.Errorf("the site administrator has disabled the creation of new pull mirrors"))
+ return
+ }
+
+ if setting.Repository.DisableMigrations {
+ ctx.Error(http.StatusForbidden, "MigrationsGlobalDisabled", fmt.Errorf("the site administrator has disabled migrations"))
+ return
+ }
+
+ form.LFS = form.LFS && setting.LFS.StartServer
+
+ if form.LFS && len(form.LFSEndpoint) > 0 {
+ ep := lfs.DetermineEndpoint("", form.LFSEndpoint)
+ if ep == nil {
+ ctx.Error(http.StatusInternalServerError, "", ctx.Tr("repo.migrate.invalid_lfs_endpoint"))
+ return
+ }
+ err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer)
+ if err != nil {
+ handleRemoteAddrError(ctx, err)
+ return
+ }
+ }
+
+ opts := migrations.MigrateOptions{
+ CloneAddr: remoteAddr,
+ RepoName: form.RepoName,
+ Description: form.Description,
+ Private: form.Private || setting.Repository.ForcePrivate,
+ Mirror: form.Mirror,
+ LFS: form.LFS,
+ LFSEndpoint: form.LFSEndpoint,
+ AuthUsername: form.AuthUsername,
+ AuthPassword: form.AuthPassword,
+ AuthToken: form.AuthToken,
+ Wiki: form.Wiki,
+ Issues: form.Issues,
+ Milestones: form.Milestones,
+ Labels: form.Labels,
+ Comments: form.Issues || form.PullRequests,
+ PullRequests: form.PullRequests,
+ Releases: form.Releases,
+ GitServiceType: gitServiceType,
+ MirrorInterval: form.MirrorInterval,
+ }
+ if opts.Mirror {
+ opts.Issues = false
+ opts.Milestones = false
+ opts.Labels = false
+ opts.Comments = false
+ opts.PullRequests = false
+ opts.Releases = false
+ }
+
+ repo, err := repo_service.CreateRepositoryDirectly(ctx, ctx.Doer, repoOwner, repo_service.CreateRepoOptions{
+ Name: opts.RepoName,
+ Description: opts.Description,
+ OriginalURL: form.CloneAddr,
+ GitServiceType: gitServiceType,
+ IsPrivate: opts.Private || setting.Repository.ForcePrivate,
+ IsMirror: opts.Mirror,
+ Status: repo_model.RepositoryBeingMigrated,
+ })
+ if err != nil {
+ handleMigrateError(ctx, repoOwner, err)
+ return
+ }
+
+ opts.MigrateToRepoID = repo.ID
+
+ defer func() {
+ if e := recover(); e != nil {
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "Handler crashed with error: %v", log.Stack(2))
+
+ err = errors.New(buf.String())
+ }
+
+ if err == nil {
+ notify_service.MigrateRepository(ctx, ctx.Doer, repoOwner, repo)
+ return
+ }
+
+ if repo != nil {
+ if errDelete := repo_service.DeleteRepositoryDirectly(ctx, ctx.Doer, repo.ID); errDelete != nil {
+ log.Error("DeleteRepository: %v", errDelete)
+ }
+ }
+ }()
+
+ if repo, err = migrations.MigrateRepository(graceful.GetManager().HammerContext(), ctx.Doer, repoOwner.Name, opts, nil); err != nil {
+ handleMigrateError(ctx, repoOwner, err)
+ return
+ }
+
+ log.Trace("Repository migrated: %s/%s", repoOwner.Name, form.RepoName)
+ ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeAdmin}))
+}
+
+func handleMigrateError(ctx *context.APIContext, repoOwner *user_model.User, err error) {
+ switch {
+ case repo_model.IsErrRepoAlreadyExist(err):
+ ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.")
+ case repo_model.IsErrRepoFilesAlreadyExist(err):
+ ctx.Error(http.StatusConflict, "", "Files already exist for this repository. Adopt them or delete them.")
+ case migrations.IsRateLimitError(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit addressed rate limitation.")
+ case migrations.IsTwoFactorAuthError(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit required two factors authentication.")
+ case repo_model.IsErrReachLimitOfRepo(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("You have already reached your limit of %d repositories.", repoOwner.MaxCreationLimit()))
+ case db.IsErrNameReserved(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' is reserved.", err.(db.ErrNameReserved).Name))
+ case db.IsErrNameCharsNotAllowed(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' contains invalid characters.", err.(db.ErrNameCharsNotAllowed).Name))
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The pattern '%s' is not allowed in a username.", err.(db.ErrNamePatternNotAllowed).Pattern))
+ case models.IsErrInvalidCloneAddr(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ case base.IsErrNotSupported(err):
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ default:
+ err = util.SanitizeErrorCredentialURLs(err)
+ if strings.Contains(err.Error(), "Authentication failed") ||
+ strings.Contains(err.Error(), "Bad credentials") ||
+ strings.Contains(err.Error(), "could not read Username") {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Authentication failed: %v.", err))
+ } else if strings.Contains(err.Error(), "fatal:") {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Migration failed: %v.", err))
+ } else {
+ ctx.Error(http.StatusInternalServerError, "MigrateRepository", err)
+ }
+ }
+}
+
+func handleRemoteAddrError(ctx *context.APIContext, err error) {
+ if models.IsErrInvalidCloneAddr(err) {
+ addrErr := err.(*models.ErrInvalidCloneAddr)
+ switch {
+ case addrErr.IsURLError:
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ case addrErr.IsPermissionDenied:
+ if addrErr.LocalPath {
+ ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import local repositories.")
+ } else {
+ ctx.Error(http.StatusUnprocessableEntity, "", "You can not import from disallowed hosts.")
+ }
+ case addrErr.IsInvalidPath:
+ ctx.Error(http.StatusUnprocessableEntity, "", "Invalid local path, it does not exist or not a directory.")
+ default:
+ ctx.Error(http.StatusInternalServerError, "ParseRemoteAddr", "Unknown error type (ErrInvalidCloneAddr): "+err.Error())
+ }
+ } else {
+ ctx.Error(http.StatusInternalServerError, "ParseRemoteAddr", err)
+ }
+}
diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go
new file mode 100644
index 0000000..b953401
--- /dev/null
+++ b/routers/api/v1/repo/milestone.go
@@ -0,0 +1,309 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/optional"
+ 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"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListMilestones list milestones for a repository
+func ListMilestones(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/milestones issue issueGetMilestonesList
+ // ---
+ // summary: Get all of a repository's opened milestones
+ // 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: Milestone state, Recognized values are open, closed and all. Defaults to "open"
+ // type: string
+ // - name: name
+ // in: query
+ // description: filter by milestone name
+ // type: string
+ // - 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/MilestoneList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ state := api.StateType(ctx.FormString("state"))
+ var isClosed optional.Option[bool]
+ switch state {
+ case api.StateClosed, api.StateOpen:
+ isClosed = optional.Some(state == api.StateClosed)
+ }
+
+ milestones, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ IsClosed: isClosed,
+ Name: ctx.FormString("name"),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "db.FindAndCount[issues_model.Milestone]", err)
+ return
+ }
+
+ apiMilestones := make([]*api.Milestone, len(milestones))
+ for i := range milestones {
+ apiMilestones[i] = convert.ToAPIMilestone(milestones[i])
+ }
+
+ ctx.SetTotalCountHeader(total)
+ ctx.JSON(http.StatusOK, &apiMilestones)
+}
+
+// GetMilestone get a milestone for a repository by ID and if not available by name
+func GetMilestone(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/milestones/{id} issue issueGetMilestone
+ // ---
+ // summary: Get a milestone
+ // 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: id
+ // in: path
+ // description: the milestone to get, identified by ID and if not available by name
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Milestone"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ milestone := getMilestoneByIDOrName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
+}
+
+// CreateMilestone create a milestone for a repository
+func CreateMilestone(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/milestones issue issueCreateMilestone
+ // ---
+ // summary: Create a milestone
+ // 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/CreateMilestoneOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Milestone"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.CreateMilestoneOption)
+
+ if form.Deadline == nil {
+ defaultDeadline, _ := time.ParseInLocation("2006-01-02", "9999-12-31", time.Local)
+ form.Deadline = &defaultDeadline
+ }
+
+ milestone := &issues_model.Milestone{
+ RepoID: ctx.Repo.Repository.ID,
+ Name: form.Title,
+ Content: form.Description,
+ DeadlineUnix: timeutil.TimeStamp(form.Deadline.Unix()),
+ }
+
+ if form.State == "closed" {
+ milestone.IsClosed = true
+ milestone.ClosedDateUnix = timeutil.TimeStampNow()
+ }
+
+ if err := issues_model.NewMilestone(ctx, milestone); err != nil {
+ ctx.Error(http.StatusInternalServerError, "NewMilestone", err)
+ return
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIMilestone(milestone))
+}
+
+// EditMilestone modify a milestone for a repository by ID and if not available by name
+func EditMilestone(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/milestones/{id} issue issueEditMilestone
+ // ---
+ // summary: Update a milestone
+ // 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: id
+ // in: path
+ // description: the milestone to edit, identified by ID and if not available by name
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditMilestoneOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Milestone"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ form := web.GetForm(ctx).(*api.EditMilestoneOption)
+ milestone := getMilestoneByIDOrName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if len(form.Title) > 0 {
+ milestone.Name = form.Title
+ }
+ if form.Description != nil {
+ milestone.Content = *form.Description
+ }
+ if form.Deadline != nil && !form.Deadline.IsZero() {
+ milestone.DeadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
+ }
+
+ oldIsClosed := milestone.IsClosed
+ if form.State != nil {
+ milestone.IsClosed = *form.State == string(api.StateClosed)
+ }
+
+ if err := issues_model.UpdateMilestone(ctx, milestone, oldIsClosed); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateMilestone", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
+}
+
+// DeleteMilestone delete a milestone for a repository by ID and if not available by name
+func DeleteMilestone(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/milestones/{id} issue issueDeleteMilestone
+ // ---
+ // summary: Delete a milestone
+ // 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: id
+ // in: path
+ // description: the milestone to delete, identified by ID and if not available by name
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ m := getMilestoneByIDOrName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, m.ID); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteMilestoneByRepoID", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+// getMilestoneByIDOrName get milestone by ID and if not available by name
+func getMilestoneByIDOrName(ctx *context.APIContext) *issues_model.Milestone {
+ mile := ctx.Params(":id")
+ mileID, _ := strconv.ParseInt(mile, 0, 64)
+
+ if mileID != 0 {
+ milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, mileID)
+ if err == nil {
+ return milestone
+ } else if !issues_model.IsErrMilestoneNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err)
+ return nil
+ }
+ }
+
+ milestone, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, mile)
+ if err != nil {
+ if issues_model.IsErrMilestoneNotExist(err) {
+ ctx.NotFound()
+ return nil
+ }
+ ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err)
+ return nil
+ }
+
+ return milestone
+}
diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go
new file mode 100644
index 0000000..ae727fd
--- /dev/null
+++ b/routers/api/v1/repo/mirror.go
@@ -0,0 +1,449 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/migrations"
+ mirror_service "code.gitea.io/gitea/services/mirror"
+)
+
+// MirrorSync adds a mirrored repository to the sync queue
+func MirrorSync(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/mirror-sync repository repoMirrorSync
+ // ---
+ // summary: Sync a mirrored repository
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to sync
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to sync
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ repo := ctx.Repo.Repository
+
+ if !ctx.Repo.CanWrite(unit.TypeCode) {
+ ctx.Error(http.StatusForbidden, "MirrorSync", "Must have write access")
+ }
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "MirrorSync", "Mirror feature is disabled")
+ return
+ }
+
+ if _, err := repo_model.GetMirrorByRepoID(ctx, repo.ID); err != nil {
+ if errors.Is(err, repo_model.ErrMirrorNotExist) {
+ ctx.Error(http.StatusBadRequest, "MirrorSync", "Repository is not a mirror")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "MirrorSync", err)
+ return
+ }
+
+ mirror_service.AddPullMirrorToQueue(repo.ID)
+
+ ctx.Status(http.StatusOK)
+}
+
+// PushMirrorSync adds all push mirrored repositories to the sync queue
+func PushMirrorSync(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/push_mirrors-sync repository repoPushMirrorSync
+ // ---
+ // summary: Sync all push mirrored repository
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to sync
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to sync
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/empty"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "PushMirrorSync", "Mirror feature is disabled")
+ return
+ }
+ // Get All push mirrors of a specific repo
+ pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{})
+ if err != nil {
+ ctx.Error(http.StatusNotFound, "PushMirrorSync", err)
+ return
+ }
+ for _, mirror := range pushMirrors {
+ ok := mirror_service.SyncPushMirror(ctx, mirror.ID)
+ if !ok {
+ ctx.Error(http.StatusInternalServerError, "PushMirrorSync", "error occurred when syncing push mirror "+mirror.RemoteName)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// ListPushMirrors get list of push mirrors of a repository
+func ListPushMirrors(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/push_mirrors repository repoListPushMirrors
+ // ---
+ // summary: Get all push mirrors of the repository
+ // 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: 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/PushMirrorList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "GetPushMirrorsByRepoID", "Mirror feature is disabled")
+ return
+ }
+
+ repo := ctx.Repo.Repository
+ // Get all push mirrors for the specified repository.
+ pushMirrors, count, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusNotFound, "GetPushMirrorsByRepoID", err)
+ return
+ }
+
+ responsePushMirrors := make([]*api.PushMirror, 0, len(pushMirrors))
+ for _, mirror := range pushMirrors {
+ m, err := convert.ToPushMirror(ctx, mirror)
+ if err == nil {
+ responsePushMirrors = append(responsePushMirrors, m)
+ }
+ }
+ ctx.SetLinkHeader(len(responsePushMirrors), utils.GetListOptions(ctx).PageSize)
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, responsePushMirrors)
+}
+
+// GetPushMirrorByName get push mirror of a repository by name
+func GetPushMirrorByName(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/push_mirrors/{name} repository repoGetPushMirrorByRemoteName
+ // ---
+ // summary: Get push mirror of the repository by remoteName
+ // 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: name
+ // in: path
+ // description: remote name of push mirror
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PushMirror"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "GetPushMirrorByRemoteName", "Mirror feature is disabled")
+ return
+ }
+
+ mirrorName := ctx.Params(":name")
+ // Get push mirror of a specific repo by remoteName
+ pushMirror, exist, err := db.Get[repo_model.PushMirror](ctx, repo_model.PushMirrorOptions{
+ RepoID: ctx.Repo.Repository.ID,
+ RemoteName: mirrorName,
+ }.ToConds())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetPushMirrors", err)
+ return
+ } else if !exist {
+ ctx.Error(http.StatusNotFound, "GetPushMirrors", nil)
+ return
+ }
+
+ m, err := convert.ToPushMirror(ctx, pushMirror)
+ if err != nil {
+ ctx.ServerError("GetPushMirrorByRemoteName", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, m)
+}
+
+// AddPushMirror adds a push mirror to a repository
+func AddPushMirror(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/push_mirrors repository repoAddPushMirror
+ // ---
+ // summary: add a push mirror to the repository
+ // 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/CreatePushMirrorOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PushMirror"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "AddPushMirror", "Mirror feature is disabled")
+ return
+ }
+
+ pushMirror := web.GetForm(ctx).(*api.CreatePushMirrorOption)
+ CreatePushMirror(ctx, pushMirror)
+}
+
+// DeletePushMirrorByRemoteName deletes a push mirror from a repository by remoteName
+func DeletePushMirrorByRemoteName(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/push_mirrors/{name} repository repoDeletePushMirror
+ // ---
+ // summary: deletes a push mirror from a repository by remoteName
+ // 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: name
+ // in: path
+ // description: remote name of the pushMirror
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "400":
+ // "$ref": "#/responses/error"
+
+ if !setting.Mirror.Enabled {
+ ctx.Error(http.StatusBadRequest, "DeletePushMirrorByName", "Mirror feature is disabled")
+ return
+ }
+
+ remoteName := ctx.Params(":name")
+ // Delete push mirror on repo by name.
+ err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{RepoID: ctx.Repo.Repository.ID, RemoteName: remoteName})
+ if err != nil {
+ ctx.Error(http.StatusNotFound, "DeletePushMirrors", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
+
+func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirrorOption) {
+ repo := ctx.Repo.Repository
+
+ interval, err := time.ParseDuration(mirrorOption.Interval)
+ if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) {
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", err)
+ return
+ }
+
+ if mirrorOption.UseSSH && !git.HasSSHExecutable {
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", "SSH authentication not available.")
+ return
+ }
+
+ if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") {
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'")
+ return
+ }
+
+ address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
+ if err == nil {
+ err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
+ }
+ if err != nil {
+ HandleRemoteAddressError(ctx, err)
+ return
+ }
+
+ remoteSuffix, err := util.CryptoRandomString(10)
+ if err != nil {
+ ctx.ServerError("CryptoRandomString", err)
+ return
+ }
+
+ remoteAddress, err := util.SanitizeURL(address)
+ if err != nil {
+ ctx.ServerError("SanitizeURL", err)
+ return
+ }
+
+ pushMirror := &repo_model.PushMirror{
+ RepoID: repo.ID,
+ Repo: repo,
+ RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix),
+ Interval: interval,
+ SyncOnCommit: mirrorOption.SyncOnCommit,
+ RemoteAddress: remoteAddress,
+ }
+
+ var plainPrivateKey []byte
+ if mirrorOption.UseSSH {
+ publicKey, privateKey, err := util.GenerateSSHKeypair()
+ if err != nil {
+ ctx.ServerError("GenerateSSHKeypair", err)
+ return
+ }
+ plainPrivateKey = privateKey
+ pushMirror.PublicKey = string(publicKey)
+ }
+
+ if err = db.Insert(ctx, pushMirror); err != nil {
+ ctx.ServerError("InsertPushMirror", err)
+ return
+ }
+
+ if mirrorOption.UseSSH {
+ if err = pushMirror.SetPrivatekey(ctx, plainPrivateKey); err != nil {
+ ctx.ServerError("SetPrivatekey", err)
+ return
+ }
+ }
+
+ // if the registration of the push mirrorOption fails remove it from the database
+ if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil {
+ if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil {
+ ctx.ServerError("DeletePushMirrors", err)
+ return
+ }
+ ctx.ServerError("AddPushMirrorRemote", err)
+ return
+ }
+ m, err := convert.ToPushMirror(ctx, pushMirror)
+ if err != nil {
+ ctx.ServerError("ToPushMirror", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, m)
+}
+
+func HandleRemoteAddressError(ctx *context.APIContext, err error) {
+ if models.IsErrInvalidCloneAddr(err) {
+ addrErr := err.(*models.ErrInvalidCloneAddr)
+ switch {
+ case addrErr.IsProtocolInvalid:
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Invalid mirror protocol")
+ case addrErr.IsURLError:
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Invalid Url ")
+ case addrErr.IsPermissionDenied:
+ ctx.Error(http.StatusUnauthorized, "CreatePushMirror", "Permission denied")
+ default:
+ ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Unknown error")
+ }
+ return
+ }
+}
diff --git a/routers/api/v1/repo/notes.go b/routers/api/v1/repo/notes.go
new file mode 100644
index 0000000..a4a1d4e
--- /dev/null
+++ b/routers/api/v1/repo/notes.go
@@ -0,0 +1,104 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// GetNote Get a note corresponding to a single commit from a repository
+func GetNote(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/notes/{sha} repository repoGetNote
+ // ---
+ // summary: Get a note corresponding to a single commit from a repository
+ // 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: sha
+ // in: path
+ // description: a git ref or commit sha
+ // type: string
+ // required: true
+ // - 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/Note"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := ctx.Params(":sha")
+ if !git.IsValidRefPattern(sha) {
+ ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha))
+ return
+ }
+ getNote(ctx, sha)
+}
+
+func getNote(ctx *context.APIContext, identifier string) {
+ if ctx.Repo.GitRepo == nil {
+ ctx.InternalServerError(fmt.Errorf("no open git repo"))
+ return
+ }
+
+ commitID, err := ctx.Repo.GitRepo.ConvertToGitID(identifier)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "ConvertToSHA1", err)
+ }
+ return
+ }
+
+ var note git.Note
+ if err := git.GetNote(ctx, ctx.Repo.GitRepo, commitID.String(), &note); err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(identifier)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetNote", err)
+ return
+ }
+
+ verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
+ files := ctx.FormString("files") == "" || ctx.FormBool("files")
+
+ cmt, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, note.Commit, nil,
+ convert.ToCommitOptions{
+ Stat: true,
+ Verification: verification,
+ Files: files,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ToCommit", err)
+ return
+ }
+ apiNote := api.Note{Message: string(note.Message), Commit: cmt}
+ ctx.JSON(http.StatusOK, apiNote)
+}
diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go
new file mode 100644
index 0000000..27c5c17
--- /dev/null
+++ b/routers/api/v1/repo/patch.go
@@ -0,0 +1,114 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/repository/files"
+)
+
+// ApplyDiffPatch handles API call for applying a patch
+func ApplyDiffPatch(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/diffpatch repository repoApplyDiffPatch
+ // ---
+ // summary: Apply diff patch to repository
+ // 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
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/UpdateFileOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/FileResponse"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions)
+
+ opts := &files.ApplyDiffPatchOptions{
+ Content: apiOpts.Content,
+ SHA: apiOpts.SHA,
+ Message: apiOpts.Message,
+ OldBranch: apiOpts.BranchName,
+ NewBranch: apiOpts.NewBranchName,
+ Committer: &files.IdentityOptions{
+ Name: apiOpts.Committer.Name,
+ Email: apiOpts.Committer.Email,
+ },
+ Author: &files.IdentityOptions{
+ Name: apiOpts.Author.Name,
+ Email: apiOpts.Author.Email,
+ },
+ Dates: &files.CommitDateOptions{
+ Author: apiOpts.Dates.Author,
+ Committer: apiOpts.Dates.Committer,
+ },
+ Signoff: apiOpts.Signoff,
+ }
+ if opts.Dates.Author.IsZero() {
+ opts.Dates.Author = time.Now()
+ }
+ if opts.Dates.Committer.IsZero() {
+ opts.Dates.Committer = time.Now()
+ }
+
+ if opts.Message == "" {
+ opts.Message = "apply-patch"
+ }
+
+ if !canWriteFiles(ctx, apiOpts.BranchName) {
+ ctx.Error(http.StatusInternalServerError, "ApplyPatch", repo_model.ErrUserDoesNotHaveAccessToRepo{
+ UserID: ctx.Doer.ID,
+ RepoName: ctx.Repo.Repository.LowerName,
+ })
+ return
+ }
+
+ fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts)
+ if err != nil {
+ if models.IsErrUserCannotCommit(err) || models.IsErrFilePathProtected(err) {
+ ctx.Error(http.StatusForbidden, "Access", err)
+ return
+ }
+ if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) ||
+ models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid", err)
+ return
+ }
+ if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) {
+ ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "ApplyPatch", err)
+ } else {
+ ctx.JSON(http.StatusCreated, fileResponse)
+ }
+}
diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go
new file mode 100644
index 0000000..bcb6ef2
--- /dev/null
+++ b/routers/api/v1/repo/pull.go
@@ -0,0 +1,1635 @@
+// 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 branch>...[<head repo>:]<head branch>
+ // 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 {
+ err := baseRepo.GetBaseRepo(ctx)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBaseRepo", err)
+ return nil, nil, nil, "", ""
+ }
+
+ // Check if baseRepo's base repository is the same as headUser's repository.
+ if baseRepo.BaseRepo == nil || baseRepo.BaseRepo.OwnerID != headUser.ID {
+ log.Trace("parseCompareInfo[%d]: does not have fork or in same repository", baseRepo.ID)
+ ctx.NotFound("GetBaseRepo")
+ return nil, nil, nil, "", ""
+ }
+ // Assign headRepo so it can be used below.
+ headRepo = baseRepo.BaseRepo
+ }
+
+ 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)
+}
diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go
new file mode 100644
index 0000000..8fba085
--- /dev/null
+++ b/routers/api/v1/repo/pull_review.go
@@ -0,0 +1,1107 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+)
+
+// ListPullReviews lists all reviews of a pull request
+func ListPullReviews(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews repository repoListPullReviews
+ // ---
+ // summary: List all reviews 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
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReviewList"
+ // "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("GetPullRequestByIndex", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err)
+ }
+ return
+ }
+
+ if err = pr.LoadIssue(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadIssue", err)
+ return
+ }
+
+ if err = pr.Issue.LoadRepo(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadRepo", err)
+ return
+ }
+
+ opts := issues_model.FindReviewOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ IssueID: pr.IssueID,
+ }
+
+ allReviews, err := issues_model.FindReviews(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ count, err := issues_model.CountReviews(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiReviews, err := convert.ToPullReviewList(ctx, allReviews, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, &apiReviews)
+}
+
+// GetPullReview gets a specific review of a pull request
+func GetPullReview(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoGetPullReview
+ // ---
+ // summary: Get a specific review 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReview"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ review, _, statusSet := prepareSingleReview(ctx)
+ if statusSet {
+ return
+ }
+
+ apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReview", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, apiReview)
+}
+
+// GetPullReviewComments lists all comments of a pull request review
+func GetPullReviewComments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments repository repoGetPullReviewComments
+ // ---
+ // summary: Get a specific review 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReviewCommentList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ review, _, statusSet := prepareSingleReview(ctx)
+ if statusSet {
+ return
+ }
+
+ apiComments, err := convert.ToPullReviewCommentList(ctx, review, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReviewCommentList", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, apiComments)
+}
+
+// GetPullReviewComment get a pull review comment
+func GetPullReviewComment(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment} repository repoGetPullReviewComment
+ // ---
+ // summary: Get a pull review comment
+ // 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
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: comment
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReviewComment"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ review, _, statusSet := prepareSingleReview(ctx)
+ if statusSet {
+ return
+ }
+
+ if err := ctx.Comment.LoadPoster(ctx); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiComment, err := convert.ToPullReviewComment(ctx, review, ctx.Comment, ctx.Doer)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, apiComment)
+}
+
+// CreatePullReviewComments add a new comment to a pull request review
+func CreatePullReviewComment(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments repository repoCreatePullReviewComment
+ // ---
+ // summary: Add a new comment to a pull request review
+ // 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
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/CreatePullReviewCommentOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReviewComment"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ opts := web.GetForm(ctx).(*api.CreatePullReviewCommentOptions)
+
+ review, pr, statusSet := prepareSingleReview(ctx)
+ if statusSet {
+ return
+ }
+
+ if err := pr.Issue.LoadRepo(ctx); err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ line := opts.NewLineNum
+ if opts.OldLineNum > 0 {
+ line = opts.OldLineNum * -1
+ }
+
+ comment, err := pull_service.CreateCodeCommentKnownReviewID(ctx,
+ ctx.Doer,
+ pr.Issue.Repo,
+ pr.Issue,
+ opts.Body,
+ opts.Path,
+ line,
+ review.ID,
+ nil,
+ )
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiComment, err := convert.ToPullReviewComment(ctx, review, comment, ctx.Doer)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, apiComment)
+}
+
+// DeletePullReview delete a specific review from a pull request
+func DeletePullReview(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoDeletePullReview
+ // ---
+ // summary: Delete a specific review from 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ review, _, statusSet := prepareSingleReview(ctx)
+ if statusSet {
+ return
+ }
+
+ if ctx.Doer == nil {
+ ctx.NotFound()
+ return
+ }
+ if !ctx.Doer.IsAdmin && ctx.Doer.ID != review.ReviewerID {
+ ctx.Error(http.StatusForbidden, "only admin and user itself can delete a review", nil)
+ return
+ }
+
+ if err := issues_model.DeleteReview(ctx, review); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d", review.ID))
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// CreatePullReview create a review to a pull request
+func CreatePullReview(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews repository repoCreatePullReview
+ // ---
+ // summary: Create a review to an 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/CreatePullReviewOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReview"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ opts := web.GetForm(ctx).(*api.CreatePullReviewOptions)
+ 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
+ }
+
+ // determine review type
+ reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body, len(opts.Comments) > 0)
+ if isWrong {
+ return
+ }
+
+ if err := pr.Issue.LoadRepo(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err)
+ return
+ }
+
+ // if CommitID is empty, set it as lastCommitID
+ if opts.CommitID == "" {
+ gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.Issue.Repo)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "git.OpenRepository", err)
+ return
+ }
+ defer closer.Close()
+
+ headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetRefCommitID", err)
+ return
+ }
+
+ opts.CommitID = headCommitID
+ }
+
+ // create review comments
+ for _, c := range opts.Comments {
+ line := c.NewLineNum
+ if c.OldLineNum > 0 {
+ line = c.OldLineNum * -1
+ }
+
+ if _, err := pull_service.CreateCodeComment(ctx,
+ ctx.Doer,
+ ctx.Repo.GitRepo,
+ pr.Issue,
+ line,
+ c.Body,
+ c.Path,
+ true, // pending review
+ 0, // no reply
+ opts.CommitID,
+ nil,
+ ); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err)
+ return
+ }
+ }
+
+ // create review and associate all pending review comments
+ review, _, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
+ return
+ }
+
+ // convert response
+ apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReview", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, apiReview)
+}
+
+// SubmitPullReview submit a pending review to an pull request
+func SubmitPullReview(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoSubmitPullReview
+ // ---
+ // summary: Submit a pending review to an 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/SubmitPullReviewOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReview"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ opts := web.GetForm(ctx).(*api.SubmitPullReviewOptions)
+ review, pr, isWrong := prepareSingleReview(ctx)
+ if isWrong {
+ return
+ }
+
+ if review.Type != issues_model.ReviewTypePending {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("only a pending review can be submitted"))
+ return
+ }
+
+ // determine review type
+ reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body, len(review.Comments) > 0)
+ if isWrong {
+ return
+ }
+
+ // if review stay pending return
+ if reviewType == issues_model.ReviewTypePending {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review stay pending"))
+ return
+ }
+
+ headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pr.GetGitRefName())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GitRepo: GetRefCommitID", err)
+ return
+ }
+
+ // create review and associate all pending review comments
+ review, _, err = pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, headCommitID, nil)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
+ return
+ }
+
+ // convert response
+ apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReview", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, apiReview)
+}
+
+// preparePullReviewType return ReviewType and false or nil and true if an error happen
+func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest, event api.ReviewStateType, body string, hasComments bool) (issues_model.ReviewType, bool) {
+ if err := pr.LoadIssue(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadIssue", err)
+ return -1, true
+ }
+
+ needsBody := true
+ hasBody := len(strings.TrimSpace(body)) > 0
+
+ var reviewType issues_model.ReviewType
+ switch event {
+ case api.ReviewStateApproved:
+ // can not approve your own PR
+ if pr.Issue.IsPoster(ctx.Doer.ID) {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("approve your own pull is not allowed"))
+ return -1, true
+ }
+ reviewType = issues_model.ReviewTypeApprove
+ needsBody = false
+
+ case api.ReviewStateRequestChanges:
+ // can not reject your own PR
+ if pr.Issue.IsPoster(ctx.Doer.ID) {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("reject your own pull is not allowed"))
+ return -1, true
+ }
+ reviewType = issues_model.ReviewTypeReject
+
+ case api.ReviewStateComment:
+ reviewType = issues_model.ReviewTypeComment
+ needsBody = false
+ // if there is no body we need to ensure that there are comments
+ if !hasBody && !hasComments {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review event %s requires a body or a comment", event))
+ return -1, true
+ }
+ default:
+ reviewType = issues_model.ReviewTypePending
+ }
+
+ // reject reviews with empty body if a body is required for this call
+ if needsBody && !hasBody {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review event %s requires a body", event))
+ return -1, true
+ }
+
+ return reviewType, false
+}
+
+// prepareSingleReview return review, related pull and false or nil, nil and true if an error happen
+func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues_model.PullRequest, bool) {
+ 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 nil, nil, true
+ }
+
+ review, err := issues_model.GetReviewByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if issues_model.IsErrReviewNotExist(err) {
+ ctx.NotFound("GetReviewByID", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetReviewByID", err)
+ }
+ return nil, nil, true
+ }
+
+ // validate the review is for the given PR
+ if review.IssueID != pr.IssueID {
+ ctx.NotFound("ReviewNotInPR")
+ return nil, nil, true
+ }
+
+ // make sure that the user has access to this review if it is pending
+ if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
+ ctx.NotFound("GetReviewByID")
+ return nil, nil, true
+ }
+
+ if err := review.LoadAttributes(ctx); err != nil && !user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "ReviewLoadAttributes", err)
+ return nil, nil, true
+ }
+
+ return review, pr, false
+}
+
+// CreateReviewRequests create review requests to an pull request
+func CreateReviewRequests(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoCreatePullReviewRequests
+ // ---
+ // summary: create review requests 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/PullReviewRequestOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/PullReviewList"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opts := web.GetForm(ctx).(*api.PullReviewRequestOptions)
+ apiReviewRequest(ctx, *opts, true)
+}
+
+// DeleteReviewRequests delete review requests to an pull request
+func DeleteReviewRequests(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoDeletePullReviewRequests
+ // ---
+ // summary: cancel review requests 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/PullReviewRequestOptions"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ opts := web.GetForm(ctx).(*api.PullReviewRequestOptions)
+ apiReviewRequest(ctx, *opts, false)
+}
+
+func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) {
+ 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.Issue.LoadRepo(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err)
+ return
+ }
+
+ reviewers := make([]*user_model.User, 0, len(opts.Reviewers))
+
+ permDoer, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+ return
+ }
+
+ for _, r := range opts.Reviewers {
+ var reviewer *user_model.User
+ if strings.Contains(r, "@") {
+ reviewer, err = user_model.GetUserByEmail(ctx, r)
+ } else {
+ reviewer, err = user_model.GetUserByName(ctx, r)
+ }
+
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUser", err)
+ return
+ }
+
+ err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, isAdd, pr.Issue, &permDoer)
+ if err != nil {
+ if issues_model.IsErrNotValidReviewRequest(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "IsValidReviewRequest", err)
+ return
+ }
+
+ reviewers = append(reviewers, reviewer)
+ }
+
+ var reviews []*issues_model.Review
+ if isAdd {
+ reviews = make([]*issues_model.Review, 0, len(reviewers))
+ }
+
+ for _, reviewer := range reviewers {
+ comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd)
+ if err != nil {
+ if issues_model.IsErrReviewRequestOnClosedPR(err) {
+ ctx.Error(http.StatusForbidden, "", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
+ return
+ }
+
+ if comment != nil && isAdd {
+ if err = comment.LoadReview(ctx); err != nil {
+ ctx.ServerError("ReviewRequest", err)
+ return
+ }
+ reviews = append(reviews, comment.Review)
+ }
+ }
+
+ if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 {
+ teamReviewers := make([]*organization.Team, 0, len(opts.TeamReviewers))
+ for _, t := range opts.TeamReviewers {
+ var teamReviewer *organization.Team
+ teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
+ return
+ }
+
+ err = issue_service.IsValidTeamReviewRequest(ctx, teamReviewer, ctx.Doer, isAdd, pr.Issue)
+ if err != nil {
+ if issues_model.IsErrNotValidReviewRequest(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "IsValidTeamReviewRequest", err)
+ return
+ }
+
+ teamReviewers = append(teamReviewers, teamReviewer)
+ }
+
+ for _, teamReviewer := range teamReviewers {
+ comment, err := issue_service.TeamReviewRequest(ctx, pr.Issue, ctx.Doer, teamReviewer, isAdd)
+ if err != nil {
+ ctx.ServerError("TeamReviewRequest", err)
+ return
+ }
+
+ if comment != nil && isAdd {
+ if err = comment.LoadReview(ctx); err != nil {
+ ctx.ServerError("ReviewRequest", err)
+ return
+ }
+ reviews = append(reviews, comment.Review)
+ }
+ }
+ }
+
+ if isAdd {
+ apiReviews, err := convert.ToPullReviewList(ctx, reviews, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err)
+ return
+ }
+ ctx.JSON(http.StatusCreated, apiReviews)
+ } else {
+ ctx.Status(http.StatusNoContent)
+ return
+ }
+}
+
+// DismissPullReview dismiss a review for a pull request
+func DismissPullReview(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals repository repoDismissPullReview
+ // ---
+ // summary: Dismiss a review 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/DismissPullReviewOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReview"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ opts := web.GetForm(ctx).(*api.DismissPullReviewOptions)
+ dismissReview(ctx, opts.Message, true, opts.Priors)
+}
+
+// UnDismissPullReview cancel to dismiss a review for a pull request
+func UnDismissPullReview(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals repository repoUnDismissPullReview
+ // ---
+ // summary: Cancel to dismiss a review 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
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/PullReview"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ dismissReview(ctx, "", false, false)
+}
+
+// DeletePullReviewComment delete a pull review comment
+func DeletePullReviewComment(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment} repository repoDeletePullReviewComment
+ // ---
+ // summary: Delete a pull review comment
+ // 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
+ // - name: id
+ // in: path
+ // description: id of the review
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: comment
+ // in: path
+ // description: id of the comment
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ deleteIssueComment(ctx, issues_model.CommentTypeCode)
+}
+
+func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors bool) {
+ if !ctx.Repo.IsAdmin() {
+ ctx.Error(http.StatusForbidden, "", "Must be repo admin")
+ return
+ }
+ review, _, isWrong := prepareSingleReview(ctx)
+ if isWrong {
+ return
+ }
+
+ if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject {
+ ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because it's type is not Approve or change request")
+ return
+ }
+
+ _, err := pull_service.DismissReview(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors)
+ if err != nil {
+ if pull_service.IsErrDismissRequestOnClosedPR(err) {
+ ctx.Error(http.StatusForbidden, "", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err)
+ return
+ }
+
+ if review, err = issues_model.GetReviewByID(ctx, review.ID); err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetReviewByID", err)
+ return
+ }
+
+ // convert response
+ apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "convertToPullReview", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, apiReview)
+}
diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go
new file mode 100644
index 0000000..5ea4dc8
--- /dev/null
+++ b/routers/api/v1/repo/release.go
@@ -0,0 +1,424 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ release_service "code.gitea.io/gitea/services/release"
+)
+
+// GetRelease get a single release of a repository
+func GetRelease(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases/{id} repository repoGetRelease
+ // ---
+ // summary: Get a release
+ // 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: id
+ // in: path
+ // description: id of the release to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Release"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ id := ctx.ParamsInt64(":id")
+ release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id)
+ if err != nil && !repo_model.IsErrReleaseNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err)
+ return
+ }
+ if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag {
+ ctx.NotFound()
+ return
+ }
+
+ if err := release.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
+}
+
+// GetLatestRelease gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at
+func GetLatestRelease(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases/latest repository repoGetLatestRelease
+ // ---
+ // summary: Gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Release"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil && !repo_model.IsErrReleaseNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetLatestRelease", err)
+ return
+ }
+ if err != nil && repo_model.IsErrReleaseNotExist(err) ||
+ release.IsTag || release.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound()
+ return
+ }
+
+ if err := release.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
+}
+
+// ListReleases list a repository's releases
+func ListReleases(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases repository repoListReleases
+ // ---
+ // summary: List a repo's releases
+ // 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: draft
+ // in: query
+ // description: filter (exclude / include) drafts, if you dont have repo write access none will show
+ // type: boolean
+ // - name: pre-release
+ // in: query
+ // description: filter (exclude / include) pre-releases
+ // type: boolean
+ // - 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/ReleaseList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ listOptions := utils.GetListOptions(ctx)
+
+ opts := repo_model.FindReleasesOptions{
+ ListOptions: listOptions,
+ IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite,
+ IncludeTags: false,
+ IsDraft: ctx.FormOptionalBool("draft"),
+ IsPreRelease: ctx.FormOptionalBool("pre-release"),
+ RepoID: ctx.Repo.Repository.ID,
+ }
+
+ releases, err := db.Find[repo_model.Release](ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetReleasesByRepoID", err)
+ return
+ }
+ rels := make([]*api.Release, len(releases))
+ for i, release := range releases {
+ if err := release.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ rels[i] = convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)
+ }
+
+ filteredCount, err := db.Count[repo_model.Release](ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.SetLinkHeader(int(filteredCount), listOptions.PageSize)
+ ctx.SetTotalCountHeader(filteredCount)
+ ctx.JSON(http.StatusOK, rels)
+}
+
+// CreateRelease create a release
+func CreateRelease(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/releases repository repoCreateRelease
+ // ---
+ // summary: Create a release
+ // 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/CreateReleaseOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Release"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // "$ref": "#/responses/error"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm(ctx).(*api.CreateReleaseOption)
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty"))
+ return
+ }
+ rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName)
+ if err != nil {
+ if !repo_model.IsErrReleaseNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetRelease", err)
+ return
+ }
+ // If target is not provided use default branch
+ if len(form.Target) == 0 {
+ form.Target = ctx.Repo.Repository.DefaultBranch
+ }
+ rel = &repo_model.Release{
+ RepoID: ctx.Repo.Repository.ID,
+ PublisherID: ctx.Doer.ID,
+ Publisher: ctx.Doer,
+ TagName: form.TagName,
+ Target: form.Target,
+ Title: form.Title,
+ Note: form.Note,
+ IsDraft: form.IsDraft,
+ IsPrerelease: form.IsPrerelease,
+ HideArchiveLinks: form.HideArchiveLinks,
+ IsTag: false,
+ Repo: ctx.Repo.Repository,
+ }
+ if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, "", nil); err != nil {
+ if repo_model.IsErrReleaseAlreadyExist(err) {
+ ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err)
+ } else if models.IsErrProtectedTagName(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "ProtectedTagName", err)
+ } else if git.IsErrNotExist(err) {
+ ctx.Error(http.StatusNotFound, "ErrNotExist", fmt.Errorf("target \"%v\" not found: %w", rel.Target, err))
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateRelease", err)
+ }
+ return
+ }
+ } else {
+ if !rel.IsTag {
+ ctx.Error(http.StatusConflict, "GetRelease", "Release is has no Tag")
+ return
+ }
+
+ rel.Title = form.Title
+ rel.Note = form.Note
+ rel.IsDraft = form.IsDraft
+ rel.IsPrerelease = form.IsPrerelease
+ rel.HideArchiveLinks = form.HideArchiveLinks
+ rel.PublisherID = ctx.Doer.ID
+ rel.IsTag = false
+ rel.Repo = ctx.Repo.Repository
+ rel.Publisher = ctx.Doer
+ rel.Target = form.Target
+
+ if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, nil); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
+ return
+ }
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel))
+}
+
+// EditRelease edit a release
+func EditRelease(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/releases/{id} repository repoEditRelease
+ // ---
+ // summary: Update a release
+ // 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: id
+ // in: path
+ // description: id of the release to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditReleaseOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Release"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.EditReleaseOption)
+ id := ctx.ParamsInt64(":id")
+ rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id)
+ if err != nil && !repo_model.IsErrReleaseNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err)
+ return
+ }
+ if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag {
+ ctx.NotFound()
+ return
+ }
+
+ if len(form.TagName) > 0 {
+ rel.TagName = form.TagName
+ }
+ if len(form.Target) > 0 {
+ rel.Target = form.Target
+ }
+ if len(form.Title) > 0 {
+ rel.Title = form.Title
+ }
+ if len(form.Note) > 0 {
+ rel.Note = form.Note
+ }
+ if form.IsDraft != nil {
+ rel.IsDraft = *form.IsDraft
+ }
+ if form.IsPrerelease != nil {
+ rel.IsPrerelease = *form.IsPrerelease
+ }
+ if form.HideArchiveLinks != nil {
+ rel.HideArchiveLinks = *form.HideArchiveLinks
+ }
+ if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, nil); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
+ return
+ }
+
+ // reload data from database
+ rel, err = repo_model.GetReleaseByID(ctx, id)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err)
+ return
+ }
+ if err := rel.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel))
+}
+
+// DeleteRelease delete a release from a repository
+func DeleteRelease(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/releases/{id} repository repoDeleteRelease
+ // ---
+ // summary: Delete a release
+ // 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: id
+ // in: path
+ // description: id of the release to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ id := ctx.ParamsInt64(":id")
+ rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id)
+ if err != nil && !repo_model.IsErrReleaseNotExist(err) {
+ ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err)
+ return
+ }
+ if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag {
+ ctx.NotFound()
+ return
+ }
+ if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, false); err != nil {
+ if models.IsErrProtectedTagName(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go
new file mode 100644
index 0000000..d569f6e
--- /dev/null
+++ b/routers/api/v1/repo/release_attachment.go
@@ -0,0 +1,467 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/attachment"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/context/upload"
+ "code.gitea.io/gitea/services/convert"
+)
+
+func checkReleaseMatchRepo(ctx *context.APIContext, releaseID int64) bool {
+ release, err := repo_model.GetReleaseByID(ctx, releaseID)
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound()
+ return false
+ }
+ ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err)
+ return false
+ }
+ if release.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound()
+ return false
+ }
+ return true
+}
+
+// GetReleaseAttachment gets a single attachment of the release
+func GetReleaseAttachment(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoGetReleaseAttachment
+ // ---
+ // summary: Get a release attachment
+ // 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: id
+ // in: path
+ // description: id of the release
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ releaseID := ctx.ParamsInt64(":id")
+ if !checkReleaseMatchRepo(ctx, releaseID) {
+ return
+ }
+
+ attachID := ctx.ParamsInt64(":attachment_id")
+ attach, err := repo_model.GetAttachmentByID(ctx, attachID)
+ if err != nil {
+ if repo_model.IsErrAttachmentNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err)
+ return
+ }
+ if attach.ReleaseID != releaseID {
+ log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID)
+ ctx.NotFound()
+ return
+ }
+ // FIXME Should prove the existence of the given repo, but results in unnecessary database requests
+ ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+}
+
+// ListReleaseAttachments lists all attachments of the release
+func ListReleaseAttachments(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases/{id}/assets repository repoListReleaseAttachments
+ // ---
+ // summary: List release's attachments
+ // 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: id
+ // in: path
+ // description: id of the release
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/AttachmentList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ releaseID := ctx.ParamsInt64(":id")
+ release, err := repo_model.GetReleaseByID(ctx, releaseID)
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err)
+ return
+ }
+ if release.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound()
+ return
+ }
+ if err := release.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release).Attachments)
+}
+
+// CreateReleaseAttachment creates an attachment and saves the given file
+func CreateReleaseAttachment(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/releases/{id}/assets repository repoCreateReleaseAttachment
+ // ---
+ // summary: Create a release attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - multipart/form-data
+ // - application/octet-stream
+ // 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: id
+ // in: path
+ // description: id of the release
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: name
+ // in: query
+ // description: name of the attachment
+ // type: string
+ // required: false
+ // # There is no good way to specify "either 'attachment' or 'external_url' is required" with OpenAPI
+ // # https://github.com/OAI/OpenAPI-Specification/issues/256
+ // - name: attachment
+ // in: formData
+ // description: attachment to upload (this parameter is incompatible with `external_url`)
+ // type: file
+ // required: false
+ // - name: external_url
+ // in: formData
+ // description: url to external asset (this parameter is incompatible with `attachment`)
+ // type: string
+ // required: false
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ // Check if attachments are enabled
+ if !setting.Attachment.Enabled {
+ ctx.NotFound("Attachment is not enabled")
+ return
+ }
+
+ // Check if release exists an load release
+ releaseID := ctx.ParamsInt64(":id")
+ if !checkReleaseMatchRepo(ctx, releaseID) {
+ return
+ }
+
+ // Get uploaded file from request
+ var isForm, hasAttachmentFile, hasExternalURL bool
+ externalURL := ctx.FormString("external_url")
+ hasExternalURL = externalURL != ""
+ filename := ctx.FormString("name")
+ isForm = strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data")
+
+ if isForm {
+ _, _, err := ctx.Req.FormFile("attachment")
+ hasAttachmentFile = err == nil
+ } else {
+ hasAttachmentFile = ctx.Req.Body != nil
+ }
+
+ if hasAttachmentFile && hasExternalURL {
+ ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive")
+ } else if hasAttachmentFile {
+ var content io.ReadCloser
+ var size int64 = -1
+
+ if isForm {
+ var header *multipart.FileHeader
+ content, header, _ = ctx.Req.FormFile("attachment")
+ size = header.Size
+ defer content.Close()
+ if filename == "" {
+ filename = header.Filename
+ }
+ } else {
+ content = ctx.Req.Body
+ defer content.Close()
+ }
+
+ if filename == "" {
+ ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter")
+ return
+ }
+
+ // Create a new attachment and save the file
+ attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{
+ Name: filename,
+ UploaderID: ctx.Doer.ID,
+ RepoID: ctx.Repo.Repository.ID,
+ ReleaseID: releaseID,
+ })
+ if err != nil {
+ if upload.IsErrFileTypeForbidden(err) {
+ ctx.Error(http.StatusBadRequest, "DetectContentType", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "NewAttachment", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+ } else if hasExternalURL {
+ url, err := url.Parse(externalURL)
+ if err != nil {
+ ctx.Error(http.StatusBadRequest, "InvalidExternalURL", err)
+ return
+ }
+
+ if filename == "" {
+ filename = path.Base(url.Path)
+
+ if filename == "." {
+ // Url path is empty
+ filename = url.Host
+ }
+ }
+
+ attach, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{
+ Name: filename,
+ UploaderID: ctx.Doer.ID,
+ RepoID: ctx.Repo.Repository.ID,
+ ReleaseID: releaseID,
+ ExternalURL: url.String(),
+ })
+ if err != nil {
+ if repo_model.IsErrInvalidExternalURL(err) {
+ ctx.Error(http.StatusBadRequest, "NewExternalAttachment", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "NewExternalAttachment", err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+ } else {
+ ctx.Error(http.StatusBadRequest, "MissingAttachment", "One of 'attachment' or 'external_url' is required")
+ }
+}
+
+// EditReleaseAttachment updates the given attachment
+func EditReleaseAttachment(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoEditReleaseAttachment
+ // ---
+ // summary: Edit a release attachment
+ // produces:
+ // - application/json
+ // consumes:
+ // - 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: id
+ // in: path
+ // description: id of the release
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to edit
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditAttachmentOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Attachment"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ form := web.GetForm(ctx).(*api.EditAttachmentOptions)
+
+ // Check if release exists an load release
+ releaseID := ctx.ParamsInt64(":id")
+ if !checkReleaseMatchRepo(ctx, releaseID) {
+ return
+ }
+
+ attachID := ctx.ParamsInt64(":attachment_id")
+ attach, err := repo_model.GetAttachmentByID(ctx, attachID)
+ if err != nil {
+ if repo_model.IsErrAttachmentNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err)
+ return
+ }
+ if attach.ReleaseID != releaseID {
+ log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID)
+ ctx.NotFound()
+ return
+ }
+ // FIXME Should prove the existence of the given repo, but results in unnecessary database requests
+ if form.Name != "" {
+ attach.Name = form.Name
+ }
+
+ if form.DownloadURL != "" {
+ if attach.ExternalURL == "" {
+ ctx.Error(http.StatusBadRequest, "EditAttachment", "existing attachment is not external")
+ return
+ }
+ attach.ExternalURL = form.DownloadURL
+ }
+
+ if err := repo_model.UpdateAttachment(ctx, attach); err != nil {
+ if repo_model.IsErrInvalidExternalURL(err) {
+ ctx.Error(http.StatusBadRequest, "UpdateAttachment", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
+ }
+ return
+ }
+ ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
+}
+
+// DeleteReleaseAttachment delete a given attachment
+func DeleteReleaseAttachment(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoDeleteReleaseAttachment
+ // ---
+ // summary: Delete a release attachment
+ // 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: id
+ // in: path
+ // description: id of the release
+ // type: integer
+ // format: int64
+ // required: true
+ // - name: attachment_id
+ // in: path
+ // description: id of the attachment to delete
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ // Check if release exists an load release
+ releaseID := ctx.ParamsInt64(":id")
+ if !checkReleaseMatchRepo(ctx, releaseID) {
+ return
+ }
+
+ attachID := ctx.ParamsInt64(":attachment_id")
+ attach, err := repo_model.GetAttachmentByID(ctx, attachID)
+ if err != nil {
+ if repo_model.IsErrAttachmentNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err)
+ return
+ }
+ if attach.ReleaseID != releaseID {
+ log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID)
+ ctx.NotFound()
+ return
+ }
+ // FIXME Should prove the existence of the given repo, but results in unnecessary database requests
+
+ if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go
new file mode 100644
index 0000000..f845fad
--- /dev/null
+++ b/routers/api/v1/repo/release_tags.go
@@ -0,0 +1,125 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ releaseservice "code.gitea.io/gitea/services/release"
+)
+
+// GetReleaseByTag get a single release of a repository by tag name
+func GetReleaseByTag(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/releases/tags/{tag} repository repoGetReleaseByTag
+ // ---
+ // summary: Get a release by tag name
+ // 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: tag
+ // in: path
+ // description: tag name of the release to get
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Release"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ tag := ctx.Params(":tag")
+
+ release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag)
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetRelease", err)
+ return
+ }
+
+ if release.IsTag {
+ ctx.NotFound()
+ return
+ }
+
+ if err = release.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
+}
+
+// DeleteReleaseByTag delete a release from a repository by tag name
+func DeleteReleaseByTag(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/releases/tags/{tag} repository repoDeleteReleaseByTag
+ // ---
+ // summary: Delete a release by tag name
+ // 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: tag
+ // in: path
+ // description: tag name of the release to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ tag := ctx.Params(":tag")
+
+ release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag)
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetRelease", err)
+ return
+ }
+
+ if release.IsTag {
+ ctx.NotFound()
+ return
+ }
+
+ if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, release, ctx.Doer, false); err != nil {
+ if models.IsErrProtectedTagName(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
new file mode 100644
index 0000000..e25593c
--- /dev/null
+++ b/routers/api/v1/repo/repo.go
@@ -0,0 +1,1334 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/label"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/validation"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ actions_service "code.gitea.io/gitea/services/actions"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/services/issue"
+ repo_service "code.gitea.io/gitea/services/repository"
+ wiki_service "code.gitea.io/gitea/services/wiki"
+)
+
+// Search repositories via options
+func Search(ctx *context.APIContext) {
+ // swagger:operation GET /repos/search repository repoSearch
+ // ---
+ // summary: Search for repositories
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: q
+ // in: query
+ // description: keyword
+ // type: string
+ // - name: topic
+ // in: query
+ // description: Limit search to repositories with keyword as topic
+ // type: boolean
+ // - name: includeDesc
+ // in: query
+ // description: include search of keyword within repository description
+ // type: boolean
+ // - name: uid
+ // in: query
+ // description: search only for repos that the user with the given id owns or contributes to
+ // type: integer
+ // format: int64
+ // - name: priority_owner_id
+ // in: query
+ // description: repo owner to prioritize in the results
+ // type: integer
+ // format: int64
+ // - name: team_id
+ // in: query
+ // description: search only for repos that belong to the given team id
+ // type: integer
+ // format: int64
+ // - name: starredBy
+ // in: query
+ // description: search only for repos that the user with the given id has starred
+ // type: integer
+ // format: int64
+ // - name: private
+ // in: query
+ // description: include private repositories this user has access to (defaults to true)
+ // type: boolean
+ // - name: is_private
+ // in: query
+ // description: show only pubic, private or all repositories (defaults to all)
+ // type: boolean
+ // - name: template
+ // in: query
+ // description: include template repositories this user has access to (defaults to true)
+ // type: boolean
+ // - name: archived
+ // in: query
+ // description: show only archived, non-archived or all repositories (defaults to all)
+ // type: boolean
+ // - name: mode
+ // in: query
+ // description: type of repository to search for. Supported values are
+ // "fork", "source", "mirror" and "collaborative"
+ // type: string
+ // - name: exclusive
+ // in: query
+ // description: if `uid` is given, search only for repos that the user owns
+ // type: boolean
+ // - name: sort
+ // in: query
+ // description: sort repos by attribute. Supported values are
+ // "alpha", "created", "updated", "size", "git_size", "lfs_size", "stars", "forks" and "id".
+ // Default is "alpha"
+ // type: string
+ // - name: order
+ // in: query
+ // description: sort order, either "asc" (ascending) or "desc" (descending).
+ // Default is "asc", ignored if "sort" is not specified.
+ // type: string
+ // - 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/SearchResults"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private"))
+ if ctx.PublicOnly {
+ private = false
+ }
+
+ opts := &repo_model.SearchRepoOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ Actor: ctx.Doer,
+ Keyword: ctx.FormTrim("q"),
+ OwnerID: ctx.FormInt64("uid"),
+ PriorityOwnerID: ctx.FormInt64("priority_owner_id"),
+ TeamID: ctx.FormInt64("team_id"),
+ TopicOnly: ctx.FormBool("topic"),
+ Collaborate: optional.None[bool](),
+ Private: private,
+ Template: optional.None[bool](),
+ StarredByID: ctx.FormInt64("starredBy"),
+ IncludeDescription: ctx.FormBool("includeDesc"),
+ }
+
+ if ctx.FormString("template") != "" {
+ opts.Template = optional.Some(ctx.FormBool("template"))
+ }
+
+ if ctx.FormBool("exclusive") {
+ opts.Collaborate = optional.Some(false)
+ }
+
+ mode := ctx.FormString("mode")
+ switch mode {
+ case "source":
+ opts.Fork = optional.Some(false)
+ opts.Mirror = optional.Some(false)
+ case "fork":
+ opts.Fork = optional.Some(true)
+ case "mirror":
+ opts.Mirror = optional.Some(true)
+ case "collaborative":
+ opts.Mirror = optional.Some(false)
+ opts.Collaborate = optional.Some(true)
+ case "":
+ default:
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid search mode: \"%s\"", mode))
+ return
+ }
+
+ if ctx.FormString("archived") != "" {
+ opts.Archived = optional.Some(ctx.FormBool("archived"))
+ }
+
+ if ctx.FormString("is_private") != "" {
+ opts.IsPrivate = optional.Some(ctx.FormBool("is_private"))
+ }
+
+ sortMode := ctx.FormString("sort")
+ if len(sortMode) > 0 {
+ sortOrder := ctx.FormString("order")
+ if len(sortOrder) == 0 {
+ sortOrder = "asc"
+ }
+ if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok {
+ if orderBy, ok := searchModeMap[sortMode]; ok {
+ opts.OrderBy = orderBy
+ } else {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid sort mode: \"%s\"", sortMode))
+ return
+ }
+ } else {
+ ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid sort order: \"%s\"", sortOrder))
+ return
+ }
+ }
+
+ var err error
+ repos, count, err := repo_model.SearchRepository(ctx, opts)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, api.SearchError{
+ OK: false,
+ Error: err.Error(),
+ })
+ return
+ }
+
+ results := make([]*api.Repository, len(repos))
+ for i, repo := range repos {
+ if err = repo.LoadOwner(ctx); err != nil {
+ ctx.JSON(http.StatusInternalServerError, api.SearchError{
+ OK: false,
+ Error: err.Error(),
+ })
+ return
+ }
+ permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, api.SearchError{
+ OK: false,
+ Error: err.Error(),
+ })
+ }
+ results[i] = convert.ToRepo(ctx, repo, permission)
+ }
+ ctx.SetLinkHeader(int(count), opts.PageSize)
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, api.SearchResults{
+ OK: true,
+ Data: results,
+ })
+}
+
+// CreateUserRepo create a repository for a user
+func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.CreateRepoOption) {
+ if opt.AutoInit && opt.Readme == "" {
+ opt.Readme = "Default"
+ }
+
+ // If the readme template does not exist, a 400 will be returned.
+ if opt.AutoInit && len(opt.Readme) > 0 && !slices.Contains(repo_module.Readmes, opt.Readme) {
+ ctx.Error(http.StatusBadRequest, "", fmt.Errorf("readme template does not exist, available templates: %v", repo_module.Readmes))
+ return
+ }
+
+ repo, err := repo_service.CreateRepository(ctx, ctx.Doer, owner, repo_service.CreateRepoOptions{
+ Name: opt.Name,
+ Description: opt.Description,
+ IssueLabels: opt.IssueLabels,
+ Gitignores: opt.Gitignores,
+ License: opt.License,
+ Readme: opt.Readme,
+ IsPrivate: opt.Private || setting.Repository.ForcePrivate,
+ AutoInit: opt.AutoInit,
+ DefaultBranch: opt.DefaultBranch,
+ TrustModel: repo_model.ToTrustModel(opt.TrustModel),
+ IsTemplate: opt.Template,
+ ObjectFormatName: opt.ObjectFormatName,
+ })
+ if err != nil {
+ if repo_model.IsErrRepoAlreadyExist(err) {
+ ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.")
+ } else if db.IsErrNameReserved(err) ||
+ db.IsErrNamePatternNotAllowed(err) ||
+ label.IsErrTemplateLoad(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateRepository", err)
+ }
+ return
+ }
+
+ // reload repo from db to get a real state after creation
+ repo, err = repo_model.GetRepositoryByID(ctx, repo.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetRepositoryByID", err)
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}))
+}
+
+// Create one repository of mine
+func Create(ctx *context.APIContext) {
+ // swagger:operation POST /user/repos repository user createCurrentUserRepo
+ // ---
+ // summary: Create a repository
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateRepoOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Repository"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "409":
+ // description: The repository with the same name already exists.
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ opt := web.GetForm(ctx).(*api.CreateRepoOption)
+ if ctx.Doer.IsOrganization() {
+ // Shouldn't reach this condition, but just in case.
+ ctx.Error(http.StatusUnprocessableEntity, "", "not allowed creating repository for organization")
+ return
+ }
+ CreateUserRepo(ctx, ctx.Doer, *opt)
+}
+
+// Generate Create a repository using a template
+func Generate(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{template_owner}/{template_repo}/generate repository generateRepo
+ // ---
+ // summary: Create a repository using a template
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: template_owner
+ // in: path
+ // description: name of the template repository owner
+ // type: string
+ // required: true
+ // - name: template_repo
+ // in: path
+ // description: name of the template repository
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/GenerateRepoOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "409":
+ // description: The repository with the same name already exists.
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ form := web.GetForm(ctx).(*api.GenerateRepoOption)
+
+ if !ctx.Repo.Repository.IsTemplate {
+ ctx.Error(http.StatusUnprocessableEntity, "", "this is not a template repo")
+ return
+ }
+
+ if ctx.Doer.IsOrganization() {
+ ctx.Error(http.StatusUnprocessableEntity, "", "not allowed creating repository for organization")
+ return
+ }
+
+ opts := repo_service.GenerateRepoOptions{
+ Name: form.Name,
+ DefaultBranch: form.DefaultBranch,
+ Description: form.Description,
+ Private: form.Private || setting.Repository.ForcePrivate,
+ GitContent: form.GitContent,
+ Topics: form.Topics,
+ GitHooks: form.GitHooks,
+ Webhooks: form.Webhooks,
+ Avatar: form.Avatar,
+ IssueLabels: form.Labels,
+ ProtectedBranch: form.ProtectedBranch,
+ }
+
+ if !opts.IsValid() {
+ ctx.Error(http.StatusUnprocessableEntity, "", "must select at least one template item")
+ return
+ }
+
+ ctxUser := ctx.Doer
+ var err error
+ if form.Owner != ctxUser.Name {
+ ctxUser, err = user_model.GetUserByName(ctx, form.Owner)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.JSON(http.StatusNotFound, map[string]any{
+ "error": "request owner `" + form.Owner + "` does not exist",
+ })
+ return
+ }
+
+ ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+ return
+ }
+
+ if !ctx.Doer.IsAdmin && !ctxUser.IsOrganization() {
+ ctx.Error(http.StatusForbidden, "", "Only admin can generate repository for other user.")
+ return
+ }
+
+ if !ctx.Doer.IsAdmin {
+ canCreate, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("CanCreateOrgRepo", err)
+ return
+ } else if !canCreate {
+ ctx.Error(http.StatusForbidden, "", "Given user is not allowed to create repository in organization.")
+ return
+ }
+ }
+ }
+
+ if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) {
+ return
+ }
+
+ repo, err := repo_service.GenerateRepository(ctx, ctx.Doer, ctxUser, ctx.Repo.Repository, opts)
+ if err != nil {
+ if repo_model.IsErrRepoAlreadyExist(err) {
+ ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.")
+ } else if db.IsErrNameReserved(err) ||
+ db.IsErrNamePatternNotAllowed(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "CreateRepository", err)
+ }
+ return
+ }
+ log.Trace("Repository generated [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name)
+
+ ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}))
+}
+
+// CreateOrgRepoDeprecated create one repository of the organization
+func CreateOrgRepoDeprecated(ctx *context.APIContext) {
+ // swagger:operation POST /org/{org}/repos organization createOrgRepoDeprecated
+ // ---
+ // summary: Create a repository in an organization
+ // deprecated: true
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: org
+ // in: path
+ // description: name of organization
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateRepoOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Repository"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ CreateOrgRepo(ctx)
+}
+
+// CreateOrgRepo create one repository of the organization
+func CreateOrgRepo(ctx *context.APIContext) {
+ // swagger:operation POST /orgs/{org}/repos organization createOrgRepo
+ // ---
+ // summary: Create a repository in an organization
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: org
+ // in: path
+ // description: name of organization
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateRepoOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Repository"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ opt := web.GetForm(ctx).(*api.CreateRepoOption)
+ org, err := organization.GetOrgByName(ctx, ctx.Params(":org"))
+ if err != nil {
+ if organization.IsErrOrgNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetOrgByName", err)
+ }
+ return
+ }
+
+ if !organization.HasOrgOrUserVisible(ctx, org.AsUser(), ctx.Doer) {
+ ctx.NotFound("HasOrgOrUserVisible", nil)
+ return
+ }
+
+ if !ctx.Doer.IsAdmin {
+ canCreate, err := org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err)
+ return
+ } else if !canCreate {
+ ctx.Error(http.StatusForbidden, "", "Given user is not allowed to create repository in organization.")
+ return
+ }
+ }
+ CreateUserRepo(ctx, org.AsUser(), *opt)
+}
+
+// Get one repository
+func Get(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo} repository repoGet
+ // ---
+ // summary: Get a repository
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Repository"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if err := ctx.Repo.Repository.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "Repository.LoadAttributes", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission))
+}
+
+// GetByID returns a single Repository
+func GetByID(ctx *context.APIContext) {
+ // swagger:operation GET /repositories/{id} repository repoGetByID
+ // ---
+ // summary: Get a repository by id
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: id
+ // in: path
+ // description: id of the repo to get
+ // type: integer
+ // format: int64
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Repository"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo, err := repo_model.GetRepositoryByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ ctx.NotFound()
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetRepositoryByID", err)
+ }
+ return
+ }
+
+ permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+ return
+ } else if !permission.HasAccess() {
+ ctx.NotFound()
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
+}
+
+// Edit edit repository properties
+func Edit(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo} repository repoEdit
+ // ---
+ // summary: Edit a repository's properties. Only fields that are set will be changed.
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to edit
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to edit
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // description: "Properties of a repo that you can edit"
+ // schema:
+ // "$ref": "#/definitions/EditRepoOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ opts := *web.GetForm(ctx).(*api.EditRepoOption)
+
+ if err := updateBasicProperties(ctx, opts); err != nil {
+ return
+ }
+
+ if err := updateRepoUnits(ctx, opts); err != nil {
+ return
+ }
+
+ if opts.Archived != nil {
+ if err := updateRepoArchivedState(ctx, opts); err != nil {
+ return
+ }
+ }
+
+ if opts.MirrorInterval != nil || opts.EnablePrune != nil {
+ if err := updateMirror(ctx, opts); err != nil {
+ return
+ }
+ }
+
+ repo, err := repo_model.GetRepositoryByID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, ctx.Repo.Permission))
+}
+
+// updateBasicProperties updates the basic properties of a repo: Name, Description, Website and Visibility
+func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) error {
+ owner := ctx.Repo.Owner
+ repo := ctx.Repo.Repository
+ newRepoName := repo.Name
+ if opts.Name != nil {
+ newRepoName = *opts.Name
+ }
+ // Check if repository name has been changed and not just a case change
+ if repo.LowerName != strings.ToLower(newRepoName) {
+ if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil {
+ switch {
+ case repo_model.IsErrRepoAlreadyExist(err):
+ ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name is already taken [name: %s]", newRepoName), err)
+ case db.IsErrNameReserved(err):
+ ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name is reserved [name: %s]", newRepoName), err)
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name's pattern is not allowed [name: %s, pattern: %s]", newRepoName, err.(db.ErrNamePatternNotAllowed).Pattern), err)
+ default:
+ ctx.Error(http.StatusUnprocessableEntity, "ChangeRepositoryName", err)
+ }
+ return err
+ }
+
+ log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName)
+ }
+ // Update the name in the repo object for the response
+ repo.Name = newRepoName
+ repo.LowerName = strings.ToLower(newRepoName)
+
+ if opts.Description != nil {
+ repo.Description = *opts.Description
+ }
+
+ if opts.Website != nil {
+ repo.Website = *opts.Website
+ }
+
+ visibilityChanged := false
+ if opts.Private != nil {
+ // Visibility of forked repository is forced sync with base repository.
+ if repo.IsFork {
+ if err := repo.GetBaseRepo(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "Unable to load base repository", err)
+ return err
+ }
+ *opts.Private = repo.BaseRepo.IsPrivate
+ }
+
+ visibilityChanged = repo.IsPrivate != *opts.Private
+ // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
+ if visibilityChanged && setting.Repository.ForcePrivate && !*opts.Private && !ctx.Doer.IsAdmin {
+ err := fmt.Errorf("cannot change private repository to public")
+ ctx.Error(http.StatusUnprocessableEntity, "Force Private enabled", err)
+ return err
+ }
+
+ repo.IsPrivate = *opts.Private
+ }
+
+ if opts.Template != nil {
+ repo.IsTemplate = *opts.Template
+ }
+
+ if ctx.Repo.GitRepo == nil && !repo.IsEmpty {
+ var err error
+ ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Unable to OpenRepository", err)
+ return err
+ }
+ defer ctx.Repo.GitRepo.Close()
+ }
+
+ // Default branch only updated if changed and exist or the repository is empty
+ if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || ctx.Repo.GitRepo.IsBranchExist(*opts.DefaultBranch)) {
+ if !repo.IsEmpty {
+ if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, *opts.DefaultBranch); err != nil {
+ if !git.IsErrUnsupportedVersion(err) {
+ ctx.Error(http.StatusInternalServerError, "SetDefaultBranch", err)
+ return err
+ }
+ }
+ }
+ repo.DefaultBranch = *opts.DefaultBranch
+ }
+
+ // Wiki branch is updated if changed
+ if opts.WikiBranch != nil && repo.WikiBranch != *opts.WikiBranch {
+ if err := wiki_service.NormalizeWikiBranch(ctx, repo, *opts.WikiBranch); err != nil {
+ ctx.Error(http.StatusInternalServerError, "NormalizeWikiBranch", err)
+ return err
+ }
+ // While NormalizeWikiBranch updates the db, we need to update *this*
+ // instance of `repo`, so that the `UpdateRepository` below will not
+ // reset the branch back.
+ repo.WikiBranch = *opts.WikiBranch
+ }
+
+ if err := repo_service.UpdateRepository(ctx, repo, visibilityChanged); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateRepository", err)
+ return err
+ }
+
+ log.Trace("Repository basic settings updated: %s/%s", owner.Name, repo.Name)
+ return nil
+}
+
+// updateRepoUnits updates repo units: Issue settings, Wiki settings, PR settings
+func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
+ owner := ctx.Repo.Owner
+ repo := ctx.Repo.Repository
+
+ var units []repo_model.RepoUnit
+ var deleteUnitTypes []unit_model.Type
+
+ currHasIssues := repo.UnitEnabled(ctx, unit_model.TypeIssues)
+ newHasIssues := currHasIssues
+ if opts.HasIssues != nil {
+ newHasIssues = *opts.HasIssues
+ }
+ if currHasIssues || newHasIssues {
+ if newHasIssues && opts.ExternalTracker != nil && !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
+ // Check that values are valid
+ if !validation.IsValidExternalURL(opts.ExternalTracker.ExternalTrackerURL) {
+ err := fmt.Errorf("External tracker URL not valid")
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid external tracker URL", err)
+ return err
+ }
+ if len(opts.ExternalTracker.ExternalTrackerFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(opts.ExternalTracker.ExternalTrackerFormat) {
+ err := fmt.Errorf("External tracker URL format not valid")
+ ctx.Error(http.StatusUnprocessableEntity, "Invalid external tracker URL format", err)
+ return err
+ }
+
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalTracker,
+ Config: &repo_model.ExternalTrackerConfig{
+ ExternalTrackerURL: opts.ExternalTracker.ExternalTrackerURL,
+ ExternalTrackerFormat: opts.ExternalTracker.ExternalTrackerFormat,
+ ExternalTrackerStyle: opts.ExternalTracker.ExternalTrackerStyle,
+ ExternalTrackerRegexpPattern: opts.ExternalTracker.ExternalTrackerRegexpPattern,
+ },
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
+ } else if newHasIssues && opts.ExternalTracker == nil && !unit_model.TypeIssues.UnitGlobalDisabled() {
+ // Default to built-in tracker
+ var config *repo_model.IssuesConfig
+
+ if opts.InternalTracker != nil {
+ config = &repo_model.IssuesConfig{
+ EnableTimetracker: opts.InternalTracker.EnableTimeTracker,
+ AllowOnlyContributorsToTrackTime: opts.InternalTracker.AllowOnlyContributorsToTrackTime,
+ EnableDependencies: opts.InternalTracker.EnableIssueDependencies,
+ }
+ } else if unit, err := repo.GetUnit(ctx, unit_model.TypeIssues); err != nil {
+ // Unit type doesn't exist so we make a new config file with default values
+ config = &repo_model.IssuesConfig{
+ EnableTimetracker: true,
+ AllowOnlyContributorsToTrackTime: true,
+ EnableDependencies: true,
+ }
+ } else {
+ config = unit.IssuesConfig()
+ }
+
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeIssues,
+ Config: config,
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
+ } else if !newHasIssues {
+ if !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
+ }
+ if !unit_model.TypeIssues.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
+ }
+ }
+ }
+
+ currHasWiki := repo.UnitEnabled(ctx, unit_model.TypeWiki)
+ newHasWiki := currHasWiki
+ if opts.HasWiki != nil {
+ newHasWiki = *opts.HasWiki
+ }
+ if currHasWiki || newHasWiki {
+ wikiPermissions := repo.MustGetUnit(ctx, unit_model.TypeWiki).DefaultPermissions
+ if opts.GloballyEditableWiki != nil {
+ if *opts.GloballyEditableWiki {
+ wikiPermissions = repo_model.UnitAccessModeWrite
+ } else {
+ wikiPermissions = repo_model.UnitAccessModeRead
+ }
+ }
+
+ if newHasWiki && opts.ExternalWiki != nil && !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
+ // Check that values are valid
+ if !validation.IsValidExternalURL(opts.ExternalWiki.ExternalWikiURL) {
+ err := fmt.Errorf("External wiki URL not valid")
+ ctx.Error(http.StatusUnprocessableEntity, "", "Invalid external wiki URL")
+ return err
+ }
+
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalWiki,
+ Config: &repo_model.ExternalWikiConfig{
+ ExternalWikiURL: opts.ExternalWiki.ExternalWikiURL,
+ },
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
+ } else if newHasWiki && opts.ExternalWiki == nil && !unit_model.TypeWiki.UnitGlobalDisabled() {
+ config := &repo_model.UnitConfig{}
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeWiki,
+ Config: config,
+ DefaultPermissions: wikiPermissions,
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
+ } else if !newHasWiki {
+ if !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
+ }
+ if !unit_model.TypeWiki.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
+ }
+ } else if *opts.GloballyEditableWiki {
+ config := &repo_model.UnitConfig{}
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeWiki,
+ Config: config,
+ DefaultPermissions: wikiPermissions,
+ })
+ }
+ }
+
+ currHasPullRequests := repo.UnitEnabled(ctx, unit_model.TypePullRequests)
+ newHasPullRequests := currHasPullRequests
+ if opts.HasPullRequests != nil {
+ newHasPullRequests = *opts.HasPullRequests
+ }
+ if currHasPullRequests || newHasPullRequests {
+ if newHasPullRequests && !unit_model.TypePullRequests.UnitGlobalDisabled() {
+ // We do allow setting individual PR settings through the API, so
+ // we get the config settings and then set them
+ // if those settings were provided in the opts.
+ unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests)
+ var config *repo_model.PullRequestsConfig
+ if err != nil {
+ // Unit type doesn't exist so we make a new config file with default values
+ config = &repo_model.PullRequestsConfig{
+ IgnoreWhitespaceConflicts: false,
+ AllowMerge: true,
+ AllowRebase: true,
+ AllowRebaseMerge: true,
+ AllowSquash: true,
+ AllowFastForwardOnly: true,
+ AllowManualMerge: true,
+ AutodetectManualMerge: false,
+ AllowRebaseUpdate: true,
+ DefaultDeleteBranchAfterMerge: false,
+ DefaultMergeStyle: repo_model.MergeStyleMerge,
+ DefaultAllowMaintainerEdit: false,
+ }
+ } else {
+ config = unit.PullRequestsConfig()
+ }
+
+ if opts.IgnoreWhitespaceConflicts != nil {
+ config.IgnoreWhitespaceConflicts = *opts.IgnoreWhitespaceConflicts
+ }
+ if opts.AllowMerge != nil {
+ config.AllowMerge = *opts.AllowMerge
+ }
+ if opts.AllowRebase != nil {
+ config.AllowRebase = *opts.AllowRebase
+ }
+ if opts.AllowRebaseMerge != nil {
+ config.AllowRebaseMerge = *opts.AllowRebaseMerge
+ }
+ if opts.AllowSquash != nil {
+ config.AllowSquash = *opts.AllowSquash
+ }
+ if opts.AllowFastForwardOnly != nil {
+ config.AllowFastForwardOnly = *opts.AllowFastForwardOnly
+ }
+ if opts.AllowManualMerge != nil {
+ config.AllowManualMerge = *opts.AllowManualMerge
+ }
+ if opts.AutodetectManualMerge != nil {
+ config.AutodetectManualMerge = *opts.AutodetectManualMerge
+ }
+ if opts.AllowRebaseUpdate != nil {
+ config.AllowRebaseUpdate = *opts.AllowRebaseUpdate
+ }
+ if opts.DefaultDeleteBranchAfterMerge != nil {
+ config.DefaultDeleteBranchAfterMerge = *opts.DefaultDeleteBranchAfterMerge
+ }
+ if opts.DefaultMergeStyle != nil {
+ config.DefaultMergeStyle = repo_model.MergeStyle(*opts.DefaultMergeStyle)
+ }
+ if opts.DefaultAllowMaintainerEdit != nil {
+ config.DefaultAllowMaintainerEdit = *opts.DefaultAllowMaintainerEdit
+ }
+
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypePullRequests,
+ Config: config,
+ })
+ } else if !newHasPullRequests && !unit_model.TypePullRequests.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests)
+ }
+ }
+
+ if opts.HasProjects != nil && !unit_model.TypeProjects.UnitGlobalDisabled() {
+ if *opts.HasProjects {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeProjects,
+ })
+ } else {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects)
+ }
+ }
+
+ if opts.HasReleases != nil && !unit_model.TypeReleases.UnitGlobalDisabled() {
+ if *opts.HasReleases {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeReleases,
+ })
+ } else {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases)
+ }
+ }
+
+ if opts.HasPackages != nil && !unit_model.TypePackages.UnitGlobalDisabled() {
+ if *opts.HasPackages {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypePackages,
+ })
+ } else {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages)
+ }
+ }
+
+ if opts.HasActions != nil && !unit_model.TypeActions.UnitGlobalDisabled() {
+ if *opts.HasActions {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeActions,
+ })
+ } else {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions)
+ }
+ }
+
+ if len(units)+len(deleteUnitTypes) > 0 {
+ if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateRepositoryUnits", err)
+ return err
+ }
+ }
+
+ log.Trace("Repository advanced settings updated: %s/%s", owner.Name, repo.Name)
+ return nil
+}
+
+// updateRepoArchivedState updates repo's archive state
+func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) error {
+ repo := ctx.Repo.Repository
+ // archive / un-archive
+ if opts.Archived != nil {
+ if repo.IsMirror {
+ err := fmt.Errorf("repo is a mirror, cannot archive/un-archive")
+ ctx.Error(http.StatusUnprocessableEntity, err.Error(), err)
+ return err
+ }
+ if *opts.Archived {
+ if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil {
+ log.Error("Tried to archive a repo: %s", err)
+ ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err)
+ return err
+ }
+ if err := actions_model.CleanRepoScheduleTasks(ctx, repo, true); err != nil {
+ log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err)
+ }
+ log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+ } else {
+ if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil {
+ log.Error("Tried to un-archive a repo: %s", err)
+ ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err)
+ return err
+ }
+ if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) {
+ if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil {
+ log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err)
+ }
+ }
+ log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+ }
+ }
+ return nil
+}
+
+// updateMirror updates a repo's mirror Interval and EnablePrune
+func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error {
+ repo := ctx.Repo.Repository
+
+ // Skip this update if the repo is not a mirror, do not return error.
+ // Because reporting errors only makes the logic more complex&fragile, it doesn't really help end users.
+ if !repo.IsMirror {
+ return nil
+ }
+
+ // get the mirror from the repo
+ mirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID)
+ if err != nil {
+ log.Error("Failed to get mirror: %s", err)
+ ctx.Error(http.StatusInternalServerError, "MirrorInterval", err)
+ return err
+ }
+
+ // update MirrorInterval
+ if opts.MirrorInterval != nil {
+ // MirrorInterval should be a duration
+ interval, err := time.ParseDuration(*opts.MirrorInterval)
+ if err != nil {
+ log.Error("Wrong format for MirrorInternal Sent: %s", err)
+ ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err)
+ return err
+ }
+
+ // Ensure the provided duration is not too short
+ if interval != 0 && interval < setting.Mirror.MinInterval {
+ err := fmt.Errorf("invalid mirror interval: %s is below minimum interval: %s", interval, setting.Mirror.MinInterval)
+ ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err)
+ return err
+ }
+
+ mirror.Interval = interval
+ mirror.Repo = repo
+ mirror.ScheduleNextUpdate()
+ log.Trace("Repository %s Mirror[%d] Set Interval: %s NextUpdateUnix: %s", repo.FullName(), mirror.ID, interval, mirror.NextUpdateUnix)
+ }
+
+ // update EnablePrune
+ if opts.EnablePrune != nil {
+ mirror.EnablePrune = *opts.EnablePrune
+ log.Trace("Repository %s Mirror[%d] Set EnablePrune: %t", repo.FullName(), mirror.ID, mirror.EnablePrune)
+ }
+
+ // finally update the mirror in the DB
+ if err := repo_model.UpdateMirror(ctx, mirror); err != nil {
+ log.Error("Failed to Set Mirror Interval: %s", err)
+ ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err)
+ return err
+ }
+
+ return nil
+}
+
+// Delete one repository
+func Delete(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo} repository repoDelete
+ // ---
+ // summary: Delete a repository
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to delete
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ owner := ctx.Repo.Owner
+ repo := ctx.Repo.Repository
+
+ canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CanUserDelete", err)
+ return
+ } else if !canDelete {
+ ctx.Error(http.StatusForbidden, "", "Given user is not owner of organization.")
+ return
+ }
+
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+
+ if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteRepository", err)
+ return
+ }
+
+ log.Trace("Repository deleted: %s/%s", owner.Name, repo.Name)
+ ctx.Status(http.StatusNoContent)
+}
+
+// GetIssueTemplates returns the issue templates for a repository
+func GetIssueTemplates(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issue_templates repository repoGetIssueTemplates
+ // ---
+ // summary: Get available issue templates for a repository
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/IssueTemplates"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
+ ctx.JSON(http.StatusOK, ret)
+}
+
+// GetIssueConfig returns the issue config for a repo
+func GetIssueConfig(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issue_config repository repoGetIssueConfig
+ // ---
+ // summary: Returns the issue config for a repo
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RepoIssueConfig"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ issueConfig, _ := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
+ ctx.JSON(http.StatusOK, issueConfig)
+}
+
+// ValidateIssueConfig returns validation errors for the issue config
+func ValidateIssueConfig(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/issue_config/validate repository repoValidateIssueConfig
+ // ---
+ // summary: Returns the validation information for a issue config
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RepoIssueConfigValidation"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ _, err := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
+
+ if err == nil {
+ ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: true, Message: ""})
+ } else {
+ ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: false, Message: err.Error()})
+ }
+}
+
+func ListRepoActivityFeeds(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/activities/feeds repository repoListActivityFeeds
+ // ---
+ // summary: List a repository's activity feeds
+ // 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: date
+ // in: query
+ // description: the date of the activities to be found
+ // type: string
+ // format: date
+ // - 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/ActivityFeedsList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ listOptions := utils.GetListOptions(ctx)
+
+ opts := activities_model.GetFeedsOptions{
+ RequestedRepo: ctx.Repo.Repository,
+ OnlyPerformedByActor: true,
+ Actor: ctx.Doer,
+ IncludePrivate: true,
+ Date: ctx.FormString("date"),
+ ListOptions: listOptions,
+ }
+
+ feeds, count, err := activities_model.GetFeeds(ctx, opts)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetFeeds", err)
+ return
+ }
+ ctx.SetTotalCountHeader(count)
+
+ ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
+}
diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go
new file mode 100644
index 0000000..8d6ca9e
--- /dev/null
+++ b/routers/api/v1/repo/repo_test.go
@@ -0,0 +1,86 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoEdit(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
+ contexttest.LoadRepo(t, ctx, 1)
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.Repo.Owner = ctx.Doer
+ description := "new description"
+ website := "http://wwww.newwebsite.com"
+ private := true
+ hasIssues := false
+ hasWiki := false
+ defaultBranch := "master"
+ hasPullRequests := true
+ ignoreWhitespaceConflicts := true
+ allowMerge := false
+ allowRebase := false
+ allowRebaseMerge := false
+ allowSquashMerge := false
+ allowFastForwardOnlyMerge := false
+ archived := true
+ opts := api.EditRepoOption{
+ Name: &ctx.Repo.Repository.Name,
+ Description: &description,
+ Website: &website,
+ Private: &private,
+ HasIssues: &hasIssues,
+ HasWiki: &hasWiki,
+ DefaultBranch: &defaultBranch,
+ HasPullRequests: &hasPullRequests,
+ IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts,
+ AllowMerge: &allowMerge,
+ AllowRebase: &allowRebase,
+ AllowRebaseMerge: &allowRebaseMerge,
+ AllowSquash: &allowSquashMerge,
+ AllowFastForwardOnly: &allowFastForwardOnlyMerge,
+ Archived: &archived,
+ }
+
+ web.SetForm(ctx, &opts)
+ Edit(ctx)
+
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ ID: 1,
+ }, unittest.Cond("name = ? AND is_archived = 1", *opts.Name))
+}
+
+func TestRepoEditNameChange(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
+ contexttest.LoadRepo(t, ctx, 1)
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.Repo.Owner = ctx.Doer
+ name := "newname"
+ opts := api.EditRepoOption{
+ Name: &name,
+ }
+
+ web.SetForm(ctx, &opts)
+ Edit(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ ID: 1,
+ }, unittest.Cond("name = ?", opts.Name))
+}
diff --git a/routers/api/v1/repo/star.go b/routers/api/v1/repo/star.go
new file mode 100644
index 0000000..99676de
--- /dev/null
+++ b/routers/api/v1/repo/star.go
@@ -0,0 +1,60 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListStargazers list a repository's stargazers
+func ListStargazers(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/stargazers repository repoListStargazers
+ // ---
+ // summary: List a repo's stargazers
+ // 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: 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/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ stargazers, err := repo_model.GetStargazers(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetStargazers", err)
+ return
+ }
+ users := make([]*api.User, len(stargazers))
+ for i, stargazer := range stargazers {
+ users[i] = convert.ToUser(ctx, stargazer, ctx.Doer)
+ }
+
+ ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumStars))
+ ctx.JSON(http.StatusOK, users)
+}
diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go
new file mode 100644
index 0000000..9e36ea0
--- /dev/null
+++ b/routers/api/v1/repo/status.go
@@ -0,0 +1,282 @@
+// Copyright 2017 Gitea. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
+)
+
+// NewCommitStatus creates a new CommitStatus
+func NewCommitStatus(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/statuses/{sha} repository repoCreateStatus
+ // ---
+ // summary: Create a commit status
+ // 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: sha
+ // in: path
+ // description: sha of the commit
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateStatusOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/CommitStatus"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ form := web.GetForm(ctx).(*api.CreateStatusOption)
+ sha := ctx.Params("sha")
+ if len(sha) == 0 {
+ ctx.Error(http.StatusBadRequest, "sha not given", nil)
+ return
+ }
+ status := &git_model.CommitStatus{
+ State: form.State,
+ TargetURL: form.TargetURL,
+ Description: form.Description,
+ Context: form.Context,
+ }
+ if err := commitstatus_service.CreateCommitStatus(ctx, ctx.Repo.Repository, ctx.Doer, sha, status); err != nil {
+ ctx.Error(http.StatusInternalServerError, "CreateCommitStatus", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToCommitStatus(ctx, status))
+}
+
+// GetCommitStatuses returns all statuses for any given commit hash
+func GetCommitStatuses(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/statuses/{sha} repository repoListStatuses
+ // ---
+ // summary: Get a commit's statuses
+ // 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: sha
+ // in: path
+ // description: sha of the commit
+ // type: string
+ // required: true
+ // - name: sort
+ // in: query
+ // description: type of sort
+ // type: string
+ // enum: [oldest, recentupdate, leastupdate, leastindex, highestindex]
+ // required: false
+ // - name: state
+ // in: query
+ // description: type of state
+ // type: string
+ // enum: [pending, success, error, failure, warning]
+ // required: false
+ // - 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/CommitStatusList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ getCommitStatuses(ctx, ctx.Params("sha"))
+}
+
+// GetCommitStatusesByRef returns all statuses for any given commit ref
+func GetCommitStatusesByRef(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/commits/{ref}/statuses repository repoListStatusesByRef
+ // ---
+ // summary: Get a commit's statuses, by branch/tag/commit reference
+ // 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: ref
+ // in: path
+ // description: name of branch/tag/commit
+ // type: string
+ // required: true
+ // - name: sort
+ // in: query
+ // description: type of sort
+ // type: string
+ // enum: [oldest, recentupdate, leastupdate, leastindex, highestindex]
+ // required: false
+ // - name: state
+ // in: query
+ // description: type of state
+ // type: string
+ // enum: [pending, success, error, failure, warning]
+ // required: false
+ // - 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/CommitStatusList"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ filter := utils.ResolveRefOrSha(ctx, ctx.Params("ref"))
+ if ctx.Written() {
+ return
+ }
+
+ getCommitStatuses(ctx, filter) // By default filter is maybe the raw SHA
+}
+
+func getCommitStatuses(ctx *context.APIContext, sha string) {
+ if len(sha) == 0 {
+ ctx.Error(http.StatusBadRequest, "ref/sha not given", nil)
+ return
+ }
+ sha = utils.MustConvertToSHA1(ctx.Base, ctx.Repo, sha)
+ repo := ctx.Repo.Repository
+
+ listOptions := utils.GetListOptions(ctx)
+
+ statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{
+ ListOptions: listOptions,
+ RepoID: repo.ID,
+ SHA: sha,
+ SortType: ctx.FormTrim("sort"),
+ State: ctx.FormTrim("state"),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommitStatuses", fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), sha, ctx.FormInt("page"), err))
+ return
+ }
+
+ apiStatuses := make([]*api.CommitStatus, 0, len(statuses))
+ for _, status := range statuses {
+ apiStatuses = append(apiStatuses, convert.ToCommitStatus(ctx, status))
+ }
+
+ ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
+ ctx.SetTotalCountHeader(maxResults)
+
+ ctx.JSON(http.StatusOK, apiStatuses)
+}
+
+// GetCombinedCommitStatusByRef returns the combined status for any given commit hash
+func GetCombinedCommitStatusByRef(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/commits/{ref}/status repository repoGetCombinedStatusByRef
+ // ---
+ // summary: Get a commit's combined status, by branch/tag/commit reference
+ // 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: ref
+ // in: path
+ // description: name of branch/tag/commit
+ // type: string
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/CombinedStatus"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := utils.ResolveRefOrSha(ctx, ctx.Params("ref"))
+ if ctx.Written() {
+ return
+ }
+
+ repo := ctx.Repo.Repository
+
+ statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetLatestCommitStatus", fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), sha, err))
+ return
+ }
+
+ if len(statuses) == 0 {
+ ctx.JSON(http.StatusOK, &api.CombinedStatus{})
+ return
+ }
+
+ combiStatus := convert.ToCombinedStatus(ctx, statuses, convert.ToRepo(ctx, repo, ctx.Repo.Permission))
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, combiStatus)
+}
diff --git a/routers/api/v1/repo/subscriber.go b/routers/api/v1/repo/subscriber.go
new file mode 100644
index 0000000..8584182
--- /dev/null
+++ b/routers/api/v1/repo/subscriber.go
@@ -0,0 +1,60 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListSubscribers list a repo's subscribers (i.e. watchers)
+func ListSubscribers(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/subscribers repository repoListSubscribers
+ // ---
+ // summary: List a repo's watchers
+ // 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: 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/UserList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ subscribers, err := repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetRepoWatchers", err)
+ return
+ }
+ users := make([]*api.User, len(subscribers))
+ for i, subscriber := range subscribers {
+ users[i] = convert.ToUser(ctx, subscriber, ctx.Doer)
+ }
+
+ ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumWatches))
+ ctx.JSON(http.StatusOK, users)
+}
diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go
new file mode 100644
index 0000000..7dbdd1f
--- /dev/null
+++ b/routers/api/v1/repo/tag.go
@@ -0,0 +1,668 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ releaseservice "code.gitea.io/gitea/services/release"
+)
+
+// ListTags list all the tags of a repository
+func ListTags(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/tags repository repoListTags
+ // ---
+ // summary: List a repository's tags
+ // 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: page
+ // in: query
+ // description: page number of results to return (1-based)
+ // type: integer
+ // - name: limit
+ // in: query
+ // description: page size of results, default maximum page size is 50
+ // type: integer
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TagList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ listOpts := utils.GetListOptions(ctx)
+
+ tags, total, err := ctx.Repo.GitRepo.GetTagInfos(listOpts.Page, listOpts.PageSize)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTags", err)
+ return
+ }
+
+ apiTags := make([]*api.Tag, len(tags))
+ for i := range tags {
+ tags[i].ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tags[i].Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err)
+ return
+ }
+
+ apiTags[i] = convert.ToTag(ctx.Repo.Repository, tags[i])
+ }
+
+ ctx.SetTotalCountHeader(int64(total))
+ ctx.JSON(http.StatusOK, &apiTags)
+}
+
+// GetAnnotatedTag get the tag of a repository.
+func GetAnnotatedTag(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/tags/{sha} repository GetAnnotatedTag
+ // ---
+ // summary: Gets the tag object of an annotated tag (not lightweight tags)
+ // 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: sha
+ // in: path
+ // description: sha of the tag. The Git tags API only supports annotated tag objects, not lightweight tags.
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/AnnotatedTag"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := ctx.Params("sha")
+ if len(sha) == 0 {
+ ctx.Error(http.StatusBadRequest, "", "SHA not provided")
+ return
+ }
+
+ if tag, err := ctx.Repo.GitRepo.GetAnnotatedTag(sha); err != nil {
+ ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err)
+ } else {
+ commit, err := tag.Commit(ctx.Repo.GitRepo)
+ if err != nil {
+ ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err)
+ }
+
+ tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToAnnotatedTag(ctx, ctx.Repo.Repository, tag, commit))
+ }
+}
+
+// GetTag get the tag of a repository
+func GetTag(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/tags/{tag} repository repoGetTag
+ // ---
+ // summary: Get the tag of a repository by tag name
+ // 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: tag
+ // in: path
+ // description: name of tag
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Tag"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ tagName := ctx.Params("*")
+
+ tag, err := ctx.Repo.GitRepo.GetTag(tagName)
+ if err != nil {
+ ctx.NotFound(tagName)
+ return
+ }
+
+ tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToTag(ctx.Repo.Repository, tag))
+}
+
+// CreateTag create a new git tag in a repository
+func CreateTag(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/tags repository repoCreateTag
+ // ---
+ // summary: Create a new git tag in a repository
+ // 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/CreateTagOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/Tag"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "405":
+ // "$ref": "#/responses/empty"
+ // "409":
+ // "$ref": "#/responses/conflict"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ form := web.GetForm(ctx).(*api.CreateTagOption)
+
+ // If target is not provided use default branch
+ if len(form.Target) == 0 {
+ form.Target = ctx.Repo.Repository.DefaultBranch
+ }
+
+ commit, err := ctx.Repo.GitRepo.GetCommit(form.Target)
+ if err != nil {
+ ctx.Error(http.StatusNotFound, "target not found", fmt.Errorf("target not found: %w", err))
+ return
+ }
+
+ if err := releaseservice.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, commit.ID.String(), form.TagName, form.Message); err != nil {
+ if models.IsErrTagAlreadyExists(err) {
+ ctx.Error(http.StatusConflict, "tag exist", err)
+ return
+ }
+ if models.IsErrProtectedTagName(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "CreateNewTag", "user not allowed to create protected tag")
+ return
+ }
+
+ ctx.InternalServerError(err)
+ return
+ }
+
+ tag, err := ctx.Repo.GitRepo.GetTag(form.TagName)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToTag(ctx.Repo.Repository, tag))
+}
+
+// DeleteTag delete a specific tag of in a repository by name
+func DeleteTag(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/tags/{tag} repository repoDeleteTag
+ // ---
+ // summary: Delete a repository's tag by name
+ // 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: tag
+ // in: path
+ // description: name of tag to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "405":
+ // "$ref": "#/responses/empty"
+ // "409":
+ // "$ref": "#/responses/conflict"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+ tagName := ctx.Params("*")
+
+ tag, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound()
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetRelease", err)
+ return
+ }
+
+ if !tag.IsTag {
+ ctx.Error(http.StatusConflict, "IsTag", errors.New("a tag attached to a release cannot be deleted directly"))
+ return
+ }
+
+ if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, tag, ctx.Doer, true); err != nil {
+ if models.IsErrProtectedTagName(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag")
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// ListTagProtection lists tag protections for a repo
+func ListTagProtection(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/tag_protections repository repoListTagProtection
+ // ---
+ // summary: List tag protections for a repository
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TagProtectionList"
+
+ repo := ctx.Repo.Repository
+ pts, err := git_model.GetProtectedTags(ctx, repo.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTags", err)
+ return
+ }
+ apiPts := make([]*api.TagProtection, len(pts))
+ for i := range pts {
+ apiPts[i] = convert.ToTagProtection(ctx, pts[i], repo)
+ }
+
+ ctx.JSON(http.StatusOK, apiPts)
+}
+
+// GetTagProtection gets a tag protection
+func GetTagProtection(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/tag_protections/{id} repository repoGetTagProtection
+ // ---
+ // summary: Get a specific tag protection for the repository
+ // 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: id
+ // in: path
+ // description: id of the tag protect to get
+ // type: integer
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TagProtection"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+ id := ctx.ParamsInt64(":id")
+ pt, err := git_model.GetProtectedTagByID(ctx, id)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err)
+ return
+ }
+
+ if pt == nil || repo.ID != pt.RepoID {
+ ctx.NotFound()
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo))
+}
+
+// CreateTagProtection creates a tag protection for a repo
+func CreateTagProtection(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/tag_protections repository repoCreateTagProtection
+ // ---
+ // summary: Create a tag protections for a repository
+ // 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/CreateTagProtectionOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/TagProtection"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ form := web.GetForm(ctx).(*api.CreateTagProtectionOption)
+ repo := ctx.Repo.Repository
+
+ namePattern := strings.TrimSpace(form.NamePattern)
+ if namePattern == "" {
+ ctx.Error(http.StatusBadRequest, "name_pattern are empty", "name_pattern are empty")
+ return
+ }
+
+ if len(form.WhitelistUsernames) == 0 && len(form.WhitelistTeams) == 0 {
+ ctx.Error(http.StatusBadRequest, "both whitelist_usernames and whitelist_teams are empty", "both whitelist_usernames and whitelist_teams are empty")
+ return
+ }
+
+ pt, err := git_model.GetProtectedTagByNamePattern(ctx, repo.ID, namePattern)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectTagOfRepo", err)
+ return
+ } else if pt != nil {
+ ctx.Error(http.StatusForbidden, "Create tag protection", "Tag protection already exist")
+ return
+ }
+
+ var whitelistUsers, whitelistTeams []int64
+ whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+
+ if repo.Owner.IsOrganization() {
+ whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ }
+
+ protectTag := &git_model.ProtectedTag{
+ RepoID: repo.ID,
+ NamePattern: strings.TrimSpace(namePattern),
+ AllowlistUserIDs: whitelistUsers,
+ AllowlistTeamIDs: whitelistTeams,
+ }
+ if err := git_model.InsertProtectedTag(ctx, protectTag); err != nil {
+ ctx.Error(http.StatusInternalServerError, "InsertProtectedTag", err)
+ return
+ }
+
+ pt, err = git_model.GetProtectedTagByID(ctx, protectTag.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err)
+ return
+ }
+
+ if pt == nil || pt.RepoID != repo.ID {
+ ctx.Error(http.StatusInternalServerError, "New tag protection not found", err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToTagProtection(ctx, pt, repo))
+}
+
+// EditTagProtection edits a tag protection for a repo
+func EditTagProtection(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/tag_protections/{id} repository repoEditTagProtection
+ // ---
+ // summary: Edit a tag protections for a repository. Only fields that are set will be changed
+ // 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: id
+ // in: path
+ // description: id of protected tag
+ // type: integer
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/EditTagProtectionOption"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TagProtection"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ repo := ctx.Repo.Repository
+ form := web.GetForm(ctx).(*api.EditTagProtectionOption)
+
+ id := ctx.ParamsInt64(":id")
+ pt, err := git_model.GetProtectedTagByID(ctx, id)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err)
+ return
+ }
+
+ if pt == nil || pt.RepoID != repo.ID {
+ ctx.NotFound()
+ return
+ }
+
+ if form.NamePattern != nil {
+ pt.NamePattern = *form.NamePattern
+ }
+
+ var whitelistUsers, whitelistTeams []int64
+ if form.WhitelistTeams != nil {
+ if repo.Owner.IsOrganization() {
+ whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
+ return
+ }
+ }
+ pt.AllowlistTeamIDs = whitelistTeams
+ }
+
+ if form.WhitelistUsernames != nil {
+ whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
+ return
+ }
+ pt.AllowlistUserIDs = whitelistUsers
+ }
+
+ err = git_model.UpdateProtectedTag(ctx, pt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "UpdateProtectedTag", err)
+ return
+ }
+
+ pt, err = git_model.GetProtectedTagByID(ctx, id)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err)
+ return
+ }
+
+ if pt == nil || pt.RepoID != repo.ID {
+ ctx.Error(http.StatusInternalServerError, "New tag protection not found", "New tag protection not found")
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo))
+}
+
+// DeleteTagProtection
+func DeleteTagProtection(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/tag_protections/{id} repository repoDeleteTagProtection
+ // ---
+ // summary: Delete a specific tag protection for the repository
+ // 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: id
+ // in: path
+ // description: id of protected tag
+ // type: integer
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ repo := ctx.Repo.Repository
+ id := ctx.ParamsInt64(":id")
+ pt, err := git_model.GetProtectedTagByID(ctx, id)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err)
+ return
+ }
+
+ if pt == nil || pt.RepoID != repo.ID {
+ ctx.NotFound()
+ return
+ }
+
+ err = git_model.DeleteProtectedTag(ctx, pt)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "DeleteProtectedTag", err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go
new file mode 100644
index 0000000..0ecf3a3
--- /dev/null
+++ b/routers/api/v1/repo/teams.go
@@ -0,0 +1,235 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ org_service "code.gitea.io/gitea/services/org"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// ListTeams list a repository's teams
+func ListTeams(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/teams repository repoListTeams
+ // ---
+ // summary: List a repository's teams
+ // 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
+ // responses:
+ // "200":
+ // "$ref": "#/responses/TeamList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ if !ctx.Repo.Owner.IsOrganization() {
+ ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization")
+ return
+ }
+
+ teams, err := organization.GetRepoTeams(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ apiTeams, err := convert.ToTeams(ctx, teams, false)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, apiTeams)
+}
+
+// IsTeam check if a team is assigned to a repository
+func IsTeam(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/teams/{team} repository repoCheckTeam
+ // ---
+ // summary: Check if a team is assigned to a repository
+ // 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: team
+ // in: path
+ // description: team name
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Team"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "405":
+ // "$ref": "#/responses/error"
+
+ if !ctx.Repo.Owner.IsOrganization() {
+ ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization")
+ return
+ }
+
+ team := getTeamByParam(ctx)
+ if team == nil {
+ return
+ }
+
+ if repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID) {
+ apiTeam, err := convert.ToTeam(ctx, team)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+ ctx.JSON(http.StatusOK, apiTeam)
+ return
+ }
+
+ ctx.NotFound()
+}
+
+// AddTeam add a team to a repository
+func AddTeam(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/teams/{team} repository repoAddTeam
+ // ---
+ // summary: Add a team to a repository
+ // 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: team
+ // in: path
+ // description: team name
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "405":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ changeRepoTeam(ctx, true)
+}
+
+// DeleteTeam delete a team from a repository
+func DeleteTeam(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/teams/{team} repository repoDeleteTeam
+ // ---
+ // summary: Delete a team from a repository
+ // 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: team
+ // in: path
+ // description: team name
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "422":
+ // "$ref": "#/responses/validationError"
+ // "405":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ changeRepoTeam(ctx, false)
+}
+
+func changeRepoTeam(ctx *context.APIContext, add bool) {
+ if !ctx.Repo.Owner.IsOrganization() {
+ ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization")
+ }
+ if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusForbidden, "noAdmin", "user is nor repo admin nor owner")
+ return
+ }
+
+ team := getTeamByParam(ctx)
+ if team == nil {
+ return
+ }
+
+ repoHasTeam := repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID)
+ var err error
+ if add {
+ if repoHasTeam {
+ ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name))
+ return
+ }
+ err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository)
+ } else {
+ if !repoHasTeam {
+ ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name))
+ return
+ }
+ err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID)
+ }
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+func getTeamByParam(ctx *context.APIContext) *organization.Team {
+ team, err := organization.GetTeam(ctx, ctx.Repo.Owner.ID, ctx.Params(":team"))
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Error(http.StatusNotFound, "TeamNotExit", err)
+ return nil
+ }
+ ctx.InternalServerError(err)
+ return nil
+ }
+ return team
+}
diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go
new file mode 100644
index 0000000..1d8e675
--- /dev/null
+++ b/routers/api/v1/repo/topic.go
@@ -0,0 +1,305 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+ "strings"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/log"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// ListTopics returns list of current topics for repo
+func ListTopics(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/topics repository repoListTopics
+ // ---
+ // summary: Get list of topics that a repository has
+ // 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: 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/TopicNames"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opts := &repo_model.FindTopicOptions{
+ ListOptions: utils.GetListOptions(ctx),
+ RepoID: ctx.Repo.Repository.ID,
+ }
+
+ topics, total, err := repo_model.FindTopics(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ topicNames := make([]string, len(topics))
+ for i, topic := range topics {
+ topicNames[i] = topic.Name
+ }
+
+ ctx.SetTotalCountHeader(total)
+ ctx.JSON(http.StatusOK, map[string]any{
+ "topics": topicNames,
+ })
+}
+
+// UpdateTopics updates repo with a new set of topics
+func UpdateTopics(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/topics repository repoUpdateTopics
+ // ---
+ // summary: Replace list of topics for a repository
+ // 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/RepoTopicOptions"
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/invalidTopicsError"
+
+ form := web.GetForm(ctx).(*api.RepoTopicOptions)
+ topicNames := form.Topics
+ validTopics, invalidTopics := repo_model.SanitizeAndValidateTopics(topicNames)
+
+ if len(validTopics) > 25 {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
+ "invalidTopics": nil,
+ "message": "Exceeding maximum number of topics per repo",
+ })
+ return
+ }
+
+ if len(invalidTopics) > 0 {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
+ "invalidTopics": invalidTopics,
+ "message": "Topic names are invalid",
+ })
+ return
+ }
+
+ err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...)
+ if err != nil {
+ log.Error("SaveTopics failed: %v", err)
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// AddTopic adds a topic name to a repo
+func AddTopic(ctx *context.APIContext) {
+ // swagger:operation PUT /repos/{owner}/{repo}/topics/{topic} repository repoAddTopic
+ // ---
+ // summary: Add a topic to a repository
+ // 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: topic
+ // in: path
+ // description: name of the topic to add
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/invalidTopicsError"
+
+ topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic")))
+
+ if !repo_model.ValidateTopic(topicName) {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
+ "invalidTopics": topicName,
+ "message": "Topic name is invalid",
+ })
+ return
+ }
+
+ // Prevent adding more topics than allowed to repo
+ count, err := repo_model.CountTopics(ctx, &repo_model.FindTopicOptions{
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ log.Error("CountTopics failed: %v", err)
+ ctx.InternalServerError(err)
+ return
+ }
+ if count >= 25 {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
+ "message": "Exceeding maximum allowed topics per repo.",
+ })
+ return
+ }
+
+ _, err = repo_model.AddTopic(ctx, ctx.Repo.Repository.ID, topicName)
+ if err != nil {
+ log.Error("AddTopic failed: %v", err)
+ ctx.InternalServerError(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// DeleteTopic removes topic name from repo
+func DeleteTopic(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/topics/{topic} repository repoDeleteTopic
+ // ---
+ // summary: Delete a topic from a repository
+ // 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: topic
+ // in: path
+ // description: name of the topic to delete
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/invalidTopicsError"
+
+ topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic")))
+
+ if !repo_model.ValidateTopic(topicName) {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
+ "invalidTopics": topicName,
+ "message": "Topic name is invalid",
+ })
+ return
+ }
+
+ topic, err := repo_model.DeleteTopic(ctx, ctx.Repo.Repository.ID, topicName)
+ if err != nil {
+ log.Error("DeleteTopic failed: %v", err)
+ ctx.InternalServerError(err)
+ return
+ }
+
+ if topic == nil {
+ ctx.NotFound()
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// TopicSearch search for creating topic
+func TopicSearch(ctx *context.APIContext) {
+ // swagger:operation GET /topics/search repository topicSearch
+ // ---
+ // summary: search topics via keyword
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: q
+ // in: query
+ // description: keywords to search
+ // required: true
+ // type: string
+ // - 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/TopicListResponse"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ opts := &repo_model.FindTopicOptions{
+ Keyword: ctx.FormString("q"),
+ ListOptions: utils.GetListOptions(ctx),
+ }
+
+ topics, total, err := repo_model.FindTopics(ctx, opts)
+ if err != nil {
+ ctx.InternalServerError(err)
+ return
+ }
+
+ topicResponses := make([]*api.TopicResponse, len(topics))
+ for i, topic := range topics {
+ topicResponses[i] = convert.ToTopicResponse(topic)
+ }
+
+ ctx.SetTotalCountHeader(total)
+ ctx.JSON(http.StatusOK, map[string]any{
+ "topics": topicResponses,
+ })
+}
diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go
new file mode 100644
index 0000000..0715aed
--- /dev/null
+++ b/routers/api/v1/repo/transfer.go
@@ -0,0 +1,254 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// Transfer transfers the ownership of a repository
+func Transfer(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/transfer repository repoTransfer
+ // ---
+ // summary: Transfer a repo ownership
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to transfer
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to transfer
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // description: "Transfer Options"
+ // required: true
+ // schema:
+ // "$ref": "#/definitions/TransferRepoOption"
+ // responses:
+ // "202":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ opts := web.GetForm(ctx).(*api.TransferRepoOption)
+
+ newOwner, err := user_model.GetUserByName(ctx, opts.NewOwner)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found")
+ return
+ }
+ ctx.InternalServerError(err)
+ return
+ }
+
+ if newOwner.Type == user_model.UserTypeOrganization {
+ if !ctx.Doer.IsAdmin && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) {
+ // The user shouldn't know about this organization
+ ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found")
+ return
+ }
+ }
+
+ if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, newOwner.ID, newOwner.Name) {
+ return
+ }
+
+ var teams []*organization.Team
+ if opts.TeamIDs != nil {
+ if !newOwner.IsOrganization() {
+ ctx.Error(http.StatusUnprocessableEntity, "repoTransfer", "Teams can only be added to organization-owned repositories")
+ return
+ }
+
+ org := convert.ToOrganization(ctx, organization.OrgFromUser(newOwner))
+ for _, tID := range *opts.TeamIDs {
+ team, err := organization.GetTeamByID(ctx, tID)
+ if err != nil {
+ ctx.Error(http.StatusUnprocessableEntity, "team", fmt.Errorf("team %d not found", tID))
+ return
+ }
+
+ if team.OrgID != org.ID {
+ ctx.Error(http.StatusForbidden, "team", fmt.Errorf("team %d belongs not to org %d", tID, org.ID))
+ return
+ }
+
+ teams = append(teams, team)
+ }
+ }
+
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }
+
+ oldFullname := ctx.Repo.Repository.FullName()
+
+ if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, ctx.Repo.Repository, teams); err != nil {
+ if errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.Error(http.StatusForbidden, "StartRepositoryTransfer", err)
+ return
+ }
+
+ if models.IsErrRepoTransferInProgress(err) {
+ ctx.Error(http.StatusConflict, "StartRepositoryTransfer", err)
+ return
+ }
+
+ if repo_model.IsErrRepoAlreadyExist(err) {
+ ctx.Error(http.StatusUnprocessableEntity, "StartRepositoryTransfer", err)
+ return
+ }
+
+ ctx.InternalServerError(err)
+ return
+ }
+
+ if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
+ log.Trace("Repository transfer initiated: %s -> %s", oldFullname, ctx.Repo.Repository.FullName())
+ ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeAdmin}))
+ return
+ }
+
+ log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName())
+ ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeAdmin}))
+}
+
+// AcceptTransfer accept a repo transfer
+func AcceptTransfer(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/transfer/accept repository acceptRepoTransfer
+ // ---
+ // summary: Accept a repo transfer
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to transfer
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to transfer
+ // type: string
+ // required: true
+ // responses:
+ // "202":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+
+ err := acceptOrRejectRepoTransfer(ctx, true)
+ if ctx.Written() {
+ return
+ }
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err)
+ return
+ }
+
+ ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission))
+}
+
+// RejectTransfer reject a repo transfer
+func RejectTransfer(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/transfer/reject repository rejectRepoTransfer
+ // ---
+ // summary: Reject a repo transfer
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo to transfer
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo to transfer
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/Repository"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ err := acceptOrRejectRepoTransfer(ctx, false)
+ if ctx.Written() {
+ return
+ }
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission))
+}
+
+func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error {
+ repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
+ if err != nil {
+ if models.IsErrNoPendingTransfer(err) {
+ ctx.NotFound()
+ return nil
+ }
+ return err
+ }
+
+ if err := repoTransfer.LoadAttributes(ctx); err != nil {
+ return err
+ }
+
+ if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) {
+ ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil)
+ return fmt.Errorf("user does not have permissions to do this")
+ }
+
+ if accept {
+ recipient := repoTransfer.Recipient
+ if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, recipient.ID, recipient.Name) {
+ return nil
+ }
+
+ return repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams)
+ }
+
+ return repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository)
+}
diff --git a/routers/api/v1/repo/tree.go b/routers/api/v1/repo/tree.go
new file mode 100644
index 0000000..353a996
--- /dev/null
+++ b/routers/api/v1/repo/tree.go
@@ -0,0 +1,70 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/services/context"
+ files_service "code.gitea.io/gitea/services/repository/files"
+)
+
+// GetTree get the tree of a repository.
+func GetTree(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/git/trees/{sha} repository GetTree
+ // ---
+ // summary: Gets the tree of a repository.
+ // 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: sha
+ // in: path
+ // description: sha of the commit
+ // type: string
+ // required: true
+ // - name: recursive
+ // in: query
+ // description: show all directories and files
+ // required: false
+ // type: boolean
+ // - name: page
+ // in: query
+ // description: page number; the 'truncated' field in the response will be true if there are still more items after this page, false if the last page
+ // required: false
+ // type: integer
+ // - name: per_page
+ // in: query
+ // description: number of items per page
+ // required: false
+ // type: integer
+ // responses:
+ // "200":
+ // "$ref": "#/responses/GitTreeResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ sha := ctx.Params(":sha")
+ if len(sha) == 0 {
+ ctx.Error(http.StatusBadRequest, "", "sha not provided")
+ return
+ }
+ if tree, err := files_service.GetTreeBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha, ctx.FormInt("page"), ctx.FormInt("per_page"), ctx.FormBool("recursive")); err != nil {
+ ctx.Error(http.StatusBadRequest, "", err.Error())
+ } else {
+ ctx.SetTotalCountHeader(int64(tree.TotalCount))
+ ctx.JSON(http.StatusOK, tree)
+ }
+}
diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go
new file mode 100644
index 0000000..12aaa8e
--- /dev/null
+++ b/routers/api/v1/repo/wiki.go
@@ -0,0 +1,536 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ notify_service "code.gitea.io/gitea/services/notify"
+ wiki_service "code.gitea.io/gitea/services/wiki"
+)
+
+// NewWikiPage response for wiki create request
+func NewWikiPage(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/wiki/new repository repoCreateWikiPage
+ // ---
+ // summary: Create a wiki page
+ // consumes:
+ // - 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/CreateWikiPageOptions"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/WikiPage"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ form := web.GetForm(ctx).(*api.CreateWikiPageOptions)
+
+ if util.IsEmptyString(form.Title) {
+ ctx.Error(http.StatusBadRequest, "emptyTitle", nil)
+ return
+ }
+
+ wikiName := wiki_service.UserTitleToWebPath("", form.Title)
+
+ if len(form.Message) == 0 {
+ form.Message = fmt.Sprintf("Add %q", form.Title)
+ }
+
+ content, err := base64.StdEncoding.DecodeString(form.ContentBase64)
+ if err != nil {
+ ctx.Error(http.StatusBadRequest, "invalid base64 encoding of content", err)
+ return
+ }
+ form.ContentBase64 = string(content)
+
+ if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.ContentBase64, form.Message); err != nil {
+ if repo_model.IsErrWikiReservedName(err) {
+ ctx.Error(http.StatusBadRequest, "IsErrWikiReservedName", err)
+ } else if repo_model.IsErrWikiAlreadyExist(err) {
+ ctx.Error(http.StatusBadRequest, "IsErrWikiAlreadyExists", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "AddWikiPage", err)
+ }
+ return
+ }
+
+ wikiPage := getWikiPage(ctx, wikiName)
+
+ if !ctx.Written() {
+ notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message)
+ ctx.JSON(http.StatusCreated, wikiPage)
+ }
+}
+
+// EditWikiPage response for wiki modify request
+func EditWikiPage(ctx *context.APIContext) {
+ // swagger:operation PATCH /repos/{owner}/{repo}/wiki/page/{pageName} repository repoEditWikiPage
+ // ---
+ // summary: Edit a wiki page
+ // consumes:
+ // - 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: pageName
+ // in: path
+ // description: name of the page
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateWikiPageOptions"
+ // responses:
+ // "200":
+ // "$ref": "#/responses/WikiPage"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "413":
+ // "$ref": "#/responses/quotaExceeded"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ form := web.GetForm(ctx).(*api.CreateWikiPageOptions)
+
+ oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName"))
+ newWikiName := wiki_service.UserTitleToWebPath("", form.Title)
+
+ if len(newWikiName) == 0 {
+ newWikiName = oldWikiName
+ }
+
+ if len(form.Message) == 0 {
+ form.Message = fmt.Sprintf("Update %q", newWikiName)
+ }
+
+ content, err := base64.StdEncoding.DecodeString(form.ContentBase64)
+ if err != nil {
+ ctx.Error(http.StatusBadRequest, "invalid base64 encoding of content", err)
+ return
+ }
+ form.ContentBase64 = string(content)
+
+ if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.ContentBase64, form.Message); err != nil {
+ ctx.Error(http.StatusInternalServerError, "EditWikiPage", err)
+ return
+ }
+
+ wikiPage := getWikiPage(ctx, newWikiName)
+
+ if !ctx.Written() {
+ notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message)
+ ctx.JSON(http.StatusOK, wikiPage)
+ }
+}
+
+func getWikiPage(ctx *context.APIContext, wikiName wiki_service.WebPath) *api.WikiPage {
+ wikiRepo, commit := findWikiRepoCommit(ctx)
+ if wikiRepo != nil {
+ defer wikiRepo.Close()
+ }
+ if ctx.Written() {
+ return nil
+ }
+
+ // lookup filename in wiki - get filecontent, real filename
+ content, pageFilename := wikiContentsByName(ctx, commit, wikiName, false)
+ if ctx.Written() {
+ return nil
+ }
+
+ sidebarContent, _ := wikiContentsByName(ctx, commit, "_Sidebar", true)
+ if ctx.Written() {
+ return nil
+ }
+
+ footerContent, _ := wikiContentsByName(ctx, commit, "_Footer", true)
+ if ctx.Written() {
+ return nil
+ }
+
+ // get commit count - wiki revisions
+ commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename)
+
+ // Get last change information.
+ lastCommit, err := wikiRepo.GetCommitByPath(pageFilename)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommitByPath", err)
+ return nil
+ }
+
+ return &api.WikiPage{
+ WikiPageMetaData: wiki_service.ToWikiPageMetaData(wikiName, lastCommit, ctx.Repo.Repository),
+ ContentBase64: content,
+ CommitCount: commitsCount,
+ Sidebar: sidebarContent,
+ Footer: footerContent,
+ }
+}
+
+// DeleteWikiPage delete wiki page
+func DeleteWikiPage(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/wiki/page/{pageName} repository repoDeleteWikiPage
+ // ---
+ // summary: Delete a wiki page
+ // 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: pageName
+ // in: path
+ // description: name of the page
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "423":
+ // "$ref": "#/responses/repoArchivedError"
+
+ wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName"))
+
+ if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil {
+ if err.Error() == "file does not exist" {
+ ctx.NotFound(err)
+ return
+ }
+ ctx.Error(http.StatusInternalServerError, "DeleteWikiPage", err)
+ return
+ }
+
+ notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName))
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// ListWikiPages get wiki pages list
+func ListWikiPages(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/wiki/pages repository repoGetWikiPages
+ // ---
+ // summary: Get all wiki pages
+ // 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: 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/WikiPageList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ wikiRepo, commit := findWikiRepoCommit(ctx)
+ if wikiRepo != nil {
+ defer wikiRepo.Close()
+ }
+ if ctx.Written() {
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ limit := ctx.FormInt("limit")
+ if limit <= 1 {
+ limit = setting.API.DefaultPagingNum
+ }
+
+ skip := (page - 1) * limit
+ max := page * limit
+
+ entries, err := commit.ListEntries()
+ if err != nil {
+ ctx.ServerError("ListEntries", err)
+ return
+ }
+ pages := make([]*api.WikiPageMetaData, 0, len(entries))
+ for i, entry := range entries {
+ if i < skip || i >= max || !entry.IsRegular() {
+ continue
+ }
+ c, err := wikiRepo.GetCommitByPath(entry.Name())
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetCommit", err)
+ return
+ }
+ wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
+ if err != nil {
+ if repo_model.IsErrWikiInvalidFileName(err) {
+ continue
+ }
+ ctx.Error(http.StatusInternalServerError, "WikiFilenameToName", err)
+ return
+ }
+ pages = append(pages, wiki_service.ToWikiPageMetaData(wikiName, c, ctx.Repo.Repository))
+ }
+
+ ctx.SetTotalCountHeader(int64(len(entries)))
+ ctx.JSON(http.StatusOK, pages)
+}
+
+// GetWikiPage get single wiki page
+func GetWikiPage(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/wiki/page/{pageName} repository repoGetWikiPage
+ // ---
+ // summary: Get a wiki page
+ // 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: pageName
+ // in: path
+ // description: name of the page
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/WikiPage"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ // get requested pagename
+ pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName"))
+
+ wikiPage := getWikiPage(ctx, pageName)
+ if !ctx.Written() {
+ ctx.JSON(http.StatusOK, wikiPage)
+ }
+}
+
+// ListPageRevisions renders file revision list of wiki page
+func ListPageRevisions(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/wiki/revisions/{pageName} repository repoGetWikiPageRevisions
+ // ---
+ // summary: Get revisions of a wiki page
+ // 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: pageName
+ // in: path
+ // description: name of the page
+ // type: string
+ // required: true
+ // - name: page
+ // in: query
+ // description: page number of results to return (1-based)
+ // type: integer
+ // responses:
+ // "200":
+ // "$ref": "#/responses/WikiCommitList"
+ // "404":
+ // "$ref": "#/responses/notFound"
+
+ wikiRepo, commit := findWikiRepoCommit(ctx)
+ if wikiRepo != nil {
+ defer wikiRepo.Close()
+ }
+ if ctx.Written() {
+ return
+ }
+
+ // get requested pagename
+ pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName"))
+ if len(pageName) == 0 {
+ pageName = "Home"
+ }
+
+ // lookup filename in wiki - get filecontent, gitTree entry , real filename
+ _, pageFilename := wikiContentsByName(ctx, commit, pageName, false)
+ if ctx.Written() {
+ return
+ }
+
+ // get commit count - wiki revisions
+ commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename)
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+
+ // get Commit Count
+ commitsHistory, err := wikiRepo.CommitsByFileAndRange(
+ git.CommitsByFileAndRangeOptions{
+ Revision: ctx.Repo.Repository.GetWikiBranchName(),
+ File: pageFilename,
+ Page: page,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "CommitsByFileAndRange", err)
+ return
+ }
+
+ ctx.SetTotalCountHeader(commitsCount)
+ ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
+}
+
+// findEntryForFile finds the tree entry for a target filepath.
+func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
+ entry, err := commit.GetTreeEntryByPath(target)
+ if err != nil {
+ return nil, err
+ }
+ if entry != nil {
+ return entry, nil
+ }
+
+ // Then the unescaped, shortest alternative
+ var unescapedTarget string
+ if unescapedTarget, err = url.QueryUnescape(target); err != nil {
+ return nil, err
+ }
+ return commit.GetTreeEntryByPath(unescapedTarget)
+}
+
+// findWikiRepoCommit opens the wiki repo and returns the latest commit, writing to context on error.
+// The caller is responsible for closing the returned repo again
+func findWikiRepoCommit(ctx *context.APIContext) (*git.Repository, *git.Commit) {
+ wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ if git.IsErrNotExist(err) || err.Error() == "no such file or directory" {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
+ }
+ return nil, nil
+ }
+
+ commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.GetWikiBranchName())
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound(err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err)
+ }
+ return wikiRepo, nil
+ }
+ return wikiRepo, commit
+}
+
+// wikiContentsByEntry returns the contents of the wiki page referenced by the
+// given tree entry, encoded with base64. Writes to ctx if an error occurs.
+func wikiContentsByEntry(ctx *context.APIContext, entry *git.TreeEntry) string {
+ blob := entry.Blob()
+ if blob.Size() > setting.API.DefaultMaxBlobSize {
+ return ""
+ }
+ content, err := blob.GetBlobContentBase64()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetBlobContentBase64", err)
+ return ""
+ }
+ return content
+}
+
+// wikiContentsByName returns the contents of a wiki page, along with a boolean
+// indicating whether the page exists. Writes to ctx if an error occurs.
+func wikiContentsByName(ctx *context.APIContext, commit *git.Commit, wikiName wiki_service.WebPath, isSidebarOrFooter bool) (string, string) {
+ gitFilename := wiki_service.WebPathToGitPath(wikiName)
+ entry, err := findEntryForFile(commit, gitFilename)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ if !isSidebarOrFooter {
+ ctx.NotFound()
+ }
+ } else {
+ ctx.ServerError("findEntryForFile", err)
+ }
+ return "", ""
+ }
+ return wikiContentsByEntry(ctx, entry), gitFilename
+}