diff options
Diffstat (limited to 'routers/api/v1/repo')
50 files changed, 19781 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..c9dc968 --- /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, total, err := repo_model.GetForks(ctx, ctx.Repo.Repository, ctx.Doer, 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(total) + 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..dd61967 --- /dev/null +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -0,0 +1,245 @@ +// 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" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + + 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..3d8abfa --- /dev/null +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -0,0 +1,637 @@ +// 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" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + + 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(), ¬e); 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..fcca180 --- /dev/null +++ b/routers/api/v1/repo/pull.go @@ -0,0 +1,1648 @@ +// 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/util" + "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 { + 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 := repo_service.DeleteBranchAfterMerge(ctx, ctx.Doer, pr, headRepo); err != nil { + switch { + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Error(http.StatusForbidden, "DefaultBranch", fmt.Errorf("the head branch is the default branch")) + case errors.Is(err, git_model.ErrBranchIsProtected): + ctx.Error(http.StatusForbidden, "IsProtectedBranch", fmt.Errorf("the head branch is protected")) + case errors.Is(err, util.ErrPermissionDenied): + ctx.Error(http.StatusForbidden, "HeadBranch", fmt.Errorf("insufficient permission to delete head branch")) + default: + ctx.Error(http.StatusInternalServerError, "DeleteBranchAfterMerge", err) + } + return + } + } + + 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. + baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(baseBranch) + baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(baseBranch) + baseIsTag := ctx.Repo.GitRepo.IsTagExist(baseBranch) + if !baseIsCommit && !baseIsBranch && !baseIsTag { + // Check for short SHA usage + if baseCommit, _ := ctx.Repo.GitRepo.GetCommit(baseBranch); baseCommit != nil { + baseBranch = baseCommit.ID.String() + } else { + 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. + headIsCommit := headGitRepo.IsBranchExist(headBranch) + headIsBranch := headGitRepo.IsTagExist(headBranch) + headIsTag := headGitRepo.IsCommitExist(baseBranch) + if !headIsCommit && !headIsBranch && !headIsTag { + // Check if headBranch is short sha commit hash + if headCommit, _ := headGitRepo.GetCommit(headBranch); headCommit != nil { + headBranch = headCommit.ID.String() + } else { + headGitRepo.Close() + ctx.NotFound("IsRefExist", nil) + return nil, nil, nil, "", "" + } + } + + baseBranchRef := baseBranch + if baseIsBranch { + baseBranchRef = git.BranchPrefix + baseBranch + } else if baseIsTag { + baseBranchRef = git.TagPrefix + baseBranch + } + headBranchRef := headBranch + if headIsBranch { + headBranchRef = headBranch + } else if headIsTag { + headBranchRef = headBranch + } + + compareInfo, err := headGitRepo.GetCompareInfo(repo_model.RepoPath(baseRepo.Owner.Name, baseRepo.Name), baseBranchRef, headBranchRef, 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..f39e582 --- /dev/null +++ b/routers/api/v1/repo/repo.go @@ -0,0 +1,1338 @@ +// 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" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "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 +} |