mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
Backport #39873 by @nazem-droubi Fixes #39651 When an org-owned repo has a repo label and an org label with the same name, `POST`/`PUT /repos/{owner}/{repo}/issues/{index}/labels` with label names attached both of them. With this change the repo label is used when the names collide, and org labels are only looked up for the names that didn't match a repo label. Names that only exist at the org level still resolve to the org label as before. I documented the behavior on `IssueLabelsOption.Labels` and regenerated the swagger files. Picking the repo label seemed the least surprising choice since it's the more specific one, but I'm happy to change it (for example to return a 422 on ambiguous names) if you'd prefer that. Tests: added `TestAPIAddIssueLabelsWithSharedRepoAndOrgLabelName`, which covers both POST and PUT. It fails on `main` (3 labels attached instead of 2) and passes with this change. I ran the existing add/replace label integration tests locally on sqlite, plus `go vet` and gofmt. AI disclosure: the code and test in this PR were written primarily by an AI coding agent working on my behalf. I'm happy to walk through it or revise anything during review. Signed-off-by: Nazem <214302554+nazem-droubi@users.noreply.github.com> Co-authored-by: Nazem <nazem98@gmail.com> Co-authored-by: Nazem <214302554+nazem-droubi@users.noreply.github.com> Co-authored-by: silverwind <me@silverwind.io>
371 lines
9.5 KiB
Go
371 lines
9.5 KiB
Go
// Copyright 2016 The Gogs Authors. All rights reserved.
|
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
|
|
issues_model "gitea.dev/models/issues"
|
|
"gitea.dev/modules/container"
|
|
api "gitea.dev/modules/structs"
|
|
"gitea.dev/modules/web"
|
|
"gitea.dev/services/context"
|
|
"gitea.dev/services/convert"
|
|
issue_service "gitea.dev/services/issue"
|
|
)
|
|
|
|
// ListIssueLabels list all the labels of an issue
|
|
func ListIssueLabels(ctx *context.APIContext) {
|
|
// swagger:operation GET /repos/{owner}/{repo}/issues/{index}/labels issue issueGetLabels
|
|
// ---
|
|
// summary: Get an issue's labels
|
|
// 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: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/LabelList"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
|
if issue == nil {
|
|
return
|
|
}
|
|
|
|
if err := issue.LoadAttributes(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToLabelList(issue.Labels, ctx.Repo.Repository, ctx.Repo.Owner))
|
|
}
|
|
|
|
// AddIssueLabels add labels for an issue
|
|
func AddIssueLabels(ctx *context.APIContext) {
|
|
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/labels issue issueAddLabel
|
|
// ---
|
|
// summary: Add a label to an issue
|
|
// consumes:
|
|
// - application/json
|
|
// 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: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/IssueLabelsOption"
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/LabelList"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "423":
|
|
// "$ref": "#/responses/repoArchivedError"
|
|
|
|
form := web.GetForm[*api.IssueLabelsOption](ctx)
|
|
issue, labels, err := prepareForReplaceOrAdd(ctx, *form)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if err = issue_service.AddLabels(ctx, issue, ctx.Doer, labels); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner))
|
|
}
|
|
|
|
// DeleteIssueLabel delete a label for an issue
|
|
func DeleteIssueLabel(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id} issue issueRemoveLabel
|
|
// ---
|
|
// summary: Remove a label from an 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: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the label to remove
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
// "423":
|
|
// "$ref": "#/responses/repoArchivedError"
|
|
|
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
|
if issue == nil {
|
|
return
|
|
}
|
|
|
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
|
ctx.Status(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// the label must belong to this repo (or its owning org); otherwise a foreign label ID
|
|
// is rejected the same way as a nonexistent one, closing a cross-repo enumeration oracle
|
|
labelID := ctx.PathParamInt64("id")
|
|
label, err := issues_model.GetLabelInRepoOrOrgByID(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner.ID, ctx.Repo.Owner.IsOrganization(), labelID)
|
|
if err != nil {
|
|
ctx.APIErrorAuto(err)
|
|
return
|
|
}
|
|
|
|
if err := issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ReplaceIssueLabels replace labels for an issue
|
|
func ReplaceIssueLabels(ctx *context.APIContext) {
|
|
// swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/labels issue issueReplaceLabels
|
|
// ---
|
|
// summary: Replace an issue's labels
|
|
// consumes:
|
|
// - application/json
|
|
// 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: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/IssueLabelsOption"
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/LabelList"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "423":
|
|
// "$ref": "#/responses/repoArchivedError"
|
|
form := web.GetForm[*api.IssueLabelsOption](ctx)
|
|
issue, labels, err := prepareForReplaceOrAdd(ctx, *form)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if err := issue_service.ReplaceLabels(ctx, issue, ctx.Doer, labels); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner))
|
|
}
|
|
|
|
// ClearIssueLabels delete all the labels for an issue
|
|
func ClearIssueLabels(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels issue issueClearLabels
|
|
// ---
|
|
// summary: Remove all labels from an 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: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "423":
|
|
// "$ref": "#/responses/repoArchivedError"
|
|
|
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
|
if issue == nil {
|
|
return
|
|
}
|
|
|
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
|
ctx.Status(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
|
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
|
if issue == nil {
|
|
return nil, nil, errors.New("issue not found")
|
|
}
|
|
|
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
|
ctx.APIError(http.StatusForbidden, "write permission is required")
|
|
return nil, nil, errors.New("permission denied")
|
|
}
|
|
|
|
var (
|
|
labelIDs []int64
|
|
labelNames []string
|
|
)
|
|
for _, label := range form.Labels {
|
|
rv := reflect.ValueOf(label)
|
|
switch rv.Kind() {
|
|
case reflect.Float64:
|
|
labelIDs = append(labelIDs, int64(rv.Float()))
|
|
case reflect.String:
|
|
labelNames = append(labelNames, rv.String())
|
|
default:
|
|
ctx.APIError(http.StatusBadRequest, "a label must be an integer or a string")
|
|
return nil, nil, errors.New("invalid label")
|
|
}
|
|
}
|
|
if len(labelIDs) > 0 && len(labelNames) > 0 {
|
|
ctx.APIError(http.StatusBadRequest, "labels should be an array of strings or integers")
|
|
return nil, nil, errors.New("invalid labels")
|
|
}
|
|
if len(labelNames) > 0 {
|
|
repoLabelIDs, err := issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return nil, nil, err
|
|
}
|
|
labelIDs = append(labelIDs, repoLabelIDs...)
|
|
if ctx.Repo.Owner.IsOrganization() {
|
|
repoLabels, err := issues_model.GetLabelsByIDs(ctx, repoLabelIDs, "name")
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return nil, nil, err
|
|
}
|
|
orgLabelNames := container.SetOf(labelNames...)
|
|
for _, label := range repoLabels {
|
|
orgLabelNames.Remove(label.Name)
|
|
}
|
|
if len(orgLabelNames) > 0 {
|
|
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, orgLabelNames.Values())
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return nil, nil, err
|
|
}
|
|
labelIDs = append(labelIDs, orgLabelIDs...)
|
|
}
|
|
}
|
|
}
|
|
|
|
labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive")
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return nil, nil, err
|
|
}
|
|
|
|
return issue, labels, err
|
|
}
|