feat(milestone): milestone tables, views, forms and one selection model

milestone view printed the list row, and milestone list hid the due date
and progress. milestone list now shows ID TITLE [STATE] DUE PROGRESS, open
milestones first and by due date; milestone view shows the state, due
date, progress, description and the milestone's web page (also --web).

Every milestone command addresses milestones by title or ID. Mutations
report ✓ lines: milestone create also prints the new milestone's URL and
takes the title as an argument; close and reopen skip milestones already
in that state; delete names the milestone in its confirmation; milestone
issue add and remove report one line such as "✓ Added #1, #2 to
milestone "v1.0" in alice/x". milestone issue list uses the issue table.
milestone edit without flags on a terminal opens the milestone form with
the current values, and every milestone command completes milestone
titles and has help text with examples.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:17:02 +02:00
1 parent 2ba02cf915
commit c161a63eb6
14 files changed
+606 -257

No files matched your search

+2 -2
View File
@@ -57,7 +57,7 @@ func TestRemoveMilestoneRejectsDifferentMembership(t *testing.T) {
_, _ = w.Write([]byte(`{"milestone":{"id":9}}`))
}))
service := Issues{Client: client, Repository: "alice/project"}
_, err := service.RemoveMilestone(context.Background(), 7, 8)
require.ErrorContains(t, err, "not assigned to milestone 8")
_, err := service.RemoveMilestone(context.Background(), 7, Milestone{ID: 8, Title: "v1.0"})
require.EqualError(t, err, `#7 is not in milestone "v1.0"`)
require.Zero(t, mutations)
}
+18 -3
View File
@@ -206,6 +206,21 @@ func (s Milestones) List(ctx context.Context, state string, limit, page int) ([]
return convertAll(milestones, milestoneFrom), nil
}
// ListPage is List with the number of milestones in state, as the server counts them.
func (s Milestones) ListPage(ctx context.Context, state string, limit, page int) (Page[Milestone], error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Page[Milestone]{}, err
}
milestones, err := listPage(limit, page, func(limit, page int) ([]*gitea.Milestone, http.Header, error) {
milestones, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{ListOptions: listOptions(limit, page), State: gitea.StateType(state)})
})
return milestones, headerOf(resp), err
})
return Page[Milestone]{Items: convertAll(milestones.Items, milestoneFrom), More: milestones.More, Total: milestones.Total, Counted: milestones.Counted}, err
}
// Create creates one milestone.
func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) {
owner, repo, err := splitRepository(s.Repository)
@@ -388,13 +403,13 @@ func mergeUnique[T any](current, add, remove []T, equal func(T, T) bool) []T {
}
// RemoveMilestone refuses to clear an issue belonging to another milestone.
func (s Issues) RemoveMilestone(ctx context.Context, n, id int64) (Issue, error) {
func (s Issues) RemoveMilestone(ctx context.Context, n int64, milestone Milestone) (Issue, error) {
current, err := s.View(ctx, n)
if err != nil {
return Issue{}, err
}
if current.Milestone == nil || current.Milestone.ID != id {
return Issue{}, fmt.Errorf("issue is not assigned to milestone %d", id)
if current.Milestone == nil || current.Milestone.ID != milestone.ID {
return Issue{}, fmt.Errorf("#%d is not in milestone %q", n, milestone.Title)
}
cleared := int64(0)
return s.Edit(ctx, n, IssueEdit{Milestone: &cleared})