loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

qwerty287
Andrew Thornton
and committed by
GitHub
3cab9c6b 85e8c837

+1074 -34
+1 -1
models/issues/dependency.go
··· 134 134 } 135 135 defer committer.Close() 136 136 137 - // Check if it aleready exists 137 + // Check if it already exists 138 138 exists, err := issueDepExists(ctx, issue.ID, dep.ID) 139 139 if err != nil { 140 140 return err
+14 -9
models/issues/issue.go
··· 189 189 190 190 // LoadRepo loads issue's repository 191 191 func (issue *Issue) LoadRepo(ctx context.Context) (err error) { 192 - if issue.Repo == nil { 192 + if issue.Repo == nil && issue.RepoID != 0 { 193 193 issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID) 194 194 if err != nil { 195 195 return fmt.Errorf("getRepositoryByID [%d]: %w", issue.RepoID, err) ··· 223 223 224 224 // LoadLabels loads labels 225 225 func (issue *Issue) LoadLabels(ctx context.Context) (err error) { 226 - if issue.Labels == nil { 226 + if issue.Labels == nil && issue.ID != 0 { 227 227 issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) 228 228 if err != nil { 229 229 return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) ··· 234 234 235 235 // LoadPoster loads poster 236 236 func (issue *Issue) LoadPoster(ctx context.Context) (err error) { 237 - if issue.Poster == nil { 237 + if issue.Poster == nil && issue.PosterID != 0 { 238 238 issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID) 239 239 if err != nil { 240 240 issue.PosterID = -1 ··· 252 252 // LoadPullRequest loads pull request info 253 253 func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) { 254 254 if issue.IsPull { 255 - if issue.PullRequest == nil { 255 + if issue.PullRequest == nil && issue.ID != 0 { 256 256 issue.PullRequest, err = GetPullRequestByIssueID(ctx, issue.ID) 257 257 if err != nil { 258 258 if IsErrPullRequestNotExist(err) { ··· 261 261 return fmt.Errorf("getPullRequestByIssueID [%d]: %w", issue.ID, err) 262 262 } 263 263 } 264 - issue.PullRequest.Issue = issue 264 + if issue.PullRequest != nil { 265 + issue.PullRequest.Issue = issue 266 + } 265 267 } 266 268 return nil 267 269 } ··· 2128 2130 } 2129 2131 2130 2132 // BlockedByDependencies finds all Dependencies an issue is blocked by 2131 - func (issue *Issue) BlockedByDependencies(ctx context.Context) (issueDeps []*DependencyInfo, err error) { 2132 - err = db.GetEngine(ctx). 2133 + func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) { 2134 + sess := db.GetEngine(ctx). 2133 2135 Table("issue"). 2134 2136 Join("INNER", "repository", "repository.id = issue.repo_id"). 2135 2137 Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id"). 2136 2138 Where("issue_id = ?", issue.ID). 2137 2139 // sort by repo id then created date, with the issues of the same repo at the beginning of the list 2138 - OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID). 2139 - Find(&issueDeps) 2140 + OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID) 2141 + if opts.Page != 0 { 2142 + sess = db.SetSessionPagination(sess, &opts) 2143 + } 2144 + err = sess.Find(&issueDeps) 2140 2145 2141 2146 for _, depInfo := range issueDeps { 2142 2147 depInfo.Issue.Repo = &depInfo.Repository
+8
modules/structs/issue.go
··· 211 211 } 212 212 return "" 213 213 } 214 + 215 + // IssueMeta basic issue information 216 + // swagger:model 217 + type IssueMeta struct { 218 + Index int64 `json:"index"` 219 + Owner string `json:"owner"` 220 + Name string `json:"repo"` 221 + }
+3
options/locale/locale_en-US.ini
··· 1489 1489 issues.dependency.title = Dependencies 1490 1490 issues.dependency.issue_no_dependencies = No dependencies set. 1491 1491 issues.dependency.pr_no_dependencies = No dependencies set. 1492 + issues.dependency.no_permission_1 = "You do not have permission to read %d dependency" 1493 + issues.dependency.no_permission_n = "You do not have permission to read %d dependencies" 1494 + issues.dependency.no_permission.can_remove = "You do not have permission to read this dependency but can remove this dependency" 1492 1495 issues.dependency.add = Add dependency… 1493 1496 issues.dependency.cancel = Cancel 1494 1497 issues.dependency.remove = Remove
+8
routers/api/v1/api.go
··· 1026 1026 Patch(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). 1027 1027 Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.DeleteIssueAttachment) 1028 1028 }, mustEnableAttachments) 1029 + m.Combo("/dependencies"). 1030 + Get(repo.GetIssueDependencies). 1031 + Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.CreateIssueDependency). 1032 + Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.RemoveIssueDependency) 1033 + m.Combo("/blocks"). 1034 + Get(repo.GetIssueBlocks). 1035 + Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking). 1036 + Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking) 1029 1037 }) 1030 1038 }, mustEnableIssuesOrPulls) 1031 1039 m.Group("/labels", func() {
+598
routers/api/v1/repo/issue_dependency.go
··· 1 + // Copyright 2016 The Gogs Authors. All rights reserved. 2 + // Copyright 2023 The Gitea Authors. All rights reserved. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package repo 6 + 7 + import ( 8 + "net/http" 9 + 10 + "code.gitea.io/gitea/models/db" 11 + issues_model "code.gitea.io/gitea/models/issues" 12 + access_model "code.gitea.io/gitea/models/perm/access" 13 + repo_model "code.gitea.io/gitea/models/repo" 14 + "code.gitea.io/gitea/modules/context" 15 + "code.gitea.io/gitea/modules/setting" 16 + api "code.gitea.io/gitea/modules/structs" 17 + "code.gitea.io/gitea/modules/web" 18 + "code.gitea.io/gitea/services/convert" 19 + ) 20 + 21 + // GetIssueDependencies list an issue's dependencies 22 + func GetIssueDependencies(ctx *context.APIContext) { 23 + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/dependencies issue issueListIssueDependencies 24 + // --- 25 + // summary: List an issue's dependencies, i.e all issues that block this issue. 26 + // produces: 27 + // - application/json 28 + // parameters: 29 + // - name: owner 30 + // in: path 31 + // description: owner of the repo 32 + // type: string 33 + // required: true 34 + // - name: repo 35 + // in: path 36 + // description: name of the repo 37 + // type: string 38 + // required: true 39 + // - name: index 40 + // in: path 41 + // description: index of the issue 42 + // type: string 43 + // required: true 44 + // - name: page 45 + // in: query 46 + // description: page number of results to return (1-based) 47 + // type: integer 48 + // - name: limit 49 + // in: query 50 + // description: page size of results 51 + // type: integer 52 + // responses: 53 + // "200": 54 + // "$ref": "#/responses/IssueList" 55 + 56 + // If this issue's repository does not enable dependencies then there can be no dependencies by default 57 + if !ctx.Repo.Repository.IsDependenciesEnabled(ctx) { 58 + ctx.NotFound() 59 + return 60 + } 61 + 62 + issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 63 + if err != nil { 64 + if issues_model.IsErrIssueNotExist(err) { 65 + ctx.NotFound("IsErrIssueNotExist", err) 66 + } else { 67 + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 68 + } 69 + return 70 + } 71 + 72 + // 1. We must be able to read this issue 73 + if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { 74 + ctx.NotFound() 75 + return 76 + } 77 + 78 + page := ctx.FormInt("page") 79 + if page <= 1 { 80 + page = 1 81 + } 82 + limit := ctx.FormInt("limit") 83 + if limit == 0 { 84 + limit = setting.API.DefaultPagingNum 85 + } else if limit > setting.API.MaxResponseItems { 86 + limit = setting.API.MaxResponseItems 87 + } 88 + 89 + canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) 90 + 91 + blockerIssues := make([]*issues_model.Issue, 0, limit) 92 + 93 + // 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>` 94 + blockersInfo, err := issue.BlockedByDependencies(ctx, db.ListOptions{ 95 + Page: page, 96 + PageSize: limit, 97 + }) 98 + if err != nil { 99 + ctx.Error(http.StatusInternalServerError, "BlockedByDependencies", err) 100 + return 101 + } 102 + 103 + var lastRepoID int64 104 + var lastPerm access_model.Permission 105 + for _, blocker := range blockersInfo { 106 + // Get the permissions for this repository 107 + perm := lastPerm 108 + if lastRepoID != blocker.Repository.ID { 109 + if blocker.Repository.ID == ctx.Repo.Repository.ID { 110 + perm = ctx.Repo.Permission 111 + } else { 112 + var err error 113 + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) 114 + if err != nil { 115 + ctx.ServerError("GetUserRepoPermission", err) 116 + return 117 + } 118 + } 119 + lastRepoID = blocker.Repository.ID 120 + } 121 + 122 + // check permission 123 + if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { 124 + if !canWrite { 125 + hiddenBlocker := &issues_model.DependencyInfo{ 126 + Issue: issues_model.Issue{ 127 + Title: "HIDDEN", 128 + }, 129 + } 130 + blocker = hiddenBlocker 131 + } else { 132 + confidentialBlocker := &issues_model.DependencyInfo{ 133 + Issue: issues_model.Issue{ 134 + RepoID: blocker.Issue.RepoID, 135 + Index: blocker.Index, 136 + Title: blocker.Title, 137 + IsClosed: blocker.IsClosed, 138 + IsPull: blocker.IsPull, 139 + }, 140 + Repository: repo_model.Repository{ 141 + ID: blocker.Issue.Repo.ID, 142 + Name: blocker.Issue.Repo.Name, 143 + OwnerName: blocker.Issue.Repo.OwnerName, 144 + }, 145 + } 146 + confidentialBlocker.Issue.Repo = &confidentialBlocker.Repository 147 + blocker = confidentialBlocker 148 + } 149 + } 150 + blockerIssues = append(blockerIssues, &blocker.Issue) 151 + } 152 + 153 + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, blockerIssues)) 154 + } 155 + 156 + // CreateIssueDependency create a new issue dependencies 157 + func CreateIssueDependency(ctx *context.APIContext) { 158 + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/dependencies issue issueCreateIssueDependencies 159 + // --- 160 + // summary: Make the issue in the url depend on the issue in the form. 161 + // produces: 162 + // - application/json 163 + // parameters: 164 + // - name: owner 165 + // in: path 166 + // description: owner of the repo 167 + // type: string 168 + // required: true 169 + // - name: repo 170 + // in: path 171 + // description: name of the repo 172 + // type: string 173 + // required: true 174 + // - name: index 175 + // in: path 176 + // description: index of the issue 177 + // type: string 178 + // required: true 179 + // - name: body 180 + // in: body 181 + // schema: 182 + // "$ref": "#/definitions/IssueMeta" 183 + // responses: 184 + // "201": 185 + // "$ref": "#/responses/Issue" 186 + // "404": 187 + // description: the issue does not exist 188 + 189 + // We want to make <:index> depend on <Form>, i.e. <:index> is the target 190 + target := getParamsIssue(ctx) 191 + if ctx.Written() { 192 + return 193 + } 194 + 195 + // and <Form> represents the dependency 196 + form := web.GetForm(ctx).(*api.IssueMeta) 197 + dependency := getFormIssue(ctx, form) 198 + if ctx.Written() { 199 + return 200 + } 201 + 202 + dependencyPerm := getPermissionForRepo(ctx, target.Repo) 203 + if ctx.Written() { 204 + return 205 + } 206 + 207 + createIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm) 208 + if ctx.Written() { 209 + return 210 + } 211 + 212 + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, target)) 213 + } 214 + 215 + // RemoveIssueDependency remove an issue dependency 216 + func RemoveIssueDependency(ctx *context.APIContext) { 217 + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/dependencies issue issueRemoveIssueDependencies 218 + // --- 219 + // summary: Remove an issue dependency 220 + // produces: 221 + // - application/json 222 + // parameters: 223 + // - name: owner 224 + // in: path 225 + // description: owner of the repo 226 + // type: string 227 + // required: true 228 + // - name: repo 229 + // in: path 230 + // description: name of the repo 231 + // type: string 232 + // required: true 233 + // - name: index 234 + // in: path 235 + // description: index of the issue 236 + // type: string 237 + // required: true 238 + // - name: body 239 + // in: body 240 + // schema: 241 + // "$ref": "#/definitions/IssueMeta" 242 + // responses: 243 + // "200": 244 + // "$ref": "#/responses/Issue" 245 + 246 + // We want to make <:index> depend on <Form>, i.e. <:index> is the target 247 + target := getParamsIssue(ctx) 248 + if ctx.Written() { 249 + return 250 + } 251 + 252 + // and <Form> represents the dependency 253 + form := web.GetForm(ctx).(*api.IssueMeta) 254 + dependency := getFormIssue(ctx, form) 255 + if ctx.Written() { 256 + return 257 + } 258 + 259 + dependencyPerm := getPermissionForRepo(ctx, target.Repo) 260 + if ctx.Written() { 261 + return 262 + } 263 + 264 + removeIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm) 265 + if ctx.Written() { 266 + return 267 + } 268 + 269 + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, target)) 270 + } 271 + 272 + // GetIssueBlocks list issues that are blocked by this issue 273 + func GetIssueBlocks(ctx *context.APIContext) { 274 + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/blocks issue issueListBlocks 275 + // --- 276 + // summary: List issues that are blocked by this issue 277 + // produces: 278 + // - application/json 279 + // parameters: 280 + // - name: owner 281 + // in: path 282 + // description: owner of the repo 283 + // type: string 284 + // required: true 285 + // - name: repo 286 + // in: path 287 + // description: name of the repo 288 + // type: string 289 + // required: true 290 + // - name: index 291 + // in: path 292 + // description: index of the issue 293 + // type: string 294 + // required: true 295 + // - name: page 296 + // in: query 297 + // description: page number of results to return (1-based) 298 + // type: integer 299 + // - name: limit 300 + // in: query 301 + // description: page size of results 302 + // type: integer 303 + // responses: 304 + // "200": 305 + // "$ref": "#/responses/IssueList" 306 + 307 + // We need to list the issues that DEPEND on this issue not the other way round 308 + // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant. 309 + 310 + issue := getParamsIssue(ctx) 311 + if ctx.Written() { 312 + return 313 + } 314 + 315 + if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { 316 + ctx.NotFound() 317 + return 318 + } 319 + 320 + page := ctx.FormInt("page") 321 + if page <= 1 { 322 + page = 1 323 + } 324 + limit := ctx.FormInt("limit") 325 + if limit <= 1 { 326 + limit = setting.API.DefaultPagingNum 327 + } 328 + 329 + skip := (page - 1) * limit 330 + max := page * limit 331 + 332 + deps, err := issue.BlockingDependencies(ctx) 333 + if err != nil { 334 + ctx.Error(http.StatusInternalServerError, "BlockingDependencies", err) 335 + return 336 + } 337 + 338 + var lastRepoID int64 339 + var lastPerm access_model.Permission 340 + 341 + var issues []*issues_model.Issue 342 + for i, depMeta := range deps { 343 + if i < skip || i >= max { 344 + continue 345 + } 346 + 347 + // Get the permissions for this repository 348 + perm := lastPerm 349 + if lastRepoID != depMeta.Repository.ID { 350 + if depMeta.Repository.ID == ctx.Repo.Repository.ID { 351 + perm = ctx.Repo.Permission 352 + } else { 353 + var err error 354 + perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer) 355 + if err != nil { 356 + ctx.ServerError("GetUserRepoPermission", err) 357 + return 358 + } 359 + } 360 + lastRepoID = depMeta.Repository.ID 361 + } 362 + 363 + if !perm.CanReadIssuesOrPulls(depMeta.Issue.IsPull) { 364 + continue 365 + } 366 + 367 + depMeta.Issue.Repo = &depMeta.Repository 368 + issues = append(issues, &depMeta.Issue) 369 + } 370 + 371 + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) 372 + } 373 + 374 + // CreateIssueBlocking block the issue given in the body by the issue in path 375 + func CreateIssueBlocking(ctx *context.APIContext) { 376 + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/blocks issue issueCreateIssueBlocking 377 + // --- 378 + // summary: Block the issue given in the body by the issue in path 379 + // produces: 380 + // - application/json 381 + // parameters: 382 + // - name: owner 383 + // in: path 384 + // description: owner of the repo 385 + // type: string 386 + // required: true 387 + // - name: repo 388 + // in: path 389 + // description: name of the repo 390 + // type: string 391 + // required: true 392 + // - name: index 393 + // in: path 394 + // description: index of the issue 395 + // type: string 396 + // required: true 397 + // - name: body 398 + // in: body 399 + // schema: 400 + // "$ref": "#/definitions/IssueMeta" 401 + // responses: 402 + // "201": 403 + // "$ref": "#/responses/Issue" 404 + // "404": 405 + // description: the issue does not exist 406 + 407 + dependency := getParamsIssue(ctx) 408 + if ctx.Written() { 409 + return 410 + } 411 + 412 + form := web.GetForm(ctx).(*api.IssueMeta) 413 + target := getFormIssue(ctx, form) 414 + if ctx.Written() { 415 + return 416 + } 417 + 418 + targetPerm := getPermissionForRepo(ctx, target.Repo) 419 + if ctx.Written() { 420 + return 421 + } 422 + 423 + createIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission) 424 + if ctx.Written() { 425 + return 426 + } 427 + 428 + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, dependency)) 429 + } 430 + 431 + // RemoveIssueBlocking unblock the issue given in the body by the issue in path 432 + func RemoveIssueBlocking(ctx *context.APIContext) { 433 + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/blocks issue issueRemoveIssueBlocking 434 + // --- 435 + // summary: Unblock the issue given in the body by the issue in path 436 + // produces: 437 + // - application/json 438 + // parameters: 439 + // - name: owner 440 + // in: path 441 + // description: owner of the repo 442 + // type: string 443 + // required: true 444 + // - name: repo 445 + // in: path 446 + // description: name of the repo 447 + // type: string 448 + // required: true 449 + // - name: index 450 + // in: path 451 + // description: index of the issue 452 + // type: string 453 + // required: true 454 + // - name: body 455 + // in: body 456 + // schema: 457 + // "$ref": "#/definitions/IssueMeta" 458 + // responses: 459 + // "200": 460 + // "$ref": "#/responses/Issue" 461 + 462 + dependency := getParamsIssue(ctx) 463 + if ctx.Written() { 464 + return 465 + } 466 + 467 + form := web.GetForm(ctx).(*api.IssueMeta) 468 + target := getFormIssue(ctx, form) 469 + if ctx.Written() { 470 + return 471 + } 472 + 473 + targetPerm := getPermissionForRepo(ctx, target.Repo) 474 + if ctx.Written() { 475 + return 476 + } 477 + 478 + removeIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission) 479 + if ctx.Written() { 480 + return 481 + } 482 + 483 + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, dependency)) 484 + } 485 + 486 + func getParamsIssue(ctx *context.APIContext) *issues_model.Issue { 487 + issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 488 + if err != nil { 489 + if issues_model.IsErrIssueNotExist(err) { 490 + ctx.NotFound("IsErrIssueNotExist", err) 491 + } else { 492 + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 493 + } 494 + return nil 495 + } 496 + issue.Repo = ctx.Repo.Repository 497 + return issue 498 + } 499 + 500 + func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue { 501 + var repo *repo_model.Repository 502 + if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name { 503 + if !setting.Service.AllowCrossRepositoryDependencies { 504 + ctx.JSON(http.StatusBadRequest, "CrossRepositoryDependencies not enabled") 505 + return nil 506 + } 507 + var err error 508 + repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name) 509 + if err != nil { 510 + if repo_model.IsErrRepoNotExist(err) { 511 + ctx.NotFound("IsErrRepoNotExist", err) 512 + } else { 513 + ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerAndName", err) 514 + } 515 + return nil 516 + } 517 + } else { 518 + repo = ctx.Repo.Repository 519 + } 520 + 521 + issue, err := issues_model.GetIssueByIndex(repo.ID, form.Index) 522 + if err != nil { 523 + if issues_model.IsErrIssueNotExist(err) { 524 + ctx.NotFound("IsErrIssueNotExist", err) 525 + } else { 526 + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 527 + } 528 + return nil 529 + } 530 + issue.Repo = repo 531 + return issue 532 + } 533 + 534 + func getPermissionForRepo(ctx *context.APIContext, repo *repo_model.Repository) *access_model.Permission { 535 + if repo.ID == ctx.Repo.Repository.ID { 536 + return &ctx.Repo.Permission 537 + } 538 + 539 + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) 540 + if err != nil { 541 + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) 542 + return nil 543 + } 544 + 545 + return &perm 546 + } 547 + 548 + func createIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) { 549 + if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) { 550 + // The target's repository doesn't have dependencies enabled 551 + ctx.NotFound() 552 + return 553 + } 554 + 555 + if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) { 556 + // We can't write to the target 557 + ctx.NotFound() 558 + return 559 + } 560 + 561 + if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) { 562 + // We can't read the dependency 563 + ctx.NotFound() 564 + return 565 + } 566 + 567 + err := issues_model.CreateIssueDependency(ctx.Doer, target, dependency) 568 + if err != nil { 569 + ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) 570 + return 571 + } 572 + } 573 + 574 + func removeIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) { 575 + if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) { 576 + // The target's repository doesn't have dependencies enabled 577 + ctx.NotFound() 578 + return 579 + } 580 + 581 + if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) { 582 + // We can't write to the target 583 + ctx.NotFound() 584 + return 585 + } 586 + 587 + if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) { 588 + // We can't read the dependency 589 + ctx.NotFound() 590 + return 591 + } 592 + 593 + err := issues_model.RemoveIssueDependency(ctx.Doer, target, dependency, issues_model.DependencyTypeBlockedBy) 594 + if err != nil { 595 + ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) 596 + return 597 + } 598 + }
+2
routers/api/v1/swagger/options.go
··· 41 41 CreateIssueCommentOption api.CreateIssueCommentOption 42 42 // in:body 43 43 EditIssueCommentOption api.EditIssueCommentOption 44 + // in:body 45 + IssueMeta api.IssueMeta 44 46 45 47 // in:body 46 48 IssueLabelsOption api.IssueLabelsOption
+54 -2
routers/web/repo/issue.go
··· 1812 1812 } 1813 1813 1814 1814 // Get Dependencies 1815 - ctx.Data["BlockedByDependencies"], err = issue.BlockedByDependencies(ctx) 1815 + blockedBy, err := issue.BlockedByDependencies(ctx, db.ListOptions{}) 1816 1816 if err != nil { 1817 1817 ctx.ServerError("BlockedByDependencies", err) 1818 1818 return 1819 1819 } 1820 - ctx.Data["BlockingDependencies"], err = issue.BlockingDependencies(ctx) 1820 + ctx.Data["BlockedByDependencies"], ctx.Data["BlockedByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blockedBy) 1821 + if ctx.Written() { 1822 + return 1823 + } 1824 + 1825 + blocking, err := issue.BlockingDependencies(ctx) 1821 1826 if err != nil { 1822 1827 ctx.ServerError("BlockingDependencies", err) 1828 + return 1829 + } 1830 + 1831 + ctx.Data["BlockingDependencies"], ctx.Data["BlockingByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) 1832 + if ctx.Written() { 1823 1833 return 1824 1834 } 1825 1835 ··· 1849 1859 } 1850 1860 1851 1861 ctx.HTML(http.StatusOK, tplIssueView) 1862 + } 1863 + 1864 + func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { 1865 + var lastRepoID int64 1866 + var lastPerm access_model.Permission 1867 + for i, blocker := range blockers { 1868 + // Get the permissions for this repository 1869 + perm := lastPerm 1870 + if lastRepoID != blocker.Repository.ID { 1871 + if blocker.Repository.ID == ctx.Repo.Repository.ID { 1872 + perm = ctx.Repo.Permission 1873 + } else { 1874 + var err error 1875 + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) 1876 + if err != nil { 1877 + ctx.ServerError("GetUserRepoPermission", err) 1878 + return 1879 + } 1880 + } 1881 + lastRepoID = blocker.Repository.ID 1882 + } 1883 + 1884 + // check permission 1885 + if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { 1886 + blockers[len(notPermitted)], blockers[i] = blocker, blockers[len(notPermitted)] 1887 + notPermitted = blockers[:len(notPermitted)+1] 1888 + } 1889 + } 1890 + blockers = blockers[len(notPermitted):] 1891 + sortDependencyInfo(blockers) 1892 + sortDependencyInfo(notPermitted) 1893 + 1894 + return blockers, notPermitted 1895 + } 1896 + 1897 + func sortDependencyInfo(blockers []*issues_model.DependencyInfo) { 1898 + sort.Slice(blockers, func(i, j int) bool { 1899 + if blockers[i].RepoID == blockers[j].RepoID { 1900 + return blockers[i].Issue.CreatedUnix < blockers[j].Issue.CreatedUnix 1901 + } 1902 + return blockers[i].RepoID < blockers[j].RepoID 1903 + }) 1852 1904 } 1853 1905 1854 1906 // GetActionIssue will return the issue which is used in the context.
+20 -3
routers/web/repo/issue_dependency.go
··· 7 7 "net/http" 8 8 9 9 issues_model "code.gitea.io/gitea/models/issues" 10 + access_model "code.gitea.io/gitea/models/perm/access" 10 11 "code.gitea.io/gitea/modules/context" 11 12 "code.gitea.io/gitea/modules/setting" 12 13 ) ··· 44 45 } 45 46 46 47 // Check if both issues are in the same repo if cross repository dependencies is not enabled 47 - if issue.RepoID != dep.RepoID && !setting.Service.AllowCrossRepositoryDependencies { 48 - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) 49 - return 48 + if issue.RepoID != dep.RepoID { 49 + if !setting.Service.AllowCrossRepositoryDependencies { 50 + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) 51 + return 52 + } 53 + if err := dep.LoadRepo(ctx); err != nil { 54 + ctx.ServerError("loadRepo", err) 55 + return 56 + } 57 + // Can ctx.Doer read issues in the dep repo? 58 + depRepoPerm, err := access_model.GetUserRepoPermission(ctx, dep.Repo, ctx.Doer) 59 + if err != nil { 60 + ctx.ServerError("GetUserRepoPermission", err) 61 + return 62 + } 63 + if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) { 64 + // you can't see this dependency 65 + return 66 + } 50 67 } 51 68 52 69 // Check if issue and dependency is the same
+20 -16
services/convert/issue.go
··· 32 32 if err := issue.LoadRepo(ctx); err != nil { 33 33 return &api.Issue{} 34 34 } 35 - if err := issue.Repo.LoadOwner(ctx); err != nil { 36 - return &api.Issue{} 37 - } 38 35 39 36 apiIssue := &api.Issue{ 40 37 ID: issue.ID, 41 - URL: issue.APIURL(), 42 - HTMLURL: issue.HTMLURL(), 43 38 Index: issue.Index, 44 39 Poster: ToUser(ctx, issue.Poster, nil), 45 40 Title: issue.Title, 46 41 Body: issue.Content, 47 42 Attachments: ToAttachments(issue.Attachments), 48 43 Ref: issue.Ref, 49 - Labels: ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner), 50 44 State: issue.State(), 51 45 IsLocked: issue.IsLocked, 52 46 Comments: issue.NumComments, ··· 54 48 Updated: issue.UpdatedUnix.AsTime(), 55 49 } 56 50 57 - apiIssue.Repo = &api.RepositoryMeta{ 58 - ID: issue.Repo.ID, 59 - Name: issue.Repo.Name, 60 - Owner: issue.Repo.OwnerName, 61 - FullName: issue.Repo.FullName(), 51 + if issue.Repo != nil { 52 + if err := issue.Repo.LoadOwner(ctx); err != nil { 53 + return &api.Issue{} 54 + } 55 + apiIssue.URL = issue.APIURL() 56 + apiIssue.HTMLURL = issue.HTMLURL() 57 + apiIssue.Labels = ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner) 58 + apiIssue.Repo = &api.RepositoryMeta{ 59 + ID: issue.Repo.ID, 60 + Name: issue.Repo.Name, 61 + Owner: issue.Repo.OwnerName, 62 + FullName: issue.Repo.FullName(), 63 + } 62 64 } 63 65 64 66 if issue.ClosedUnix != 0 { ··· 85 87 if err := issue.LoadPullRequest(ctx); err != nil { 86 88 return &api.Issue{} 87 89 } 88 - apiIssue.PullRequest = &api.PullRequestMeta{ 89 - HasMerged: issue.PullRequest.HasMerged, 90 - } 91 - if issue.PullRequest.HasMerged { 92 - apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() 90 + if issue.PullRequest != nil { 91 + apiIssue.PullRequest = &api.PullRequestMeta{ 92 + HasMerged: issue.PullRequest.HasMerged, 93 + } 94 + if issue.PullRequest.HasMerged { 95 + apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() 96 + } 93 97 } 94 98 } 95 99 if issue.DeadlineUnix != 0 {
+36 -3
templates/repo/issue/view_content/sidebar.tmpl
··· 420 420 <div class="ui divider"></div> 421 421 422 422 <div class="ui depending"> 423 - {{if (and (not .BlockedByDependencies) (not .BlockingDependencies))}} 423 + {{if (and (not .BlockedByDependencies) (not .BlockedByDependenciesNotPermitted) (not .BlockingDependencies) (not .BlockingDependenciesNotPermitted))}} 424 424 <span class="text"><strong>{{.locale.Tr "repo.issues.dependency.title"}}</strong></span> 425 425 <br> 426 426 <p> ··· 432 432 </p> 433 433 {{end}} 434 434 435 - {{if .BlockingDependencies}} 435 + {{if or .BlockingDependencies .BlockingDependenciesNotPermitted}} 436 436 <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}}"> 437 437 <strong>{{.locale.Tr "repo.issues.dependency.blocks_short"}}</strong> 438 438 </span> ··· 456 456 </div> 457 457 </div> 458 458 {{end}} 459 + {{if .BlockingDependenciesNotPermitted}} 460 + <div class="item gt-df gt-ac gt-sb"> 461 + <span>{{$.locale.TrN (len .BlockingDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockingDependenciesNotPermitted)}}</span> 462 + </div> 463 + {{end}} 459 464 </div> 460 465 {{end}} 461 466 462 - {{if .BlockedByDependencies}} 467 + {{if or .BlockedByDependencies .BlockedByDependenciesNotPermitted}} 463 468 <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}}"> 464 469 <strong>{{.locale.Tr "repo.issues.dependency.blocked_by_short"}}</strong> 465 470 </span> ··· 481 486 </a> 482 487 {{end}} 483 488 </div> 489 + </div> 490 + {{end}} 491 + {{if $.CanCreateIssueDependencies}} 492 + {{range .BlockedByDependenciesNotPermitted}} 493 + <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} gt-df gt-ac gt-sb"> 494 + <div class="item-left gt-df gt-jc gt-fc gt-f1"> 495 + <div> 496 + <span data-tooltip-content="{{$.locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span> 497 + <span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> 498 + #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} 499 + </span> 500 + </div> 501 + <div class="text small"> 502 + {{.Repository.OwnerName}}/{{.Repository.Name}} 503 + </div> 504 + </div> 505 + <div class="item-right gt-df gt-ac"> 506 + {{if and $.CanCreateIssueDependencies (not $.Repository.IsArchived)}} 507 + <a class="delete-dependency-button ci muted" data-id="{{.Issue.ID}}" data-type="blocking" data-tooltip-content="{{$.locale.Tr "repo.issues.dependency.remove_info"}}"> 508 + {{svg "octicon-trash" 16}} 509 + </a> 510 + {{end}} 511 + </div> 512 + </div> 513 + {{end}} 514 + {{else if .BlockedByDependenciesNotPermitted}} 515 + <div class="item gt-df gt-ac gt-sb"> 516 + <span>{{$.locale.TrN (len .BlockedByDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockedByDependenciesNotPermitted)}}</span> 484 517 </div> 485 518 {{end}} 486 519 </div>
+310
templates/swagger/v1_json.tmpl
··· 6256 6256 } 6257 6257 } 6258 6258 }, 6259 + "/repos/{owner}/{repo}/issues/{index}/blocks": { 6260 + "get": { 6261 + "produces": [ 6262 + "application/json" 6263 + ], 6264 + "tags": [ 6265 + "issue" 6266 + ], 6267 + "summary": "List issues that are blocked by this issue", 6268 + "operationId": "issueListBlocks", 6269 + "parameters": [ 6270 + { 6271 + "type": "string", 6272 + "description": "owner of the repo", 6273 + "name": "owner", 6274 + "in": "path", 6275 + "required": true 6276 + }, 6277 + { 6278 + "type": "string", 6279 + "description": "name of the repo", 6280 + "name": "repo", 6281 + "in": "path", 6282 + "required": true 6283 + }, 6284 + { 6285 + "type": "string", 6286 + "description": "index of the issue", 6287 + "name": "index", 6288 + "in": "path", 6289 + "required": true 6290 + }, 6291 + { 6292 + "type": "integer", 6293 + "description": "page number of results to return (1-based)", 6294 + "name": "page", 6295 + "in": "query" 6296 + }, 6297 + { 6298 + "type": "integer", 6299 + "description": "page size of results", 6300 + "name": "limit", 6301 + "in": "query" 6302 + } 6303 + ], 6304 + "responses": { 6305 + "200": { 6306 + "$ref": "#/responses/IssueList" 6307 + } 6308 + } 6309 + }, 6310 + "post": { 6311 + "produces": [ 6312 + "application/json" 6313 + ], 6314 + "tags": [ 6315 + "issue" 6316 + ], 6317 + "summary": "Block the issue given in the body by the issue in path", 6318 + "operationId": "issueCreateIssueBlocking", 6319 + "parameters": [ 6320 + { 6321 + "type": "string", 6322 + "description": "owner of the repo", 6323 + "name": "owner", 6324 + "in": "path", 6325 + "required": true 6326 + }, 6327 + { 6328 + "type": "string", 6329 + "description": "name of the repo", 6330 + "name": "repo", 6331 + "in": "path", 6332 + "required": true 6333 + }, 6334 + { 6335 + "type": "string", 6336 + "description": "index of the issue", 6337 + "name": "index", 6338 + "in": "path", 6339 + "required": true 6340 + }, 6341 + { 6342 + "name": "body", 6343 + "in": "body", 6344 + "schema": { 6345 + "$ref": "#/definitions/IssueMeta" 6346 + } 6347 + } 6348 + ], 6349 + "responses": { 6350 + "201": { 6351 + "$ref": "#/responses/Issue" 6352 + }, 6353 + "404": { 6354 + "description": "the issue does not exist" 6355 + } 6356 + } 6357 + }, 6358 + "delete": { 6359 + "produces": [ 6360 + "application/json" 6361 + ], 6362 + "tags": [ 6363 + "issue" 6364 + ], 6365 + "summary": "Unblock the issue given in the body by the issue in path", 6366 + "operationId": "issueRemoveIssueBlocking", 6367 + "parameters": [ 6368 + { 6369 + "type": "string", 6370 + "description": "owner of the repo", 6371 + "name": "owner", 6372 + "in": "path", 6373 + "required": true 6374 + }, 6375 + { 6376 + "type": "string", 6377 + "description": "name of the repo", 6378 + "name": "repo", 6379 + "in": "path", 6380 + "required": true 6381 + }, 6382 + { 6383 + "type": "string", 6384 + "description": "index of the issue", 6385 + "name": "index", 6386 + "in": "path", 6387 + "required": true 6388 + }, 6389 + { 6390 + "name": "body", 6391 + "in": "body", 6392 + "schema": { 6393 + "$ref": "#/definitions/IssueMeta" 6394 + } 6395 + } 6396 + ], 6397 + "responses": { 6398 + "200": { 6399 + "$ref": "#/responses/Issue" 6400 + } 6401 + } 6402 + } 6403 + }, 6259 6404 "/repos/{owner}/{repo}/issues/{index}/comments": { 6260 6405 "get": { 6261 6406 "produces": [ ··· 6534 6679 }, 6535 6680 "404": { 6536 6681 "$ref": "#/responses/notFound" 6682 + } 6683 + } 6684 + } 6685 + }, 6686 + "/repos/{owner}/{repo}/issues/{index}/dependencies": { 6687 + "get": { 6688 + "produces": [ 6689 + "application/json" 6690 + ], 6691 + "tags": [ 6692 + "issue" 6693 + ], 6694 + "summary": "List an issue's dependencies, i.e all issues that block this issue.", 6695 + "operationId": "issueListIssueDependencies", 6696 + "parameters": [ 6697 + { 6698 + "type": "string", 6699 + "description": "owner of the repo", 6700 + "name": "owner", 6701 + "in": "path", 6702 + "required": true 6703 + }, 6704 + { 6705 + "type": "string", 6706 + "description": "name of the repo", 6707 + "name": "repo", 6708 + "in": "path", 6709 + "required": true 6710 + }, 6711 + { 6712 + "type": "string", 6713 + "description": "index of the issue", 6714 + "name": "index", 6715 + "in": "path", 6716 + "required": true 6717 + }, 6718 + { 6719 + "type": "integer", 6720 + "description": "page number of results to return (1-based)", 6721 + "name": "page", 6722 + "in": "query" 6723 + }, 6724 + { 6725 + "type": "integer", 6726 + "description": "page size of results", 6727 + "name": "limit", 6728 + "in": "query" 6729 + } 6730 + ], 6731 + "responses": { 6732 + "200": { 6733 + "$ref": "#/responses/IssueList" 6734 + } 6735 + } 6736 + }, 6737 + "post": { 6738 + "produces": [ 6739 + "application/json" 6740 + ], 6741 + "tags": [ 6742 + "issue" 6743 + ], 6744 + "summary": "Make the issue in the url depend on the issue in the form.", 6745 + "operationId": "issueCreateIssueDependencies", 6746 + "parameters": [ 6747 + { 6748 + "type": "string", 6749 + "description": "owner of the repo", 6750 + "name": "owner", 6751 + "in": "path", 6752 + "required": true 6753 + }, 6754 + { 6755 + "type": "string", 6756 + "description": "name of the repo", 6757 + "name": "repo", 6758 + "in": "path", 6759 + "required": true 6760 + }, 6761 + { 6762 + "type": "string", 6763 + "description": "index of the issue", 6764 + "name": "index", 6765 + "in": "path", 6766 + "required": true 6767 + }, 6768 + { 6769 + "name": "body", 6770 + "in": "body", 6771 + "schema": { 6772 + "$ref": "#/definitions/IssueMeta" 6773 + } 6774 + } 6775 + ], 6776 + "responses": { 6777 + "201": { 6778 + "$ref": "#/responses/Issue" 6779 + }, 6780 + "404": { 6781 + "description": "the issue does not exist" 6782 + } 6783 + } 6784 + }, 6785 + "delete": { 6786 + "produces": [ 6787 + "application/json" 6788 + ], 6789 + "tags": [ 6790 + "issue" 6791 + ], 6792 + "summary": "Remove an issue dependency", 6793 + "operationId": "issueRemoveIssueDependencies", 6794 + "parameters": [ 6795 + { 6796 + "type": "string", 6797 + "description": "owner of the repo", 6798 + "name": "owner", 6799 + "in": "path", 6800 + "required": true 6801 + }, 6802 + { 6803 + "type": "string", 6804 + "description": "name of the repo", 6805 + "name": "repo", 6806 + "in": "path", 6807 + "required": true 6808 + }, 6809 + { 6810 + "type": "string", 6811 + "description": "index of the issue", 6812 + "name": "index", 6813 + "in": "path", 6814 + "required": true 6815 + }, 6816 + { 6817 + "name": "body", 6818 + "in": "body", 6819 + "schema": { 6820 + "$ref": "#/definitions/IssueMeta" 6821 + } 6822 + } 6823 + ], 6824 + "responses": { 6825 + "200": { 6826 + "$ref": "#/responses/Issue" 6537 6827 } 6538 6828 } 6539 6829 } ··· 17928 18218 "format": "int64" 17929 18219 }, 17930 18220 "x-go-name": "Labels" 18221 + } 18222 + }, 18223 + "x-go-package": "code.gitea.io/gitea/modules/structs" 18224 + }, 18225 + "IssueMeta": { 18226 + "description": "IssueMeta basic issue information", 18227 + "type": "object", 18228 + "properties": { 18229 + "index": { 18230 + "type": "integer", 18231 + "format": "int64", 18232 + "x-go-name": "Index" 18233 + }, 18234 + "owner": { 18235 + "type": "string", 18236 + "x-go-name": "Owner" 18237 + }, 18238 + "repo": { 18239 + "type": "string", 18240 + "x-go-name": "Name" 17931 18241 } 17932 18242 }, 17933 18243 "x-go-package": "code.gitea.io/gitea/modules/structs"