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>
689 lines
24 KiB
Go
689 lines
24 KiB
Go
package cmd
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"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 = sdk.OptionalInt64(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 *sdk.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.ToEdit(i, opt), "issue-"+id)
|
|
} else {
|
|
sent = 0
|
|
got, err = client.CreateIssue(mapping.ToCreate(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.Index, 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))
|
|
}
|
|
|
|
// THE LABELS THE TRACKER ENDED UP WITH HAVE TO BE THE
|
|
// ISSUE'S, and two different things leave them disagreeing:
|
|
//
|
|
// - a create can DROP labels handed to it, which Gitea has
|
|
// been seen to do, so the echo is checked rather than
|
|
// trusted;
|
|
// - an edit cannot carry labels AT ALL — Gitea's PATCH
|
|
// takes none — so a label added or removed locally is
|
|
// not in the answer either way.
|
|
//
|
|
// One answer to both: compare the set the tracker echoed
|
|
// with the set this issue wants, and PUT the whole list when
|
|
// they differ. 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.
|
|
if drift := pushLabelDrift(got.Labels, i.Labels, labelIDs); len(drift) > 0 {
|
|
if _, err := client.SetLabels(number, mapping.LabelIDsFor(i, opt), "labels-"+id); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: %s: could not set the labels (%s): %v\n",
|
|
id, strings.Join(drift, ", "), err)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "%s: labels set via PUT (%s)\n",
|
|
id, strings.Join(drift, ", "))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// pushLabelDrift is the names on which the tracker's answer and the issue
|
|
// disagree — what a receipt says when the PUT that follows is made.
|
|
//
|
|
// BOTH DIRECTIONS. A wanted label the answer does not carry is one Gitea
|
|
// dropped or one an edit could not send; a label the answer carries that the
|
|
// issue no longer wants is one somebody removed locally, and leaving it would
|
|
// make `push --update` a command that can add a label but never take one off.
|
|
//
|
|
// Only labels this run resolved to an id count as wanted: an unknown name was
|
|
// already left off deliberately, and one the repository holds but the taxonomy
|
|
// does not know is not this command's to remove either — which is why the
|
|
// comparison is against the resolved set and not against `labels:` as written.
|
|
func pushLabelDrift(got []*sdk.Label, want []string, ids map[string]int64) []string {
|
|
have := map[string]bool{}
|
|
for _, l := range got {
|
|
if l != nil {
|
|
have[l.Name] = true
|
|
}
|
|
}
|
|
wanted := map[string]bool{}
|
|
var drift []string
|
|
for _, name := range want {
|
|
if _, resolved := ids[name]; !resolved {
|
|
continue
|
|
}
|
|
wanted[name] = true
|
|
if !have[name] {
|
|
drift = append(drift, name)
|
|
}
|
|
}
|
|
for _, l := range got {
|
|
if l != nil && !wanted[l.Name] {
|
|
drift = append(drift, "-"+l.Name)
|
|
}
|
|
}
|
|
return drift
|
|
}
|
|
|
|
// 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 *sdk.Issue, sent int) (int, bool) {
|
|
if got == nil || got.Index <= 0 {
|
|
return 0, false
|
|
}
|
|
number := int(got.Index)
|
|
if sent != 0 && number != sent {
|
|
return 0, false
|
|
}
|
|
return 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
|
|
}
|