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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()}`, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ńàḿē",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "名称",
|
||||
|
||||
@@ -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": "名稱",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
Reference in New Issue
Block a user