summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorqwerty287 <80460567+qwerty287@users.noreply.github.com>2023-03-28 19:23:25 +0200
committerGitHub <noreply@github.com>2023-03-28 13:23:25 -0400
commit3cab9c6b0c050bfcb9f2f067e7dc1b0242875254 (patch)
tree0a781efb66090c244fece965cd7dfe3c57c6cb5e
parent85e8c837b8472ec20e657d2a7ebc6c78c04bffae (diff)
downloadgitea-3cab9c6b0c050bfcb9f2f067e7dc1b0242875254.tar.gz
gitea-3cab9c6b0c050bfcb9f2f067e7dc1b0242875254.zip
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 <art27@cantab.net>
-rw-r--r--models/issues/dependency.go2
-rw-r--r--models/issues/issue.go23
-rw-r--r--modules/structs/issue.go8
-rw-r--r--options/locale/locale_en-US.ini3
-rw-r--r--routers/api/v1/api.go8
-rw-r--r--routers/api/v1/repo/issue_dependency.go598
-rw-r--r--routers/api/v1/swagger/options.go2
-rw-r--r--routers/web/repo/issue.go56
-rw-r--r--routers/web/repo/issue_dependency.go23
-rw-r--r--services/convert/issue.go36
-rw-r--r--templates/repo/issue/view_content/sidebar.tmpl39
-rw-r--r--templates/swagger/v1_json.tmpl310
12 files changed, 1074 insertions, 34 deletions
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>`: `<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
+ }
+}
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 @@
<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>
@@ -432,7 +432,7 @@
</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>
@@ -456,10 +456,15 @@
</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>
@@ -483,6 +488,34 @@
</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}}
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",