feat(gitlab): filter issues by milestone (#3278)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-02 16:55:31 +00:00
committed by GitHub
parent c1161e0fbe
commit ffec4aade9
47 changed files with 3945 additions and 272 deletions
+5 -3
View File
@@ -106,8 +106,10 @@ type Client interface {
// ListIssues searches for open issues. filter is an optional
// additional API filter; customQuery, when non-empty, replaces the
// entire generated query.
ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error)
// entire generated query. milestone, when non-empty, restricts results
// to that exact milestone title (folded into customQuery when both are
// set and customQuery doesn't already name a milestone).
ListIssues(ctx context.Context, filter, customQuery, milestone string) ([]*Issue, error)
// SearchMRs searches for MRs matching the given query.
SearchMRs(ctx context.Context, filter, customQuery string) ([]*MR, error)
@@ -117,7 +119,7 @@ type Client interface {
SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error)
// ListIssuesPaged is the paginated variant of ListIssues.
ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error)
ListIssuesPaged(ctx context.Context, filter, customQuery, milestone string, page, perPage int) (*IssueSearchPage, error)
// GetIssueState returns the state of a single issue ("opened" or "closed").
GetIssueState(ctx context.Context, projectPath string, iid int) (string, error)
+61 -12
View File
@@ -70,16 +70,17 @@ type rawProject struct {
}
type rawIssue struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
WebURL string `json:"web_url"`
Author rawUser `json:"author"`
Labels []string `json:"labels"`
Assignees []rawUser `json:"assignees"`
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
WebURL string `json:"web_url"`
Author rawUser `json:"author"`
Labels []string `json:"labels"`
Assignees []rawUser `json:"assignees"`
Milestone *rawMilestone `json:"milestone"`
References struct {
Full string `json:"full"`
} `json:"references"`
@@ -88,6 +89,12 @@ type rawIssue struct {
ClosedAt *time.Time `json:"closed_at"`
}
// rawMilestone is GitLab's milestone sub-object on an issue. GitLab returns
// milestone: null for an issue with no milestone.
type rawMilestone struct {
Title string `json:"title"`
}
type rawDiscussion struct {
ID string `json:"id"`
IndividualNote bool `json:"individual_note"`
@@ -226,6 +233,10 @@ func convertRawIssue(raw *rawIssue) *Issue {
assignees = append(assignees, a.Username)
}
}
milestone := ""
if raw.Milestone != nil {
milestone = raw.Milestone.Title
}
return &Issue{
ID: raw.ID,
IID: raw.IID,
@@ -240,6 +251,7 @@ func convertRawIssue(raw *rawIssue) *Issue {
ProjectPath: projectPath,
Labels: append([]string(nil), raw.Labels...),
Assignees: assignees,
Milestone: milestone,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
ClosedAt: raw.ClosedAt,
@@ -485,9 +497,12 @@ func buildMRSearchQuery(filter, customQuery string) string {
return values.Encode()
}
func buildIssueSearchQuery(filter, customQuery string) string {
func buildIssueSearchQuery(filter, customQuery, milestone string) string {
if customQuery != "" {
return customQuery
if milestone == "" {
return customQuery
}
return foldMilestoneIntoQuery(customQuery, milestone)
}
values := url.Values{}
values.Set("state", gitlabStateOpened)
@@ -495,9 +510,43 @@ func buildIssueSearchQuery(filter, customQuery string) string {
if filter != "" {
appendFilter(values, filter)
}
if milestone != "" {
values.Set("milestone", milestone)
}
return values.Encode()
}
// appendQueryParam adds an escaped key/value pair to a raw query unless that
// key already exists. URL fragments are kept at the end, outside the query.
// This also preserves malformed-query behavior for callers that do not validate
// their input at the HTTP boundary.
func appendQueryParam(customQuery, key, value string) string {
if parsed, err := url.ParseQuery(customQuery); err == nil && parsed.Has(key) {
return customQuery
}
fragment := ""
if index := strings.IndexByte(customQuery, '#'); index >= 0 {
fragment = customQuery[index:]
customQuery = customQuery[:index]
}
encoded := url.QueryEscape(value)
if customQuery == "" {
return key + "=" + encoded + fragment
}
return customQuery + "&" + key + "=" + encoded + fragment
}
// foldMilestoneIntoQuery merges a milestone into an existing, non-empty
// customQuery string, mirroring appendLabelsToQuery's precedent: a custom
// query that already names the `milestone` key wins (even if the value is
// empty), and an unparseable custom query still gets the milestone appended
// rather than silently dropping the user's filter selection. The only caller,
// buildIssueSearchQuery, guards both arguments non-empty before calling this,
// so there is no empty-customQuery case to handle here.
func foldMilestoneIntoQuery(customQuery, milestone string) string {
return appendQueryParam(customQuery, "milestone", milestone)
}
// filterTokenReviewRequested is the /gitlab page tab value that maps to
// GitLab's reviewer_username param. Defined as a constant because it appears
// in multiple spots across the package (translator, MR controller, issues
@@ -0,0 +1,228 @@
package gitlab
import (
"net/url"
"testing"
)
func TestBuildIssueSearchQuery_PresetPathComposesMilestoneAfterFilter(t *testing.T) {
got := buildIssueSearchQuery("scope=assigned_to_me", "", "Next")
values, err := url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("scope"); got != "assigned_to_me" {
t.Errorf("scope = %q, want assigned_to_me (milestone must not clobber the preset)", got)
}
if got := values.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened", got)
}
if got := values.Get("milestone"); got != "Next" {
t.Errorf("milestone = %q, want Next", got)
}
}
func TestBuildIssueSearchQuery_EmptyMilestoneIsByteIdenticalToBeforeFeature(t *testing.T) {
got := buildIssueSearchQuery("scope=assigned_to_me", "", "")
want := buildIssueSearchQueryWithoutMilestoneForTest("scope=assigned_to_me", "")
if got != want {
t.Errorf("query = %q, want %q (empty milestone must add nothing)", got, want)
}
}
// buildIssueSearchQueryWithoutMilestoneForTest reproduces the pre-feature
// query builder so the byte-identical claim can be checked without a second
// production code path.
func buildIssueSearchQueryWithoutMilestoneForTest(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", gitlabStateOpened)
values.Set("scope", "all")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
func TestBuildIssueSearchQuery_CustomQueryWithMilestoneWins(t *testing.T) {
got := buildIssueSearchQuery("", "state=closed&milestone=Old", "Next")
if got != "state=closed&milestone=Old" {
t.Errorf("query = %q, want custom query unchanged (custom milestone wins)", got)
}
}
// Scenario 31, live-path clause: a custom query carrying a repeated
// milestone key is forwarded upstream unchanged, both pairs included — the
// live path never resolves which one wins, only the mock does (Get,
// first-value).
func TestBuildIssueSearchQuery_CustomQueryWithRepeatedMilestoneKeyForwardedUnchanged(t *testing.T) {
got := buildIssueSearchQuery("", "milestone=Old&milestone=Next", "Next")
want := "milestone=Old&milestone=Next"
if got != want {
t.Errorf("query = %q, want %q (both pairs forwarded verbatim)", got, want)
}
}
func TestBuildIssueSearchQuery_CustomQueryWithoutMilestoneIsExtended(t *testing.T) {
got := buildIssueSearchQuery("", "state=closed", "Next")
values, err := url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("state"); got != "closed" {
t.Errorf("state = %q, want closed", got)
}
if got := values.Get("milestone"); got != "Next" {
t.Errorf("milestone = %q, want Next", got)
}
if values.Has("scope") {
t.Errorf("scope present in %q, want absent (custom query defaults are not reintroduced)", got)
}
}
func TestBuildIssueSearchQuery_CustomQueryFragmentKeepsMilestoneInQuery(t *testing.T) {
got := buildIssueSearchQuery("", "state=closed#client-fragment", "Next")
want := "state=closed&milestone=Next#client-fragment"
if got != want {
t.Errorf("query = %q, want %q (milestone must precede the URL fragment)", got, want)
}
}
// The HTTP controller rejects malformed custom queries before this helper is
// called. Keep this builder-level case to pin its defensive append behavior
// for other callers that construct the query directly.
func TestBuildIssueSearchQuery_MalformedCustomQueryAppendsMilestone(t *testing.T) {
got := buildIssueSearchQuery("", "%zz", "Next")
want := "%zz&milestone=Next"
if got != want {
t.Errorf("query = %q, want %q", got, want)
}
}
func TestBuildIssueSearchQuery_EmptyMilestoneKeyInCustomQueryStillWins(t *testing.T) {
got := buildIssueSearchQuery("", "state=closed&milestone=", "Next")
if got != "state=closed&milestone=" {
t.Errorf("query = %q, want custom query unchanged (empty milestone key still wins)", got)
}
}
func TestBuildIssueSearchQuery_MalformedFilterCannotTakeMilestoneDownWithIt(t *testing.T) {
got := buildIssueSearchQuery("%zz", "", "Next")
values, err := url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("milestone"); got != "Next" {
t.Errorf("milestone = %q, want Next (unparseable filter must not drop it)", got)
}
if got := values.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened", got)
}
if got := values.Get("scope"); got != "all" {
t.Errorf("scope = %q, want all", got)
}
}
func TestBuildIssueSearchQuery_MilestoneEscapedExactlyOnce(t *testing.T) {
// Preset path: values.Encode() does the single escape.
got := buildIssueSearchQuery("", "", "Q3 & Q4=x")
values, err := url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("milestone"); got != "Q3 & Q4=x" {
t.Errorf("milestone = %q, want %q", got, "Q3 & Q4=x")
}
if len(values) != 3 {
t.Errorf("values = %v, want exactly state/scope/milestone (no injected key)", values)
}
// Escape-hatch path: url.QueryEscape does the single escape.
got = buildIssueSearchQuery("", "state=closed", "Q3 & Q4=x")
values, err = url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("milestone"); got != "Q3 & Q4=x" {
t.Errorf("milestone = %q, want %q", got, "Q3 & Q4=x")
}
if len(values) != 2 {
t.Errorf("values = %v, want exactly state/milestone (no injected key)", values)
}
}
// Scenario 22: GitLab's predefined milestone values (None, Any, Upcoming)
// are forwarded verbatim on "milestone" — kandev applies no special
// handling and never translates any of them onto "milestone_id".
func TestBuildIssueSearchQuery_PredefinedMilestoneValuesPassThroughUntranslated(t *testing.T) {
for _, value := range []string{"None", "Any", "Upcoming"} {
got := buildIssueSearchQuery("", "", value)
values, err := url.ParseQuery(got)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", got, err)
}
if got := values.Get("milestone"); got != value {
t.Errorf("milestone = %q, want %q", got, value)
}
if values.Has("milestone_id") {
t.Errorf("query %q carries milestone_id, want it absent for value %q", got, value)
}
}
}
func TestConvertRawIssue_MilestoneTitle(t *testing.T) {
raw := &rawIssue{Milestone: &rawMilestone{Title: "Next"}}
issue := convertRawIssue(raw)
if issue.Milestone != "Next" {
t.Errorf("Milestone = %q, want Next", issue.Milestone)
}
}
func TestConvertRawIssue_NullMilestoneDecodesToEmptyString(t *testing.T) {
raw := &rawIssue{Milestone: nil}
issue := convertRawIssue(raw)
if issue.Milestone != "" {
t.Errorf("Milestone = %q, want empty for a null milestone", issue.Milestone)
}
}
// trimGitLabWhitespace is the Go-side normative trim helper: Unicode
// White_Space together with U+FEFF. strings.TrimSpace already covers the
// Unicode White_Space set, including U+0085, but it does not cover U+FEFF.
// Scenario 27.
func TestTrimGitLabWhitespace_MatchesNormativeSet(t *testing.T) {
trimmed := []rune{
0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0,
0x1680, 0x2000, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF,
}
for _, r := range trimmed {
got := trimGitLabWhitespace(string(r))
if got != "" {
t.Errorf("trimGitLabWhitespace(%U) = %q, want empty", r, got)
}
}
preserved := []rune{0x200B, 0x180E, 0x2060, 0x00B7}
for _, r := range preserved {
got := trimGitLabWhitespace(string(r))
if got != string(r) {
t.Errorf("trimGitLabWhitespace(%U) = %q, want unchanged", r, got)
}
}
nelPrefixed := string(rune(0x0085)) + "Next" + string(rune(0xFEFF))
if got := trimGitLabWhitespace(nelPrefixed); got != "Next" {
t.Errorf("trimGitLabWhitespace(NEL+Next+BOM) = %q, want Next", got)
}
if got := trimGitLabWhitespace(" Q1 Planning "); got != "Q1 Planning" {
t.Errorf("trimGitLabWhitespace(padded) = %q, want interior space preserved", got)
}
// The helper adds the one normative rune that TrimSpace does not trim.
bomOnly := string(rune(0xFEFF))
if got := trimGitLabWhitespace(bomOnly); got != "" {
t.Errorf("trimGitLabWhitespace(BOM alone) = %q, want empty (TrimSpace keeps U+FEFF)", got)
}
}
+21 -1
View File
@@ -3,7 +3,10 @@ package gitlab
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/gin-gonic/gin"
@@ -284,6 +287,12 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
page, perPage := paginationFromQuery(ctx)
filter := ctx.Query("filter")
customQuery := ctx.Query("custom_query")
if customQuery != "" {
if _, err := url.ParseQuery(customQuery); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "custom_query must be a valid URL query"})
return
}
}
if customQuery == "" {
if filter == filterTokenReviewRequested {
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "review_requested is not supported for issues"})
@@ -297,8 +306,9 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
if !ok {
return
}
milestone := trimGitLabWhitespace(ctx.Query("milestone"))
result, err := client.ListIssuesPaged(
ctx.Request.Context(), filter, customQuery, page, perPage,
ctx.Request.Context(), filter, customQuery, milestone, page, perPage,
)
if err != nil {
writeWorkspaceClientActionError(ctx, err, "issue search")
@@ -337,6 +347,16 @@ func (c *Controller) translateMRFilter(ctx *gin.Context, filter string) (string,
return translateUserSearchFilter(filter, username), nil
}
// trimGitLabWhitespace trims Unicode White_Space plus U+FEFF (byte order
// mark). strings.TrimSpace already covers Unicode White_Space, including
// U+0085 (NEL), but it does not trim U+FEFF. The extra rune keeps a pasted BOM
// from surviving as a non-empty milestone that cannot match.
func trimGitLabWhitespace(s string) string {
return strings.TrimFunc(s, func(r rune) bool {
return unicode.IsSpace(r) || r == '\uFEFF'
})
}
// paginationFromQuery reads ?page=&per_page= with the same clamps SearchMRsPaged
// applies internally — surfaced here so 400s on bad input are uniform.
func paginationFromQuery(ctx *gin.Context) (int, int) {
@@ -403,3 +403,65 @@ func TestHttpSearchUserIssues_TranslatesTabFilters(t *testing.T) {
})
}
}
// httpSearchUserIssues wiring is otherwise only unit-tested one layer down
// (buildIssueSearchQuery, trimGitLabWhitespace); nothing previously asserted
// end to end that the ?milestone= query key is actually read off the
// request and reaches the outbound GitLab call. A typo in the query key
// name would have passed every other test in this package.
func TestHttpSearchUserIssues_MilestoneReachesUpstreamRequest(t *testing.T) {
router, rec, stop := newControllerFixture(t, "alice")
defer stop()
resp := hit(router, "/api/v1/gitlab/user/issues?filter=assigned_to_me&milestone="+url.QueryEscape("Next"))
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String())
}
req := rec.findByPath("/api/v4/issues")
if req == nil {
t.Fatal("/api/v4/issues was never called")
}
if got := req.Query.Get("milestone"); got != "Next" {
t.Errorf("milestone = %q, want Next", got)
}
if got := req.Query.Get("scope"); got != "assigned_to_me" {
t.Errorf("scope = %q, want assigned_to_me (milestone must not clobber the preset)", got)
}
}
// A whitespace-only milestone (Scenario 3's second boundary) trims to "" and
// must produce a request identical to omitting the parameter entirely — no
// milestone key on the wire at all.
func TestHttpSearchUserIssues_WhitespaceOnlyMilestoneOmitsKey(t *testing.T) {
router, rec, stop := newControllerFixture(t, "alice")
defer stop()
resp := hit(router, "/api/v1/gitlab/user/issues?filter=assigned_to_me&milestone="+url.QueryEscape(" "))
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String())
}
req := rec.findByPath("/api/v4/issues")
if req == nil {
t.Fatal("/api/v4/issues was never called")
}
if req.Query.Has("milestone") {
t.Errorf("query = %v, want no milestone key for a whitespace-only value", req.Query)
}
}
func TestHttpSearchUserIssues_MalformedCustomQueryReturns400(t *testing.T) {
router, rec, stop := newControllerFixture(t, "alice")
defer stop()
resp := hit(
router,
"/api/v1/gitlab/user/issues?custom_query="+url.QueryEscape("%zz")+
"&milestone="+url.QueryEscape("Next"),
)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body: %s)", resp.Code, resp.Body.String())
}
if req := rec.findByPath("/api/v4/issues"); req != nil {
t.Errorf("/api/v4/issues was called with query %v — malformed custom query should be rejected", req.Query)
}
}
@@ -559,21 +559,6 @@ func (c *MockClient) GetRepoFileContent(
return content, nil
}
func (c *MockClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
c.mu.Lock()
defer c.mu.Unlock()
out := []*Issue{}
for _, i := range c.issues {
out = append(out, i)
}
return out, nil
}
func (c *MockClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
issues, _ := c.ListIssues(ctx, filter, customQuery)
return &IssueSearchPage{Issues: issues, TotalCount: len(issues), Page: page, PerPage: perPage}, nil
}
func (c *MockClient) SearchMRs(context.Context, string, string) ([]*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -0,0 +1,109 @@
package gitlab
import (
"context"
"net/url"
"sort"
)
// resolveEffectiveMilestone derives the milestone MockClient.ListIssues
// filters on from the three raw search inputs the controller passes through
// unmodified — MockClient never calls buildIssueSearchQuery, so it must
// reproduce the fold precedence itself. The first matching rule wins:
//
// 1. customQuery carries a "milestone" key: its first value, even if empty
// (an empty key still wins outright — it must not fall through to the
// milestone parameter, matching how an empty fold reaches GitLab as an
// empty milestone on the live path).
// 2. the milestone parameter, if non-empty.
// 3. filter carries a non-empty "milestone" key: its first value.
// 4. otherwise, empty.
func resolveEffectiveMilestone(filter, customQuery, milestone string) string {
if parsed, err := url.ParseQuery(customQuery); err == nil && parsed.Has("milestone") {
return parsed.Get("milestone")
}
if milestone != "" {
return milestone
}
if parsed, err := url.ParseQuery(filter); err == nil {
if v := parsed.Get("milestone"); v != "" {
return v
}
}
return ""
}
// ListIssues returns seeded issues, optionally narrowed to an exact
// case-sensitive milestone match. See resolveEffectiveMilestone for how
// filter/customQuery/milestone combine. Every other filter key is ignored —
// PATClient is the only client that talks to real GitLab filtering.
func (c *MockClient) ListIssues(_ context.Context, filter, customQuery, milestone string) ([]*Issue, error) {
effective := resolveEffectiveMilestone(filter, customQuery, milestone)
c.mu.Lock()
defer c.mu.Unlock()
out := []*Issue{}
for _, i := range c.issues {
if effective != "" && i.Milestone != effective {
continue
}
out = append(out, i)
}
sort.Slice(out, func(a, b int) bool {
if out[a].ProjectPath != out[b].ProjectPath {
return out[a].ProjectPath < out[b].ProjectPath
}
return out[a].IID < out[b].IID
})
return out, nil
}
// ListIssuesPaged pages over ListIssues' filtered, sorted result. page < 1 is
// normalised to 1; perPage < 1 normalises to 0 (no issues, any page). There
// is no upper clamp on perPage: the echoed Page/PerPage are the normalised
// values actually used, from those two rules only. TotalCount is always the
// full filtered count, not the length of the returned slice.
func (c *MockClient) ListIssuesPaged(
ctx context.Context, filter, customQuery, milestone string, page, perPage int,
) (*IssueSearchPage, error) {
issues, err := c.ListIssues(ctx, filter, customQuery, milestone)
if err != nil {
return nil, err
}
normPage := page
if normPage < 1 {
normPage = 1
}
normPerPage := perPage
if normPerPage < 1 {
normPerPage = 0
}
// Guard the multiplication itself: normPage is not bounded above, so
// (normPage-1)*normPerPage can overflow int before the "start >
// len(issues)" clamp below ever runs. Comparing via division first is
// safe from overflow and gives the same answer.
var start int
if normPerPage == 0 || normPage-1 > len(issues)/normPerPage {
start = len(issues)
} else {
start = (normPage - 1) * normPerPage
}
// Guard start+normPerPage the same way: normPerPage is unbounded above
// (no clamp), so the addition can itself overflow int. Comparing against
// the remaining count first avoids ever computing the sum when it would
// exceed len(issues).
end := len(issues)
if normPerPage < len(issues)-start {
end = start + normPerPage
}
return &IssueSearchPage{
Issues: issues[start:end],
TotalCount: len(issues),
Page: normPage,
PerPage: normPerPage,
}, nil
}
@@ -0,0 +1,334 @@
package gitlab
import (
"math"
"testing"
)
func seedMilestoneIssues(t *testing.T, c *MockClient) {
t.Helper()
c.SeedIssue("acme/beta", &Issue{IID: 1, ProjectPath: "acme/beta", Title: "beta-1", Milestone: "Next"})
c.SeedIssue("acme/alpha", &Issue{IID: 2, ProjectPath: "acme/alpha", Title: "alpha-2", Milestone: "Old"})
c.SeedIssue("acme/alpha", &Issue{IID: 1, ProjectPath: "acme/alpha", Title: "alpha-1", Milestone: "Next"})
c.SeedIssue("acme/alpha", &Issue{IID: 3, ProjectPath: "acme/alpha", Title: "alpha-3", Milestone: ""})
}
func TestMockClient_ListIssues_EmptyMilestoneReturnsEverything(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "", "", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 4 {
t.Fatalf("len(got) = %d, want 4", len(got))
}
}
func TestMockClient_ListIssues_ParameterMilestoneFiltersExact(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "", "", "Next")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2: %#v", len(got), got)
}
// Deterministic order: ProjectPath asc, then IID asc.
if got[0].ProjectPath != "acme/alpha" || got[0].IID != 1 {
t.Errorf("got[0] = %+v, want acme/alpha#1", got[0])
}
if got[1].ProjectPath != "acme/beta" || got[1].IID != 1 {
t.Errorf("got[1] = %+v, want acme/beta#1", got[1])
}
}
func TestMockClient_ListIssues_DeterministicOrderAcrossAllIssues(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "", "", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
want := []struct {
Project string
IID int
}{
{"acme/alpha", 1}, {"acme/alpha", 2}, {"acme/alpha", 3}, {"acme/beta", 1},
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].ProjectPath != w.Project || got[i].IID != w.IID {
t.Errorf("got[%d] = %s#%d, want %s#%d", i, got[i].ProjectPath, got[i].IID, w.Project, w.IID)
}
}
}
func TestMockClient_ListIssues_CustomQueryMilestoneWinsOverParameter(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "", "milestone=Old", "Next")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 1 || got[0].Milestone != "Old" {
t.Fatalf("got = %#v, want exactly one Old-milestone issue", got)
}
}
func TestMockClient_ListIssues_CustomQueryEmptyMilestoneKeySuppressesParameterFallback(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
// Rule 1: an empty milestone= key in customQuery wins outright (does not
// fall through to the milestone parameter), matching the live-path fold.
got, err := c.ListIssues(t.Context(), "", "milestone=", "Next")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 4 {
t.Fatalf("len(got) = %d, want 4 (empty milestone key means unfiltered)", len(got))
}
}
func TestMockClient_ListIssues_HandwrittenCustomQueryUsesFirstMilestoneValue(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "", "milestone=Old&milestone=Next", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 1 || got[0].Milestone != "Old" {
t.Fatalf("got = %#v, want exactly one Old-milestone issue (first value wins)", got)
}
}
func TestMockClient_ListIssues_FilterMilestoneFallbackWhenNonEmpty(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "milestone=Old", "", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 1 || got[0].Milestone != "Old" {
t.Fatalf("got = %#v, want exactly one Old-milestone issue", got)
}
}
func TestMockClient_ListIssues_FilterMilestoneEmptyValueIsIgnored(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
got, err := c.ListIssues(t.Context(), "milestone=", "", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(got) != 4 {
t.Fatalf("len(got) = %d, want 4 (empty filter milestone value is not adopted)", len(got))
}
}
func TestMockClient_ListIssuesPaged_PagesOverFilteredSortedResult(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, 2)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
}
if len(page.Issues) != 2 || page.Issues[0].IID != 1 || page.Issues[1].IID != 2 {
t.Fatalf("page 1 issues = %#v, want alpha#1, alpha#2", page.Issues)
}
page2, err := c.ListIssuesPaged(t.Context(), "", "", "", 2, 2)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page2.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4", page2.TotalCount)
}
if len(page2.Issues) != 2 || page2.Issues[0].IID != 3 || page2.Issues[1].ProjectPath != "acme/beta" {
t.Fatalf("page 2 issues = %#v, want alpha#3, beta#1", page2.Issues)
}
}
func TestMockClient_ListIssuesPaged_PageBeyondEndYieldsEmptyWithSameTotal(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 5, 2)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
}
if len(page.Issues) != 0 {
t.Errorf("len(Issues) = %d, want 0", len(page.Issues))
}
if page.Page != 5 || page.PerPage != 2 {
t.Errorf("Page/PerPage = %d/%d, want 5/2", page.Page, page.PerPage)
}
}
// Scenario 16: 30 seeded issues, 26 in milestone "Next", per_page=25 pages
// the filtered set (not the unfiltered 30), with TotalCount staying at the
// filtered count on every page including the beyond-end one.
func TestMockClient_ListIssuesPaged_PagesOverFilteredMilestoneSetAcrossMultiplePages(t *testing.T) {
c := NewMockClient(DefaultHost)
for i := 1; i <= 26; i++ {
c.SeedIssue("acme/repo", &Issue{IID: i, ProjectPath: "acme/repo", Milestone: "Next"})
}
for i := 27; i <= 30; i++ {
c.SeedIssue("acme/repo", &Issue{IID: i, ProjectPath: "acme/repo", Milestone: "Old"})
}
page1, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 1, 25)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page1.TotalCount != 26 {
t.Errorf("page1 TotalCount = %d, want 26", page1.TotalCount)
}
if len(page1.Issues) != 25 {
t.Errorf("page1 len(Issues) = %d, want 25", len(page1.Issues))
}
page2, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 2, 25)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page2.TotalCount != 26 {
t.Errorf("page2 TotalCount = %d, want 26", page2.TotalCount)
}
if len(page2.Issues) != 1 {
t.Errorf("page2 len(Issues) = %d, want 1", len(page2.Issues))
}
page3, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 3, 25)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page3.TotalCount != 26 {
t.Errorf("page3 TotalCount = %d, want 26", page3.TotalCount)
}
if len(page3.Issues) != 0 {
t.Errorf("page3 len(Issues) = %d, want 0", len(page3.Issues))
}
}
func TestMockClient_ListIssuesPaged_NegativePageNormalisedToOne(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
page, err := c.ListIssuesPaged(t.Context(), "", "", "", -3, 2)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page.Page != 1 {
t.Errorf("Page = %d, want normalised to 1", page.Page)
}
if len(page.Issues) != 2 || page.Issues[0].IID != 1 {
t.Fatalf("issues = %#v, want the first page's worth", page.Issues)
}
}
func TestMockClient_ListIssuesPaged_NonPositivePerPageReturnsNoIssues(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, 0)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if len(page.Issues) != 0 {
t.Errorf("len(Issues) = %d, want 0", len(page.Issues))
}
if page.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4 (full filtered count, even with no results)", page.TotalCount)
}
if page.PerPage != 0 {
t.Errorf("PerPage = %d, want normalised to 0", page.PerPage)
}
negative, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, -5)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if len(negative.Issues) != 0 {
t.Errorf("len(Issues) = %d, want 0", len(negative.Issues))
}
if negative.PerPage != 0 {
t.Errorf("PerPage = %d, want normalised to 0", negative.PerPage)
}
}
// The HTTP layer's paginationFromQuery only floors page/perPage, it never
// caps the upper bound (unlike PATClient's clampSearchPage), so an
// out-of-range per_page can reach the mock directly. ListIssuesPaged must
// not overflow its start/end arithmetic and panic on the resulting slice.
// Spec (spec.md:736-747) authorizes only two normalisations — page < 1 and
// perPage < 1 — so an extreme-but-positive perPage is echoed back as given,
// not clamped to a page size the spec never mentions.
func TestMockClient_ListIssuesPaged_ExtremePerPageDoesNotOverflowOrPanic(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
first, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, math.MaxInt)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if len(first.Issues) != 4 {
t.Errorf("len(Issues) = %d, want all 4 on page 1", len(first.Issues))
}
if first.PerPage != math.MaxInt {
t.Errorf("PerPage = %d, want echoed back as math.MaxInt, not clamped", first.PerPage)
}
if first.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4", first.TotalCount)
}
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 2, math.MaxInt)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page.TotalCount != 4 {
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
}
if len(page.Issues) != 0 {
t.Errorf("len(Issues) = %d, want 0 (page 2 is beyond the end of the unfiltered set)", len(page.Issues))
}
if page.PerPage != math.MaxInt {
t.Errorf("PerPage = %d, want echoed back as math.MaxInt, not clamped", page.PerPage)
}
}
func TestMockClient_ListIssuesPaged_ForwardsMilestoneWithoutResolvingTwice(t *testing.T) {
c := NewMockClient(DefaultHost)
seedMilestoneIssues(t, c)
page, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 1, 10)
if err != nil {
t.Fatalf("ListIssuesPaged() error = %v", err)
}
if page.TotalCount != 2 {
t.Errorf("TotalCount = %d, want 2", page.TotalCount)
}
for _, issue := range page.Issues {
if issue.Milestone != "Next" {
t.Errorf("issue %+v has milestone %q, want Next", issue, issue.Milestone)
}
}
}
+1
View File
@@ -349,6 +349,7 @@ type Issue struct {
ProjectPath string `json:"project_path"`
Labels []string `json:"labels"`
Assignees []string `json:"assignees"`
Milestone string `json:"milestone,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
+2 -2
View File
@@ -123,11 +123,11 @@ func (c *NoopClient) GetRepoFileContent(context.Context, string, string, string)
return nil, ErrNoClient
}
func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
func (c *NoopClient) ListIssues(context.Context, string, string, string) ([]*Issue, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
func (c *NoopClient) ListIssuesPaged(context.Context, string, string, string, int, int) (*IssueSearchPage, error) {
return nil, ErrNoClient
}
+4 -4
View File
@@ -262,17 +262,17 @@ func (c *PATClient) SearchMRsPaged(ctx context.Context, filter, customQuery stri
}, nil
}
func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) {
page, err := c.ListIssuesPaged(ctx, filter, customQuery, defaultPageStart, defaultPageSize)
func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery, milestone string) ([]*Issue, error) {
page, err := c.ListIssuesPaged(ctx, filter, customQuery, milestone, defaultPageStart, defaultPageSize)
if err != nil {
return nil, err
}
return page.Issues, nil
}
func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery, milestone string, page, perPage int) (*IssueSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildIssueSearchQuery(filter, customQuery)
query := buildIssueSearchQuery(filter, customQuery, milestone)
endpoint := fmt.Sprintf("/issues?%s&page=%d&per_page=%d", query, page, perPage)
var raw []rawIssue
total, err := c.getWithTotal(ctx, endpoint, &raw)
@@ -54,7 +54,7 @@ func TestPATClientListIssuesUsesDefaultPagination(t *testing.T) {
}))
t.Cleanup(stop)
issues, err := NewPATClient(host, "tok").ListIssues(t.Context(), "open", "assignee_username=alice")
issues, err := NewPATClient(host, "tok").ListIssues(t.Context(), "open", "assignee_username=alice", "")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
@@ -430,7 +430,7 @@ func TestPATClient_ListIssuesPaged_ScopeAssignedToMe(t *testing.T) {
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.ListIssuesPaged(context.Background(), "scope=assigned_to_me", "", 1, 25); err != nil {
if _, err := c.ListIssuesPaged(context.Background(), "scope=assigned_to_me", "", "Next", 1, 25); err != nil {
t.Fatalf("err = %v", err)
}
if receivedPath != "/issues" {
@@ -442,6 +442,9 @@ func TestPATClient_ListIssuesPaged_ScopeAssignedToMe(t *testing.T) {
if got := receivedQuery.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened", got)
}
if got := receivedQuery.Get("milestone"); got != "Next" {
t.Errorf("milestone = %q, want Next — must reach the server", got)
}
}
func TestPATClient_ListIssuesPaged_HonoursTotalHeader(t *testing.T) {
@@ -467,7 +470,7 @@ func TestPATClient_ListIssuesPaged_HonoursTotalHeader(t *testing.T) {
defer stop()
c := NewPATClient(host, "tok")
page, err := c.ListIssuesPaged(context.Background(), "", "", 2, 20)
page, err := c.ListIssuesPaged(context.Background(), "", "", "", 2, 20)
if err != nil {
t.Fatalf("err = %v", err)
}
@@ -13,7 +13,7 @@ type captureIssueQueryClient struct {
listCalls int
}
func (c *captureIssueQueryClient) ListIssues(_ context.Context, filter, customQuery string) ([]*Issue, error) {
func (c *captureIssueQueryClient) ListIssues(_ context.Context, filter, customQuery, _ string) ([]*Issue, error) {
c.filter = filter
c.customQuery = customQuery
c.listCalls++
@@ -287,7 +287,7 @@ func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue,
filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
}
}
issues, err := client.ListIssues(ctx, filter, customQuery)
issues, err := client.ListIssues(ctx, filter, customQuery, "")
if err != nil {
return nil, fmt.Errorf("list issues: %w", err)
}
@@ -151,7 +151,7 @@ func (s *Service) SearchMentionIssuesForWorkspace(
return collectMentionSearchPages(ctx, scope, limit, MentionIssueURL, func(
ctx context.Context, pageNumber, pageSize int,
) (mentionSearchPage, error) {
page, err := client.ListIssuesPaged(ctx, "", titleQuery, pageNumber, pageSize)
page, err := client.ListIssuesPaged(ctx, "", titleQuery, "", pageNumber, pageSize)
if err != nil {
return mentionSearchPage{}, fmt.Errorf("search GitLab issue mentions: %w", err)
}
@@ -18,7 +18,7 @@ type mentionRecordingClient struct {
func (c *mentionRecordingClient) ListIssuesPaged(
ctx context.Context,
filter, customQuery string,
filter, customQuery, _ string,
page, perPage int,
) (*IssueSearchPage, error) {
return c.issues(ctx, filter, customQuery, page, perPage)
@@ -67,8 +67,8 @@ func TestServiceClientActionsReturnErrNoClient(t *testing.T) {
{name: "branches", call: func() error { _, err := service.ListProjectBranches(t.Context(), "acme/widget"); return err }},
{name: "search MRs", call: func() error { _, err := service.SearchUserMRs(t.Context(), "", ""); return err }},
{name: "search MRs paged", call: func() error { _, err := service.SearchUserMRsPaged(t.Context(), "", "", 1, 10); return err }},
{name: "search issues", call: func() error { _, err := service.SearchUserIssues(t.Context(), "", ""); return err }},
{name: "search issues paged", call: func() error { _, err := service.SearchUserIssuesPaged(t.Context(), "", "", 1, 10); return err }},
{name: "search issues", call: func() error { _, err := service.SearchUserIssues(t.Context(), "", "", ""); return err }},
{name: "search issues paged", call: func() error { _, err := service.SearchUserIssuesPaged(t.Context(), "", "", "", 1, 10); return err }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -25,21 +25,21 @@ func (s *Service) SearchUserMRsPaged(ctx context.Context, filter, customQuery st
}
// SearchUserIssues returns issues matching a filter for the configured user.
func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) {
func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery, milestone string) ([]*Issue, error) {
client := s.Client()
if client == nil {
return nil, ErrNoClient
}
return client.ListIssues(ctx, filter, customQuery)
return client.ListIssues(ctx, filter, customQuery, milestone)
}
// SearchUserIssuesPaged returns paginated issues.
func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery, milestone string, page, perPage int) (*IssueSearchPage, error) {
client := s.Client()
if client == nil {
return nil, ErrNoClient
}
return client.ListIssuesPaged(ctx, filter, customQuery, page, perPage)
return client.ListIssuesPaged(ctx, filter, customQuery, milestone, page, perPage)
}
// GetStats aggregates open MR / awaiting-review / open-issue counts.
@@ -85,7 +85,7 @@ func (s *Service) getStatsWithClient(ctx context.Context, client Client) (*Stats
} else if awaiting != nil {
awaitingMRs = awaiting.TotalCount
}
issues, err := client.ListIssuesPaged(ctx, "scope=assigned_to_me&state=opened", "", 1, 1)
issues, err := client.ListIssuesPaged(ctx, "scope=assigned_to_me&state=opened", "", "", 1, 1)
openIssues := 0
if err != nil {
s.logger.Warn("gitlab stats: open issues", zap.Error(err))
@@ -836,17 +836,11 @@ func (s *Service) requireStore() *Store {
// appendLabelsToQuery merges a label list into an existing customQuery string.
// If the query already has a `labels` key the caller's value is kept (we
// don't want to silently double-up). url.ParseQuery is used for an exact
// key match — strings.Contains("labels=") would false-positive on keys
// like `mylabels=` and silently drop the watch's labels.
// key match by the shared appendQueryParam helper — strings.Contains("labels=")
// would false-positive on keys like `mylabels=` and silently drop the watch's
// labels.
func appendLabelsToQuery(customQuery string, labels []string) string {
if parsed, err := url.ParseQuery(customQuery); err == nil && parsed.Has("labels") {
return customQuery
}
encoded := url.QueryEscape(strings.Join(labels, ","))
if customQuery == "" {
return "labels=" + encoded
}
return customQuery + "&labels=" + encoded
return appendQueryParam(customQuery, "labels", strings.Join(labels, ","))
}
func normalizeProjectFilters(in []ProjectFilter) []ProjectFilter {
@@ -38,6 +38,14 @@ func TestAppendLabelsToQuery_DoesNotMatchSimilarKeys(t *testing.T) {
}
}
func TestAppendLabelsToQuery_InsertsBeforeFragment(t *testing.T) {
got := appendLabelsToQuery("state=opened#client-fragment", []string{"bug"})
want := "state=opened&labels=bug#client-fragment"
if got != want {
t.Fatalf("got %q, want %q (labels must precede the URL fragment)", got, want)
}
}
func TestIsValidCleanupPolicy(t *testing.T) {
cases := map[string]bool{
"": true,
+9 -160
View File
@@ -11,20 +11,12 @@ import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
import { MRList } from "@/components/gitlab/my-gitlab/mr-list";
import { IssueList } from "@/components/gitlab/my-gitlab/issue-list";
import {
PresetsSidebar,
type SidebarSelection,
} from "@/components/gitlab/my-gitlab/presets-sidebar";
import type { SidebarSelection } from "@/components/gitlab/my-gitlab/presets-sidebar";
import { PresetsSidebar } from "@/components/gitlab/my-gitlab/presets-sidebar";
import { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar";
import { MR_PRESETS, ISSUE_PRESETS, presetLabel } from "@/components/gitlab/my-gitlab/presets";
import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search";
import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets";
import {
useKnownProjects,
resetKnownProjectsStore,
} from "@/components/gitlab/my-gitlab/use-known-projects";
import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-query";
import { resetKnownProjectsStore } from "@/components/gitlab/my-gitlab/use-known-projects";
import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar";
import { useGitLabPageState, type GitLabPageState } from "./use-gitlab-page-state";
import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination";
import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog";
import { useMRKeyToTasks } from "@/hooks/domains/gitlab/use-mr-key-to-tasks";
@@ -67,23 +59,6 @@ function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) {
);
}
function resolveTitle(
selection: SidebarSelection,
saved: SavedPreset[],
t: (key: string, values?: Record<string, unknown>) => string,
): string {
if (selection.source === "saved") {
return saved.find((p) => p.id === selection.id)?.label ?? t("gitlab:savedQueryFallback");
}
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
return (
presetLabel(
t,
presets.find((p) => p.value === selection.id),
) ?? (selection.kind === "mr" ? t("gitlab:titleMergeRequests") : t("gitlab:titleIssues"))
);
}
function ResultsList({
selection,
items,
@@ -132,137 +107,6 @@ function ResultsList({
);
}
type GitLabPageState = ReturnType<typeof useGitLabPageState>;
function useProjectOptions(
selection: SidebarSelection,
committedQuery: string,
items: Array<MR | Issue>,
projectFilter: string,
): string[] {
const pageProjects = useMemo(
() => items.filter((it) => !!it.project_path).map((it) => it.project_path),
[items],
);
const resetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`;
const knownProjects = useKnownProjects(resetKey, pageProjects);
return useMemo(() => {
const set = new Set(knownProjects);
if (projectFilter) set.add(projectFilter);
return Array.from(set).sort();
}, [knownProjects, projectFilter]);
}
/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */
function initialSelection(): SidebarSelection {
return { kind: "mr", source: "preset", id: MR_PRESETS[0]?.value ?? "" };
}
function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) {
const { t } = useTranslation();
const [selection, setSelection] = useState<SidebarSelection>(initialSelection);
const {
draft: customQuery,
committed: committedQuery,
setDraft: setCustomQuery,
setImmediate: setQueryImmediate,
commit: commitCustomQuery,
} = useCommittedQuery("");
const [projectFilter, setProjectFilter] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets();
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
const search = useGitLabSearch({
workspaceId: workspaceId ?? "",
kind: selection.kind,
presets,
preset: selection.source === "preset" ? selection.id : "",
customQuery: committedQuery,
projectFilter,
enabled: searchEnabled && Boolean(workspaceId),
});
const projectOptions = useProjectOptions(
selection,
committedQuery,
search.rawItems,
projectFilter,
);
const title = useMemo(
() => resolveTitle(selection, savedPresets, t),
[selection, savedPresets, t],
);
const onSelect = useCallback(
(s: SidebarSelection) => {
setSelection(s);
if (s.source === "saved") {
const found = savedPresets.find((p) => p.id === s.id);
setQueryImmediate(found?.customQuery ?? "");
setProjectFilter(found?.projectFilter ?? "");
return;
}
setQueryImmediate("");
setProjectFilter("");
},
[savedPresets, setQueryImmediate],
);
// Use committedQuery (not the unflushed draft) so the saved preset always
// matches what is currently displayed in the list.
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
const suggestedLabel =
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
const onOpenSaveDialog = () => {
if (canSaveCurrent) setSaveDialogOpen(true);
};
const onConfirmSave = (label: string) => {
const created = saveSavedPreset({
kind: selection.kind,
label,
customQuery: committedQuery,
projectFilter,
});
setSelection({ kind: selection.kind, source: "saved", id: created.id });
};
const onDeleteSaved = (id: string) => {
removeSavedPreset(id);
if (selection.source === "saved" && selection.id === id) {
const fallbackPresets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
setSelection({ kind: selection.kind, source: "preset", id: fallbackPresets[0]?.value ?? "" });
setQueryImmediate("");
setProjectFilter("");
}
};
return {
selection,
customQuery,
committedQuery,
setCustomQuery,
commitCustomQuery,
projectFilter,
setProjectFilter,
savedPresets,
search,
projectOptions,
title,
onSelect,
canSaveCurrent,
suggestedLabel,
saveDialogOpen,
setSaveDialogOpen,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
function AuthenticatedLayout({
workspaceId,
state,
@@ -311,6 +155,11 @@ function AuthenticatedLayout({
onProjectFilterChange={state.setProjectFilter}
projectOptions={projectOptions}
onRefresh={search.refresh}
showMilestoneFilter={state.showMilestoneFilter}
milestone={state.milestone}
committedMilestone={state.committedMilestone}
onMilestoneChange={state.setMilestone}
onCommitMilestone={state.onCommitMilestone}
/>
<div className="flex-1 overflow-auto px-6 py-4">
<ResultsList
@@ -0,0 +1,424 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { Issue, IssueSearchPage } from "@/lib/types/gitlab";
import { ISSUE_PRESETS, MR_PRESETS } from "@/components/gitlab/my-gitlab/presets";
import {
__resetSnapshotForTests,
type SavedPreset,
} from "@/components/gitlab/my-gitlab/use-saved-presets";
import { resetKnownProjectsStore } from "@/components/gitlab/my-gitlab/use-known-projects";
import type { SidebarSelection } from "@/components/gitlab/my-gitlab/presets-sidebar";
const searchUserMRsMock = vi.fn<(input: unknown) => Promise<unknown>>();
const searchUserIssuesMock = vi.fn<(input: unknown) => Promise<IssueSearchPage | null>>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
}));
const fetchUserSettingsMock = vi.fn();
const updateUserSettingsMock = vi.fn();
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchUserSettings: (...args: unknown[]) => fetchUserSettingsMock(...args),
updateUserSettings: (...args: unknown[]) => updateUserSettingsMock(...args),
}));
import {
buildProjectOptionsResetKey,
trimGitLabMilestone,
useGitLabPageState,
useProjectOptions,
} from "./use-gitlab-page-state";
afterEach(() => cleanup());
const WORKSPACE_ID = "ws-1";
const CREATED_AT = "2026-01-01T00:00:00Z";
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: "",
updated_at: "",
...overrides,
};
}
function issuePage(items: Issue[]): IssueSearchPage {
return { issues: items, total_count: items.length, page: 1, per_page: 25 };
}
beforeEach(() => {
vi.clearAllMocks();
resetKnownProjectsStore();
__resetSnapshotForTests();
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [] } });
updateUserSettingsMock.mockResolvedValue({ settings: {} });
searchUserMRsMock.mockResolvedValue({ mrs: [], total_count: 0, page: 1, per_page: 25 });
searchUserIssuesMock.mockResolvedValue(issuePage([]));
});
describe("trimGitLabMilestone", () => {
it("trims ordinary leading/trailing whitespace", () => {
expect(trimGitLabMilestone(" v1.0 ")).toBe("v1.0");
});
it("trims a leading BOM (\uFEFF) and a trailing NBSP (\u00A0)", () => {
expect(trimGitLabMilestone("\uFEFFv1.0\u00A0")).toBe("v1.0");
});
it("trims a trailing NEL (\u0085), which the bare \\s regex class omits", () => {
expect(trimGitLabMilestone("v1.0\u0085")).toBe("v1.0");
});
it("preserves internal whitespace", () => {
expect(trimGitLabMilestone(" Sprint 42 ")).toBe("Sprint 42");
});
it("returns empty for a whitespace-only value including a NEL", () => {
expect(trimGitLabMilestone(" \u0085 ")).toBe("");
});
// Mirrors the Go side's TestTrimGitLabWhitespace_MatchesNormativeSet
// (client_helpers_milestone_test.go) code point for code point: the
// normative set is Unicode White_Space together with U+FEFF, on BOTH
// sides, and the two languages' own defaults disagree with it and with
// each other in both directions (Scenario 27). Asserting only a handful
// of ad-hoc cases on one side does not establish the cross-language
// agreement the spec requires.
it("matches the normative set for every code point in the Go-side conformance table", () => {
const trimmed = [
0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x0020, 0x0085, 0x00a0, 0x1680, 0x2000, 0x2028,
0x2029, 0x202f, 0x205f, 0x3000, 0xfeff,
];
for (const cp of trimmed) {
expect(trimGitLabMilestone(String.fromCodePoint(cp))).toBe("");
}
// Preserved: not part of Unicode White_Space or U+FEFF.
const preserved = [0x200b, 0x180e, 0x2060, 0x00b7];
for (const cp of preserved) {
const ch = String.fromCodePoint(cp);
expect(trimGitLabMilestone(ch)).toBe(ch);
}
});
});
describe("buildProjectOptionsResetKey", () => {
it("keeps delimiter-containing query and milestone values distinct", () => {
const selection: SidebarSelection = { kind: "issue", source: "preset", id: "assigned" };
const queryWithDelimiter = buildProjectOptionsResetKey(selection, "labels=backend:v1", "Q2");
const milestoneWithDelimiter = buildProjectOptionsResetKey(
selection,
"labels=backend",
"v1:Q2",
);
expect(queryWithDelimiter).not.toBe(milestoneWithDelimiter);
});
});
describe("useProjectOptions — ordinary pagination (key unchanged)", () => {
beforeEach(() => resetKnownProjectsStore());
const SELECTION: SidebarSelection = { kind: "issue", source: "preset", id: "assigned" };
it("keeps projects seen on an earlier page while a same-key page turn is loading", () => {
const items = [fakeIssue({ project_path: "acme/api" })];
const { result, rerender } = renderHook(
(props: { loading: boolean; items: Issue[] }) =>
useProjectOptions({
selection: SELECTION,
committedQuery: "",
milestone: "",
items: props.items,
loading: props.loading,
projectFilter: "",
}),
{ initialProps: { loading: false, items } },
);
expect(result.current).toEqual(["acme/api"]);
// Page 2 is loading: same key, so the accumulator must not clear.
rerender({ loading: true, items: [] });
expect(result.current).toEqual(["acme/api"]);
// Page 2 resolves with a different project; it accumulates on top.
rerender({ loading: false, items: [fakeIssue({ project_path: "acme/web" })] });
expect(result.current).toEqual(["acme/api", "acme/web"]);
});
});
describe("useGitLabPageState", () => {
it("does not leak the previous milestone's projects into the dropdown while the new milestone's fetch is in flight (Scenario 29)", async () => {
searchUserIssuesMock.mockResolvedValueOnce(
issuePage([fakeIssue({ project_path: "acme/api" })]),
);
let resolveNext: (v: IssueSearchPage) => void = () => {};
searchUserIssuesMock.mockReturnValueOnce(
new Promise<IssueSearchPage>((res) => {
resolveNext = res;
}),
);
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.projectOptions).toEqual(["acme/api"]));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
// The new milestone's fetch is in flight; the previous milestone's
// project must not still be offered.
expect(result.current.projectOptions).toEqual([]);
await act(async () => {
resolveNext(issuePage([fakeIssue({ project_path: "acme/web" })]));
await Promise.resolve();
});
await waitFor(() => expect(result.current.projectOptions).toEqual(["acme/web"]));
expect(result.current.projectOptions).not.toContain("acme/api");
});
it("clears the milestone draft and committed value when the selection changes, including switching to the MR view", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.onSelect({ kind: "mr", source: "preset", id: MR_PRESETS[0].value }));
expect(result.current.milestone).toBe("");
expect(result.current.committedMilestone).toBe("");
expect(result.current.showMilestoneFilter).toBe(false);
});
it("onCommitMilestone normalizes the visible input via the trim helper, resets to page 1, and issues exactly one request (Scenario 7)", async () => {
searchUserIssuesMock.mockResolvedValue(issuePage([]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.search.loading).toBe(false));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
const callsBeforeCommit = searchUserIssuesMock.mock.calls.length;
act(() => result.current.setMilestone(" Next "));
act(() => result.current.onCommitMilestone());
expect(result.current.milestone).toBe("Next");
expect(result.current.committedMilestone).toBe("Next");
await waitFor(() => expect(result.current.search.page).toBe(1));
// Committing and resetting the page together must fire exactly one
// request for the new value — adding milestone to the existing
// [preset, customQuery, kind] reset effect would instead fire twice
// (once for the stale page, once for page 1); see spec "Client
// function, and where trimming happens".
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(callsBeforeCommit + 1));
const lastCall = searchUserIssuesMock.mock.calls.at(-1)?.[0] as {
page: number;
milestone?: string;
};
expect(lastCall.page).toBe(1);
expect(lastCall.milestone).toBe("Next");
});
it("resets to page 1 when selecting a sidebar preset clears a committed milestone (Scenario 6, clause 3)", async () => {
searchUserIssuesMock.mockResolvedValue(issuePage([]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
// Re-select the SAME preset: effectivePreset/customQuery/kind are all
// unchanged, so useGitLabSearch's `[preset, customQuery, kind]` reset
// effect will not fire on its own — only the milestone clears.
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.committedMilestone).toBe("");
await waitFor(() => expect(result.current.search.page).toBe(1));
});
});
describe("useGitLabPageState — saved query save", () => {
it("canSaveCurrent and suggestedLabel consider the milestone alongside the query and project filter", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.canSaveCurrent).toBe(false);
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
expect(result.current.canSaveCurrent).toBe(true);
expect(result.current.suggestedLabel).toBe("Next");
});
it("suggestedLabel precedence ladder: custom query beats project filter beats milestone beats the default (Scenario 25)", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.suggestedLabel).toBe("Saved query");
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
expect(result.current.suggestedLabel).toBe("Next");
act(() => result.current.setProjectFilter("group/app"));
expect(result.current.suggestedLabel).toBe("In group/app");
act(() => result.current.setCustomQuery("state=closed"));
act(() => result.current.commitCustomQuery());
await waitFor(() => expect(result.current.committedQuery).toBe("state=closed"));
expect(result.current.suggestedLabel).toBe("state=closed");
});
it("onConfirmSave writes the committed (trimmed) milestone and the effective preset, never the raw draft", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone(" Next "));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
await act(async () => {
result.current.onConfirmSave("My saved query");
await Promise.resolve();
});
await waitFor(() => expect(updateUserSettingsMock).toHaveBeenCalled());
const payload = updateUserSettingsMock.mock.calls.at(-1)?.[0] as {
gitlab_saved_presets: SavedPreset[];
};
const saved = payload.gitlab_saved_presets.at(-1);
expect(saved?.label).toBe("My saved query");
expect(saved?.milestone).toBe("Next");
expect(saved?.preset).toBe("assigned");
expect(saved?.customQuery).toBe("");
});
});
describe("useGitLabPageState — saved query restore", () => {
it("restores both the milestone and the effective preset (including a preset-only query with no custom text) when the saved query is selected", async () => {
const created: SavedPreset = {
id: "g_1",
kind: "issue",
label: "Next sprint",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [created] } });
searchUserIssuesMock.mockResolvedValue(issuePage([fakeIssue({ project_path: "acme/api" })]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(1));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_1" }));
expect(result.current.committedMilestone).toBe("Next");
expect(result.current.committedQuery).toBe("");
// Preset-only saved query with no custom query text: the original
// preset filter (persisted as `preset`) must still drive the fetch.
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalled());
const lastCall = searchUserIssuesMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(lastCall.filter).toBe("assigned_to_me");
});
it("resets to page 1 when selecting a saved query that differs from the current state only in its milestone (Scenario 6, clause 2)", async () => {
const created: SavedPreset = {
id: "g_1",
kind: "issue",
label: "Next sprint",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [created] } });
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(1));
// Same effective preset ("assigned"), same empty custom query, same kind
// as the saved query below — only the milestone will differ, so the
// `[preset, customQuery, kind]` reset effect in useGitLabSearch will not
// fire on its own.
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.search.loading).toBe(false));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_1" }));
expect(result.current.committedMilestone).toBe("Next");
await waitFor(() => expect(result.current.search.page).toBe(1));
});
});
describe("useGitLabPageState — saved query delete", () => {
it("clears milestone and resets to page 1 only when the deleted saved query was the active selection", async () => {
const other: SavedPreset = {
id: "g_other",
kind: "issue",
label: "Other",
customQuery: "labels=bug",
projectFilter: "",
milestone: "",
preset: "",
createdAt: CREATED_AT,
};
const selected: SavedPreset = {
id: "g_selected",
kind: "issue",
label: "Selected",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({
settings: { gitlab_saved_presets: [other, selected] },
});
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(2));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_selected" }));
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.search.setPage(2));
await waitFor(() => expect(result.current.search.page).toBe(2));
// Deleting an unrelated saved query leaves the active scope untouched.
act(() => result.current.onDeleteSaved("g_other"));
expect(result.current.committedMilestone).toBe("Next");
expect(result.current.search.page).toBe(2);
// Deleting the currently-selected saved query clears the scope and
// resets the page in the same batch.
act(() => result.current.onDeleteSaved("g_selected"));
expect(result.current.committedMilestone).toBe("");
await waitFor(() => expect(result.current.search.page).toBe(1));
expect(result.current.selection).toEqual({
kind: "issue",
source: "preset",
id: ISSUE_PRESETS[0]?.value ?? "",
});
});
});
@@ -0,0 +1,421 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Issue, MR } from "@/lib/types/gitlab";
import type { SidebarSelection } from "@/components/gitlab/my-gitlab/presets-sidebar";
import { MR_PRESETS, ISSUE_PRESETS, presetLabel } from "@/components/gitlab/my-gitlab/presets";
import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search";
import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets";
import { useKnownProjects } from "@/components/gitlab/my-gitlab/use-known-projects";
import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-query";
// Normative trim set for the milestone filter: Unicode White_Space plus
// U+0085 (NEL), which JS's built-in String.prototype.trim() and the bare \s
// regex class both omit. Mirrors the backend's trimGitLabWhitespace. Every
// commit site for the milestone draft must go through this helper, never
// String.prototype.trim() directly.
export function trimGitLabMilestone(value: string): string {
return value.replace(/^[\s\u0085]+|[\s\u0085]+$/gu, "");
}
function resolveTitle(
selection: SidebarSelection,
saved: SavedPreset[],
t: (key: string, values?: Record<string, unknown>) => string,
): string {
if (selection.source === "saved") {
return saved.find((p) => p.id === selection.id)?.label ?? t("gitlab:savedQueryFallback");
}
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
return (
presetLabel(
t,
presets.find((p) => p.value === selection.id),
) ?? (selection.kind === "mr" ? t("gitlab:titleMergeRequests") : t("gitlab:titleIssues"))
);
}
const EMPTY_PROJECTS: string[] = [];
export type UseProjectOptionsArgs = {
selection: SidebarSelection;
committedQuery: string;
milestone: string;
items: Array<MR | Issue>;
loading: boolean;
projectFilter: string;
};
/** Encode every reset-key component as a JSON tuple so user values cannot collide. */
export function buildProjectOptionsResetKey(
selection: SidebarSelection,
committedQuery: string,
milestone: string,
): string {
return JSON.stringify([
selection.kind,
selection.source,
selection.id,
committedQuery.trim(),
milestone,
]);
}
export function useProjectOptions({
selection,
committedQuery,
milestone,
items,
loading,
projectFilter,
}: UseProjectOptionsArgs): string[] {
const resetKey = buildProjectOptionsResetKey(selection, committedQuery, milestone);
// `useKnownProjects`'s accumulator clears the instant `resetKey` changes and
// immediately refills with whatever project list it is handed in that same
// effect call. On the render where a key change first lands, `items` (from
// useGitLabSearch) still holds the *previous* key's results, and `loading`
// still reads false, because the fetch for the new key is dispatched from
// an effect that has not run yet (state.loading only flips inside
// fetchData, which runs from an effect). `committedKey` mirrors `resetKey`
// one render late via an ordinary effect, so on that first render
// `committedKey !== resetKey` forces the page list empty regardless of
// what `loading` happens to read at that instant; by the time
// `committedKey` catches up, the fetch has been dispatched and `loading`
// governs the empty state correctly for the rest of the request.
const [committedKey, setCommittedKey] = useState(resetKey);
useEffect(() => {
setCommittedKey(resetKey);
}, [resetKey]);
const pageProjects = useMemo(() => {
if (loading || committedKey !== resetKey) return EMPTY_PROJECTS;
return items.filter((it) => !!it.project_path).map((it) => it.project_path);
}, [loading, committedKey, resetKey, items]);
const knownProjects = useKnownProjects(resetKey, pageProjects);
return useMemo(() => {
const set = new Set(knownProjects);
if (projectFilter) set.add(projectFilter);
return Array.from(set).sort();
}, [knownProjects, projectFilter]);
}
/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */
export function initialSelection(): SidebarSelection {
return { kind: "mr", source: "preset", id: MR_PRESETS[0]?.value ?? "" };
}
type SaveQueryDialogArgs = {
selection: SidebarSelection;
setSelection: (s: SidebarSelection) => void;
committedQuery: string;
projectFilter: string;
committedMilestone: string;
effectivePreset: string;
saveSavedPreset: ReturnType<typeof useSavedPresets>["save"];
removeSavedPreset: ReturnType<typeof useSavedPresets>["remove"];
clearScope: () => void;
resetPage: () => void;
};
/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */
function useSaveQueryDialog({
selection,
setSelection,
committedQuery,
projectFilter,
committedMilestone,
effectivePreset,
saveSavedPreset,
removeSavedPreset,
clearScope,
resetPage,
}: SaveQueryDialogArgs) {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
// Use committed values (not unflushed drafts) so the saved preset always
// matches what is currently displayed in the list.
const canSaveCurrent =
committedQuery.trim().length > 0 || projectFilter.length > 0 || committedMilestone.length > 0;
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
const suggestedLabel =
committedQuery.trim() ||
(projectFilter ? `In ${projectFilter}` : "") ||
committedMilestone ||
"Saved query";
const onOpenSaveDialog = useCallback(() => {
if (canSaveCurrent) setSaveDialogOpen(true);
}, [canSaveCurrent]);
const onConfirmSave = useCallback(
(label: string) => {
const created = saveSavedPreset({
kind: selection.kind,
label,
customQuery: committedQuery,
projectFilter,
milestone: committedMilestone,
preset: effectivePreset,
});
setSelection({ kind: selection.kind, source: "saved", id: created.id });
},
[
selection.kind,
committedQuery,
projectFilter,
committedMilestone,
effectivePreset,
saveSavedPreset,
setSelection,
],
);
const onDeleteSaved = useCallback(
(id: string) => {
removeSavedPreset(id);
if (selection.source === "saved" && selection.id === id) {
const fallbackPresets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
setSelection({
kind: selection.kind,
source: "preset",
id: fallbackPresets[0]?.value ?? "",
});
clearScope();
resetPage();
}
},
[removeSavedPreset, selection, setSelection, clearScope, resetPage],
);
return {
saveDialogOpen,
setSaveDialogOpen,
canSaveCurrent,
suggestedLabel,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
type ScopeActionsArgs = {
savedPresets: SavedPreset[];
setSelection: (s: SidebarSelection) => void;
setQueryImmediate: (v: string) => void;
setProjectFilter: (v: string) => void;
setMilestoneImmediate: (v: string) => void;
milestone: string;
setPage: (p: number) => void;
};
/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */
function useScopeActions({
savedPresets,
setSelection,
setQueryImmediate,
setProjectFilter,
setMilestoneImmediate,
milestone,
setPage,
}: ScopeActionsArgs) {
const onSelect = useCallback(
(s: SidebarSelection) => {
setSelection(s);
if (s.source === "saved") {
const found = savedPresets.find((p) => p.id === s.id);
setQueryImmediate(found?.customQuery ?? "");
setProjectFilter(found?.projectFilter ?? "");
setMilestoneImmediate(found?.milestone ?? "");
setPage(1);
return;
}
setQueryImmediate("");
setProjectFilter("");
setMilestoneImmediate("");
setPage(1);
},
[
savedPresets,
setSelection,
setQueryImmediate,
setProjectFilter,
setMilestoneImmediate,
setPage,
],
);
// Every path that changes the committed milestone calls setPage(1) from
// inside the same event handler (commit, select, delete), so a stale later
// page is never left showing results for a filter that no longer applies —
// a saved query or sidebar preset can differ from the current state only
// in its milestone, in which case useGitLabSearch's own
// `[preset, customQuery, kind]` reset effect would not otherwise fire.
const onCommitMilestone = useCallback(() => {
setMilestoneImmediate(trimGitLabMilestone(milestone));
setPage(1);
}, [milestone, setMilestoneImmediate, setPage]);
const clearScope = useCallback(() => {
setQueryImmediate("");
setProjectFilter("");
setMilestoneImmediate("");
}, [setQueryImmediate, setProjectFilter, setMilestoneImmediate]);
const resetPage = useCallback(() => setPage(1), [setPage]);
return { onSelect, onCommitMilestone, clearScope, resetPage };
}
type SearchAndProjectsArgs = {
searchEnabled: boolean;
workspaceId?: string;
selection: SidebarSelection;
committedQuery: string;
committedMilestone: string;
projectFilter: string;
savedPresets: SavedPreset[];
t: (key: string, values?: Record<string, unknown>) => string;
};
/** Lifted out of `useGitLabPageState`, which is at the 100-line function cap. */
function useSearchAndProjects({
searchEnabled,
workspaceId,
selection,
committedQuery,
committedMilestone,
projectFilter,
savedPresets,
t,
}: SearchAndProjectsArgs) {
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
// A saved query with no custom query text still needs the preset filter it
// was saved under, or restoring it would silently widen to "no filter".
const effectivePreset = useMemo(() => {
if (selection.source === "preset") return selection.id;
return savedPresets.find((p) => p.id === selection.id)?.preset ?? "";
}, [selection, savedPresets]);
const search = useGitLabSearch({
workspaceId: workspaceId ?? "",
kind: selection.kind,
presets,
preset: effectivePreset,
customQuery: committedQuery,
milestone: committedMilestone,
projectFilter,
enabled: searchEnabled && Boolean(workspaceId),
});
const projectOptions = useProjectOptions({
selection,
committedQuery,
milestone: committedMilestone,
items: search.rawItems,
loading: search.loading,
projectFilter,
});
const title = useMemo(
() => resolveTitle(selection, savedPresets, t),
[selection, savedPresets, t],
);
return { search, projectOptions, title, effectivePreset };
}
export function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) {
const { t } = useTranslation();
const [selection, setSelection] = useState<SidebarSelection>(initialSelection);
const {
draft: customQuery,
committed: committedQuery,
setDraft: setCustomQuery,
setImmediate: setQueryImmediate,
commit: commitCustomQuery,
} = useCommittedQuery("");
const {
draft: milestone,
committed: committedMilestone,
setDraft: setMilestone,
setImmediate: setMilestoneImmediate,
} = useCommittedQuery("");
const [projectFilter, setProjectFilter] = useState("");
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets();
const { search, projectOptions, title, effectivePreset } = useSearchAndProjects({
searchEnabled,
workspaceId,
selection,
committedQuery,
committedMilestone,
projectFilter,
savedPresets,
t,
});
const { onSelect, onCommitMilestone, clearScope, resetPage } = useScopeActions({
savedPresets,
setSelection,
setQueryImmediate,
setProjectFilter,
setMilestoneImmediate,
milestone,
setPage: search.setPage,
});
const {
saveDialogOpen,
setSaveDialogOpen,
canSaveCurrent,
suggestedLabel,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
} = useSaveQueryDialog({
selection,
setSelection,
committedQuery,
projectFilter,
committedMilestone,
effectivePreset,
saveSavedPreset,
removeSavedPreset,
clearScope,
resetPage,
});
return {
selection,
customQuery,
committedQuery,
setCustomQuery,
commitCustomQuery,
milestone,
committedMilestone,
setMilestone,
onCommitMilestone,
showMilestoneFilter: selection.kind === "issue",
projectFilter,
setProjectFilter,
savedPresets,
search,
projectOptions,
title,
onSelect,
canSaveCurrent,
suggestedLabel,
saveDialogOpen,
setSaveDialogOpen,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
export type GitLabPageState = ReturnType<typeof useGitLabPageState>;
@@ -0,0 +1,91 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { Issue } from "@/lib/types/gitlab";
import { IssueList } from "./issue-list";
afterEach(() => cleanup());
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "Fix the thing",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: new Date().toISOString(),
updated_at: "",
...overrides,
};
}
describe("IssueList milestone chip", () => {
it("renders no chip when the issue has no milestone, and leaves the label chips unmoved (Scenario 14)", () => {
render(
<IssueList
items={[fakeIssue({ labels: ["bug", "frontend"] })]}
loading={false}
error={null}
/>,
);
expect(screen.queryByTestId("gitlab-issue-milestone")).toBeNull();
expect(screen.getByText("bug")).not.toBeNull();
expect(screen.getByText("frontend")).not.toBeNull();
});
it("positions the milestone chip after the project#iid/author/time metadata and before the first label chip (Scenario 14)", () => {
render(
<IssueList
items={[fakeIssue({ milestone: "Next", labels: ["bug", "frontend"] })]}
loading={false}
error={null}
/>,
);
const row = screen.getByTestId("issue-row");
const metadata = within(row).getByText(/acme\/api#1/);
const chip = within(row).getByTestId("gitlab-issue-milestone");
const firstLabel = within(row).getByText("bug");
expect(chip.textContent).toBe("Next");
// DOM order: metadata, then the milestone chip, then the first label chip.
expect(
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
metadata.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
expect(
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
chip.compareDocumentPosition(firstLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
});
it("does not consume a label slot: a milestone plus six labels renders the chip, four labels, and a +2 overflow indicator (Scenario 26)", () => {
render(
<IssueList
items={[
fakeIssue({
milestone: "Next",
labels: ["l1", "l2", "l3", "l4", "l5", "l6"],
}),
]}
loading={false}
error={null}
/>,
);
const row = screen.getByTestId("issue-row");
expect(within(row).getByTestId("gitlab-issue-milestone").textContent).toBe("Next");
for (const label of ["l1", "l2", "l3", "l4"]) {
expect(within(row).getByText(label)).not.toBeNull();
}
expect(within(row).queryByText("l5")).toBeNull();
expect(within(row).queryByText("l6")).toBeNull();
expect(within(row).getByText("+2")).not.toBeNull();
});
});
@@ -21,6 +21,19 @@ type IssueListProps = {
host?: string;
};
function IssueMilestoneChip({ milestone }: { milestone: string }) {
if (!milestone) return null;
return (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
data-testid="gitlab-issue-milestone"
>
{milestone}
</Badge>
);
}
function IssueLabels({ labels }: { labels: string[] }) {
if (!labels?.length) return null;
return (
@@ -76,6 +89,7 @@ function IssueRow({
time: formatRelativeTime(issue.created_at),
})}
</span>
<IssueMilestoneChip milestone={issue.milestone ?? ""} />
<IssueLabels labels={issue.labels} />
</div>
</div>
@@ -0,0 +1,124 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ListToolbar } from "./list-toolbar";
afterEach(() => cleanup());
const MILESTONE_TEST_ID = "gitlab-milestone-filter";
function renderToolbar({
milestone = "",
committedMilestone = "",
}: { milestone?: string; committedMilestone?: string } = {}) {
const onMilestoneChange = vi.fn();
const onCommitMilestone = vi.fn();
render(
<TooltipProvider>
<ListToolbar
title="Assigned to me"
count={2}
loading={false}
lastFetchedAt={null}
customQuery=""
committedQuery=""
onCustomQueryChange={vi.fn()}
onCommitCustomQuery={vi.fn()}
projectFilter=""
onProjectFilterChange={vi.fn()}
projectOptions={[]}
onRefresh={vi.fn()}
showMilestoneFilter
milestone={milestone}
committedMilestone={committedMilestone}
onMilestoneChange={onMilestoneChange}
onCommitMilestone={onCommitMilestone}
/>
</TooltipProvider>,
);
return { onMilestoneChange, onCommitMilestone };
}
describe("MilestoneFilterInput", () => {
it("does not render when showMilestoneFilter is false", () => {
render(
<TooltipProvider>
<ListToolbar
title="Assigned to me"
count={2}
loading={false}
lastFetchedAt={null}
customQuery=""
committedQuery=""
onCustomQueryChange={vi.fn()}
onCommitCustomQuery={vi.fn()}
projectFilter=""
onProjectFilterChange={vi.fn()}
projectOptions={[]}
onRefresh={vi.fn()}
showMilestoneFilter={false}
milestone=""
committedMilestone=""
onMilestoneChange={vi.fn()}
onCommitMilestone={vi.fn()}
/>
</TooltipProvider>,
);
expect(screen.queryByTestId(MILESTONE_TEST_ID)).toBeNull();
});
it("commits on blur when the draft differs from the committed milestone", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
});
it("does not commit on blur when the draft matches the committed milestone", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "Next" });
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("commits on Enter and prevents the default form submission", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
const input = screen.getByTestId(MILESTONE_TEST_ID);
const event = fireEvent.keyDown(input, { key: "Enter" });
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
expect(event).toBe(false);
});
it("does not commit while an IME is composing a milestone candidate", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "次", committedMilestone: "" });
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), {
key: "Enter",
isComposing: true,
});
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("does not commit on keys other than Enter", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), { key: "Escape" });
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("reports every keystroke through onMilestoneChange without committing", () => {
const { onMilestoneChange, onCommitMilestone } = renderToolbar();
fireEvent.change(screen.getByTestId(MILESTONE_TEST_ID), { target: { value: "Sprint 42" } });
expect(onMilestoneChange).toHaveBeenCalledWith("Sprint 42");
expect(onCommitMilestone).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Input } from "@kandev/ui/input";
import { useTranslation } from "react-i18next";
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
@@ -19,8 +20,76 @@ type ListToolbarProps = {
onProjectFilterChange: (value: string) => void;
projectOptions: string[];
onRefresh: () => void;
showMilestoneFilter: boolean;
milestone: string;
committedMilestone: string;
onMilestoneChange: (value: string) => void;
onCommitMilestone: () => void;
};
function ProjectFilterSelect({
projectFilter,
onProjectFilterChange,
projectOptions,
}: Pick<ListToolbarProps, "projectFilter" | "onProjectFilterChange" | "projectOptions">) {
const { t } = useTranslation();
const selectValue = projectFilter || ALL_PROJECTS;
return (
<Select
value={selectValue}
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
>
<SelectTrigger
className="h-8 w-full cursor-pointer md:w-[220px]"
data-testid="gitlab-project-filter-trigger"
>
<SelectValue placeholder={t("gitlab:allProjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
{t("gitlab:allProjects")}
</SelectItem>
{projectOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MilestoneFilterInput({
milestone,
committedMilestone,
onMilestoneChange,
onCommitMilestone,
}: Pick<
ListToolbarProps,
"milestone" | "committedMilestone" | "onMilestoneChange" | "onCommitMilestone"
>) {
const { t } = useTranslation();
return (
<Input
value={milestone}
onChange={(event) => onMilestoneChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
event.preventDefault();
onCommitMilestone();
}
}}
onBlur={() => {
if (milestone !== committedMilestone) onCommitMilestone();
}}
placeholder={t("gitlab:eGSprint42")}
aria-label={t("gitlab:milestoneFilterLabel")}
className="h-11 w-full md:h-8 md:w-[180px]"
data-testid="gitlab-milestone-filter"
/>
);
}
export function ListToolbar({
title,
count,
@@ -34,9 +103,13 @@ export function ListToolbar({
onProjectFilterChange,
projectOptions,
onRefresh,
showMilestoneFilter,
milestone,
committedMilestone,
onMilestoneChange,
onCommitMilestone,
}: ListToolbarProps) {
const { t } = useTranslation();
const selectValue = projectFilter || ALL_PROJECTS;
return (
<IntegrationListToolbar
title={title}
@@ -49,27 +122,21 @@ export function ListToolbar({
onCommitCustomQuery={onCommitCustomQuery}
onRefresh={onRefresh}
filter={
<Select
value={selectValue}
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
>
<SelectTrigger
className="h-8 w-full cursor-pointer md:w-[220px]"
data-testid="gitlab-project-filter-trigger"
>
<SelectValue placeholder={t("gitlab:allProjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
{t("gitlab:allProjects")}
</SelectItem>
{projectOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<>
<ProjectFilterSelect
projectFilter={projectFilter}
onProjectFilterChange={onProjectFilterChange}
projectOptions={projectOptions}
/>
{showMilestoneFilter ? (
<MilestoneFilterInput
milestone={milestone}
committedMilestone={committedMilestone}
onMilestoneChange={onMilestoneChange}
onCommitMilestone={onCommitMilestone}
/>
) : null}
</>
}
queryPlaceholder={t("gitlab:customQueryPressEnterEG")}
titleTestId="gitlab-list-toolbar-title"
@@ -167,6 +167,7 @@ describe("GitLab QuickTaskLauncher recovery", () => {
project_path: "group/project",
labels: [],
assignees: [],
milestone: "",
created_at: FIXED_DATE,
updated_at: FIXED_DATE,
},
@@ -110,6 +110,7 @@ function fakeIssue(overrides: Partial<Issue> = {}): Issue {
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: "",
updated_at: "",
...overrides,
@@ -225,6 +226,73 @@ describe("useGitLabSearch — fetch wiring", () => {
});
});
describe("useGitLabSearch — milestone", () => {
beforeEach(resetMocks);
it("forwards the milestone to the issues API", async () => {
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
renderHook(() =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "issue",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone: "Next",
}),
);
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalled());
const args = searchUserIssuesMock.mock.calls[0][0] as Record<string, unknown>;
expect(args.milestone).toBe("Next");
});
it("never forwards a milestone to the MR API", async () => {
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
renderHook(() =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "mr",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone: "Next",
}),
);
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
const args = searchUserMRsMock.mock.calls[0][0] as Record<string, unknown>;
expect(args).not.toHaveProperty("milestone");
});
it("refetches when the milestone changes, without moving the current page", async () => {
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
const { result, rerender } = renderHook(
({ milestone }: { milestone: string }) =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "issue",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone,
}),
{ initialProps: { milestone: "" } },
);
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(1));
act(() => result.current.setPage(3));
await waitFor(() => expect(result.current.page).toBe(3));
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(2));
rerender({ milestone: "Next" });
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(3));
const args = searchUserIssuesMock.mock.calls[2][0] as Record<string, unknown>;
expect(args.milestone).toBe("Next");
// The hook itself does not reset the page on a milestone change — callers
// that commit a milestone are responsible for calling setPage(1) in the
// same event handler (see use-gitlab-page-state.ts).
expect(args.page).toBe(3);
});
});
describe("useGitLabSearch — state", () => {
beforeEach(resetMocks);
@@ -23,6 +23,7 @@ type SearchState = {
type FetchArgs = {
filter: string;
customQuery: string;
milestone: string;
page: number;
};
@@ -32,6 +33,7 @@ type UseGitLabSearchOptions = {
presets: PresetOption[];
preset: string;
customQuery: string;
milestone?: string;
projectFilter?: string;
// Gate the network fetch on the integration being connected. When GitLab is
// not configured the page renders the connect notice instead of the list, so
@@ -73,12 +75,42 @@ function filterByProject(items: Item[], project: string): Item[] {
return items.filter((it) => it.project_path === project);
}
async function fetchGitLabSearchPage(
kind: SearchKind,
workspaceId: string,
args: FetchArgs,
): Promise<Omit<SearchState, "workspaceId">> {
const params = {
workspaceId,
filter: args.filter,
customQuery: args.customQuery,
page: args.page,
perPage: SEARCH_PAGE_SIZE,
};
const response =
kind === "mr"
? await searchUserMRs(params)
: await searchUserIssues({ ...params, milestone: args.milestone });
const items: Item[] =
kind === "mr"
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
return {
items,
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response?.total_count ?? items.length,
};
}
export function useGitLabSearch({
workspaceId,
kind,
presets,
preset,
customQuery,
milestone = "",
projectFilter = "",
enabled = true,
}: UseGitLabSearchOptions) {
@@ -91,7 +123,7 @@ export function useGitLabSearch({
}, [preset, customQuery, kind]);
const fetchData = useCallback(
async ({ filter: ef, customQuery: ec, page: epage }: FetchArgs) => {
async (args: FetchArgs) => {
if (!enabled || !workspaceId) return;
const seq = ++requestSeq.current;
const requestedWorkspaceId = workspaceId;
@@ -108,28 +140,9 @@ export function useGitLabSearch({
},
);
try {
const params = {
workspaceId,
filter: ef,
customQuery: ec,
page: epage,
perPage: SEARCH_PAGE_SIZE,
};
const response =
kind === "mr" ? await searchUserMRs(params) : await searchUserIssues(params);
const next = await fetchGitLabSearchPage(kind, workspaceId, args);
if (seq !== requestSeq.current) return;
const items: Item[] =
kind === "mr"
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
setState({
items,
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response?.total_count ?? items.length,
workspaceId: requestedWorkspaceId,
});
setState({ ...next, workspaceId: requestedWorkspaceId });
} catch (err) {
if (seq !== requestSeq.current) return;
setState((s) => ({
@@ -155,12 +168,18 @@ export function useGitLabSearch({
// its run callback and its debounce effect) so a disabled integration never
// schedules a fetch. The callback keeps its own guard to also cover refresh().
if (!enabled || !workspaceId) return;
void fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page });
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, page]);
void fetchData({
filter: resolved.filter,
customQuery: resolved.customQuery,
milestone,
page,
});
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, milestone, page]);
const refresh = useCallback(
() => fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page }),
[fetchData, resolved.filter, resolved.customQuery, page],
() =>
fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, milestone, page }),
[fetchData, resolved.filter, resolved.customQuery, milestone, page],
);
const isCurrentWorkspace = state.workspaceId === workspaceId;
@@ -21,6 +21,8 @@ const valid: SavedPreset = {
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
milestone: "",
preset: "",
createdAt: "2026-01-01T00:00:00Z",
};
@@ -45,6 +47,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(result.current.presets).toHaveLength(1);
@@ -64,6 +68,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "Triage",
customQuery: "labels=bug",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(result.current.presets).toHaveLength(1);
@@ -80,6 +86,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "Shared",
customQuery: "q",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(a.result.current.presets).toHaveLength(1);
@@ -97,3 +105,93 @@ describe("useSavedPresets (gitlab)", () => {
expect(updateUserSettings).not.toHaveBeenCalled();
});
});
const LEGACY_CREATED_AT = "2025-01-01T00:00:00Z";
describe("useSavedPresets (gitlab) — legacy preset field normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
__resetSnapshotForTests();
vi.mocked(updateUserSettings).mockResolvedValue({
settings: {},
} as Awaited<ReturnType<typeof updateUserSettings>>);
});
it("keeps a legacy preset with neither a milestone nor a preset key, defaulting both to empty", async () => {
const legacy = {
id: "g_legacy",
kind: "issue",
label: "Legacy triage",
customQuery: "labels=bug",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
};
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: [legacy] },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(result.current.presets).toHaveLength(1));
expect(result.current.presets[0]).toMatchObject({
id: "g_legacy",
milestone: "",
preset: "",
});
});
it("normalizes a non-string milestone/preset (number, null, object) to empty rather than dropping the entry", async () => {
const withBadTypes = [
{
id: "g_number",
kind: "issue",
label: "Number",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
milestone: 42,
preset: null,
},
{
id: "g_object",
kind: "issue",
label: "Object",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
milestone: { title: "Next" },
preset: 7,
},
];
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: withBadTypes },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(result.current.presets).toHaveLength(2));
expect(result.current.presets[0]).toMatchObject({ id: "g_number", milestone: "", preset: "" });
expect(result.current.presets[1]).toMatchObject({ id: "g_object", milestone: "", preset: "" });
});
it("still rejects an entry missing a required legacy field", async () => {
const missingLabel = [
{
id: "g_bad",
kind: "issue",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
},
];
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: missingLabel },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(fetchUserSettings).toHaveBeenCalled());
expect(result.current.presets).toEqual([]);
});
});
@@ -10,6 +10,12 @@ export type SavedPreset = {
label: string;
customQuery: string;
projectFilter: string;
// Milestone title in effect when the query was saved, and the sidebar
// preset value it was saved under. Both "" when none. Presets written
// before this feature carry neither key; isSavedPreset tolerates a missing
// or non-string value for either and normalizeSavedPreset reads it as "".
milestone: string;
preset: string;
createdAt: string;
};
@@ -39,7 +45,10 @@ function publish(next: SavedPreset[]) {
for (const l of listeners) l();
}
function isSavedPreset(p: unknown): p is SavedPreset {
// Checks the fields every SavedPreset has always had. milestone/preset are
// deliberately not checked here: a preset saved before this feature has
// neither key, and that SHALL NOT disqualify it (Scenario 13).
function isSavedPreset(p: unknown): p is Omit<SavedPreset, "milestone" | "preset"> {
return (
typeof p === "object" &&
p !== null &&
@@ -52,9 +61,29 @@ function isSavedPreset(p: unknown): p is SavedPreset {
);
}
// A missing value, or one present but not a string, reads as "" (Scenario 23).
function normalizeStoredString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function normalizeSavedPreset(p: unknown): SavedPreset | null {
if (!isSavedPreset(p)) return null;
const raw = p as Record<string, unknown>;
return {
...p,
milestone: normalizeStoredString(raw.milestone),
preset: normalizeStoredString(raw.preset),
};
}
function readServerPresets(value: unknown): SavedPreset[] | null {
if (!Array.isArray(value)) return null;
return value.filter(isSavedPreset);
const normalized: SavedPreset[] = [];
for (const item of value) {
const preset = normalizeSavedPreset(item);
if (preset) normalized.push(preset);
}
return normalized;
}
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>((next) => ({
@@ -0,0 +1,30 @@
import type { MockGitLabIssueSeed } from "../../helpers/api-client";
import { GITLAB_PROJECT } from "../../helpers/gitlab";
export const ISSUES_ENDPOINT = /^\/api\/v1\/gitlab\/user\/issues$/;
export function seededIssue(
iid: number,
title: string,
overrides: { milestone?: string } = {},
): MockGitLabIssueSeed {
const now = new Date().toISOString();
return {
id: iid + 20_000,
iid,
project_id: 101,
title,
body: `Body for ${title}`,
url: `https://gitlab.example.test/${GITLAB_PROJECT}/-/issues/${iid}`,
web_url: `https://gitlab.example.test/${GITLAB_PROJECT}/-/issues/${iid}`,
state: "opened",
author_username: "reporter",
project_namespace: "platform",
project_path: GITLAB_PROJECT,
labels: [],
assignees: [],
milestone: overrides.milestone ?? "",
created_at: now,
updated_at: now,
};
}
@@ -0,0 +1,210 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { GITLAB_PROJECT } from "../../helpers/gitlab";
import { GitLabPage } from "../../pages/gitlab-page";
import { ISSUES_ENDPOINT, seededIssue } from "./gitlab-issue-milestone-filter-helpers";
test.describe("GitLab issue milestone filter", () => {
test("composes with a preset, narrows the list, and clears on preset/kind switch", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.configureGitLab(seedData.workspaceId);
await apiClient.mockGitLabAddIssues(seedData.workspaceId, GITLAB_PROJECT, [
seededIssue(501, "Ship OAuth callback", { milestone: "Next" }),
seededIssue(502, "Improve onboarding copy"),
]);
const gitlab = new GitLabPage(testPage);
await gitlab.goto();
const scopeBar = testPage.getByTestId("gitlab-presets-scope-bar");
const initialIssuesLoaded = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
await initialIssuesLoaded;
await expect(gitlab.issueRow(501)).toBeVisible();
await expect(gitlab.issueRow(502)).toBeVisible();
// The chip reflects the issue's own milestone regardless of the filter.
await expect(gitlab.issueRow(501).getByTestId("gitlab-issue-milestone")).toHaveText("Next");
await expect(gitlab.issueRow(502).getByTestId("gitlab-issue-milestone")).toHaveCount(0);
const milestoneInput = testPage.getByTestId("gitlab-milestone-filter");
await expect(milestoneInput).toBeVisible();
// Typing without committing issues no request: both rows stay put.
await milestoneInput.fill("Next");
await expect(gitlab.issueRow(502)).toBeVisible();
const narrowed = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.press("Enter");
await narrowed;
await expect(gitlab.issueRow(501)).toBeVisible();
await expect(gitlab.issueRow(502)).toHaveCount(0);
// No match renders the empty state, not an error.
const noMatch = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill("Nonexistent");
await milestoneInput.press("Enter");
await noMatch;
await expect(testPage.getByText("No issues match this filter.", { exact: true })).toBeVisible();
// Clearing the milestone restores the unfiltered list.
const cleared = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill("");
await milestoneInput.press("Enter");
await cleared;
await expect(gitlab.issueRow(501)).toBeVisible();
await expect(gitlab.issueRow(502)).toBeVisible();
const reNarrowed = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill("Next");
await milestoneInput.press("Enter");
await reNarrowed;
await expect(gitlab.issueRow(502)).toHaveCount(0);
// Switching the sidebar preset clears the committed milestone.
const presetSwitched = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await scopeBar.getByRole("button", { name: "Created", exact: true }).click();
await presetSwitched;
await expect(milestoneInput).toHaveValue("");
await expect(gitlab.issueRow(502)).toBeVisible();
// Merge requests view never renders the milestone control and is unaffected.
await scopeBar.getByRole("button", { name: "Merge requests", exact: true }).click();
await expect(testPage.getByTestId("gitlab-milestone-filter")).toHaveCount(0);
// Switching back to Issues shows an empty input over an unnarrowed list.
const backToIssues = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
await backToIssues;
await expect(testPage.getByTestId("gitlab-milestone-filter")).toHaveValue("");
await expect(gitlab.issueRow(501)).toBeVisible();
await expect(gitlab.issueRow(502)).toBeVisible();
});
test("trims the committed milestone, saves a query, and restores it on reselect", async ({
testPage,
apiClient,
seedData,
}) => {
// This test chains many sequential network round-trips (commit, save,
// preset switch, reselect, delete); give it headroom beyond the 60s
// project default rather than relying on causal waits to also outrun
// per-step host-load variance.
test.setTimeout(180_000);
await apiClient.configureGitLab(seedData.workspaceId);
await apiClient.mockGitLabAddIssues(seedData.workspaceId, GITLAB_PROJECT, [
seededIssue(601, "Milestone-scoped issue", { milestone: "Sprint 42" }),
seededIssue(602, "Unrelated issue"),
]);
const gitlab = new GitLabPage(testPage);
await gitlab.goto();
const scopeBar = testPage.getByTestId("gitlab-presets-scope-bar");
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
const milestoneInput = testPage.getByTestId("gitlab-milestone-filter");
const committed = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill(" Sprint 42 ");
await milestoneInput.press("Enter");
await committed;
// Commit normalizes the visible input, not just the outgoing request.
await expect(milestoneInput).toHaveValue("Sprint 42");
await expect(gitlab.issueRow(601)).toBeVisible();
await expect(gitlab.issueRow(602)).toHaveCount(0);
const savedMenu = testPage.getByTestId("gitlab-saved-queries-menu");
await savedMenu.click();
const saveItem = testPage.getByRole("menuitem", { name: "Save current query" });
await expect(saveItem).toBeEnabled();
await saveItem.click();
const dialog = testPage.getByRole("dialog", { name: "Save GitLab query" });
await expect(dialog).toBeVisible();
// Suggested-label precedence: milestone alone, since query and project are empty.
await expect(dialog.getByLabel("Name")).toHaveValue("Sprint 42");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(savedMenu).toContainText("Sprint 42");
// Selecting a sidebar preset clears the milestone and widens the list again.
const presetSwitched = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await scopeBar.getByRole("button", { name: "Created", exact: true }).click();
await presetSwitched;
await expect(milestoneInput).toHaveValue("");
await expect(gitlab.issueRow(602)).toBeVisible();
// Reselecting the saved query restores its committed milestone.
await savedMenu.click();
const reselected = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await testPage.getByRole("menuitem", { name: "Sprint 42", exact: true }).click();
await reselected;
await expect(milestoneInput).toHaveValue("Sprint 42");
await expect(gitlab.issueRow(601)).toBeVisible();
await expect(gitlab.issueRow(602)).toHaveCount(0);
// Selecting the item above closes the dropdown via Radix's own
// data-closed:animate-out exit transition (duration-100 in
// @kandev/ui/dropdown-menu.tsx); Radix keeps the outgoing menu instance
// mounted for that ~100ms while it animates out. Reopening the trigger
// before that finishes races the old instance's unmount and can leave
// the freshly reopened menu without content (root cause of this test's
// documented pre-existing flake — confirmed via a CI failure artifact
// showing the menu closed, not mid-click, at time of failure). Waiting
// for the menu to fully close first removes the race.
await expect(testPage.getByRole("menu")).toBeHidden();
// Deleting the selected saved query clears the milestone in the same update.
await savedMenu.click();
const deleted = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await testPage.getByRole("menuitem", { name: "Delete Sprint 42 saved query" }).click();
await deleted;
await expect(milestoneInput).toHaveValue("");
await expect(gitlab.issueRow(602)).toBeVisible();
});
test("resets pagination to page 1 when the committed milestone changes from page 2 (Scenario 6, clause 1)", async ({
testPage,
apiClient,
seedData,
}) => {
// All 30 issues share one milestone, so filtering by it does not shrink the
// result count below the 25-per-page threshold: the page still resets even
// though the total (and therefore totalPages) is unchanged by the commit.
const issues = Array.from({ length: 30 }, (_, i) =>
seededIssue(801 + i, `Milestone issue ${i + 1}`, { milestone: "Sprint 1" }),
);
await apiClient.configureGitLab(seedData.workspaceId);
await apiClient.mockGitLabAddIssues(seedData.workspaceId, GITLAB_PROJECT, issues);
const gitlab = new GitLabPage(testPage);
await gitlab.goto();
const scopeBar = testPage.getByTestId("gitlab-presets-scope-bar");
const initialIssuesLoaded = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
await initialIssuesLoaded;
await expect(gitlab.issueRow(801)).toBeVisible();
const page2Loaded = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await testPage.getByRole("link", { name: "2", exact: true }).click();
await page2Loaded;
await expect(gitlab.issueRow(830)).toBeVisible();
await expect(testPage.getByRole("link", { name: "2", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
const milestoneInput = testPage.getByTestId("gitlab-milestone-filter");
const filtered = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill("Sprint 1");
await milestoneInput.press("Enter");
await filtered;
await expect(gitlab.issueRow(801)).toBeVisible();
await expect(testPage.getByRole("link", { name: "1", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
});
});
@@ -0,0 +1,45 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { GITLAB_PROJECT } from "../../helpers/gitlab";
import { GitLabPage } from "../../pages/gitlab-page";
import { ISSUES_ENDPOINT, seededIssue } from "./gitlab-issue-milestone-filter-helpers";
test.describe("Mobile GitLab issue milestone filter", () => {
test("filters issues from the mobile sheet without clipping the milestone input", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await apiClient.configureGitLab(seedData.workspaceId);
await apiClient.mockGitLabAddIssues(seedData.workspaceId, GITLAB_PROJECT, [
seededIssue(901, "Mobile milestone issue", { milestone: "Next" }),
seededIssue(902, "Mobile unrelated issue"),
]);
const gitlab = new GitLabPage(testPage);
await gitlab.goto();
await gitlab.mobileFiltersButton.tap();
await expect(gitlab.mobileSidebar).toBeVisible();
const issuesLoaded = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await gitlab.mobileSidebar.getByTestId("gitlab-kind-issue").tap();
await issuesLoaded;
await expect(gitlab.mobileSidebar).toBeHidden();
const milestoneInput = testPage.getByTestId("gitlab-milestone-filter");
await expect(milestoneInput).toBeVisible();
const inputBox = await milestoneInput.boundingBox();
expect(inputBox, "mobile milestone input has no bounding box").not.toBeNull();
if (inputBox) expect(Math.round(inputBox.height)).toBeGreaterThanOrEqual(44);
const filtered = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await milestoneInput.fill("Next");
await milestoneInput.press("Enter");
await filtered;
await expect(gitlab.issueRow(901)).toBeVisible();
await expect(gitlab.issueRow(902)).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab issue milestone filter");
});
});
@@ -242,6 +242,31 @@ describe("gitlab-api — user search", () => {
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.cache).toBe("no-store");
});
it("searchUserIssues includes the milestone param when set", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ workspaceId: "ws-2", milestone: "Next" });
const url = fetchSpy.mock.calls[0]![0] as string;
expect(new URL(url).searchParams.get("milestone")).toBe("Next");
});
it("searchUserIssues omits the milestone param when empty, byte-identical to before", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ workspaceId: "ws-2", filter: "created_by_me", perPage: 10 });
const withoutMilestone = fetchSpy.mock.calls[0]![0] as string;
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({
workspaceId: "ws-2",
filter: "created_by_me",
perPage: 10,
milestone: "",
});
const withEmptyMilestone = fetchSpy.mock.calls[1]![0] as string;
expect(withEmptyMilestone).toBe(withoutMilestone);
expect(new URL(withoutMilestone).searchParams.has("milestone")).toBe(false);
});
});
describe("gitlab-api — workspace watch actions", () => {
+2
View File
@@ -195,12 +195,14 @@ export async function searchUserIssues(params: {
workspaceId: string;
filter?: string;
customQuery?: string;
milestone?: string;
page?: number;
perPage?: number;
}) {
const qs = new URLSearchParams({ workspace_id: params.workspaceId });
if (params.filter) qs.set("filter", params.filter);
if (params.customQuery) qs.set("custom_query", params.customQuery);
if (params.milestone) qs.set("milestone", params.milestone);
if (params.page) qs.set("page", String(params.page));
if (params.perPage) qs.set("per_page", String(params.perPage));
return fetchJson<IssueSearchPage>(`/api/v1/gitlab/user/issues?${qs.toString()}`, {
+1
View File
@@ -161,6 +161,7 @@ export type Issue = {
project_path: string;
labels: string[];
assignees: string[];
milestone?: string;
created_at: string;
updated_at: string;
closed_at?: string;
+2
View File
@@ -66,6 +66,7 @@
"discussions": "Discussions",
"draft": "Draft",
"eGNeedsMyReview": "e.g. Needs my review",
"eGSprint42": "e.g. Sprint 42",
"editIssueWatch": "Edit GitLab issue watch",
"editReviewWatch": "Edit GitLab review watch",
"editWatch": "Edit watch",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · merge requests and issues",
"mergeThisMergeRequest": "Merge this merge request?",
"mergeability": "Mergeability",
"milestoneFilterLabel": "Filter by milestone",
"mockTest": "Mock (test)",
"mrs": "MRs",
"name": "Name",
+2
View File
@@ -66,6 +66,7 @@
"discussions": "Ďĩśćũśśĩōńś",
"draft": "Ďŕàƒţ",
"eGNeedsMyReview": "ē.ĝ. Ńēēďś ḿŷ ŕēvĩēŵ",
"eGSprint42": "ē.ĝ. Śƥŕĩńţ 42",
"editIssueWatch": "Ēďĩţ ĜĩţĹàƀ ĩśśũē ŵàţćĥ",
"editReviewWatch": "Ēďĩţ ĜĩţĹàƀ ŕēvĩēŵ ŵàţćĥ",
"editWatch": "Ēďĩţ ŵàţćĥ",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · ḿēŕĝē ŕēqũēśţś àńď ĩśśũēś",
"mergeThisMergeRequest": "Ḿēŕĝē ţĥĩś ḿēŕĝē ŕēqũēśţ?",
"mergeability": "Ḿēŕĝēàƀĩĺĩţŷ",
"milestoneFilterLabel": "Ƒĩĺţēŕ ƀŷ ḿĩĺēśţōńē",
"mockTest": "Ḿōćķ (ţēśţ)",
"mrs": "ḾŔś",
"name": "Ńàḿē",
+2
View File
@@ -66,6 +66,7 @@
"discussions": "Discussões",
"draft": "Rascunho",
"eGNeedsMyReview": "ex.: Precisa da minha revisão",
"eGSprint42": "ex.: Sprint 42",
"editIssueWatch": "Editar a vigilância de issues do GitLab",
"editReviewWatch": "Editar a vigilância de revisões do GitLab",
"editWatch": "Editar vigilância",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · merge requests e issues",
"mergeThisMergeRequest": "Fazer merge deste merge request?",
"mergeability": "Possibilidade de merge",
"milestoneFilterLabel": "Filtrar por marco",
"mockTest": "Mock (teste)",
"mrs": "MRs",
"name": "Nome",
+2
View File
@@ -66,6 +66,7 @@
"discussions": "讨论",
"draft": "草稿",
"eGNeedsMyReview": "例如:需要我审阅",
"eGSprint42": "例如 Sprint 42",
"editIssueWatch": "编辑 GitLab 议题监控",
"editReviewWatch": "编辑 GitLab 审阅监控",
"editWatch": "编辑监控",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · 合并请求与议题",
"mergeThisMergeRequest": "合并此合并请求?",
"mergeability": "可合并性",
"milestoneFilterLabel": "按里程碑筛选",
"mockTest": "模拟(测试)",
"mrs": "MR",
"name": "名称",
+2
View File
@@ -66,6 +66,7 @@
"discussions": "討論",
"draft": "草稿",
"eGNeedsMyReview": "例如:需要我審閱",
"eGSprint42": "例如 Sprint 42",
"editIssueWatch": "編輯 GitLab 議題監控",
"editReviewWatch": "編輯 GitLab 審閱監控",
"editWatch": "編輯監控",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · 合併請求與議題",
"mergeThisMergeRequest": "合併此合併請求?",
"mergeability": "可合併性",
"milestoneFilterLabel": "按里程碑篩選",
"mockTest": "模擬(測試)",
"mrs": "MR",
"name": "名稱",
+3 -1
View File
@@ -66,6 +66,7 @@
"discussions": "討論",
"draft": "草稿",
"eGNeedsMyReview": "例如:需要我審閱",
"eGSprint42": "例如 Sprint 42",
"editIssueWatch": "編輯 GitLab 議題監控",
"editReviewWatch": "編輯 GitLab 審閱監控",
"editWatch": "編輯監控",
@@ -159,6 +160,7 @@
"mergeRequestsAndIssues": "{{host}} · 合併請求與議題",
"mergeThisMergeRequest": "合併此合併請求?",
"mergeability": "可合併性",
"milestoneFilterLabel": "按里程碑篩選",
"mockTest": "模擬(測試)",
"mrs": "MR",
"name": "名稱",
@@ -207,7 +209,7 @@
"promptSentToTheSelectedAgent": "為每個新匹配項傳送給所選代理程式設定檔的提示詞。",
"placeholderActionTitle": "合併請求或問題的標題",
"placeholderActionUrl": "合併請求或問題的 URL",
"placeholderDescription": "描述內容",
"placeholderDescription": "描述正文",
"placeholderIssueIid": "問題 IID",
"placeholderMergeRequestIid": "合併請求 IID",
"placeholderProjectPath": "專案路徑",
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
docs/specs/gitlab-issue-milestone-filter/spec.md 80514
docs/specs/office-task-content-editing/spec.md 84103
docs/specs/task-cost-ledger/spec.md 91931
docs/specs/task-delivery-ledger/spec.md 157207
1 docs/specs/office-task-content-editing/spec.md docs/specs/gitlab-issue-milestone-filter/spec.md 84103 80514
1 docs/specs/gitlab-issue-milestone-filter/spec.md 80514
2 docs/specs/office-task-content-editing/spec.md docs/specs/office-task-content-editing/spec.md 84103 84103
3 docs/specs/task-cost-ledger/spec.md docs/specs/task-cost-ledger/spec.md 91931 91931
4 docs/specs/task-delivery-ledger/spec.md docs/specs/task-delivery-ledger/spec.md 157207 157207