]> source.dussan.org Git - gitea.git/commitdiff
Add API to manage issue dependencies (#17935)
authorqwerty287 <80460567+qwerty287@users.noreply.github.com>
Tue, 28 Mar 2023 17:23:25 +0000 (19:23 +0200)
committerGitHub <noreply@github.com>
Tue, 28 Mar 2023 17:23:25 +0000 (13:23 -0400)
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 <art27@cantab.net>
12 files changed:
models/issues/dependency.go
models/issues/issue.go
modules/structs/issue.go
options/locale/locale_en-US.ini
routers/api/v1/api.go
routers/api/v1/repo/issue_dependency.go [new file with mode: 0644]
routers/api/v1/swagger/options.go
routers/web/repo/issue.go
routers/web/repo/issue_dependency.go
services/convert/issue.go
templates/repo/issue/view_content/sidebar.tmpl
templates/swagger/v1_json.tmpl

index bd39824369a93a1d0abcd2f58a9b000adbedb6dd..4dc5a4aec791f10d253208b89e96ca70da77eca5 100644 (file)
@@ -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
index edd74261ecfa5b6822cdc24c24161dcddf46b230..64b0edd3e7bac711833d92595d6703ba4234c614 100644 (file)
@@ -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
index 48e4e0e7e369526cbeabf491129808cf02352574..1d1de9ee5eca683bbd7373e1b49740a2a43aca95 100644 (file)
@@ -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"`
+}
index d09ea26942b4df51e6fc8f7bbae0e3e04a99b91b..4e5838b5ee4828161335622ab1e9dae09488df83 100644 (file)
@@ -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
index 8fd824640ff7f0cb070b62eb5d6b6b4273875ec0..7d1980baeb91e330899d1c0b6803878e8d90a3c9 100644 (file)
@@ -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 (file)
index 0000000..8a57ad5
--- /dev/null
@@ -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>`: `<issue> <- <#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 <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, 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 <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, 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
+       }
+}
index 1ddc93c383d642c2a9f2e2c088754e54ad36b77f..09bb1d18f3ae03782e13240a5ff653b56ef2eede 100644 (file)
@@ -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
index 3715320f10c84f95d898c5773f91d222cc7aa7c8..00551a88486fb63516825efcee792249b51c38a7 100644 (file)
@@ -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"))
index 365d9609d65cccd4c31a7b5d3e5649365927e0fa..d3af319c711a65e4a1a489e3614a4bb9565ffbd4 100644 (file)
@@ -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
index e79fcfcccb7bbd6338d0e9552d6617c401c50dc2..6d31a123bd9fc55719ed2448c6a3cebdbd12441b 100644 (file)
@@ -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 {
index 0deb0a18914f3932f0b9c8b90c8c3de2a6e735ac..25df29e5fb3872a7cc3d8110a01aff301deffab3 100644 (file)
                        <div class="ui divider"></div>
 
                        <div class="ui depending">
-                               {{if (and (not .BlockedByDependencies) (not .BlockingDependencies))}}
+                               {{if (and (not .BlockedByDependencies) (not .BlockedByDependenciesNotPermitted) (not .BlockingDependencies) (not .BlockingDependenciesNotPermitted))}}
                                        <span class="text"><strong>{{.locale.Tr "repo.issues.dependency.title"}}</strong></span>
                                        <br>
                                        <p>
                                        </p>
                                {{end}}
 
-                               {{if .BlockingDependencies}}
+                               {{if or .BlockingDependencies .BlockingDependenciesNotPermitted}}
                                        <span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{.locale.Tr "repo.issues.dependency.pr_close_blocks"}}{{else}}{{.locale.Tr "repo.issues.dependency.issue_close_blocks"}}{{end}}">
                                                <strong>{{.locale.Tr "repo.issues.dependency.blocks_short"}}</strong>
                                        </span>
                                                                </div>
                                                        </div>
                                                {{end}}
+                                               {{if .BlockingDependenciesNotPermitted}}
+                                                       <div class="item gt-df gt-ac gt-sb">
+                                                               <span>{{$.locale.TrN (len .BlockingDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockingDependenciesNotPermitted)}}</span>
+                                                       </div>
+                                               {{end}}
                                        </div>
                                {{end}}
 
-                               {{if .BlockedByDependencies}}
+                               {{if or .BlockedByDependencies .BlockedByDependenciesNotPermitted}}
                                        <span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{.locale.Tr "repo.issues.dependency.pr_closing_blockedby"}}{{else}}{{.locale.Tr "repo.issues.dependency.issue_closing_blockedby"}}{{end}}">
                                                <strong>{{.locale.Tr "repo.issues.dependency.blocked_by_short"}}</strong>
                                        </span>
                                                                </div>
                                                        </div>
                                                {{end}}
+                                               {{if $.CanCreateIssueDependencies}}
+                                                       {{range .BlockedByDependenciesNotPermitted}}
+                                                               <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} gt-df gt-ac gt-sb">
+                                                                       <div class="item-left gt-df gt-jc gt-fc gt-f1">
+                                                                               <div>
+                                                                                       <span data-tooltip-content="{{$.locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
+                                                                                       <span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
+                                                                                               #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
+                                                                                       </span>
+                                                                               </div>
+                                                                               <div class="text small">
+                                                                                       {{.Repository.OwnerName}}/{{.Repository.Name}}
+                                                                               </div>
+                                                                       </div>
+                                                                       <div class="item-right gt-df gt-ac">
+                                                                               {{if and $.CanCreateIssueDependencies (not $.Repository.IsArchived)}}
+                                                                                       <a class="delete-dependency-button ci muted" data-id="{{.Issue.ID}}" data-type="blocking" data-tooltip-content="{{$.locale.Tr "repo.issues.dependency.remove_info"}}">
+                                                                                               {{svg "octicon-trash" 16}}
+                                                                                       </a>
+                                                                               {{end}}
+                                                                       </div>
+                                                               </div>
+                                                       {{end}}
+                                               {{else if .BlockedByDependenciesNotPermitted}}
+                                                       <div class="item gt-df gt-ac gt-sb">
+                                                               <span>{{$.locale.TrN (len .BlockedByDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockedByDependenciesNotPermitted)}}</span>
+                                                       </div>
+                                               {{end}}
                                        </div>
                                {{end}}
 
index 0f7e60c598bf8f7749e775cbaef061d60de2983c..2401b5d15e754779199d58a8c737f47ca4705f6b 100644 (file)
         }
       }
     },
+    "/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": [
         }
       }
     },
+    "/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": [
       },
       "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",