package wire import ( "fmt" "regexp" "strconv" "strings" ) // Repo is one repository, spelled the way a tracker spells it. type Repo struct { Owner string Name string } func (r Repo) String() string { if r.Zero() { return "" } return r.Owner + "/" + r.Name } // Zero reports whether this names no repository. Both halves are required: // half a name addresses nothing. func (r Repo) Zero() bool { return r.Owner == "" || r.Name == "" } // ParseRepo reads owner/name. func ParseRepo(s string) (Repo, error) { owner, name, ok := strings.Cut(strings.TrimSpace(s), "/") if !ok || owner == "" || name == "" { return Repo{}, fmt.Errorf("repo %q is not owner/name", s) } return Repo{Owner: owner, Name: name}, nil } // Key is a stable cross-repo handle for one issue: owner/repo#42. // // It is what the ledger is keyed by and what the `gitea:` metadata field holds, // so it has to survive being written to a file and read back — which is why it // is a repository and a number and not a bare number. A number is ambiguous the // moment a dependency lives in another repository, and dependencies are allowed // to. type Key struct { // Repo is zero when the caller named a number and nothing else, which is // the common case on a command line: "42" means "42 in this project's // repository", and which repository that is, is the client's business. Repo Repo Number int } func (k Key) String() string { if k.Repo.Zero() { return "#" + strconv.Itoa(k.Number) } return fmt.Sprintf("%s#%d", k.Repo, k.Number) } // In returns this key with r filled in when it names no repository of its own. func (k Key) In(r Repo) Key { if k.Repo.Zero() { k.Repo = r } return k } // The four spellings, as patterns. // // Digits and only digits after the `#`, which is the test strconv.Atoi is too // generous to make on its own: it accepts a sign, and `owner/repo#-3` is not a // handle anybody ever wrote. Anything that is not a key has to be recognizable // as not a key — a hand-edited metadata line and a number are told apart here // and nowhere else. var ( keyURL = regexp.MustCompile(`^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$`) keyQualified = regexp.MustCompile(`^([\w.-]+/[\w.-]+)#(\d+)$`) keyNumber = regexp.MustCompile(`^#?(\d+)$`) ) // ParseKey reads an issue key: 42, #42, owner/repo#42, or the issue's URL. // // All four spellings because all four are what somebody has in hand — a number // from a receipt, a `#42` copied out of a body, a qualified key out of the // ledger, a URL pasted from a browser. Refusing three of them buys nothing. func ParseKey(s string) (Key, error) { s = strings.TrimSpace(s) if m := keyURL.FindStringSubmatch(s); m != nil { n, _ := strconv.Atoi(m[3]) return Key{Repo: Repo{Owner: m[1], Name: m[2]}, Number: n}, nil } if m := keyQualified.FindStringSubmatch(s); m != nil { repo, err := ParseRepo(m[1]) if err != nil { return Key{}, err } n, _ := strconv.Atoi(m[2]) return Key{Repo: repo, Number: n}, nil } if m := keyNumber.FindStringSubmatch(s); m != nil { n, _ := strconv.Atoi(m[1]) return Key{Number: n}, nil } return Key{}, fmt.Errorf("cannot parse issue key %q — want 42, #42, owner/repo#42, or an issue URL", s) }