Files
gitea-tea/internal/services/issues.go
T
bircni 0b6232087e feat(issue): gh-style issue list, view, create, edit, close and reopen
issue list renders through the shared table: a context line, ID TITLE
LABELS [STATE] [KIND] AUTHOR UPDATED with relative times on a terminal,
the ID colored by state and linked, and a stable tab-separated column
set when piped; empty results say so on stderr. It gains -s -L -l -a -A
-m -S -w, @me, comma-separated label and milestone filters, and --web.

issue view, edit, close and reopen accept #N, OWNER/REPO#N and issue
URLs. Mutations report "✓ Created/Edited/Closed issue alice/x#5 (title)"
on a terminal; issue create prints the new issue's URL on stdout (also
as issue new), and close and reopen skip issues already in that state
with a ! line and take -c/--comment. issue create and edit gain the gh
short flags and singular --add-label, --remove-label, --add-assignee and
--remove-assignee (the plural names still work), and create gains --web.

The interactive create form asks for the title and body, then "What's
next?" (submit, add metadata or cancel), without a confirmation after
it; the edit form names the issue it edits. Every issue command has help
text with examples and completes its flags and issue numbers.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 00:15:53 +02:00

173 lines
5.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"net/http"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// Issue is the stable command representation of an issue.
type Issue struct {
Author string `json:"author"`
Labels []Label `json:"labels"`
Ref string `json:"ref"`
ID int64 `json:"id"`
Number int64 `json:"number"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Deadline *string `json:"due_date"`
Assignees []string `json:"assignees"`
Milestone *IssueMilestone `json:"milestone"`
Comments int64 `json:"comments"`
}
// Kinds of Issue: Gitea lists pull requests among issues.
const (
KindIssue = "issue"
KindPull = "pr"
)
// discussionFrom converts an issue or a pull request listed among issues; the state of a merged
// pull request is "merged" rather than "closed".
func discussionFrom(issue *gitea.Issue) Issue {
item := issueFrom(issue)
item.Kind, item.State = KindIssue, discussionState(issue)
if issue.PullRequest != nil {
item.Kind = KindPull
}
return item
}
// discussionState is the state of an issue, or "merged" for a merged pull request.
func discussionState(issue *gitea.Issue) string {
if issue.PullRequest != nil && issue.PullRequest.HasMerged {
return "merged"
}
return string(issue.State)
}
// IssueMilestone identifies the milestone associated with an issue or pull request.
type IssueMilestone struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// IssueOptions supplies issue creation input.
type IssueOptions struct {
Ref string `json:"ref,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
Assignees []string `json:"assignees,omitempty"`
Labels []int64 `json:"labels,omitempty"`
Milestone int64 `json:"milestone,omitempty"`
Deadline *time.Time `json:"due_date,omitempty"`
}
// ListOptions supplies explicit issue pagination and filters.
type ListOptions struct {
State string
Limit, Page int
Search string
Assignee, Labels, Milestones, Kind string
Author, Mentions string
Since, Before *time.Time
}
// Issues implements repository issue operations.
type Issues struct {
Client *transport.Client
Repository string
}
func (s Issues) endpoint() string { return "repos/" + s.Repository + "/issues" }
// List returns a page of issues, even when the server caps its page size below the limit,
// and reports whether more issues follow.
func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, error) {
page, err := s.ListPage(ctx, opts)
return page.Items, page.More, err
}
// ListPage is List with the number of issues that match, as the server counts them.
func (s Issues) ListPage(ctx context.Context, opts ListOptions) (Page[Issue], error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Page[Issue]{}, err
}
kind := "issues"
if opts.Kind != "" {
kind = opts.Kind
}
query := gitea.ListIssueOption{
State: gitea.StateType(opts.State), Type: gitea.IssueType(kind), KeyWord: opts.Search,
Labels: filterValues(opts.Labels), Milestones: filterValues(opts.Milestones),
CreatedBy: opts.Author, AssignedBy: opts.Assignee, MentionedBy: opts.Mentions,
}
if opts.Since != nil {
query.Since = *opts.Since
}
if opts.Before != nil {
query.Before = *opts.Before
}
issues, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Issue, http.Header, error) {
query.ListOptions = listOptions(limit, page)
issues, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) {
return c.Issues.ListRepoIssues(ctx, owner, repo, query)
})
return issues, headerOf(resp), err
})
if err != nil {
return Page[Issue]{}, err
}
return Page[Issue]{Items: convertAll(issues.Items, discussionFrom), More: issues.More, Total: issues.Total, Counted: issues.Counted}, nil
}
// filterValues splits a comma-separated filter, which the SDK joins again.
func filterValues(value string) []string {
if value == "" {
return nil
}
return strings.Split(value, ",")
}
// View retrieves one issue.
func (s Issues) View(ctx context.Context, number int64) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.GetIssue(ctx, owner, repo, number)
})
}
// Create opens an issue.
func (s Issues) Create(ctx context.Context, options IssueOptions) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.CreateIssue(ctx, owner, repo, gitea.CreateIssueOption{
Title: options.Title, Body: options.Body, Ref: options.Ref, Assignees: options.Assignees,
Deadline: options.Deadline, Milestone: options.Milestone, Labels: options.Labels,
})
})
}
// SetState updates only the selected issue state.
func (s Issues) SetState(ctx context.Context, number int64, state string) (Issue, error) {
value := gitea.StateType(state)
return s.edit(ctx, number, gitea.EditIssueOption{State: &value})
}
func (s Issues) edit(ctx context.Context, number int64, opts gitea.EditIssueOption) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.EditIssue(ctx, owner, repo, number, opts)
})
}