1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
5.2 KiB
Go
148 lines
5.2 KiB
Go
package cmd
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
|
)
|
|
|
|
func init() {
|
|
register(&Command{
|
|
Name: "comment",
|
|
Group: GroupSync,
|
|
Args: "<id>",
|
|
Short: "post or edit a comment on a synced issue",
|
|
Long: `The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
|
|
work; where it lives in the tracker is bookkeeping, and the ` + "`gitea:`" + ` handle on the
|
|
file is what turns one into the other. An ` + "`origin: local`" + ` issue cannot be
|
|
commented on at all — it is not in the tracker, so there is nothing there to
|
|
comment on; push it first.
|
|
|
|
The body comes from a file or from --body, and multi-line prose is what --file
|
|
is for. This is why comments go through the API rather than through a tracker
|
|
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
|
|
TTY that does not exist it hangs forever.
|
|
|
|
After the write the whole thread is refetched into ` + "`<id>.comments.md`" + `, so the
|
|
local copy is not stale by one comment — the one this run just made.
|
|
|
|
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
|
|
` + "`<id>.comments.md`" + ` by hand changes nothing in the tracker. Use --edit with a
|
|
comment id for that.`,
|
|
Examples: []Example{
|
|
{"kettle comment wire-sqlc-appclick --file notes.md", "post the contents of a file"},
|
|
{`kettle comment wire-sqlc-appclick --body "готово, задеплоено"`, "post one line"},
|
|
{"kettle comment wire-sqlc-appclick --file fix.md --edit 1234", "rewrite comment 1234 instead"},
|
|
},
|
|
Setup: func(fs *flag.FlagSet) func([]string) error {
|
|
file := fs.String("file", "", "markdown file holding the comment body")
|
|
body := fs.String("body", "", "comment body inline (short, single-line)")
|
|
edit := fs.Int64("edit", 0, "comment id to rewrite, instead of posting a new one")
|
|
out := storeFlag(fs)
|
|
|
|
return func(args []string) error {
|
|
if len(args) != 1 {
|
|
return Fail("give exactly one issue id")
|
|
}
|
|
id := args[0]
|
|
withFile, withBody := wasSet(fs, "file"), wasSet(fs, "body")
|
|
switch {
|
|
case withFile && withBody:
|
|
return Fail("--file and --body are mutually exclusive")
|
|
case !withFile && !withBody:
|
|
return Fail("give the comment body: --file <path>, or --body \"…\"")
|
|
}
|
|
|
|
root, client, err := syncStartExisting(*out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i, err := issue.Load(root, id)
|
|
if err != nil {
|
|
return Fail("no issue %q in %s", id, root)
|
|
}
|
|
key, ok := mapping.RemoteKeyOf(i)
|
|
if !ok {
|
|
return Fail("%s is local-only (origin: %s, no usable `%s:` handle) — "+
|
|
"there is nothing in the tracker to comment on; `kettle push %s` first",
|
|
id, i.Origin, mapping.GiteaKey, id)
|
|
}
|
|
text, err := commentBodyFrom(*file, *body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The handle names the repository, so a comment lands where the
|
|
// issue actually is — even when the store has ever pointed at two.
|
|
client = client.For(key.Repo)
|
|
|
|
var got *sdk.Comment
|
|
verb := "posted"
|
|
if *edit != 0 {
|
|
verb = "edited"
|
|
got, err = client.EditComment(*edit, text, fmt.Sprintf("comment-%d", *edit))
|
|
} else {
|
|
got, err = client.CreateComment(key.Number, text, "comment-"+id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A 2xx that carries no id is not a comment. Nothing local has been
|
|
// written yet, and nothing will be if the answer is that shape.
|
|
if got.ID == 0 {
|
|
return Fail("the %s answer carries no comment id — nothing local was changed", verb)
|
|
}
|
|
fmt.Printf("%s comment %d on %s (%s) %s\n", verb, got.ID, id, key, got.HTMLURL)
|
|
|
|
comments, err := client.ListComments(key.Number)
|
|
if err != nil {
|
|
return Fail("the comment went up, but refetching the thread failed: %v — "+
|
|
"`kettle pull %d` to refresh the local copy", err, key.Number)
|
|
}
|
|
path := commentsSidecarPath(root, id)
|
|
if len(comments) == 0 {
|
|
// Only reachable when the thread was emptied elsewhere between
|
|
// the write and the read. A stale sidecar for a thread that no
|
|
// longer exists is worse than no sidecar.
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
fmt.Printf("thread: none — %s removed\n", path)
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(path, []byte(mapping.RenderComments(comments)), 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("thread: %s (%d comment(s))\n", path, len(comments))
|
|
return nil
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
// commentBodyFrom reads the comment body from a file or takes it as given.
|
|
//
|
|
// Trimmed and then required to be non-empty: a file of whitespace is somebody
|
|
// pointing at the wrong path, and posting it would leave an empty comment in a
|
|
// thread that nobody can delete from here.
|
|
func commentBodyFrom(file, inline string) (string, error) {
|
|
if file != "" {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", Fail("cannot read the comment body: %v", err)
|
|
}
|
|
inline = string(raw)
|
|
}
|
|
text := strings.TrimSpace(inline)
|
|
if text == "" {
|
|
return "", Fail("the comment body is empty — nothing was posted")
|
|
}
|
|
return text, nil
|
|
}
|