mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-18 07:12:20 +00:00
3cab9c6b0c
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>
598 lines
15 KiB
Go
598 lines
15 KiB
Go
// 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
|
|
}
|
|
}
|