diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 086dadcf5b7b96d0de0094fd7d4cdbe80fd7d455..9f3ca385d93f530725590499ac342c8c2be0b2ce 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -15,7 +15,8 @@ # state: 'opened' or 'closed' or 'locked' or 'all' # group_id: integer # project_id: integer -# milestone_title: string +# milestone_title: string (cannot be simultaneously used with milestone_wildcard_id) +# milestone_wildcard_id: 'none', 'any', 'upcoming', 'started' (cannot be simultaneously used with milestone_title) # release_tag: string # author_id: integer # author_username: string diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index 51e12dde51dd64590441b23998e804cf64962875..595f4e4cf8aada308b02c8a8cd41f6d93ddd3c4c 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -4,9 +4,11 @@ class IssuableFinder class Params < SimpleDelegator include Gitlab::Utils::StrongMemoize - # This is used as a common filter for None / Any + # This is used as a common filter for None / Any / Upcoming / Started FILTER_NONE = 'none' FILTER_ANY = 'any' + FILTER_STARTED = 'started' + FILTER_UPCOMING = 'upcoming' # This is used in unassigning users NONE = '0' @@ -42,25 +44,35 @@ def labels? end def milestones? - params[:milestone_title].present? + params[:milestone_title].present? || params[:milestone_wildcard_id].present? end def filter_by_no_milestone? - # Accepts `No Milestone` for compatibility - params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title + # Usage of `No Milestone` and `none`/`None` in milestone_title to be deprecated + # https://gitlab.com/gitlab-org/gitlab/-/issues/336044 + params[:milestone_title].to_s.downcase == FILTER_NONE || + params[:milestone_title] == Milestone::None.title || + params[:milestone_wildcard_id].to_s.downcase == FILTER_NONE end def filter_by_any_milestone? - # Accepts `Any Milestone` for compatibility - params[:milestone_title].to_s.downcase == FILTER_ANY || params[:milestone_title] == Milestone::Any.title + # Usage of `Any Milestone` and `any`/`Any` in milestone_title to be deprecated + # https://gitlab.com/gitlab-org/gitlab/-/issues/336044 + params[:milestone_title].to_s.downcase == FILTER_ANY || + params[:milestone_title] == Milestone::Any.title || + params[:milestone_wildcard_id].to_s.downcase == FILTER_ANY end def filter_by_upcoming_milestone? - params[:milestone_title] == Milestone::Upcoming.name + # Usage of `#upcoming` in milestone_title to be deprecated + # https://gitlab.com/gitlab-org/gitlab/-/issues/336044 + params[:milestone_title] == Milestone::Upcoming.name || params[:milestone_wildcard_id].to_s.downcase == FILTER_UPCOMING end def filter_by_started_milestone? - params[:milestone_title] == Milestone::Started.name + # Usage of `#started` in milestone_title to be deprecated + # https://gitlab.com/gitlab-org/gitlab/-/issues/336044 + params[:milestone_title] == Milestone::Started.name || params[:milestone_wildcard_id].to_s.downcase == FILTER_STARTED end def filter_by_no_release? diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 40d6730d232d5c3cc98b99b3207f470d986a662b..c5ffc29d4ef8de5a8461a0328e1b1ec9b828920b 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -11,7 +11,8 @@ # state: 'opened' or 'closed' or 'all' # group_id: integer # project_id: integer -# milestone_title: string +# milestone_title: string (cannot be simultaneously used with milestone_wildcard_id) +# milestone_wildcard_id: 'none', 'any', 'upcoming', 'started' (cannot be simultaneously used with milestone_title) # assignee_id: integer # search: string # in: 'title', 'description', or a string joining them with comma diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index b91dca3fc47ae63eb6ec4e17005da086a1036d61..8d77c0f3a8d01586526035760a2d1fdb7d2bd64c 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -56,6 +56,9 @@ module IssueResolverArguments as: :issue_types, description: 'Filter issues by the given issue types.', required: false + argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum, + required: false, + description: 'Filter issues by milestone ID wildcard.' argument :not, Types::Issues::NegatedIssueFilterInputType, description: 'Negated arguments.', prepare: ->(negated_args, ctx) { negated_args.to_h }, @@ -82,10 +85,9 @@ def resolve_with_lookahead(**args) end def ready?(**args) - if args.slice(*mutually_exclusive_assignee_username_args).compact.size > 1 - arg_str = mutually_exclusive_assignee_username_args.map { |x| x.to_s.camelize(:lower) }.join(', ') - raise Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." - end + params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args) + params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) + params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) super end @@ -106,6 +108,17 @@ def prepare_assignee_username_params(args) args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present? end + def params_not_mutually_exclusive(args, mutually_exclusive_args) + if args.slice(*mutually_exclusive_args).compact.size > 1 + arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') + raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." + end + end + + def mutually_exclusive_milestone_args + [:milestone_title, :milestone_wildcard_id] + end + def mutually_exclusive_assignee_username_args [:assignee_usernames, :assignee_username] end diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index 201b1ee6b977330c67cadb26476cc693e3b18d7b..e5125c554a4e560311a120e1f89b949172776407 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -20,6 +20,9 @@ class NegatedIssueFilterInputType < BaseInputObject argument :assignee_id, GraphQL::Types::String, required: false, description: 'ID of a user not assigned to the issues.' + argument :milestone_wildcard_id, ::Types::NegatedMilestoneWildcardIdEnum, + required: false, + description: 'Filter by negated milestone wildcard values.' end end end diff --git a/app/graphql/types/milestone_wildcard_id_enum.rb b/app/graphql/types/milestone_wildcard_id_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..b5b339b1e5b77d8ea17504b72295e599acc83c14 --- /dev/null +++ b/app/graphql/types/milestone_wildcard_id_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class MilestoneWildcardIdEnum < BaseEnum + graphql_name 'MilestoneWildcardId' + description 'Milestone ID wildcard values' + + value 'NONE', 'No milestone is assigned.' + value 'ANY', 'A milestone is assigned.' + value 'STARTED', 'An open, started milestone (start date <= today).' + value 'UPCOMING', 'An open milestone due in the future (due date >= today).' + end +end diff --git a/app/graphql/types/negated_milestone_wildcard_id_enum.rb b/app/graphql/types/negated_milestone_wildcard_id_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..ca27a6c7b6edf9c7cb86d33277c81a5b17f5aa05 --- /dev/null +++ b/app/graphql/types/negated_milestone_wildcard_id_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class NegatedMilestoneWildcardIdEnum < BaseEnum + graphql_name 'NegatedMilestoneWildcardId' + description 'Negated Milestone ID wildcard values' + + value 'STARTED', 'An open, started milestone (start date <= today).' + value 'UPCOMING', 'An open milestone due in the future (due date >= today).' + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 4e0147815cb32e54bd2586d899a88dbb0270bcdd..68c5dd46db5bdfed123bca0fea5aa60e50f1da5e 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9662,6 +9662,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | | <a id="groupissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | | <a id="groupissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | +| <a id="groupissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | | <a id="groupissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. | | <a id="groupissuessearch"></a>`search` | [`String`](#string) | Search query for issue title or description. | | <a id="groupissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. | @@ -11902,6 +11903,7 @@ Returns [`Issue`](#issue). | <a id="projectissueiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | | <a id="projectissuelabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | | <a id="projectissuemilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | +| <a id="projectissuemilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | | <a id="projectissuenot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. | | <a id="projectissuesearch"></a>`search` | [`String`](#string) | Search query for issue title or description. | | <a id="projectissuesort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. | @@ -11933,6 +11935,7 @@ Returns [`IssueStatusCountsType`](#issuestatuscountstype). | <a id="projectissuestatuscountsiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | | <a id="projectissuestatuscountslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | | <a id="projectissuestatuscountsmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | +| <a id="projectissuestatuscountsmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | | <a id="projectissuestatuscountsnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. | | <a id="projectissuestatuscountssearch"></a>`search` | [`String`](#string) | Search query for issue title or description. | | <a id="projectissuestatuscountstypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter issues by the given issue types. | @@ -11968,6 +11971,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="projectissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | | <a id="projectissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | | <a id="projectissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | +| <a id="projectissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | | <a id="projectissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. | | <a id="projectissuessearch"></a>`search` | [`String`](#string) | Search query for issue title or description. | | <a id="projectissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. | @@ -15044,6 +15048,17 @@ Current state of milestone. | <a id="milestonestateenumactive"></a>`active` | Milestone is currently active. | | <a id="milestonestateenumclosed"></a>`closed` | Milestone is closed. | +### `MilestoneWildcardId` + +Milestone ID wildcard values. + +| Value | Description | +| ----- | ----------- | +| <a id="milestonewildcardidany"></a>`ANY` | A milestone is assigned. | +| <a id="milestonewildcardidnone"></a>`NONE` | No milestone is assigned. | +| <a id="milestonewildcardidstarted"></a>`STARTED` | An open, started milestone (start date <= today). | +| <a id="milestonewildcardidupcoming"></a>`UPCOMING` | An open milestone due in the future (due date >= today). | + ### `MoveType` The position to which the adjacent object should be moved. @@ -15080,6 +15095,15 @@ Negated Iteration ID wildcard values. | ----- | ----------- | | <a id="negatediterationwildcardidcurrent"></a>`CURRENT` | Current iteration. | +### `NegatedMilestoneWildcardId` + +Negated Milestone ID wildcard values. + +| Value | Description | +| ----- | ----------- | +| <a id="negatedmilestonewildcardidstarted"></a>`STARTED` | An open, started milestone (start date <= today). | +| <a id="negatedmilestonewildcardidupcoming"></a>`UPCOMING` | An open milestone due in the future (due date >= today). | + ### `NetworkPolicyKind` Kind of the network policy. @@ -16774,6 +16798,7 @@ Represents an escalation rule. | <a id="negatedissuefilterinputiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by negated iteration ID wildcard. | | <a id="negatedissuefilterinputlabelname"></a>`labelName` | [`[String!]`](#string) | Labels not applied to this issue. | | <a id="negatedissuefilterinputmilestonetitle"></a>`milestoneTitle` | [`[String!]`](#string) | Milestone not applied to this issue. | +| <a id="negatedissuefilterinputmilestonewildcardid"></a>`milestoneWildcardId` | [`NegatedMilestoneWildcardId`](#negatedmilestonewildcardid) | Filter by negated milestone wildcard values. | | <a id="negatedissuefilterinputweight"></a>`weight` | [`String`](#string) | Weight not applied to the issue. | ### `OncallRotationActivePeriodInputType` diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 89e4e803fa821166729a82e1ad5265ee6558cd0e..d0675633a9b161bbca6db7c695d17835f5df411b 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -11,9 +11,9 @@ let_it_be(:project) { create(:project, group: group) } let_it_be(:other_project) { create(:project, group: group) } - let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:started_milestone) { create(:milestone, project: project, title: "started milestone", start_date: 1.day.ago) } let_it_be(:assignee) { create(:user) } - let_it_be(:issue1) { create(:incident, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: milestone) } + let_it_be(:issue1) { create(:incident, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: started_milestone) } let_it_be(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) } let_it_be(:issue3) { create(:issue, project: other_project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) } let_it_be(:issue4) { create(:issue) } @@ -43,7 +43,63 @@ end it 'filters by milestone' do - expect(resolve_issues(milestone_title: [milestone.title])).to contain_exactly(issue1) + expect(resolve_issues(milestone_title: [started_milestone.title])).to contain_exactly(issue1) + end + + describe 'filtering by milestone wildcard id' do + let_it_be(:upcoming_milestone) { create(:milestone, project: project, title: "upcoming milestone", start_date: 1.day.ago, due_date: 1.day.from_now) } + let_it_be(:past_milestone) { create(:milestone, project: project, title: "past milestone", due_date: 1.day.ago) } + let_it_be(:future_milestone) { create(:milestone, project: project, title: "future milestone", start_date: 1.day.from_now) } + let_it_be(:issue5) { create(:issue, project: project, state: :opened, milestone: upcoming_milestone) } + let_it_be(:issue6) { create(:issue, project: project, state: :opened, milestone: past_milestone) } + let_it_be(:issue7) { create(:issue, project: project, state: :opened, milestone: future_milestone) } + + let(:wildcard_started) { 'STARTED' } + let(:wildcard_upcoming) { 'UPCOMING' } + let(:wildcard_any) { 'ANY' } + let(:wildcard_none) { 'NONE' } + + it 'returns issues with started milestone' do + expect(resolve_issues(milestone_wildcard_id: wildcard_started)).to contain_exactly(issue1, issue5) + end + + it 'returns issues with upcoming milestone' do + expect(resolve_issues(milestone_wildcard_id: wildcard_upcoming)).to contain_exactly(issue5) + end + + it 'returns issues with any milestone' do + expect(resolve_issues(milestone_wildcard_id: wildcard_any)).to contain_exactly(issue1, issue5, issue6, issue7) + end + + it 'returns issues with no milestone' do + expect(resolve_issues(milestone_wildcard_id: wildcard_none)).to contain_exactly(issue2) + end + + it 'raises a mutually exclusive filter error when wildcard and title are provided' do + expect do + resolve_issues(milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') + end + + context 'negated filtering' do + it 'returns issues matching the searched title after applying a negated filter' do + expect(resolve_issues(milestone_title: ['past milestone'], not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6) + end + + it 'returns issues excluding the ones with started milestone' do + expect(resolve_issues(not: { milestone_wildcard_id: wildcard_started })).to contain_exactly(issue7) + end + + it 'returns issues excluding the ones with upcoming milestone' do + expect(resolve_issues(not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6) + end + + it 'raises a mutually exclusive filter error when wildcard and title are provided as negated filters' do + expect do + resolve_issues(not: { milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started }) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') + end + end end it 'filters by two assignees' do @@ -169,7 +225,7 @@ end it 'returns issues without the specified milestone' do - expect(resolve_issues(not: { milestone_title: [milestone.title] })).to contain_exactly(issue2) + expect(resolve_issues(not: { milestone_title: [started_milestone.title] })).to contain_exactly(issue2) end it 'returns issues without the specified assignee_usernames' do