feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,652 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"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: "push",
|
||||
Group: GroupSync,
|
||||
Args: "[<id>…]",
|
||||
Short: "send local issues to the tracker; the local copy goes with them",
|
||||
Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
|
||||
slug — and prints the number and the URL the issue now lives at. Once the tracker
|
||||
has the issue, the tracker IS the issue: what is left in the store is what has
|
||||
not left this machine. Get it back with ` + "`kettle pull <n>`" + `, which returns it under
|
||||
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
|
||||
and was recorded in the number -> slug ledger.
|
||||
|
||||
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
|
||||
that has just been sent is no more local than one that was just created. Two
|
||||
rules would put back exactly the question this removes — "is my copy the fresh
|
||||
one?".
|
||||
|
||||
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, and only after all
|
||||
three of:
|
||||
|
||||
1. the call came back without an error and with a 2xx,
|
||||
2. the answer carries a plausible number — on --update the very number that
|
||||
was PATCHed, and
|
||||
3. the ledger has been written with number -> slug.
|
||||
|
||||
Network down, non-2xx, an answer that does not confirm the write: the file stays
|
||||
and the run stops. Nothing removes a file it has not just watched the tracker
|
||||
accept, and nothing removes a file for an issue it did not send — ` + "`origin: local`" + `
|
||||
work that has never been pushed is never touched by any of this. Get the ordering
|
||||
wrong and a slug is lost at exactly the moment the local copy stops being the
|
||||
record, which is why the ledger is written before anything is deleted and not
|
||||
after.
|
||||
|
||||
Every issue is validated against the canonical format first, offline and before
|
||||
a socket is opened. --force posts anyway; say why when you use it.
|
||||
|
||||
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
|
||||
the issue that names it. Every ` + "`depends:`" + ` entry that has a number becomes a NATIVE
|
||||
tracker link — the same /dependencies a pull reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. A link that is already
|
||||
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
|
||||
dependency that is still local-only has no number and becomes no link: it is
|
||||
reported, never silently dropped.
|
||||
|
||||
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
|
||||
` + "`depends:`" + ` leaves its tracker link standing; unlink it in the web UI.
|
||||
|
||||
The ` + "`## Depends on`" + ` prose is never touched: slugs stay slugs and are not rewritten
|
||||
to #N, so a pull -> push round trip is byte for byte.
|
||||
|
||||
Labels the repository is missing are created with the canonical colour and, for
|
||||
type/* and severity/*, exclusive: true. ` + "`branch:`" + ` carries the tracker's ` + "`ref`" + `: an
|
||||
empty one is filled with the current git branch and an already-set one is sent as
|
||||
written. Detached HEAD or no repository at all is not an error — no ref is sent
|
||||
and a warning says so.`,
|
||||
Examples: []Example{
|
||||
{"kettle push", "every issue the tracker does not have yet, blockers first"},
|
||||
{"kettle push wire-sqlc-appclick", "one issue"},
|
||||
{"kettle push --update wire-sqlc-appclick", "PATCH one that is already there — the file still goes"},
|
||||
{"kettle push --dry-run", "validate and print the plan; no network, nothing deleted"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
update := fs.Bool("update", false, "PATCH issues that already carry a gitea: field")
|
||||
dryRun := fs.Bool("dry-run", false, "validate and print the plan; no network, nothing deleted")
|
||||
force := fs.Bool("force", false, "push despite format violations")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
// A dry run touches no network, so it must not need a credential
|
||||
// to say what it would do. The real run goes through
|
||||
// syncStartExisting below, which resolves the store before it
|
||||
// builds a client — a missing store reported as a network
|
||||
// problem sends the operator to the wrong place.
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return Fail("%s — create an issue with `kettle new` first", err)
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chosen, err := pushSelect(issues, args, *update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The domain's own check, offline, before anything is sent.
|
||||
known := map[string]bool{}
|
||||
for id := range issues {
|
||||
known[id] = true
|
||||
}
|
||||
blocked := false
|
||||
for _, id := range chosen {
|
||||
errs, warns := issue.Validate(issues[id], known)
|
||||
for _, w := range warns {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", id, w)
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", id, e)
|
||||
}
|
||||
blocked = blocked || len(errs) > 0
|
||||
}
|
||||
if blocked && !*force {
|
||||
return Fail("format violations (see above); --force overrides")
|
||||
}
|
||||
|
||||
// Dependencies first, so a blocker has its number by the time the
|
||||
// issue that names it is sent. A cycle is reported and ordered
|
||||
// around rather than refused: it is a data problem, not a reason
|
||||
// to send nothing.
|
||||
edges := map[string][]string{}
|
||||
for _, id := range chosen {
|
||||
var deps []string
|
||||
for _, d := range issues[id].Depends {
|
||||
if _, ok := issues[d]; ok {
|
||||
deps = append(deps, d)
|
||||
}
|
||||
}
|
||||
edges[id] = deps
|
||||
}
|
||||
pushing := map[string]bool{}
|
||||
for _, id := range chosen {
|
||||
pushing[id] = true
|
||||
}
|
||||
var order []string
|
||||
for _, id := range issue.TopoOrder(chosen, edges) {
|
||||
if pushing[id] {
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
for _, c := range issue.FindCycles(edges) {
|
||||
fmt.Fprintf(os.Stderr, "warning: dependency cycle: %s\n", strings.Join(c, " -> "))
|
||||
}
|
||||
|
||||
// Only an EMPTY branch: one written by hand is the author's
|
||||
// decision and a push does not argue with it. The value is set on
|
||||
// the in-memory issue only — the file it came from is about to be
|
||||
// deleted, and the branch comes back with the next pull.
|
||||
var blank []string
|
||||
for _, id := range order {
|
||||
if strings.TrimSpace(issues[id].Extra[mapping.BranchKey]) == "" {
|
||||
blank = append(blank, id)
|
||||
}
|
||||
}
|
||||
if len(blank) > 0 {
|
||||
if branch := pushGitBranch(); branch != "" {
|
||||
for _, id := range blank {
|
||||
if issues[id].Extra == nil {
|
||||
issues[id].Extra = map[string]string{}
|
||||
}
|
||||
issues[id].Extra[mapping.BranchKey] = branch
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: no current git branch (detached HEAD, or "+
|
||||
"outside a git repository) — no `ref` on: %s\n", strings.Join(blank, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
// Not one request is made here: everything below is read off
|
||||
// the store and the ledger, which costs nothing.
|
||||
pushPlan(root, issues, order, pushing, *update)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo := client.Repo()
|
||||
|
||||
var wanted []string
|
||||
for _, id := range order {
|
||||
for _, l := range issues[id].Labels {
|
||||
if !contains(wanted, l) {
|
||||
wanted = append(wanted, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(wanted)
|
||||
labelIDs, err := pushLabelIDs(client, wanted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
milestones := map[string]*int64{}
|
||||
ledger := loadLedgerOrFold(root, issues)
|
||||
keyOf := pushLedgerKeys(ledger, repo)
|
||||
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
|
||||
// Local-only means "this machine has never sent it": no
|
||||
// `gitea:` on the file AND no entry in the ledger. A blocker
|
||||
// whose file an earlier push already dropped is in the ledger
|
||||
// and is not one of these.
|
||||
var unsynced []string
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
if !onDisk || pushing[d] {
|
||||
continue
|
||||
}
|
||||
if _, synced := mapping.RemoteKeyOf(dep); synced {
|
||||
continue
|
||||
}
|
||||
if _, inLedger := keyOf[d]; !inLedger {
|
||||
unsynced = append(unsynced, d)
|
||||
}
|
||||
}
|
||||
if len(unsynced) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: depends on local-only issue(s) %s"+
|
||||
" — no cross-link in the tracker\n", id, strings.Join(unsynced, ", "))
|
||||
}
|
||||
|
||||
msID, ok := milestones[i.Milestone]
|
||||
if i.Milestone != "" && !ok {
|
||||
m, err := client.FindMilestone(i.Milestone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m != nil {
|
||||
msID = wire.Set(m.ID)
|
||||
}
|
||||
milestones[i.Milestone] = msID
|
||||
}
|
||||
if i.Milestone != "" && msID == nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: milestone %q does not exist in %s"+
|
||||
" — not set\n", id, i.Milestone, repo)
|
||||
}
|
||||
|
||||
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
|
||||
sent, synced := mapping.NumberOf(i)
|
||||
var got *wire.Issue
|
||||
verb := "created"
|
||||
if synced {
|
||||
// An edit says what state it means; a create takes the
|
||||
// tracker's default.
|
||||
opt.IncludeState = true
|
||||
verb = "updated"
|
||||
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
|
||||
} else {
|
||||
sent = 0
|
||||
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
|
||||
}
|
||||
// THE GATE. Below this line a local file is going to be
|
||||
// deleted, so anything short of a confirmed write stops the
|
||||
// run right here.
|
||||
if err != nil {
|
||||
return Fail("%s: not %s: %v — %s is untouched", id, verb, err, issue.PathOf(root, id))
|
||||
}
|
||||
number, confirmed := pushConfirmedNumber(got, sent)
|
||||
if !confirmed {
|
||||
return Fail("%s: the tracker's answer does not confirm the write "+
|
||||
"(it carries number %d) — %s is untouched, nothing was deleted",
|
||||
id, got.Number, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// The number is confirmed, so the ledger learns it NOW —
|
||||
// before the label fix-up and the links, both of which can
|
||||
// still fail, and well before the file is removed. This entry
|
||||
// is what a later `kettle pull <n>` lands on; an interrupted
|
||||
// run must cost a re-pull, never a slug.
|
||||
key := wire.Key{Repo: repo, Number: number}
|
||||
ledger.Set(key, id)
|
||||
keyOf[id] = key
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return Fail("%s: the tracker has it as #%d but the ledger could not be "+
|
||||
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// Gitea occasionally drops labels handed to it on create, so
|
||||
// the echo is checked and the set re-applied rather than
|
||||
// trusted. A failure here is a warning and not an abort: the
|
||||
// issue IS in the tracker, and a run that stopped now would
|
||||
// leave a file whose `gitea:` field was never written — which
|
||||
// the next push would file all over again as a new issue.
|
||||
applied := map[string]bool{}
|
||||
for _, l := range got.Labels {
|
||||
applied[l.Name] = true
|
||||
}
|
||||
var ids []int64
|
||||
var missing []string
|
||||
for _, name := range i.Labels {
|
||||
lid, in := labelIDs[name]
|
||||
if !in {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, lid)
|
||||
if !applied[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
|
||||
id, strings.Join(missing, ", "), err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
|
||||
id, strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// The in-memory issue is stamped even though its file is
|
||||
// going: the rest of this loop reads `gitea:` off it to link
|
||||
// dependencies, and a later issue in topological order asks
|
||||
// the same of this one.
|
||||
mapping.ApplyRemote(i, got, repo, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// The number and the URL lead, because in a moment the local
|
||||
// path is gone and this is the only address the issue has.
|
||||
fmt.Printf("%s %s #%d %s\n", verb, id, number, got.HTMLURL)
|
||||
|
||||
if err := pushLinks(client, id, number, i, issues, pushing, keyOf, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// And now the local copy goes: the last thing that happens to
|
||||
// this issue, after the write, the ledger and the links. A
|
||||
// warning above lands here anyway — the issue is in the
|
||||
// tracker, and keeping a stale file beside it would put back
|
||||
// exactly the two-copies question this removes.
|
||||
gone, err := issue.Remove(root, id)
|
||||
for _, p := range gone {
|
||||
fmt.Printf(" dropped %s\n", p)
|
||||
}
|
||||
if err != nil {
|
||||
return Fail("%s: the tracker has it as #%d, but the local copy could not "+
|
||||
"be removed: %v", id, number, err)
|
||||
}
|
||||
fmt.Printf(" kettle pull %d to work on it again\n", number)
|
||||
}
|
||||
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// pushSelect is which issues to send, and the refusal of the ambiguous
|
||||
// combinations.
|
||||
//
|
||||
// Named ids are taken as typed. With none, the default is everything this
|
||||
// machine has never sent — pushing the whole store on a bare `kettle push` would
|
||||
// re-PATCH every working copy in it.
|
||||
func pushSelect(issues map[string]*issue.Issue, ids []string, update bool) ([]string, error) {
|
||||
var chosen []string
|
||||
if len(ids) > 0 {
|
||||
var missing []string
|
||||
for _, id := range ids {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
chosen = append(chosen, ids...)
|
||||
} else {
|
||||
for id, i := range issues {
|
||||
if _, synced := mapping.RemoteKeyOf(i); update || !synced {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
if len(chosen) == 0 {
|
||||
return nil, Fail("nothing to push: every issue in the store is already in the " +
|
||||
"tracker — pass --update to PATCH them, or `kettle new` to make one")
|
||||
}
|
||||
}
|
||||
if !update {
|
||||
var already []string
|
||||
for _, id := range chosen {
|
||||
if _, synced := mapping.RemoteKeyOf(issues[id]); synced {
|
||||
already = append(already, id)
|
||||
}
|
||||
}
|
||||
if len(already) > 0 {
|
||||
return nil, Fail("already in the tracker: %s — pass --update to PATCH them",
|
||||
strings.Join(already, ", "))
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// pushLabelIDs is name -> id for the labels these issues carry, creating what
|
||||
// the repository is missing.
|
||||
//
|
||||
// Decided against the repository as it is right now, in one request, and never
|
||||
// against a cache: a cache answers "what did we create last time", and the
|
||||
// question here is "what does this repository have". A label the tracker does
|
||||
// not have and this cannot create is the one failure worth stopping for — an
|
||||
// issue filed without its `type/*` label is an issue nothing can find again.
|
||||
func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
if len(names) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
have, err := c.ListLabels()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := make(map[string]int64, len(have))
|
||||
for _, l := range have {
|
||||
known[l.Name] = l.ID
|
||||
}
|
||||
// The spec — colour, description, exclusivity — is the bridge's, read off the
|
||||
// domain's taxonomy. This layer only decides which names are wanted.
|
||||
for _, spec := range mapping.LabelSpecs(names) {
|
||||
if id, ok := known[spec.Name]; ok {
|
||||
out[spec.Name] = id
|
||||
continue
|
||||
}
|
||||
created, err := c.CreateLabel(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[spec.Name] = created.ID
|
||||
note := ""
|
||||
if spec.Exclusive {
|
||||
note = " (exclusive)"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "created label %s%s\n", spec.Name, note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pushDep is what one `depends:` entry is, as far as linking is concerned.
|
||||
type pushDep struct {
|
||||
// Slug is the dependency as `depends:` spells it.
|
||||
Slug string
|
||||
// Key is where it lives in the tracker; HasKey is false while it is
|
||||
// local-only.
|
||||
Key wire.Key
|
||||
HasKey bool
|
||||
// InRun says this push is about to give it a number.
|
||||
InRun bool
|
||||
}
|
||||
|
||||
// pushDepState is every dependency this run can say anything about.
|
||||
//
|
||||
// A dependency's key is read from its `gitea:` field while the file is still on
|
||||
// disk, and from the ledger when it is not — which, since push deletes what it
|
||||
// sends, is the normal state of an already-published blocker. Without that
|
||||
// fallback the graph would quietly lose an edge every time a blocker was pushed
|
||||
// before its dependent: the file is gone, the field goes with it, and the link is
|
||||
// never made.
|
||||
//
|
||||
// A slug that is in neither the store nor the ledger names nothing this machine
|
||||
// has ever seen, and is dropped — validation has already warned about it.
|
||||
func pushDepState(i *issue.Issue, issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key) []pushDep {
|
||||
var out []pushDep
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
var key wire.Key
|
||||
found := false
|
||||
if onDisk {
|
||||
key, found = mapping.RemoteKeyOf(dep)
|
||||
}
|
||||
if !found {
|
||||
key, found = keyOf[d]
|
||||
}
|
||||
if !onDisk && !found {
|
||||
continue
|
||||
}
|
||||
out = append(out, pushDep{Slug: d, Key: key, HasKey: found, InRun: pushing[d]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushLinks turns `depends:` into the tracker's own dependency links.
|
||||
//
|
||||
// Topological order means every blocker that is going to have a number has one
|
||||
// already. The GET is the idempotence check — one request per issue that has
|
||||
// dependencies at all, and what makes a repeat push a no-op. A failure is a
|
||||
// warning, never an abort: one missing cross-link must not undo a push that has
|
||||
// already created issues.
|
||||
func pushLinks(c *gitea.Client, id string, number int, i *issue.Issue,
|
||||
issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key, repo wire.Repo) error {
|
||||
var wanted []pushDep
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
if d.HasKey && d.Key.Number > 0 {
|
||||
wanted = append(wanted, d)
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
have, err := c.DependencyKeys(number)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not read the links #%d already has (%v)"+
|
||||
" — no link was made\n", id, number, err)
|
||||
return nil
|
||||
}
|
||||
for _, d := range wanted {
|
||||
key := d.Key.In(repo)
|
||||
if containsKey(have, key) {
|
||||
continue
|
||||
}
|
||||
if err := c.AddDependency(number, key); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not link #%d -> %s (%s): %v — link it by "+
|
||||
"hand, or `kettle pull %d` and push it again\n", id, number, key, d.Slug, err, number)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" depends on %s (%s)\n", key, d.Slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushPlan is the --dry-run receipt: what would be sent, and which links would
|
||||
// exist. `#?` is a number this run has not handed out yet.
|
||||
func pushPlan(root string, issues map[string]*issue.Issue, order []string,
|
||||
pushing map[string]bool, update bool) {
|
||||
// The ledger costs no request, so a dry run resolves an already-pushed
|
||||
// blocker exactly the way the real run does.
|
||||
keyOf := pushLedgerKeys(loadLedgerOrFold(root, issues), wire.Repo{})
|
||||
links := 0
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
typ := i.Type()
|
||||
if typ == "" {
|
||||
typ = "?"
|
||||
}
|
||||
labels := strings.Join(i.Labels, ", ")
|
||||
if labels == "" {
|
||||
labels = "no labels"
|
||||
}
|
||||
fmt.Printf("ok %s [type/%s] %s (%s)\n", id, typ, i.Title, labels)
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
switch {
|
||||
case d.HasKey:
|
||||
fmt.Printf(" link -> %s (%s)\n", d.Key, d.Slug)
|
||||
links++
|
||||
case d.InRun:
|
||||
fmt.Printf(" link -> #? (%s, created by this run)\n", d.Slug)
|
||||
links++
|
||||
default:
|
||||
fmt.Printf(" no link: %s is local-only\n", d.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
verb := "created"
|
||||
if update {
|
||||
verb = "updated"
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s, %d dependency link(s) would be created\n",
|
||||
len(order), verb, links)
|
||||
}
|
||||
|
||||
// pushLedgerKeys is slug -> key, the reverse of the ledger.
|
||||
//
|
||||
// Where a dependency's number comes from once push has deleted its file. The
|
||||
// ledger is keyed by number because that is what a pull has in hand; a push has a
|
||||
// slug, so it needs the other direction. An entry in the repository being pushed
|
||||
// to wins when a slug somehow appears under two keys.
|
||||
func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
|
||||
raw := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
raw = append(raw, k)
|
||||
}
|
||||
sort.Strings(raw)
|
||||
|
||||
out := map[string]wire.Key{}
|
||||
for _, r := range raw {
|
||||
key, err := wire.ParseKey(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
slug := m[r]
|
||||
if _, seen := out[slug]; !seen || key.Repo == repo {
|
||||
out[slug] = key
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushConfirmedNumber is the number the tracker confirmed for a write, or ok
|
||||
// false — the deletion gate.
|
||||
//
|
||||
// Every local file this command removes is removed because this returned ok, so
|
||||
// it is written to be boring and to say no by default: a positive number, and on
|
||||
// a PATCH the very number that was addressed. What it does not have to catch,
|
||||
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
|
||||
// expected, or a `number` that is not a number — the transport fails all three
|
||||
// before returning, and the file survives by never reaching the delete.
|
||||
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
|
||||
if got == nil || got.Number <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if sent != 0 && got.Number != sent {
|
||||
return 0, false
|
||||
}
|
||||
return got.Number, true
|
||||
}
|
||||
|
||||
// pushGitBranch is the branch HEAD is on, or "".
|
||||
//
|
||||
// The one git call this binary makes — read, never write. A detached HEAD prints
|
||||
// `HEAD` and outside a repository git exits non-zero; both mean "no branch to
|
||||
// name", which is not an error.
|
||||
func pushGitBranch() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(string(out))
|
||||
if name == "HEAD" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func containsKey(keys []wire.Key, want wire.Key) bool {
|
||||
for _, k := range keys {
|
||||
if k == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user