Commit 6b0f410e authored by Lee Tickett's avatar Lee Tickett

Expose timelogs in GraphQL query type

Changelog: added
parent 03903b0a
# 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 @@
module ResolvesSnippets
extend ActiveSupport::Concern
include ResolvesIds
included do
type Types::SnippetType.connection_type, null: true
......@@ -27,22 +28,11 @@ module ResolvesSnippets
def snippet_finder_params(args)
{
ids: resolve_ids(args[:ids]),
ids: resolve_ids(args[:ids], ::Types::GlobalIDType[::Snippet]),
scope: args[:visibility]
}.merge(options_by_type(args[:type]))
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)
case type
when 'personal'
......
......@@ -3,6 +3,7 @@
module Resolvers
class SnippetsResolver < BaseResolver
include ResolvesIds
include ResolvesSnippets
ERROR_MESSAGE = 'Filtering by both an author and a project is not supported'
......
......@@ -3,33 +3,50 @@
module Resolvers
class TimelogResolver < BaseResolver
include LooksAhead
include ResolvesIds
type ::Types::TimelogType.connection_type, null: false
argument :start_date, Types::TimeType,
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,
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,
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,
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)
build_timelogs
validate_args!(object, args)
timelogs = object&.timelogs || Timelog.limit(GitlabSchema.default_max_page_size)
if args.any?
validate_args!(args)
build_parsed_args(args)
validate_time_difference!
apply_time_filter
args = parse_datetime_args(args)
timelogs = apply_user_filter(timelogs, args)
timelogs = apply_project_filter(timelogs, args)
timelogs = apply_time_filter(timelogs, args)
timelogs = apply_group_filter(timelogs, args)
end
apply_lookahead(timelogs)
......@@ -37,30 +54,32 @@ module Resolvers
private
attr_reader :parsed_args, :timelogs
def preloads
{
note: [:note]
}
end
def validate_args!(args)
if args[:start_time] && args[:start_date]
def validate_args!(object, args)
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')
elsif args[:end_time] && args[:end_date]
raise_argument_error('Provide either an end date or time, but not both')
end
end
def build_parsed_args(args)
def parse_datetime_args(args)
if times_provided?(args)
@parsed_args = args
args
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[:end_time] = args[:end_date].end_of_day if args[:end_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
end
......@@ -68,23 +87,51 @@ module Resolvers
args[:start_time] && args[:end_time]
end
def validate_time_difference!
return unless end_time_before_start_time?
def validate_time_difference!(args)
return unless end_time_before_start_time?(args)
raise_argument_error('Start argument must be before End argument')
end
def end_time_before_start_time?
times_provided?(parsed_args) && parsed_args[:end_time] < parsed_args[:start_time]
def end_time_before_start_time?(args)
times_provided?(args) && args[:end_time] < args[:start_time]
end
def build_timelogs
@timelogs = Timelog.in_group(object)
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_time_filter
@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]
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
if args[:end_time]
timelogs = timelogs.at_or_before(args[:end_time])
end
timelogs
end
def raise_argument_error(message)
......
......@@ -354,6 +354,13 @@ module Types
description: 'The CI Job Tokens scope of access.',
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:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
......
......@@ -131,6 +131,13 @@ module Types
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
DesignManagementObject.new(nil)
end
......
......@@ -104,6 +104,13 @@ module Types
Types::UserCalloutType.connection_type,
null: true,
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
def resolve_type(object, context)
......
......@@ -730,6 +730,10 @@ class Group < Namespace
end
# rubocop: enable CodeReuse/ServiceClass
def timelogs
Timelog.in_group(self)
end
private
def max_member_access(user_ids)
......
......@@ -19,6 +19,14 @@ class Timelog < ApplicationRecord
joins(:project).where(projects: { namespace: group.self_and_descendants })
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
where('spent_at >= ?', start_time)
end
......
......@@ -211,6 +211,8 @@ class User < ApplicationRecord
has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail'
has_many :timelogs
#
# Validations
#
......
This diff is collapsed.
......@@ -130,4 +130,7 @@ With this option enabled, `75h` is displayed instead of `1w 4d 3h`.
- [Connection](../../api/graphql/reference/index.md#timelogconnection)
- [Edge](../../api/graphql/reference/index.md#timelogedge)
- [Fields](../../api/graphql/reference/index.md#timelog)
- [Timelogs](../../api/graphql/reference/index.md#querytimelogs)
- [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
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(:timelogs, complexity: 5) }
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(:vulnerabilities_count_by_day) }
......@@ -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(: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
let_it_be(:group) { create(: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
......@@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Group'] do
two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones group_members
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)
......@@ -39,6 +39,15 @@ RSpec.describe GitlabSchema.types['Group'] do
it { is_expected.to have_graphql_resolver(Resolvers::GroupMembersResolver) }
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
let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups, :includeDescendantGroups, :onlyGroupLabels] }
end
......
......@@ -32,6 +32,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
callouts
merge_request_interaction
namespace
timelogs
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -33,7 +33,7 @@ RSpec.describe GitlabSchema.types['Project'] do
issue_status_counts terraform_states alert_management_integrations
container_repositories container_repositories_count
pipeline_analytics squash_read_only sast_ci_configuration
ci_template
ci_template timelogs
]
expect(described_class).to include_graphql_fields(*expected_fields)
......@@ -392,6 +392,15 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) }
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
let(:labels_resolver_arguments) { [:search_term, :includeAncestorGroups] }
end
......
......@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Query'] do
runner_platforms
runner
runners
timelogs
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
......@@ -125,4 +126,14 @@ RSpec.describe GitlabSchema.types['Query'] do
it { is_expected.to have_graphql_type(Types::Packages::PackageDetailsType) }
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
......@@ -37,6 +37,7 @@ RSpec.describe GitlabSchema.types['User'] do
starredProjects
callouts
namespace
timelogs
]
expect(described_class).to have_graphql_fields(*expected_fields)
......@@ -58,4 +59,13 @@ RSpec.describe GitlabSchema.types['User'] do
is_expected.to have_graphql_type(Types::UserCalloutType.connection_type)
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
......@@ -2598,6 +2598,21 @@ RSpec.describe Group do
it { is_expected.to eq(Set.new([child_1.id])) }
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
it 'returns group' do
group = build(:group)
......
......@@ -70,8 +70,9 @@ RSpec.describe Timelog do
let_it_be(:medium_time_ago) { 15.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(:timelog1) { create(:issue_timelog, spent_at: medium_time_ago, issue: group_issue) }
let_it_be(:user) { create(:user) }
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(: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) }
......@@ -83,6 +84,25 @@ RSpec.describe Timelog do
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
it 'returns timelogs at the time limit' do
timelogs = described_class.at_or_after(short_time_ago)
......
......@@ -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(:created_custom_emoji).inverse_of(:creator) }
it { is_expected.to have_many(:in_product_marketing_emails) }
it { is_expected.to have_many(:timelogs) }
describe "#user_detail" 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