package gitea import ( "errors" "fmt" "net/url" "strings" sdk "code.gitea.io/sdk/gitea" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // GetIssue fetches one issue by number. // // A number is an address, not a query: this answers for a closed issue exactly // as it does for an open one. func (c *Client) GetIssue(number int) (*sdk.Issue, error) { owner, repo := c.owned() got, resp, err := c.api.GetIssue(owner, repo, int64(number)) if err := fail(resp, err); err != nil { return nil, err } // A 200 that carries no number is not this issue. Gitea has answered that // way for a repository whose issue tracker is disabled. if got == nil || got.Index == 0 { return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo) } return got, nil } // CreateIssue files a new issue. name labels the request body in the // scratchpad; the issue's slug is what makes that dump worth keeping. func (c *Client) CreateIssue(opt sdk.CreateIssueOption, name string) (*sdk.Issue, error) { owner, repo := c.owned() c.dump.label(name) got, resp, err := c.api.CreateIssue(owner, repo, opt) if err := fail(resp, err); err != nil { return nil, err } return got, nil } // EditIssue patches an existing issue. Only the fields set on opt are sent. // // LABELS DO NOT GO THROUGH HERE. Gitea's edit endpoint takes no label list and // neither does the SDK's EditIssueOption, so an issue whose labels changed // needs SetLabels after this — push does exactly that, and says so. func (c *Client) EditIssue(number int, opt sdk.EditIssueOption, name string) (*sdk.Issue, error) { owner, repo := c.owned() c.dump.label(name) got, resp, err := c.api.EditIssue(owner, repo, int64(number), opt) if err := fail(resp, err); err != nil { return nil, err } return got, nil } // SetLabels replaces an issue's labels with exactly these ids. // // It exists because Gitea occasionally drops labels handed to it on create, and // the answer to that is to re-apply them rather than to trust the echo. It is // also the only way to change the labels of an issue that already exists — see // EditIssue. func (c *Client) SetLabels(number int, ids []int64, name string) ([]*sdk.Label, error) { if ids == nil { ids = []int64{} } owner, repo := c.owned() c.dump.label(name) got, resp, err := c.api.ReplaceIssueLabels(owner, repo, int64(number), sdk.IssueLabelsOption{Labels: ids}) if err := fail(resp, err); err != nil { return nil, err } return got, nil } // ListComments is an issue's whole thread, every page of it. func (c *Client) ListComments(number int) ([]*sdk.Comment, error) { owner, repo := c.owned() return paginate(func(page, limit int) ([]*sdk.Comment, error) { got, resp, err := c.api.ListIssueComments(owner, repo, int64(number), sdk.ListIssueCommentOptions{ListOptions: listOptions(page, limit)}) return got, fail(resp, err) }, pageLimit) } // CreateComment posts a comment on an issue. func (c *Client) CreateComment(number int, text, name string) (*sdk.Comment, error) { owner, repo := c.owned() c.dump.label(name) got, resp, err := c.api.CreateIssueComment(owner, repo, int64(number), sdk.CreateIssueCommentOption{Body: text}) if err := fail(resp, err); err != nil { return nil, err } return got, nil } // EditComment rewrites one comment, addressed by its own id and not by the // issue it is on — which is how Gitea addresses it. func (c *Client) EditComment(id int64, text, name string) (*sdk.Comment, error) { owner, repo := c.owned() c.dump.label(name) got, resp, err := c.api.EditIssueComment(owner, repo, id, sdk.EditIssueCommentOption{Body: text}) if err := fail(resp, err); err != nil { return nil, err } return got, nil } // -------------------------------------------------------------------------- // listing, and the filter the server does not honour // -------------------------------------------------------------------------- // IssueFilter is what a listing asks for. type IssueFilter struct { // State is open (the default), closed, or all. State string // Labels are label names; an issue must carry all of them. Labels []string // Query is Gitea's keyword search over title and body. Query string // Milestone is an id or a title. It is resolved against the repository // before it is trusted — see ResolveMilestone. Milestone string // Limit counts the payloads the CALLER cares about, not the ones the server // returned. Must be 1 or more. Limit int // Keep says whether a payload counts against Limit. Without it every // payload counts and a listing behaves as any other. With it, pages keep // coming until Limit have counted, and the returned list carries the ones // that did not count too — they were enumerated, and a caller with // something to say about them ("11 closed, not stored") still can. // // What Keep means is the caller's business; this package only counts. Keep func(*sdk.Issue) bool } // IssueListing is what a filtered read found. type IssueListing struct { // Issues are every payload that passed the filter, kept or not. Issues []*sdk.Issue // Milestone is the resolved milestone title, for a receipt. Milestone string // Warning is set when a Keep-bounded read ran out of page budget with the // budget unfilled. Returned rather than printed: the transport does not own // the operator's terminal, and a caller that is rendering JSON needs it as // data. Warning string } // ListIssues reads filtered issue payloads. // // One request per page, and a payload already carries the issue body — a whole // milestone costs one call per page, not one per issue. // // Two boundaries hold whatever Keep decides: // // - Stop at the limit. The page after the one that completed the budget is // never requested. // - Stop at the page budget. A predicate that rejects everything must not turn // a bounded read into a walk of the whole tracker, so a filtered read scans // at most PageSlack times the pages Limit would need if every payload // counted. Hitting that with the budget unfilled sets Warning rather than // answering short in silence: the caller asked for N and is told it got // fewer. func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) { if f.Limit < 1 { return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit) } out := &IssueListing{} var milestoneID int64 if f.Milestone != "" { ms, err := c.ResolveMilestone(f.Milestone) if err != nil { return nil, err } milestoneID, out.Milestone = ms.ID, ms.Title } state := f.State if state == "" { state = "open" } owner, repo := c.owned() fetch := func(page, limit int) ([]*sdk.Issue, error) { opt := sdk.ListIssueOption{ ListOptions: listOptions(page, limit), State: sdk.StateType(state), // Issues and not pull requests. The server has been known to // ignore this, which is why matches re-checks it. Type: sdk.IssueTypeIssue, Labels: f.Labels, KeyWord: f.Query, } if out.Milestone != "" { opt.Milestones = []string{out.Milestone} } got, resp, err := c.api.ListRepoIssues(owner, repo, opt) return got, fail(resp, err) } perPage := min(f.Limit, pageLimit) ideal := max(1, (f.Limit+perPage-1)/perPage) budget := ideal if f.Keep != nil { budget = ideal * PageSlack } kept, seen, lastFull := 0, 0, false err := pages(fetch, perPage, budget, func(batch []*sdk.Issue) (bool, error) { seen++ lastFull = len(batch) == perPage for _, p := range batch { if !matches(p, milestoneID, f.Labels) { continue } out.Issues = append(out.Issues, p) if f.Keep == nil || f.Keep(p) { kept++ if kept >= f.Limit { return false, nil } } } return true, nil }) if err != nil { return nil, err } if f.Keep != nil && seen >= budget && lastFull { out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+ " — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit) } return out, nil } // matches re-checks on the client what the server was already asked for. // // Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve // and answers with the whole backlog, which is why the milestone is resolved to // an id first and every payload is checked against that id here. The same // re-check on labels costs nothing, and `pull_request` is the one filter that // matters most — a pull request rendered as a unit of work is not a bug the // operator can see until it is in the store. // // A function and not a method: the payload is the SDK's, and re-checking a // filter the server ignored is this package's business, not the payload's. func matches(i *sdk.Issue, milestoneID int64, labels []string) bool { if i.PullRequest != nil { return false } if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) { return false } have := make(map[string]bool, len(i.Labels)) for _, l := range i.Labels { have[l.Name] = true } for _, want := range labels { if !have[want] { return false } } return true } // -------------------------------------------------------------------------- // dependencies // -------------------------------------------------------------------------- // dependenciesSince is the first Gitea release that answers at // /issues/{index}/dependencies at all. // // Checked against the release tags themselves and not guessed: the routes are // absent from routers/api/v1/api.go through 1.19 and present in 1.20. Asking // the version rather than the endpoint is what turns "some error came back" // into an answer — and it costs no request, because the SDK already negotiated // the version when the client was built. const dependenciesSince = ">= 1.20.0" // hasDependencies reports whether this instance is new enough to have the // dependency endpoints. func (c *Client) hasDependencies() bool { return c.api.CheckServerVersionConstraint(dependenciesSince) == nil } // Dependencies are the issues that block this one — Gitea's own dependency // links, read in the direction AddDependency writes them. // // TWO WAYS FOR THERE TO BE NO ANSWER, and both are reported as "no // dependencies" rather than as a failure, because a pull must still bring the // issue itself back: // // - the instance predates the endpoint, which the version says before a // request is made; // - the instance has it but this repository does not — dependencies turned // off, a tracker disabled — which only the tracker's own answer can say. // // Deliberately narrower than the Python it replaces, which swallowed every // failure here including a dead connection. "The server said no" and "there was // no server" are different answers, and only the first one means the feature is // missing. func (c *Client) Dependencies(number int) ([]*sdk.Issue, error) { if !c.hasDependencies() { return nil, nil } owner, repo := c.owned() got, resp, err := c.api.ListIssueDependencies(owner, repo, int64(number), sdk.ListIssueDependenciesOptions{ListOptions: listOptions(1, pageLimit)}) err = fail(resp, err) var apiErr *APIError if errors.As(err, &apiErr) { return nil, nil } if err != nil { return nil, err } return got, nil } // DependencyKeys is the same links as cross-repo handles — what a repeat push // compares against so it does not POST a link the tracker already has. // // A bare number is ambiguous the moment a dependency lives in another // repository, and Gitea lets it, so the repository travels with it. func (c *Client) DependencyKeys(number int) ([]wire.Key, error) { deps, err := c.Dependencies(number) if err != nil { return nil, err } out := make([]wire.Key, 0, len(deps)) for _, d := range deps { out = append(out, keyIn(d, c.repo)) } return out, nil } // keyIn is a payload's cross-repo handle. The payload's own repository wins // when it carries one — a dependency listing answers with issues from other // repositories — and fallback is the repository that was asked. func keyIn(p *sdk.Issue, fallback wire.Repo) wire.Key { repo := fallback if p.Repository != nil { if r, err := wire.ParseRepo(p.Repository.FullName); err == nil { repo = r } } return wire.Key{Repo: repo, Number: int(p.Index)} } // issueMeta is Gitea's own IssueMeta: how a dependency names another issue. // // The SDK has a type of this name too and it carries only `index`, so it can // only ever link inside one repository. Gitea's has taken an owner and a repo // since the endpoint existed, and a `depends:` entry is allowed to live // somewhere else — so this one struct and the raw POST that sends it are all // that is left of the hand-rolled client. type issueMeta struct { Index int `json:"index"` Owner string `json:"owner"` Repo string `json:"repo"` } // AddDependency makes issue number depend on dep. // // Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1): // // POST /repos/{owner}/{repo}/issues/{index}/dependencies // body: IssueMeta — {"index": , "owner": "", "repo": ""} // "Make the issue in the url depend on the issue in the form." // // So the URL names the blocked issue and the body the blocker, which is the // direction Dependencies reads back. A link that already exists answers 409, so // callers pre-filter with DependencyKeys and treat a failure here as a note // rather than an abort: one missing cross-link must not undo a push that has // already created issues. func (c *Client) AddDependency(number int, dep wire.Key) error { if dep.Repo.Zero() { return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep) } if dep.Number < 1 { return fmt.Errorf("dependency %s names no issue number", dep) } if !c.hasDependencies() { return fmt.Errorf("this Gitea has no issue-dependency API (it is not %s) — link #%d -> %s by hand", strings.TrimPrefix(dependenciesSince, ">= "), number, dep) } // Owner and name are escaped, the way the SDK escapes them for every other // call: they arrive from a config file, and a file is a thing people type // into. return c.post( fmt.Sprintf("repos/%s/%s/issues/%d/dependencies", url.PathEscape(c.repo.Owner), url.PathEscape(c.repo.Name), number), issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name}, fmt.Sprintf("dep-%d-%d", number, dep.Number)) }