From 3cab9c6b0c050bfcb9f2f067e7dc1b0242875254 Mon Sep 17 00:00:00 2001 From: qwerty287 <80460567+qwerty287@users.noreply.github.com> Date: Tue, 28 Mar 2023 19:23:25 +0200 Subject: [PATCH] Add API to manage issue dependencies (#17935) Adds API endpoints to manage issue/PR dependencies * `GET /repos/{owner}/{repo}/issues/{index}/blocks` List issues that are blocked by this issue * `POST /repos/{owner}/{repo}/issues/{index}/blocks` Block the issue given in the body by the issue in path * `DELETE /repos/{owner}/{repo}/issues/{index}/blocks` Unblock the issue given in the body by the issue in path * `GET /repos/{owner}/{repo}/issues/{index}/dependencies` List an issue's dependencies * `POST /repos/{owner}/{repo}/issues/{index}/dependencies` Create a new issue dependencies * `DELETE /repos/{owner}/{repo}/issues/{index}/dependencies` Remove an issue dependency Closes https://github.com/go-gitea/gitea/issues/15393 Closes #22115 Co-authored-by: Andrew Thornton --- models/issues/dependency.go | 2 +- models/issues/issue.go | 23 +- modules/structs/issue.go | 8 + options/locale/locale_en-US.ini | 3 + routers/api/v1/api.go | 8 + routers/api/v1/repo/issue_dependency.go | 598 ++++++++++++++++++ routers/api/v1/swagger/options.go | 2 + routers/web/repo/issue.go | 56 +- routers/web/repo/issue_dependency.go | 23 +- services/convert/issue.go | 36 +- .../repo/issue/view_content/sidebar.tmpl | 39 +- templates/swagger/v1_json.tmpl | 310 +++++++++ 12 files changed, 1074 insertions(+), 34 deletions(-) create mode 100644 routers/api/v1/repo/issue_dependency.go diff --git a/models/issues/dependency.go b/models/issues/dependency.go index bd39824369..4dc5a4aec7 100644 --- a/models/issues/dependency.go +++ b/models/issues/dependency.go @@ -134,7 +134,7 @@ func CreateIssueDependency(user *user_model.User, issue, dep *Issue) error { } defer committer.Close() - // Check if it aleready exists + // Check if it already exists exists, err := issueDepExists(ctx, issue.ID, dep.ID) if err != nil { return err diff --git a/models/issues/issue.go b/models/issues/issue.go index edd74261ec..64b0edd3e7 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -189,7 +189,7 @@ func (issue *Issue) IsOverdue() bool { // LoadRepo loads issue's repository func (issue *Issue) LoadRepo(ctx context.Context) (err error) { - if issue.Repo == nil { + if issue.Repo == nil && issue.RepoID != 0 { issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID) if err != nil { return fmt.Errorf("getRepositoryByID [%d]: %w", issue.RepoID, err) @@ -223,7 +223,7 @@ func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) { // LoadLabels loads labels func (issue *Issue) LoadLabels(ctx context.Context) (err error) { - if issue.Labels == nil { + if issue.Labels == nil && issue.ID != 0 { issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) if err != nil { return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) @@ -234,7 +234,7 @@ func (issue *Issue) LoadLabels(ctx context.Context) (err error) { // LoadPoster loads poster func (issue *Issue) LoadPoster(ctx context.Context) (err error) { - if issue.Poster == nil { + if issue.Poster == nil && issue.PosterID != 0 { issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID) if err != nil { issue.PosterID = -1 @@ -252,7 +252,7 @@ func (issue *Issue) LoadPoster(ctx context.Context) (err error) { // LoadPullRequest loads pull request info func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) { if issue.IsPull { - if issue.PullRequest == nil { + if issue.PullRequest == nil && issue.ID != 0 { issue.PullRequest, err = GetPullRequestByIssueID(ctx, issue.ID) if err != nil { if IsErrPullRequestNotExist(err) { @@ -261,7 +261,9 @@ func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) { return fmt.Errorf("getPullRequestByIssueID [%d]: %w", issue.ID, err) } } - issue.PullRequest.Issue = issue + if issue.PullRequest != nil { + issue.PullRequest.Issue = issue + } } return nil } @@ -2128,15 +2130,18 @@ func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, erro } // BlockedByDependencies finds all Dependencies an issue is blocked by -func (issue *Issue) BlockedByDependencies(ctx context.Context) (issueDeps []*DependencyInfo, err error) { - err = db.GetEngine(ctx). +func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) { + sess := db.GetEngine(ctx). Table("issue"). Join("INNER", "repository", "repository.id = issue.repo_id"). Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id"). Where("issue_id = ?", issue.ID). // sort by repo id then created date, with the issues of the same repo at the beginning of the list - OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID). - Find(&issueDeps) + OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID) + if opts.Page != 0 { + sess = db.SetSessionPagination(sess, &opts) + } + err = sess.Find(&issueDeps) for _, depInfo := range issueDeps { depInfo.Issue.Repo = &depInfo.Repository diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 48e4e0e7e3..1d1de9ee5e 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -211,3 +211,11 @@ func (it IssueTemplate) Type() IssueTemplateType { } return "" } + +// IssueMeta basic issue information +// swagger:model +type IssueMeta struct { + Index int64 `json:"index"` + Owner string `json:"owner"` + Name string `json:"repo"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index d09ea26942..4e5838b5ee 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1489,6 +1489,9 @@ issues.due_date_invalid = "The due date is invalid or out of range. Please use t issues.dependency.title = Dependencies issues.dependency.issue_no_dependencies = No dependencies set. issues.dependency.pr_no_dependencies = No dependencies set. +issues.dependency.no_permission_1 = "You do not have permission to read %d dependency" +issues.dependency.no_permission_n = "You do not have permission to read %d dependencies" +issues.dependency.no_permission.can_remove = "You do not have permission to read this dependency but can remove this dependency" issues.dependency.add = Add dependency… issues.dependency.cancel = Cancel issues.dependency.remove = Remove diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8fd824640f..7d1980baeb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1026,6 +1026,14 @@ func Routes(ctx gocontext.Context) *web.Route { Patch(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.DeleteIssueAttachment) }, mustEnableAttachments) + m.Combo("/dependencies"). + Get(repo.GetIssueDependencies). + Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.CreateIssueDependency). + Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.RemoveIssueDependency) + m.Combo("/blocks"). + Get(repo.GetIssueBlocks). + Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking). + Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking) }) }, mustEnableIssuesOrPulls) m.Group("/labels", func() { diff --git a/routers/api/v1/repo/issue_dependency.go b/routers/api/v1/repo/issue_dependency.go new file mode 100644 index 0000000000..8a57ad581e --- /dev/null +++ b/routers/api/v1/repo/issue_dependency.go @@ -0,0 +1,598 @@ +// 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/context" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "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" + + // 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.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>`: ` <- <#b>` + blockersInfo, err := issue.BlockedByDependencies(ctx, db.ListOptions{ + Page: page, + PageSize: limit, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "BlockedByDependencies", err) + return + } + + var lastRepoID int64 + var lastPerm access_model.Permission + for _, blocker := range blockersInfo { + // Get the permissions for this repository + perm := lastPerm + if lastRepoID != blocker.Repository.ID { + if blocker.Repository.ID == ctx.Repo.Repository.ID { + perm = ctx.Repo.Permission + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + } + lastRepoID = blocker.Repository.ID + } + + // 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, 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 + + // We want to make <:index> depend on
, i.e. <:index> is the target + target := getParamsIssue(ctx) + if ctx.Written() { + return + } + + // and 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, 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" + + // We want to make <:index> depend on , i.e. <:index> is the target + target := getParamsIssue(ctx) + if ctx.Written() { + return + } + + // and 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, 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" + + // 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 lastRepoID int64 + var lastPerm access_model.Permission + + var issues []*issues_model.Issue + for i, depMeta := range deps { + if i < skip || i >= max { + continue + } + + // Get the permissions for this repository + perm := lastPerm + if lastRepoID != depMeta.Repository.ID { + if depMeta.Repository.ID == ctx.Repo.Repository.ID { + perm = ctx.Repo.Permission + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + } + lastRepoID = depMeta.Repository.ID + } + + if !perm.CanReadIssuesOrPulls(depMeta.Issue.IsPull) { + continue + } + + depMeta.Issue.Repo = &depMeta.Repository + issues = append(issues, &depMeta.Issue) + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, 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, 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" + + 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, dependency)) +} + +func getParamsIssue(ctx *context.APIContext) *issues_model.Issue { + issue, err := issues_model.GetIssueByIndex(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(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.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.Doer, target, dependency, issues_model.DependencyTypeBlockedBy) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) + return + } +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1ddc93c383..09bb1d18f3 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -41,6 +41,8 @@ type swaggerParameterBodies struct { CreateIssueCommentOption api.CreateIssueCommentOption // in:body EditIssueCommentOption api.EditIssueCommentOption + // in:body + IssueMeta api.IssueMeta // in:body IssueLabelsOption api.IssueLabelsOption diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3715320f10..00551a8848 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1812,17 +1812,27 @@ func ViewIssue(ctx *context.Context) { } // Get Dependencies - ctx.Data["BlockedByDependencies"], err = issue.BlockedByDependencies(ctx) + blockedBy, err := issue.BlockedByDependencies(ctx, db.ListOptions{}) if err != nil { ctx.ServerError("BlockedByDependencies", err) return } - ctx.Data["BlockingDependencies"], err = issue.BlockingDependencies(ctx) + ctx.Data["BlockedByDependencies"], ctx.Data["BlockedByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blockedBy) + if ctx.Written() { + return + } + + blocking, err := issue.BlockingDependencies(ctx) if err != nil { ctx.ServerError("BlockingDependencies", err) return } + ctx.Data["BlockingDependencies"], ctx.Data["BlockingByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) + if ctx.Written() { + return + } + ctx.Data["Participants"] = participants ctx.Data["NumParticipants"] = len(participants) ctx.Data["Issue"] = issue @@ -1851,6 +1861,48 @@ func ViewIssue(ctx *context.Context) { ctx.HTML(http.StatusOK, tplIssueView) } +func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { + var lastRepoID int64 + var lastPerm access_model.Permission + for i, blocker := range blockers { + // Get the permissions for this repository + perm := lastPerm + if lastRepoID != blocker.Repository.ID { + if blocker.Repository.ID == ctx.Repo.Repository.ID { + perm = ctx.Repo.Permission + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + } + lastRepoID = blocker.Repository.ID + } + + // check permission + if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { + blockers[len(notPermitted)], blockers[i] = blocker, blockers[len(notPermitted)] + notPermitted = blockers[:len(notPermitted)+1] + } + } + blockers = blockers[len(notPermitted):] + sortDependencyInfo(blockers) + sortDependencyInfo(notPermitted) + + return blockers, notPermitted +} + +func sortDependencyInfo(blockers []*issues_model.DependencyInfo) { + sort.Slice(blockers, func(i, j int) bool { + if blockers[i].RepoID == blockers[j].RepoID { + return blockers[i].Issue.CreatedUnix < blockers[j].Issue.CreatedUnix + } + return blockers[i].RepoID < blockers[j].RepoID + }) +} + // GetActionIssue will return the issue which is used in the context. func GetActionIssue(ctx *context.Context) *issues_model.Issue { issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go index 365d9609d6..d3af319c71 100644 --- a/routers/web/repo/issue_dependency.go +++ b/routers/web/repo/issue_dependency.go @@ -7,6 +7,7 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" ) @@ -44,9 +45,25 @@ func AddDependency(ctx *context.Context) { } // Check if both issues are in the same repo if cross repository dependencies is not enabled - if issue.RepoID != dep.RepoID && !setting.Service.AllowCrossRepositoryDependencies { - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) - return + if issue.RepoID != dep.RepoID { + if !setting.Service.AllowCrossRepositoryDependencies { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) + return + } + if err := dep.LoadRepo(ctx); err != nil { + ctx.ServerError("loadRepo", err) + return + } + // Can ctx.Doer read issues in the dep repo? + depRepoPerm, err := access_model.GetUserRepoPermission(ctx, dep.Repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) { + // you can't see this dependency + return + } } // Check if issue and dependency is the same diff --git a/services/convert/issue.go b/services/convert/issue.go index e79fcfcccb..6d31a123bd 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -32,21 +32,15 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { if err := issue.LoadRepo(ctx); err != nil { return &api.Issue{} } - if err := issue.Repo.LoadOwner(ctx); err != nil { - return &api.Issue{} - } apiIssue := &api.Issue{ ID: issue.ID, - URL: issue.APIURL(), - HTMLURL: issue.HTMLURL(), Index: issue.Index, Poster: ToUser(ctx, issue.Poster, nil), Title: issue.Title, Body: issue.Content, Attachments: ToAttachments(issue.Attachments), Ref: issue.Ref, - Labels: ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner), State: issue.State(), IsLocked: issue.IsLocked, Comments: issue.NumComments, @@ -54,11 +48,19 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { Updated: issue.UpdatedUnix.AsTime(), } - apiIssue.Repo = &api.RepositoryMeta{ - ID: issue.Repo.ID, - Name: issue.Repo.Name, - Owner: issue.Repo.OwnerName, - FullName: issue.Repo.FullName(), + if issue.Repo != nil { + if err := issue.Repo.LoadOwner(ctx); err != nil { + return &api.Issue{} + } + apiIssue.URL = issue.APIURL() + apiIssue.HTMLURL = issue.HTMLURL() + apiIssue.Labels = ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner) + apiIssue.Repo = &api.RepositoryMeta{ + ID: issue.Repo.ID, + Name: issue.Repo.Name, + Owner: issue.Repo.OwnerName, + FullName: issue.Repo.FullName(), + } } if issue.ClosedUnix != 0 { @@ -85,11 +87,13 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { if err := issue.LoadPullRequest(ctx); err != nil { return &api.Issue{} } - apiIssue.PullRequest = &api.PullRequestMeta{ - HasMerged: issue.PullRequest.HasMerged, - } - if issue.PullRequest.HasMerged { - apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() + if issue.PullRequest != nil { + apiIssue.PullRequest = &api.PullRequestMeta{ + HasMerged: issue.PullRequest.HasMerged, + } + if issue.PullRequest.HasMerged { + apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() + } } } if issue.DeadlineUnix != 0 { diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 0deb0a1891..25df29e5fb 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -420,7 +420,7 @@
- {{if (and (not .BlockedByDependencies) (not .BlockingDependencies))}} + {{if (and (not .BlockedByDependencies) (not .BlockedByDependenciesNotPermitted) (not .BlockingDependencies) (not .BlockingDependenciesNotPermitted))}} {{.locale.Tr "repo.issues.dependency.title"}}

@@ -432,7 +432,7 @@

{{end}} - {{if .BlockingDependencies}} + {{if or .BlockingDependencies .BlockingDependenciesNotPermitted}} {{.locale.Tr "repo.issues.dependency.blocks_short"}} @@ -456,10 +456,15 @@
{{end}} + {{if .BlockingDependenciesNotPermitted}} +
+ {{$.locale.TrN (len .BlockingDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockingDependenciesNotPermitted)}} +
+ {{end}} {{end}} - {{if .BlockedByDependencies}} + {{if or .BlockedByDependencies .BlockedByDependenciesNotPermitted}} {{.locale.Tr "repo.issues.dependency.blocked_by_short"}} @@ -483,6 +488,34 @@ {{end}} + {{if $.CanCreateIssueDependencies}} + {{range .BlockedByDependenciesNotPermitted}} +
+
+
+ {{svg "octicon-lock" 16}} + + #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + +
+
+ {{.Repository.OwnerName}}/{{.Repository.Name}} +
+
+
+ {{if and $.CanCreateIssueDependencies (not $.Repository.IsArchived)}} + + {{svg "octicon-trash" 16}} + + {{end}} +
+
+ {{end}} + {{else if .BlockedByDependenciesNotPermitted}} +
+ {{$.locale.TrN (len .BlockedByDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockedByDependenciesNotPermitted)}} +
+ {{end}} {{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0f7e60c598..2401b5d15e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6256,6 +6256,151 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/blocks": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "List issues that are blocked by this issue", + "operationId": "issueListBlocks", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Block the issue given in the body by the issue in path", + "operationId": "issueCreateIssueBlocking", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/IssueMeta" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Issue" + }, + "404": { + "description": "the issue does not exist" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Unblock the issue given in the body by the issue in path", + "operationId": "issueRemoveIssueBlocking", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/IssueMeta" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Issue" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/comments": { "get": { "produces": [ @@ -6538,6 +6683,151 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/dependencies": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "List an issue's dependencies, i.e all issues that block this issue.", + "operationId": "issueListIssueDependencies", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Make the issue in the url depend on the issue in the form.", + "operationId": "issueCreateIssueDependencies", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/IssueMeta" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Issue" + }, + "404": { + "description": "the issue does not exist" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Remove an issue dependency", + "operationId": "issueRemoveIssueDependencies", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/IssueMeta" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Issue" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/labels": { "get": { "produces": [ @@ -17932,6 +18222,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "IssueMeta": { + "description": "IssueMeta basic issue information", + "type": "object", + "properties": { + "index": { + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, + "owner": { + "type": "string", + "x-go-name": "Owner" + }, + "repo": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "IssueTemplate": { "description": "IssueTemplate represents an issue template for a repository", "type": "object",