package cmd import ( "flag" "fmt" "os" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" "git.noodles.cam/claude-skills/marketplace/cli/internal/mapping" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) func init() { register(&Command{ Name: "comment", Group: GroupSync, Args: "", 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 ` + "`.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 ` + "`.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 , 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 *wire.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 }