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

+95 -1
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
@@ -17,7 +18,7 @@ import (
type milestoneFormFunc func(context.Context) (terminal.MilestoneFormValues, error)
func (f milestoneFormFunc) CreateMilestone(ctx context.Context) (terminal.MilestoneFormValues, error) {
func (f milestoneFormFunc) EditMilestone(ctx context.Context, _ string, _ terminal.MilestoneFormValues) (terminal.MilestoneFormValues, error) {
return f(ctx)
}
@@ -67,3 +68,96 @@ func TestMilestoneCreateState(t *testing.T) {
require.Equal(t, "closed", payload["state"])
require.JSONEq(t, `{"id":9,"state":"closed"}`, out.String())
}
func TestMilestoneTablesViewsAndDoneLines(t *testing.T) {
var changed []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/milestones":
writeMilestones(w, r, `[{"id":5,"title":"later","state":"open"},{"id":4,"title":"v0.9","state":"closed","closed_issues":3},`+
`{"id":3,"title":"v1.0","state":"open","description":"First release","due_on":"2026-12-31T00:00:00Z","open_issues":3,"closed_issues":1}]`)
case "GET /api/v1/repos/alice/project/issues/1", "GET /api/v1/repos/alice/project/issues/2":
_, _ = w.Write([]byte(`{"number":1,"milestone":{"id":4,"title":"v0.9"}}`))
case "PATCH /api/v1/repos/alice/project/issues/1", "PATCH /api/v1/repos/alice/project/issues/2":
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = fmt.Fprintf(w, `{"number":%s}`, r.URL.Path[len(r.URL.Path)-1:])
default:
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0","state":"closed"}`))
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"})
run := func(tty bool, args ...string) {
t.Helper()
out.Reset()
diagnostics.Reset()
deps.Streams.OutTTY = tty
require.NoError(t, Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")))
}
run(true, "milestone", "list", "--state", "all")
require.Equal(t, "Showing 3 of 3 milestones in alice/project\n\n"+
"ID TITLE STATE DUE PROGRESS\n"+
"3 v1.0 open in about 2 months 1/4 (25%)\n"+
"5 later open 0/0\n"+
"4 v0.9 closed 3/3 (100%)\n", out.String())
run(false, "milestone", "list", "--state", "all")
require.Equal(t, "3\tv1.0\topen\t2026-12-31T00:00:00Z\t1/4 (25%)\n5\tlater\topen\t\t0/0\n4\tv0.9\tclosed\t\t3/3 (100%)\n", out.String())
run(true, "milestone", "view", "v1.0")
require.Equal(t, "v1.0 #3\nOpen • due Dec 31, 2026 (in about 2 months) • 1 of 4 closed (25%)\n\nFirst release\n\n"+
"View this milestone on Gitea: "+server.URL+"/alice/project/milestone/3\n", out.String())
run(true, "milestone", "close", "v1.0", "v0.9")
require.Equal(t, "✓ Closed milestone \"v1.0\" in alice/project\n! Milestone \"v0.9\" in alice/project is already closed\n", diagnostics.String())
run(true, "milestone", "issue", "add", "v1.0", "1", "#2")
require.Equal(t, "✓ Added #1, #2 to milestone \"v1.0\" in alice/project\n", diagnostics.String())
run(true, "milestone", "issue", "remove", "v0.9", "1")
require.Equal(t, "✓ Removed #1 from milestone \"v0.9\" in alice/project\n", diagnostics.String())
run(false, "milestone", "create", "v2.0")
require.Equal(t, server.URL+"/alice/project/milestone/3\n", out.String())
require.Equal(t, []string{
"GET /api/v1/repos/alice/project/milestones/3",
"PATCH /api/v1/repos/alice/project/milestones/3",
"PATCH /api/v1/repos/alice/project/issues/1",
"PATCH /api/v1/repos/alice/project/issues/2",
"PATCH /api/v1/repos/alice/project/issues/1",
"POST /api/v1/repos/alice/project/milestones",
}, changed)
}
func TestMilestoneEditFormStartsFromTheMilestone(t *testing.T) {
var edited map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/milestones":
writeMilestones(w, r, `[{"id":3,"title":"v1.0","description":"First","due_on":"2026-12-31T00:00:00Z"}]`)
case "PATCH /api/v1/repos/alice/project/milestones/3":
require.NoError(t, json.NewDecoder(r.Body).Decode(&edited))
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0.0"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
var heading string
var current terminal.MilestoneFormValues
deps.MilestoneForm = milestoneFormEditor(func(h string, c terminal.MilestoneFormValues) terminal.MilestoneFormValues {
heading, current = h, c
return terminal.MilestoneFormValues{Title: "v1.0.0", Description: c.Description}
})
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "edit", "v1.0", "-R", "alice/project"}))
require.Equal(t, "Editing milestone \"v1.0\" in alice/project", heading)
require.Equal(t, "First", current.Description)
require.Equal(t, "2026-12-31", current.Deadline.Format(time.DateOnly))
require.Equal(t, "v1.0.0", edited["title"])
require.Equal(t, "First", edited["description"])
}
type milestoneFormEditor func(string, terminal.MilestoneFormValues) terminal.MilestoneFormValues
func (f milestoneFormEditor) EditMilestone(_ context.Context, heading string, current terminal.MilestoneFormValues) (terminal.MilestoneFormValues, error) {
return f(heading, current), nil
}