From b5e974c8a5a90e7c166792d03d97f2ea5b6cfba6 Mon Sep 17 00:00:00 2001
From: John Olheiser <john.olheiser@gmail.com>
Date: Fri, 30 Oct 2020 20:56:34 -0500
Subject: [PATCH] Delete tag API (#13358)

* Delete tag API

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Wording

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Add conflict response and fix API tests

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Fix other test

Signed-off-by: jolheiser <john.olheiser@gmail.com>

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
---
 integrations/api_releases_test.go   | 23 ++++++++++++
 integrations/api_repo_test.go       |  2 +-
 integrations/release_test.go        |  6 ++--
 models/fixtures/release.yml         | 16 +++++++++
 modules/context/api.go              |  4 +++
 routers/api/v1/api.go               |  4 ++-
 routers/api/v1/repo/release_tags.go | 55 +++++++++++++++++++++++++++++
 templates/swagger/v1_json.tmpl      | 44 +++++++++++++++++++++++
 8 files changed, 149 insertions(+), 5 deletions(-)

diff --git a/integrations/api_releases_test.go b/integrations/api_releases_test.go
index 58c2e3544..8328b014d 100644
--- a/integrations/api_releases_test.go
+++ b/integrations/api_releases_test.go
@@ -154,3 +154,26 @@ func TestAPIGetReleaseByTag(t *testing.T) {
 	DecodeJSON(t, resp, &err)
 	assert.True(t, strings.HasPrefix(err.Message, "release tag does not exist"))
 }
+
+func TestAPIDeleteTagByName(t *testing.T) {
+	defer prepareTestEnv(t)()
+
+	repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
+	owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
+	session := loginUser(t, owner.LowerName)
+	token := getTokenForLoggedInUser(t, session)
+
+	urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/delete-tag/?token=%s",
+		owner.Name, repo.Name, token)
+
+	req := NewRequestf(t, http.MethodDelete, urlStr)
+	_ = session.MakeRequest(t, req, http.StatusNoContent)
+
+	// Make sure that actual releases can't be deleted outright
+	createNewReleaseUsingAPI(t, session, token, owner, repo, "release-tag", "", "Release Tag", "test")
+	urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/release-tag/?token=%s",
+		owner.Name, repo.Name, token)
+
+	req = NewRequestf(t, http.MethodDelete, urlStr)
+	_ = session.MakeRequest(t, req, http.StatusConflict)
+}
diff --git a/integrations/api_repo_test.go b/integrations/api_repo_test.go
index c8afa73ae..fed63a959 100644
--- a/integrations/api_repo_test.go
+++ b/integrations/api_repo_test.go
@@ -223,7 +223,7 @@ func TestAPIViewRepo(t *testing.T) {
 	DecodeJSON(t, resp, &repo)
 	assert.EqualValues(t, 1, repo.ID)
 	assert.EqualValues(t, "repo1", repo.Name)
-	assert.EqualValues(t, 1, repo.Releases)
+	assert.EqualValues(t, 2, repo.Releases)
 	assert.EqualValues(t, 1, repo.OpenIssues)
 	assert.EqualValues(t, 3, repo.OpenPulls)
 
diff --git a/integrations/release_test.go b/integrations/release_test.go
index 4d2260d88..c817dcaec 100644
--- a/integrations/release_test.go
+++ b/integrations/release_test.go
@@ -83,7 +83,7 @@ func TestCreateRelease(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, false)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.stable"), 2)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.stable"), 3)
 }
 
 func TestCreateReleasePreRelease(t *testing.T) {
@@ -92,7 +92,7 @@ func TestCreateReleasePreRelease(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", true, false)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.prerelease"), 2)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.prerelease"), 3)
 }
 
 func TestCreateReleaseDraft(t *testing.T) {
@@ -101,7 +101,7 @@ func TestCreateReleaseDraft(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, true)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.draft"), 2)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.draft"), 3)
 }
 
 func TestCreateReleasePaging(t *testing.T) {
diff --git a/models/fixtures/release.yml b/models/fixtures/release.yml
index f95eb048b..8d3f5840e 100644
--- a/models/fixtures/release.yml
+++ b/models/fixtures/release.yml
@@ -27,3 +27,19 @@
   is_prerelease: false
   is_tag: false
   created_unix: 946684800
+
+-
+  id: 3
+  repo_id: 1
+  publisher_id: 2
+  tag_name: "delete-tag"
+  lower_tag_name: "delete-tag"
+  target: "master"
+  title: "delete-tag"
+  sha1: "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+  num_commits: 10
+  is_draft: false
+  is_prerelease: false
+  is_tag: true
+  created_unix: 946684800
+
diff --git a/modules/context/api.go b/modules/context/api.go
index 772e1f8f5..9dad588c7 100644
--- a/modules/context/api.go
+++ b/modules/context/api.go
@@ -61,6 +61,10 @@ type APIForbiddenError struct {
 // swagger:response notFound
 type APINotFound struct{}
 
+//APIConflict is a conflict empty response
+// swagger:response conflict
+type APIConflict struct{}
+
 //APIRedirect is a redirect response
 // swagger:response redirect
 type APIRedirect struct{}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 147cb8e27..42489cd4a 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -798,7 +798,9 @@ func RegisterRoutes(m *macaron.Macaron) {
 						})
 					})
 					m.Group("/tags", func() {
-						m.Get("/:tag", repo.GetReleaseTag)
+						m.Combo("/:tag").
+							Get(repo.GetReleaseTag).
+							Delete(reqToken(), reqRepoWriter(models.UnitTypeReleases), repo.DeleteReleaseTag)
 					})
 				}, reqRepoReader(models.UnitTypeReleases))
 				m.Post("/mirror-sync", reqToken(), reqRepoWriter(models.UnitTypeCode), repo.MirrorSync)
diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go
index 2a72e0000..ef07ce5e1 100644
--- a/routers/api/v1/repo/release_tags.go
+++ b/routers/api/v1/repo/release_tags.go
@@ -5,11 +5,13 @@
 package repo
 
 import (
+	"errors"
 	"net/http"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/convert"
+	releaseservice "code.gitea.io/gitea/services/release"
 )
 
 // GetReleaseTag get a single release of a repository by its tagname
@@ -59,3 +61,56 @@ func GetReleaseTag(ctx *context.APIContext) {
 	}
 	ctx.JSON(http.StatusOK, convert.ToRelease(release))
 }
+
+// DeleteReleaseTag delete a tag from a repository
+func DeleteReleaseTag(ctx *context.APIContext) {
+	// swagger:operation DELETE /repos/{owner}/{repo}/releases/tags/{tag} repository repoDeleteReleaseTag
+	// ---
+	// summary: Delete a release tag
+	// 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: tag
+	//   in: path
+	//   description: name of the tag to delete
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+
+	tag := ctx.Params(":tag")
+
+	release, err := models.GetRelease(ctx.Repo.Repository.ID, tag)
+	if err != nil {
+		if models.IsErrReleaseNotExist(err) {
+			ctx.Error(http.StatusNotFound, "GetRelease", err)
+			return
+		}
+		ctx.Error(http.StatusInternalServerError, "GetRelease", err)
+		return
+	}
+
+	if !release.IsTag {
+		ctx.Error(http.StatusConflict, "IsTag", errors.New("a tag attached to a release cannot be deleted directly"))
+		return
+	}
+
+	if err := releaseservice.DeleteReleaseByID(release.ID, ctx.User, true); err != nil {
+		ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err)
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 90a76643d..b8f81bb8f 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -7834,6 +7834,47 @@
             "$ref": "#/responses/notFound"
           }
         }
+      },
+      "delete": {
+        "tags": [
+          "repository"
+        ],
+        "summary": "Delete a release tag",
+        "operationId": "repoDeleteReleaseTag",
+        "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": "name of the tag to delete",
+            "name": "tag",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "409": {
+            "$ref": "#/responses/conflict"
+          }
+        }
       }
     },
     "/repos/{owner}/{repo}/releases/{id}": {
@@ -16249,6 +16290,9 @@
         "$ref": "#/definitions/WatchInfo"
       }
     },
+    "conflict": {
+      "description": "APIConflict is a conflict empty response"
+    },
     "empty": {
       "description": "APIEmpty is an empty response"
     },