Subject: [PATCH links] Add tags delete and rename to Pinboard API bridge. Should now have complete coverage for relevant operations.

From: Peter Sanchez <[email-masked]>
Date: Sun, 17 Aug 2025 09:03:12 -0600

Changelog-added: Added ability to rename and delete tags via the
  Pinboard API bridge.
---
 pinboard/input.go       |  11 ++++
 pinboard/routes.go      |  97 ++++++++++++++++++++++++++++++-
 pinboard/routes_test.go | 124 ++++++++++++++++++++++++++++++++++++++--
 3 files changed, 226 insertions(+), 6 deletions(-)

diff --git a/pinboard/input.go b/pinboard/input.go
index 1a72e47..baac2a6 100644
--- a/pinboard/input.go
+++ b/pinboard/input.go
@@ -44,4 +44,15 @@ type AllPostInput struct {
 	Fromdt  string `query:"fromdt"`
 	Todt    string `query:"todt"`
 	Meta    string `query:"meta"`
+}
+
+// DeleteTagInput represents the input for /v1/tags/delete
+type DeleteTagInput struct {
+	Tag string `query:"tag" validate:"required"`
+}
+
+// RenameTagInput represents the input for /v1/tags/rename
+type RenameTagInput struct {
+	Old string `query:"old" validate:"required"`
+	New string `query:"new" validate:"required"`
 }
\ No newline at end of file
diff --git a/pinboard/routes.go b/pinboard/routes.go
index 5393056..19c838a 100644
--- a/pinboard/routes.go
+++ b/pinboard/routes.go
@@ -847,10 +847,103 @@ func (s *Service) TagsGet(c echo.Context) error {
 
 // TagsRename handles /v1/tags/rename
 func (s *Service) TagsRename(c echo.Context) error {
-	return formatError(c, "Tag renaming is unsupported")
+	var input RenameTagInput
+	if err := c.Bind(&input); err != nil {
+		return formatError(c, "Invalid input parameters")
+	}
+
+	if err := c.Validate(input); err != nil {
+		return formatError(c, err.Error())
+	}
+
+	org, err := s.getUserOrg(c)
+	if err != nil {
+		return formatError(c, "Failed to get organization")
+	}
+
+	type GraphQLResponse struct {
+		RenameTag struct {
+			Success bool   `json:"success"`
+			Message string `json:"message"`
+		} `json:"renameTag"`
+	}
+
+	var result GraphQLResponse
+	op := gqlclient.NewOperation(
+		`mutation RenameTag($orgSlug: String!, $service: DomainService!, $tag: String!, $newTag: String!) {
+			renameTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag, newTag: $newTag}) {
+				success
+				message
+			}
+		}`)
+
+	op.Var("orgSlug", org.Slug)
+	op.Var("service", "LINKS")
+	op.Var("tag", input.Old)
+	op.Var("newTag", input.New)
+
+	err = links.Execute(c.Request().Context(), op, &result)
+	if err != nil {
+		if graphError, ok := err.(*gqlclient.Error); ok {
+			return formatError(c, graphError.Error())
+		}
+		return formatError(c, err.Error())
+	}
+
+	if !result.RenameTag.Success {
+		return formatError(c, "Failed to rename tag")
+	}
+
+	return formatSuccess(c)
 }
 
 // TagsDelete handles /v1/tags/delete
 func (s *Service) TagsDelete(c echo.Context) error {
-	return formatError(c, "Tag deletion is unsupported")
+	var input DeleteTagInput
+	if err := c.Bind(&input); err != nil {
+		return formatError(c, "Invalid input parameters")
+	}
+
+	if err := c.Validate(input); err != nil {
+		return formatError(c, err.Error())
+	}
+
+	org, err := s.getUserOrg(c)
+	if err != nil {
+		return formatError(c, "Failed to get organization")
+	}
+
+	type GraphQLResponse struct {
+		DeleteTag struct {
+			Success  bool   `json:"success"`
+			ObjectID string `json:"objectId"`
+		} `json:"deleteTag"`
+	}
+
+	var result GraphQLResponse
+	op := gqlclient.NewOperation(
+		`mutation DeleteTag($orgSlug: String!, $service: DomainService!, $tag: String!) {
+			deleteTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag}) {
+				success
+				objectId
+			}
+		}`)
+
+	op.Var("orgSlug", org.Slug)
+	op.Var("service", "LINKS")
+	op.Var("tag", input.Tag)
+
+	err = links.Execute(c.Request().Context(), op, &result)
+	if err != nil {
+		if graphError, ok := err.(*gqlclient.Error); ok {
+			return formatError(c, graphError.Error())
+		}
+		return formatError(c, err.Error())
+	}
+
+	if !result.DeleteTag.Success {
+		return formatError(c, "Failed to delete tag")
+	}
+
+	return formatSuccess(c)
 }
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
index 07bea5b..44831df 100644
--- a/pinboard/routes_test.go
+++ b/pinboard/routes_test.go
@@ -1453,7 +1453,20 @@ func TestTagEndpoints(t *testing.T) {
 		c.False(strings.Contains(body, "<tag "))
 	})
 
-	t.Run("tags/rename unsupported", func(t *testing.T) {
+	t.Run("tags/rename success", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+				"data": map[string]any{
+					"renameTag": map[string]any{
+						"success": true,
+						"message": "Tag renamed successfully",
+					},
+				},
+			}))
+
 		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil)
 		recorder := httptest.NewRecorder()
 
@@ -1466,10 +1479,23 @@ func TestTagEndpoints(t *testing.T) {
 
 		body := recorder.Body.String()
 		checkXMLResponse(body, "result")
-		c.True(strings.Contains(body, "Tag renaming is unsupported"))
+		c.True(strings.Contains(body, `code="done"`))
 	})
 
-	t.Run("tags/delete unsupported", func(t *testing.T) {
+	t.Run("tags/delete success", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+				"data": map[string]any{
+					"deleteTag": map[string]any{
+						"success":  true,
+						"objectId": "tag123",
+					},
+				},
+			}))
+
 		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil)
 		recorder := httptest.NewRecorder()
 
@@ -1482,7 +1508,97 @@ func TestTagEndpoints(t *testing.T) {
 
 		body := recorder.Body.String()
 		checkXMLResponse(body, "result")
-		c.True(strings.Contains(body, "Tag deletion is unsupported"))
+		c.True(strings.Contains(body, `code="done"`))
+	})
+
+	t.Run("tags/rename missing required fields", func(t *testing.T) {
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/rename")
+
+		err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "something went wrong"))
+	})
+
+	t.Run("tags/rename failure", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+				"data": map[string]any{
+					"renameTag": map[string]any{
+						"success": false,
+						"message": "Tag not found",
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=nonexistent&new=newtag", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/rename")
+
+		err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "Failed to rename tag"))
+	})
+
+	t.Run("tags/delete missing required field", func(t *testing.T) {
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/delete")
+
+		err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "something went wrong"))
+	})
+
+	t.Run("tags/delete failure", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+				"data": map[string]any{
+					"deleteTag": map[string]any{
+						"success":  false,
+						"objectId": "",
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=nonexistent", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/delete")
+
+		err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "Failed to delete tag"))
 	})
 
 	t.Run("tags/get with GraphQL error", func(t *testing.T) {
-- 
2.49.1

---

From: "Peter Sanchez" <[email-masked]>
Date: Sun, 17 Aug 2025 09:05:32 -0600

Applied.

To [email-masked]:~netlandish/links
   f58fc6e..8db5562  master -> master