Commit aea760ee authored by Matthias Käppler's avatar Matthias Käppler

Merge branch '3918-graphql-expose-timelogs-at-root' into 'master'

Expose timelogs in GraphQL query type and add user/project filter

See merge request gitlab-org/gitlab!67185
parents a9f10260 6b0f410e
# frozen_string_literal: true
module ResolvesIds
extend ActiveSupport::Concern
def resolve_ids(ids, type)
Array.wrap(ids).map do |id|
next unless id.present?
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = type.coerce_isolated_input(id)
id.model_id
end.compact
end
end
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
module ResolvesSnippets module ResolvesSnippets
extend ActiveSupport::Concern extend ActiveSupport::Concern
include ResolvesIds
included do included do
type Types::SnippetType.connection_type, null: true type Types::SnippetType.connection_type, null: true
...@@ -27,22 +28,11 @@ module ResolvesSnippets ...@@ -27,22 +28,11 @@ module ResolvesSnippets
def snippet_finder_params(args) def snippet_finder_params(args)
{ {
ids: resolve_ids(args[:ids]), ids: resolve_ids(args[:ids], ::Types::GlobalIDType[::Snippet]),
scope: args[:visibility] scope: args[:visibility]
}.merge(options_by_type(args[:type])) }.merge(options_by_type(args[:type]))
end end
def resolve_ids(ids, type = ::Types::GlobalIDType[::Snippet])
Array.wrap(ids).map do |id|
next unless id.present?
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = type.coerce_isolated_input(id)
id.model_id
end.compact
end
def options_by_type(type) def options_by_type(type)
case type case type
when 'personal' when 'personal'
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
module Resolvers module Resolvers
class SnippetsResolver < BaseResolver class SnippetsResolver < BaseResolver
include ResolvesIds
include ResolvesSnippets include ResolvesSnippets
ERROR_MESSAGE = 'Filtering by both an author and a project is not supported' ERROR_MESSAGE = 'Filtering by both an author and a project is not supported'
......
...@@ -3,33 +3,50 @@ ...@@ -3,33 +3,50 @@
module Resolvers module Resolvers
class TimelogResolver < BaseResolver class TimelogResolver < BaseResolver
include LooksAhead include LooksAhead
include ResolvesIds
type ::Types::TimelogType.connection_type, null: false type ::Types::TimelogType.connection_type, null: false
argument :start_date, Types::TimeType, argument :start_date, Types::TimeType,
required: false, required: false,
description: 'List time logs within a date range where the logged date is equal to or after startDate.' description: 'List timelogs within a date range where the logged date is equal to or after startDate.'
argument :end_date, Types::TimeType, argument :end_date, Types::TimeType,
required: false, required: false,
description: 'List time logs within a date range where the logged date is equal to or before endDate.' description: 'List timelogs within a date range where the logged date is equal to or before endDate.'
argument :start_time, Types::TimeType, argument :start_time, Types::TimeType,
required: false, required: false,
description: 'List time-logs within a time range where the logged time is equal to or after startTime.' description: 'List timelogs within a time range where the logged time is equal to or after startTime.'
argument :end_time, Types::TimeType, argument :end_time, Types::TimeType,
required: false, required: false,
description: 'List time-logs within a time range where the logged time is equal to or before endTime.' description: 'List timelogs within a time range where the logged time is equal to or before endTime.'
argument :project_id, ::Types::GlobalIDType[::Project],
required: false,
description: 'List timelogs for a project.'
argument :group_id, ::Types::GlobalIDType[::Group],
required: false,
description: 'List timelogs for a group.'
argument :username, GraphQL::Types::String,
required: false,
description: 'List timelogs for a user.'
def resolve_with_lookahead(**args) def resolve_with_lookahead(**args)
build_timelogs validate_args!(object, args)
timelogs = object&.timelogs || Timelog.limit(GitlabSchema.default_max_page_size)
if args.any? if args.any?
validate_args!(args) args = parse_datetime_args(args)
build_parsed_args(args)
validate_time_difference! timelogs = apply_user_filter(timelogs, args)
apply_time_filter timelogs = apply_project_filter(timelogs, args)
timelogs = apply_time_filter(timelogs, args)
timelogs = apply_group_filter(timelogs, args)
end end
apply_lookahead(timelogs) apply_lookahead(timelogs)
...@@ -37,30 +54,32 @@ module Resolvers ...@@ -37,30 +54,32 @@ module Resolvers
private private
attr_reader :parsed_args, :timelogs
def preloads def preloads
{ {
note: [:note] note: [:note]
} }
end end
def validate_args!(args) def validate_args!(object, args)
if args[:start_time] && args[:start_date] if args.empty? && object.nil?
raise_argument_error('Provide at least one argument')
elsif args[:start_time] && args[:start_date]
raise_argument_error('Provide either a start date or time, but not both') raise_argument_error('Provide either a start date or time, but not both')
elsif args[:end_time] && args[:end_date] elsif args[:end_time] && args[:end_date]
raise_argument_error('Provide either an end date or time, but not both') raise_argument_error('Provide either an end date or time, but not both')
end end
end end
def build_parsed_args(args) def parse_datetime_args(args)
if times_provided?(args) if times_provided?(args)
@parsed_args = args args
else else
@parsed_args = args.except(:start_date, :end_date) parsed_args = args.except(:start_date, :end_date)
@parsed_args[:start_time] = args[:start_date].beginning_of_day if args[:start_date] parsed_args[:start_time] = args[:start_date].beginning_of_day if args[:start_date]
@parsed_args[:end_time] = args[:end_date].end_of_day if args[:end_date] parsed_args[:end_time] = args[:end_date].end_of_day if args[:end_date]
parsed_args
end end
end end
...@@ -68,23 +87,51 @@ module Resolvers ...@@ -68,23 +87,51 @@ module Resolvers
args[:start_time] && args[:end_time] args[:start_time] && args[:end_time]
end end
def validate_time_difference! def validate_time_difference!(args)
return unless end_time_before_start_time? return unless end_time_before_start_time?(args)
raise_argument_error('Start argument must be before End argument') raise_argument_error('Start argument must be before End argument')
end end
def end_time_before_start_time? def end_time_before_start_time?(args)
times_provided?(parsed_args) && parsed_args[:end_time] < parsed_args[:start_time] times_provided?(args) && args[:end_time] < args[:start_time]
end
def apply_project_filter(timelogs, args)
return timelogs unless args[:project_id]
project = resolve_ids(args[:project_id], ::Types::GlobalIDType[::Project])
timelogs.in_project(project)
end
def apply_group_filter(timelogs, args)
return timelogs unless args[:group_id]
group = Group.find_by_id(resolve_ids(args[:group_id], ::Types::GlobalIDType[::Group]))
timelogs.in_group(group)
end
def apply_user_filter(timelogs, args)
return timelogs unless args[:username]
user = UserFinder.new(args[:username]).find_by_username!
timelogs.for_user(user)
end
def apply_time_filter(timelogs, args)
return timelogs unless args[:start_time] || args[:end_time]
validate_time_difference!(args)
if args[:start_time]
timelogs = timelogs.at_or_after(args[:start_time])
end end
def build_timelogs if args[:end_time]
@timelogs = Timelog.in_group(object) timelogs = timelogs.at_or_before(args[:end_time])
end end
def apply_time_filter timelogs
@timelogs = timelogs.at_or_after(parsed_args[:start_time]) if parsed_args[:start_time]
@timelogs = timelogs.at_or_before(parsed_args[:end_time]) if parsed_args[:end_time]
end end
def raise_argument_error(message) def raise_argument_error(message)
......
...@@ -354,6 +354,13 @@ module Types ...@@ -354,6 +354,13 @@ module Types
description: 'The CI Job Tokens scope of access.', description: 'The CI Job Tokens scope of access.',
resolver: Resolvers::Ci::JobTokenScopeResolver resolver: Resolvers::Ci::JobTokenScopeResolver
field :timelogs,
Types::TimelogType.connection_type, null: true,
description: 'Time logged on issues and merge requests in the project.',
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
def label(title:) def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder LabelsFinder
......
...@@ -131,6 +131,13 @@ module Types ...@@ -131,6 +131,13 @@ module Types
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1 field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
field :timelogs, Types::TimelogType.connection_type,
null: true,
description: 'Find timelogs visible to the current user.',
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
def design_management def design_management
DesignManagementObject.new(nil) DesignManagementObject.new(nil)
end end
......
...@@ -104,6 +104,13 @@ module Types ...@@ -104,6 +104,13 @@ module Types
Types::UserCalloutType.connection_type, Types::UserCalloutType.connection_type,
null: true, null: true,
description: 'User callouts that belong to the user.' description: 'User callouts that belong to the user.'
field :timelogs,
Types::TimelogType.connection_type,
null: true,
description: 'Time logged by the user.',
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
definition_methods do definition_methods do
def resolve_type(object, context) def resolve_type(object, context)
......
...@@ -730,6 +730,10 @@ class Group < Namespace ...@@ -730,6 +730,10 @@ class Group < Namespace
end end
# rubocop: enable CodeReuse/ServiceClass # rubocop: enable CodeReuse/ServiceClass
def timelogs
Timelog.in_group(self)
end
private private
def max_member_access(user_ids) def max_member_access(user_ids)
......
...@@ -19,6 +19,14 @@ class Timelog < ApplicationRecord ...@@ -19,6 +19,14 @@ class Timelog < ApplicationRecord
joins(:project).where(projects: { namespace: group.self_and_descendants }) joins(:project).where(projects: { namespace: group.self_and_descendants })
end end
scope :in_project, -> (project) do
where(project: project)
end
scope :for_user, -> (user) do
where(user: user)
end
scope :at_or_after, -> (start_time) do scope :at_or_after, -> (start_time) do
where('spent_at >= ?', start_time) where('spent_at >= ?', start_time)
end end
......
...@@ -211,6 +211,8 @@ class User < ApplicationRecord ...@@ -211,6 +211,8 @@ class User < ApplicationRecord
has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail'
has_many :timelogs
# #
# Validations # Validations
# #
......
This diff is collapsed.
...@@ -130,4 +130,7 @@ With this option enabled, `75h` is displayed instead of `1w 4d 3h`. ...@@ -130,4 +130,7 @@ With this option enabled, `75h` is displayed instead of `1w 4d 3h`.
- [Connection](../../api/graphql/reference/index.md#timelogconnection) - [Connection](../../api/graphql/reference/index.md#timelogconnection)
- [Edge](../../api/graphql/reference/index.md#timelogedge) - [Edge](../../api/graphql/reference/index.md#timelogedge)
- [Fields](../../api/graphql/reference/index.md#timelog) - [Fields](../../api/graphql/reference/index.md#timelog)
- [Timelogs](../../api/graphql/reference/index.md#querytimelogs)
- [Group timelogs](../../api/graphql/reference/index.md#grouptimelogs) - [Group timelogs](../../api/graphql/reference/index.md#grouptimelogs)
- [Project Timelogs](../../api/graphql/reference/index.md#projecttimelogs)
- [User Timelogs](../../api/graphql/reference/index.md#usertimelogs)
...@@ -13,7 +13,6 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -13,7 +13,6 @@ RSpec.describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:iterations) } it { expect(described_class).to have_graphql_field(:iterations) }
it { expect(described_class).to have_graphql_field(:iteration_cadences) } it { expect(described_class).to have_graphql_field(:iteration_cadences) }
it { expect(described_class).to have_graphql_field(:timelogs, complexity: 5) }
it { expect(described_class).to have_graphql_field(:vulnerabilities) } it { expect(described_class).to have_graphql_field(:vulnerabilities) }
it { expect(described_class).to have_graphql_field(:vulnerability_scanners) } it { expect(described_class).to have_graphql_field(:vulnerability_scanners) }
it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day) } it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day) }
...@@ -22,16 +21,6 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -22,16 +21,6 @@ RSpec.describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:stats) } it { expect(described_class).to have_graphql_field(:stats) }
it { expect(described_class).to have_graphql_field(:billable_members_count) } it { expect(described_class).to have_graphql_field(:billable_members_count) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'finds timelogs between start time and end time' do
is_expected.to have_graphql_arguments(:start_time, :end_time, :start_date, :end_date, :after, :before, :first, :last)
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_non_null_graphql_type(Types::TimelogType.connection_type)
end
end
describe 'vulnerabilities' do describe 'vulnerabilities' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) } let_it_be(:project) { create(:project, namespace: group) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ResolvesIds do
# gid://gitlab/Project/6
# gid://gitlab/Issue/6
# gid://gitlab/Project/6 gid://gitlab/Issue/6
context 'with a single project' do
let(:ids) { 'gid://gitlab/Project/6' }
let(:type) { ::Types::GlobalIDType[::Project] }
it 'returns the correct array' do
expect(resolve_ids).to match_array(['6'])
end
end
context 'with a single issue' do
let(:ids) { 'gid://gitlab/Issue/9' }
let(:type) { ::Types::GlobalIDType[::Issue] }
it 'returns the correct array' do
expect(resolve_ids).to match_array(['9'])
end
end
context 'with multiple users' do
let(:ids) { ['gid://gitlab/User/7', 'gid://gitlab/User/13', 'gid://gitlab/User/21'] }
let(:type) { ::Types::GlobalIDType[::User] }
it 'returns the correct array' do
expect(resolve_ids).to match_array(%w[7 13 21])
end
end
def mock_resolver
Class.new(GraphQL::Schema::Resolver) { extend ResolvesIds }
end
def resolve_ids
mock_resolver.resolve_ids(ids, type)
end
end
...@@ -5,16 +5,123 @@ require 'spec_helper' ...@@ -5,16 +5,123 @@ require 'spec_helper'
RSpec.describe Resolvers::TimelogResolver do RSpec.describe Resolvers::TimelogResolver do
include GraphqlHelpers include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :empty_repo, :public, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:error_class) { Gitlab::Graphql::Errors::ArgumentError }
specify do specify do
expect(described_class).to have_non_null_graphql_type(::Types::TimelogType.connection_type) expect(described_class).to have_non_null_graphql_type(::Types::TimelogType.connection_type)
end end
context "with a group" do shared_examples_for 'with a project' do
let_it_be(:current_user) { create(:user) } let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:group) { create(:group) } let_it_be(:timelog1) { create(:issue_timelog, issue: issue, spent_at: 2.days.ago.beginning_of_day) }
let_it_be(:project) { create(:project, :empty_repo, :public, group: group) } let_it_be(:timelog2) { create(:issue_timelog, issue: issue, spent_at: 2.days.ago.end_of_day) }
let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, spent_at: 10.days.ago) }
let(:args) { { start_time: 6.days.ago, end_time: 2.days.ago.noon } }
it 'finds all timelogs within given dates' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog1)
end
context 'when no dates specified' do
let(:args) { {} }
it 'finds all timelogs' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog1, timelog2, timelog3)
end
end
context 'when only start_time present' do
let(:args) { { start_time: 2.days.ago.noon } }
it 'finds timelogs after the start_time' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog2)
end
end
context 'when only end_time present' do
let(:args) { { end_time: 2.days.ago.noon } }
it 'finds timelogs before the end_time' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog1, timelog3)
end
end
context 'when start_time and end_date are present' do
let(:args) { { start_time: 6.days.ago, end_date: 2.days.ago } }
it 'finds timelogs until the end of day of end_date' do
timelogs = resolve_timelogs(**args)
describe '#resolve' do expect(timelogs).to contain_exactly(timelog1, timelog2)
end
end
context 'when start_date and end_time are present' do
let(:args) { { start_date: 6.days.ago, end_time: 2.days.ago.noon } }
it 'finds all timelogs within start_date and end_time' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog1)
end
end
it 'return nothing when user has insufficient permissions' do
project2 = create(:project, :empty_repo, :private)
issue2 = create(:issue, project: project2)
create(:issue_timelog, issue: issue2, spent_at: 2.days.ago.beginning_of_day)
user = create(:user)
expect(resolve_timelogs(user: user, obj: project2, **args)).to be_empty
end
context 'when arguments are invalid' do
let_it_be(:error_class) { Gitlab::Graphql::Errors::ArgumentError }
context 'when start_time and start_date are present' do
let(:args) { { start_time: 6.days.ago, start_date: 6.days.ago } }
it 'returns correct error' do
expect { resolve_timelogs(**args) }
.to raise_error(error_class, /Provide either a start date or time, but not both/)
end
end
context 'when end_time and end_date are present' do
let(:args) { { end_time: 2.days.ago, end_date: 2.days.ago } }
it 'returns correct error' do
expect { resolve_timelogs(**args) }
.to raise_error(error_class, /Provide either an end date or time, but not both/)
end
end
context 'when start argument is after end argument' do
let(:args) { { start_time: 2.days.ago, end_time: 6.days.ago } }
it 'returns correct error' do
expect { resolve_timelogs(**args) }
.to raise_error(error_class, /Start argument must be before End argument/)
end
end
end
end
shared_examples "with a group" do
let_it_be(:short_time_ago) { 5.days.ago.beginning_of_day } let_it_be(:short_time_ago) { 5.days.ago.beginning_of_day }
let_it_be(:medium_time_ago) { 15.days.ago.beginning_of_day } let_it_be(:medium_time_ago) { 15.days.ago.beginning_of_day }
...@@ -80,8 +187,6 @@ RSpec.describe Resolvers::TimelogResolver do ...@@ -80,8 +187,6 @@ RSpec.describe Resolvers::TimelogResolver do
end end
context 'when arguments are invalid' do context 'when arguments are invalid' do
let_it_be(:error_class) { Gitlab::Graphql::Errors::ArgumentError }
context 'when start_time and start_date are present' do context 'when start_time and start_date are present' do
let(:args) { { start_time: short_time_ago, start_date: short_time_ago } } let(:args) { { start_time: short_time_ago, start_date: short_time_ago } }
...@@ -110,10 +215,96 @@ RSpec.describe Resolvers::TimelogResolver do ...@@ -110,10 +215,96 @@ RSpec.describe Resolvers::TimelogResolver do
end end
end end
end end
shared_examples "with a user" do
let_it_be(:short_time_ago) { 5.days.ago.beginning_of_day }
let_it_be(:medium_time_ago) { 15.days.ago.beginning_of_day }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:timelog1) { create(:issue_timelog, issue: issue, user: current_user) }
let_it_be(:timelog2) { create(:issue_timelog, issue: issue, user: create(:user)) }
let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, user: current_user) }
it 'blah' do
timelogs = resolve_timelogs(**args)
expect(timelogs).to contain_exactly(timelog1, timelog3)
end
end
context "on a project" do
let(:object) { project }
let(:extra_args) { {} }
it_behaves_like 'with a project'
end
context "with a project filter" do
let(:object) { nil }
let(:extra_args) { { project_id: project.to_global_id } }
it_behaves_like 'with a project'
end
context 'on a group' do
let(:object) { group }
let(:extra_args) { {} }
it_behaves_like 'with a group'
end
context 'with a group filter' do
let(:object) { nil }
let(:extra_args) { { group_id: group.to_global_id } }
it_behaves_like 'with a group'
end
context 'on a user' do
let(:object) { current_user }
let(:extra_args) { {} }
let(:args) { {} }
it_behaves_like 'with a user'
end
context 'with a user filter' do
let(:object) { nil }
let(:extra_args) { { username: current_user.username } }
let(:args) { {} }
it_behaves_like 'with a user'
end
context 'when > `default_max_page_size` records' do
let(:object) { nil }
let!(:timelog_list) { create_list(:timelog, 101, issue: issue) }
let(:args) { { project_id: "gid://gitlab/Project/#{project.id}" } }
let(:extra_args) { {} }
it 'pagination returns `default_max_page_size` and sets `has_next_page` true' do
timelogs = resolve_timelogs(**args)
expect(timelogs.items.count).to be(100)
expect(timelogs.has_next_page).to be(true)
end
end
context 'when no object or arguments provided' do
let(:object) { nil }
let(:args) { {} }
let(:extra_args) { {} }
it 'returns correct error' do
expect { resolve_timelogs(**args) }
.to raise_error(error_class, /Provide at least one argument/)
end
end end
def resolve_timelogs(user: current_user, **args) def resolve_timelogs(user: current_user, obj: object, **args)
context = { current_user: user } context = { current_user: user }
resolve(described_class, obj: group, args: args, ctx: context) resolve(described_class, obj: obj, args: args.merge(extra_args), ctx: context)
end end
end end
...@@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Group'] do
two_factor_grace_period auto_devops_enabled emails_disabled two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones group_members mentions_disabled parent boards milestones group_members
merge_requests container_repositories container_repositories_count merge_requests container_repositories container_repositories_count
packages shared_runners_setting packages shared_runners_setting timelogs
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
...@@ -39,6 +39,15 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -39,6 +39,15 @@ RSpec.describe GitlabSchema.types['Group'] do
it { is_expected.to have_graphql_resolver(Resolvers::GroupMembersResolver) } it { is_expected.to have_graphql_resolver(Resolvers::GroupMembersResolver) }
end end
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'finds timelogs between start time and end time' do
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_non_null_graphql_type(Types::TimelogType.connection_type)
end
end
it_behaves_like 'a GraphQL type with labels' do it_behaves_like 'a GraphQL type with labels' do
let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups, :includeDescendantGroups, :onlyGroupLabels] } let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups, :includeDescendantGroups, :onlyGroupLabels] }
end end
......
...@@ -32,6 +32,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do ...@@ -32,6 +32,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
callouts callouts
merge_request_interaction merge_request_interaction
namespace namespace
timelogs
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
...@@ -33,7 +33,7 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -33,7 +33,7 @@ RSpec.describe GitlabSchema.types['Project'] do
issue_status_counts terraform_states alert_management_integrations issue_status_counts terraform_states alert_management_integrations
container_repositories container_repositories_count container_repositories container_repositories_count
pipeline_analytics squash_read_only sast_ci_configuration pipeline_analytics squash_read_only sast_ci_configuration
ci_template ci_template timelogs
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
...@@ -392,6 +392,15 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -392,6 +392,15 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) } it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) }
end end
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'finds timelogs for project' do
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_graphql_type(Types::TimelogType.connection_type)
end
end
it_behaves_like 'a GraphQL type with labels' do it_behaves_like 'a GraphQL type with labels' do
let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups] } let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups] }
end end
......
...@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Query'] do ...@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Query'] do
runner_platforms runner_platforms
runner runner
runners runners
timelogs
] ]
expect(described_class).to have_graphql_fields(*expected_fields).at_least expect(described_class).to have_graphql_fields(*expected_fields).at_least
...@@ -125,4 +126,14 @@ RSpec.describe GitlabSchema.types['Query'] do ...@@ -125,4 +126,14 @@ RSpec.describe GitlabSchema.types['Query'] do
it { is_expected.to have_graphql_type(Types::Packages::PackageDetailsType) } it { is_expected.to have_graphql_type(Types::Packages::PackageDetailsType) }
end end
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'returns timelogs' do
is_expected.to have_graphql_arguments(:startDate, :endDate, :startTime, :endTime, :username, :projectId, :groupId, :after, :before, :first, :last)
is_expected.to have_graphql_type(Types::TimelogType.connection_type)
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
end
end
end end
...@@ -37,6 +37,7 @@ RSpec.describe GitlabSchema.types['User'] do ...@@ -37,6 +37,7 @@ RSpec.describe GitlabSchema.types['User'] do
starredProjects starredProjects
callouts callouts
namespace namespace
timelogs
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
...@@ -58,4 +59,13 @@ RSpec.describe GitlabSchema.types['User'] do ...@@ -58,4 +59,13 @@ RSpec.describe GitlabSchema.types['User'] do
is_expected.to have_graphql_type(Types::UserCalloutType.connection_type) is_expected.to have_graphql_type(Types::UserCalloutType.connection_type)
end end
end end
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'returns user timelogs' do
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_graphql_type(Types::TimelogType.connection_type)
end
end
end end
...@@ -2598,6 +2598,21 @@ RSpec.describe Group do ...@@ -2598,6 +2598,21 @@ RSpec.describe Group do
it { is_expected.to eq(Set.new([child_1.id])) } it { is_expected.to eq(Set.new([child_1.id])) }
end end
describe '.timelogs' do
let(:project) { create(:project, namespace: group) }
let(:issue) { create(:issue, project: project) }
let(:other_project) { create(:project, namespace: create(:group)) }
let(:other_issue) { create(:issue, project: other_project) }
let!(:timelog1) { create(:timelog, issue: issue) }
let!(:timelog2) { create(:timelog, issue: other_issue) }
let!(:timelog3) { create(:timelog, issue: issue) }
it 'returns timelogs belonging to the group' do
expect(group.timelogs).to contain_exactly(timelog1, timelog3)
end
end
describe '#to_ability_name' do describe '#to_ability_name' do
it 'returns group' do it 'returns group' do
group = build(:group) group = build(:group)
......
...@@ -70,8 +70,9 @@ RSpec.describe Timelog do ...@@ -70,8 +70,9 @@ RSpec.describe Timelog do
let_it_be(:medium_time_ago) { 15.days.ago } let_it_be(:medium_time_ago) { 15.days.ago }
let_it_be(:long_time_ago) { 65.days.ago } let_it_be(:long_time_ago) { 65.days.ago }
let_it_be(:timelog) { create(:issue_timelog, spent_at: long_time_ago) } let_it_be(:user) { create(:user) }
let_it_be(:timelog1) { create(:issue_timelog, spent_at: medium_time_ago, issue: group_issue) } let_it_be(:timelog) { create(:issue_timelog, spent_at: long_time_ago, user: user) }
let_it_be(:timelog1) { create(:issue_timelog, spent_at: medium_time_ago, issue: group_issue, user: user) }
let_it_be(:timelog2) { create(:issue_timelog, spent_at: short_time_ago, issue: subgroup_issue) } let_it_be(:timelog2) { create(:issue_timelog, spent_at: short_time_ago, issue: subgroup_issue) }
let_it_be(:timelog3) { create(:merge_request_timelog, spent_at: long_time_ago) } let_it_be(:timelog3) { create(:merge_request_timelog, spent_at: long_time_ago) }
let_it_be(:timelog4) { create(:merge_request_timelog, spent_at: medium_time_ago, merge_request: group_merge_request) } let_it_be(:timelog4) { create(:merge_request_timelog, spent_at: medium_time_ago, merge_request: group_merge_request) }
...@@ -83,6 +84,25 @@ RSpec.describe Timelog do ...@@ -83,6 +84,25 @@ RSpec.describe Timelog do
end end
end end
describe '.for_user' do
it 'return timelogs created by user' do
expect(described_class.for_user(user)).to contain_exactly(timelog, timelog1)
end
end
describe '.in_project' do
it 'returns timelogs created for project issues and merge requests' do
project = create(:project, :empty_repo)
create(:issue_timelog)
create(:merge_request_timelog)
timelog1 = create(:issue_timelog, issue: create(:issue, project: project))
timelog2 = create(:merge_request_timelog, merge_request: create(:merge_request, source_project: project))
expect(described_class.in_project(project.id)).to contain_exactly(timelog1, timelog2)
end
end
describe '.at_or_after' do describe '.at_or_after' do
it 'returns timelogs at the time limit' do it 'returns timelogs at the time limit' do
timelogs = described_class.at_or_after(short_time_ago) timelogs = described_class.at_or_after(short_time_ago)
......
...@@ -124,6 +124,7 @@ RSpec.describe User do ...@@ -124,6 +124,7 @@ RSpec.describe User do
it { is_expected.to have_many(:merge_request_reviewers).inverse_of(:reviewer) } it { is_expected.to have_many(:merge_request_reviewers).inverse_of(:reviewer) }
it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) } it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) }
it { is_expected.to have_many(:in_product_marketing_emails) } it { is_expected.to have_many(:in_product_marketing_emails) }
it { is_expected.to have_many(:timelogs) }
describe "#user_detail" do describe "#user_detail" do
it 'does not persist `user_detail` by default' do it 'does not persist `user_detail` by default' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment