From f7cd394680f885061144d236abc3c25f30be3147 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Thu, 17 Jun 2021 18:04:10 +0200
Subject: [PATCH] [API] Add repoCreateTag (#16165)

* Add API CreateTag

* Add Test

* API: expose Tag Message
---
 integrations/api_repo_tags_test.go | 35 ++++++++++++++-
 modules/convert/convert.go         |  2 +
 modules/structs/repo_tag.go        |  9 ++++
 routers/api/v1/api.go              |  1 +
 routers/api/v1/repo/tag.go         | 61 +++++++++++++++++++++++++
 routers/api/v1/swagger/options.go  |  3 ++
 templates/swagger/v1_json.tmpl     | 72 +++++++++++++++++++++++++++++-
 7 files changed, 180 insertions(+), 3 deletions(-)

diff --git a/integrations/api_repo_tags_test.go b/integrations/api_repo_tags_test.go
index 1ffec576d8..1bd9fa6168 100644
--- a/integrations/api_repo_tags_test.go
+++ b/integrations/api_repo_tags_test.go
@@ -5,6 +5,7 @@
 package integrations
 
 import (
+	"fmt"
 	"net/http"
 	"testing"
 
@@ -15,14 +16,16 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestAPIReposGetTags(t *testing.T) {
+func TestAPIRepoTags(t *testing.T) {
 	defer prepareTestEnv(t)()
 	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
 	// Login as User2.
 	session := loginUser(t, user.Name)
 	token := getTokenForLoggedInUser(t, session)
 
-	req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/tags?token="+token, user.Name)
+	repoName := "repo1"
+
+	req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/tags?token=%s", user.Name, repoName, token)
 	resp := session.MakeRequest(t, req, http.StatusOK)
 
 	var tags []*api.Tag
@@ -30,8 +33,36 @@ func TestAPIReposGetTags(t *testing.T) {
 
 	assert.Len(t, tags, 1)
 	assert.Equal(t, "v1.1", tags[0].Name)
+	assert.Equal(t, "Initial commit", tags[0].Message)
 	assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.SHA)
 	assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.URL)
 	assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.zip", tags[0].ZipballURL)
 	assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.tar.gz", tags[0].TarballURL)
+
+	newTag := createNewTagUsingAPI(t, session, token, user.Name, repoName, "awesome-tag", "", "nice!\nand some text")
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &tags)
+	assert.Len(t, tags, 2)
+	for _, tag := range tags {
+		if tag.Name != "v1.1" {
+			assert.EqualValues(t, newTag.Name, tag.Name)
+			assert.EqualValues(t, newTag.Message, tag.Message)
+			assert.EqualValues(t, "nice!\nand some text", tag.Message)
+			assert.EqualValues(t, newTag.Commit.SHA, tag.Commit.SHA)
+		}
+	}
+}
+
+func createNewTagUsingAPI(t *testing.T, session *TestSession, token string, ownerName, repoName, name, target, msg string) *api.Tag {
+	urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/tags?token=%s", ownerName, repoName, token)
+	req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateTagOption{
+		TagName: name,
+		Message: msg,
+		Target:  target,
+	})
+	resp := session.MakeRequest(t, req, http.StatusCreated)
+
+	var respObj api.Tag
+	DecodeJSON(t, resp, &respObj)
+	return &respObj
 }
diff --git a/modules/convert/convert.go b/modules/convert/convert.go
index 109931dbc3..0b2135c580 100644
--- a/modules/convert/convert.go
+++ b/modules/convert/convert.go
@@ -8,6 +8,7 @@ package convert
 import (
 	"fmt"
 	"strconv"
+	"strings"
 	"time"
 
 	"code.gitea.io/gitea/models"
@@ -135,6 +136,7 @@ func ToBranchProtection(bp *models.ProtectedBranch) *api.BranchProtection {
 func ToTag(repo *models.Repository, t *git.Tag) *api.Tag {
 	return &api.Tag{
 		Name:       t.Name,
+		Message:    strings.TrimSpace(t.Message),
 		ID:         t.ID.String(),
 		Commit:     ToCommitMeta(repo, t),
 		ZipballURL: util.URLJoin(repo.HTMLURL(), "archive", t.Name+".zip"),
diff --git a/modules/structs/repo_tag.go b/modules/structs/repo_tag.go
index b62395cac4..80ee1ccf17 100644
--- a/modules/structs/repo_tag.go
+++ b/modules/structs/repo_tag.go
@@ -7,6 +7,7 @@ package structs
 // Tag represents a repository tag
 type Tag struct {
 	Name       string      `json:"name"`
+	Message    string      `json:"message"`
 	ID         string      `json:"id"`
 	Commit     *CommitMeta `json:"commit"`
 	ZipballURL string      `json:"zipball_url"`
@@ -30,3 +31,11 @@ type AnnotatedTagObject struct {
 	URL  string `json:"url"`
 	SHA  string `json:"sha"`
 }
+
+// CreateTagOption options when creating a tag
+type CreateTagOption struct {
+	// required: true
+	TagName string `json:"tag_name" binding:"Required"`
+	Message string `json:"message"`
+	Target  string `json:"target"`
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 0b47953e58..34cf80e072 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -775,6 +775,7 @@ func Routes() *web.Route {
 				}, reqToken(), reqAdmin())
 				m.Group("/tags", func() {
 					m.Get("", repo.ListTags)
+					m.Post("", reqRepoWriter(models.UnitTypeCode), bind(api.CreateTagOption{}), repo.CreateTag)
 					m.Delete("/{tag}", repo.DeleteTag)
 				}, reqRepoReader(models.UnitTypeCode), context.ReferencesGitRepo(true))
 				m.Group("/keys", func() {
diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go
index ec9b541bd4..51ba43ea89 100644
--- a/routers/api/v1/repo/tag.go
+++ b/routers/api/v1/repo/tag.go
@@ -6,12 +6,14 @@ package repo
 
 import (
 	"errors"
+	"fmt"
 	"net/http"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/convert"
 	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	releaseservice "code.gitea.io/gitea/services/release"
 )
@@ -160,3 +162,62 @@ func DeleteTag(ctx *context.APIContext) {
 
 	ctx.Status(http.StatusNoContent)
 }
+
+// CreateTag create a new git tag in a repository
+func CreateTag(ctx *context.APIContext) {
+	// swagger:operation POST /repos/{owner}/{repo}/tags repository repoCreateTag
+	// ---
+	// summary: Create a new git tag in a repository
+	// 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: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/CreateTagOption"
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/AnnotatedTag"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+	form := web.GetForm(ctx).(*api.CreateTagOption)
+
+	// If target is not provided use default branch
+	if len(form.Target) == 0 {
+		form.Target = ctx.Repo.Repository.DefaultBranch
+	}
+
+	commit, err := ctx.Repo.GitRepo.GetCommit(form.Target)
+	if err != nil {
+		ctx.Error(http.StatusNotFound, "target not found", fmt.Errorf("target not found: %v", err))
+		return
+	}
+
+	if err := releaseservice.CreateNewTag(ctx.User, ctx.Repo.Repository, commit.ID.String(), form.TagName, form.Message); err != nil {
+		if models.IsErrTagAlreadyExists(err) {
+			ctx.Error(http.StatusConflict, "tag exist", err)
+			return
+		}
+		ctx.InternalServerError(err)
+		return
+	}
+
+	tag, err := ctx.Repo.GitRepo.GetTag(form.TagName)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	ctx.JSON(http.StatusCreated, convert.ToTag(ctx.Repo.Repository, tag))
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index dad025710d..11158fb86d 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -158,4 +158,7 @@ type swaggerParameterBodies struct {
 
 	// in:body
 	PullReviewRequestOptions api.PullReviewRequestOptions
+
+	// in:body
+	CreateTagOption api.CreateTagOption
 }
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 9b0d07ebfc..017dc824d7 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -9082,6 +9082,50 @@
             "$ref": "#/responses/TagList"
           }
         }
+      },
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Create a new git tag in a repository",
+        "operationId": "repoCreateTag",
+        "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
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/CreateTagOption"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/AnnotatedTag"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "409": {
+            "$ref": "#/responses/conflict"
+          }
+        }
       }
     },
     "/repos/{owner}/{repo}/tags/{tag}": {
@@ -13092,6 +13136,28 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "CreateTagOption": {
+      "description": "CreateTagOption options when creating a tag",
+      "type": "object",
+      "required": [
+        "tag_name"
+      ],
+      "properties": {
+        "message": {
+          "type": "string",
+          "x-go-name": "Message"
+        },
+        "tag_name": {
+          "type": "string",
+          "x-go-name": "TagName"
+        },
+        "target": {
+          "type": "string",
+          "x-go-name": "Target"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "CreateTeamOption": {
       "description": "CreateTeamOption options for creating a team",
       "type": "object",
@@ -16149,6 +16215,10 @@
           "type": "string",
           "x-go-name": "ID"
         },
+        "message": {
+          "type": "string",
+          "x-go-name": "Message"
+        },
         "name": {
           "type": "string",
           "x-go-name": "Name"
@@ -17265,7 +17335,7 @@
     "parameterBodies": {
       "description": "parameterBodies",
       "schema": {
-        "$ref": "#/definitions/PullReviewRequestOptions"
+        "$ref": "#/definitions/CreateTagOption"
       }
     },
     "redirect": {