package gitea import ( "errors" "fmt" "net/http" "net/url" "strings" "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) (*wire.Issue, error) { var got wire.Issue if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); 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.Number == 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(req wire.IssueRequest, name string) (*wire.Issue, error) { var got wire.Issue body := &Body{Name: name, Data: req} if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil { return nil, err } return &got, nil } // EditIssue patches an existing issue. Only the fields set on req are sent. func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) { var got wire.Issue body := &Body{Name: name, Data: req} if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); 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. func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) { if ids == nil { ids = []int64{} } var got []wire.Label body := &Body{Name: name, Data: struct { Labels []int64 `json:"labels"` }{ids}} if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); 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) ([]wire.Comment, error) { return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit) } // CreateComment posts a comment on an issue. func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) { var got wire.Comment body := &Body{Name: name, Data: commentBody{Body: text}} if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); 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) (*wire.Comment, error) { var got wire.Comment body := &Body{Name: name, Data: commentBody{Body: text}} if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil { return nil, err } return &got, nil } type commentBody struct { Body string `json:"body"` } // -------------------------------------------------------------------------- // 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(*wire.Issue) bool } // IssueListing is what a filtered read found. type IssueListing struct { // Issues are every payload that passed the filter, kept or not. Issues []wire.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 } params := url.Values{} state := f.State if state == "" { state = "open" } params.Set("state", state) params.Set("type", "issues") if len(f.Labels) > 0 { params.Set("labels", strings.Join(f.Labels, ",")) } if f.Query != "" { params.Set("q", f.Query) } if out.Milestone != "" { params.Set("milestones", out.Milestone) } path := c.repoPath("issues?" + params.Encode()) 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(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) { seen++ lastFull = len(batch) == perPage for i := range batch { p := &batch[i] 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 protocol's, and re-checking a // filter the server ignored is this package's business, not the protocol's. func matches(i *wire.Issue, milestoneID int64, labels []string) bool { if i.IsPullRequest() { 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 // -------------------------------------------------------------------------- // issueMeta is Gitea's IssueMeta: how a dependency names another issue. type issueMeta struct { Index int `json:"index"` Owner string `json:"owner"` Repo string `json:"repo"` } // Dependencies are the issues that block this one — Gitea's own dependency // links, read in the direction AddDependency writes them. // // An instance that does not have the endpoint, or has dependencies turned off // for this repository, answers with a status rather than a list. That is // reported as "no dependencies" and not as a failure: a pull must still bring // the issue itself back from a tracker whose dependency support is off. // // 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) ([]wire.Issue, error) { var got []wire.Issue err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got) 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 i := range deps { out = append(out, deps[i].KeyIn(c.repo)) } return out, nil } // 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) } body := &Body{ Name: fmt.Sprintf("dep-%d-%d", number, dep.Number), Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name}, } return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil) }