Commit 197d88f6 authored by Stan Hu's avatar Stan Hu

Merge branch 'epics-count' into 'master'

Expose epic descendant counts

See merge request gitlab-org/gitlab!19450
parents e04920dd 96167244
...@@ -66,6 +66,8 @@ class Issue < ApplicationRecord ...@@ -66,6 +66,8 @@ class Issue < ApplicationRecord
scope :public_only, -> { where(confidential: false) } scope :public_only, -> { where(confidential: false) }
scope :confidential_only, -> { where(confidential: true) } scope :confidential_only, -> { where(confidential: true) }
scope :counts_by_state, -> { reorder(nil).group(:state).count }
after_commit :expire_etag_cache after_commit :expire_etag_cache
after_save :ensure_metrics, unless: :imported? after_save :ensure_metrics, unless: :imported?
......
...@@ -1124,6 +1124,11 @@ type Epic implements Noteable { ...@@ -1124,6 +1124,11 @@ type Epic implements Noteable {
): EpicConnection ): EpicConnection
closedAt: Time closedAt: Time
createdAt: Time createdAt: Time
"""
Number of open and closed descendant epics and issues
"""
descendantCounts: EpicDescendantCount
description: String description: String
""" """
...@@ -1304,6 +1309,28 @@ type EpicConnection { ...@@ -1304,6 +1309,28 @@ type EpicConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
type EpicDescendantCount {
"""
Number of closed sub-epics
"""
closedEpics: Int
"""
Number of closed epic issues
"""
closedIssues: Int
"""
Number of opened sub-epics
"""
openedEpics: Int
"""
Number of opened epic issues
"""
openedIssues: Int
}
""" """
An edge in a connection. An edge in a connection.
""" """
......
...@@ -3518,6 +3518,20 @@ ...@@ -3518,6 +3518,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "descendantCounts",
"description": "Number of open and closed descendant epics and issues",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "EpicDescendantCount",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "description", "name": "description",
"description": null, "description": null,
...@@ -9619,6 +9633,75 @@ ...@@ -9619,6 +9633,75 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "EpicDescendantCount",
"description": null,
"fields": [
{
"name": "closedEpics",
"description": "Number of closed sub-epics",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "closedIssues",
"description": "Number of closed epic issues",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "openedEpics",
"description": "Number of opened sub-epics",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "openedIssues",
"description": "Number of opened epic issues",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "ProjectStatistics", "name": "ProjectStatistics",
......
...@@ -220,6 +220,16 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -220,6 +220,16 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `relationPath` | String | | | `relationPath` | String | |
| `reference` | String! | | | `reference` | String! | |
| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this epic | | `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this epic |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
### EpicDescendantCount
| Name | Type | Description |
| --- | ---- | ---------- |
| `openedEpics` | Int | Number of opened sub-epics |
| `closedEpics` | Int | Number of closed sub-epics |
| `openedIssues` | Int | Number of opened epic issues |
| `closedIssues` | Int | Number of closed epic issues |
### EpicIssue ### EpicIssue
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class EpicDescendantCountType < BaseObject
graphql_name 'EpicDescendantCount'
field :opened_epics, GraphQL::INT_TYPE, null: true, description: 'Number of opened sub-epics'
field :closed_epics, GraphQL::INT_TYPE, null: true, description: 'Number of closed sub-epics'
field :opened_issues, GraphQL::INT_TYPE, null: true, description: 'Number of opened epic issues'
field :closed_issues, GraphQL::INT_TYPE, null: true, description: 'Number of closed epic issues'
end
# rubocop: enable Graphql/AuthorizeTypes
end
...@@ -71,5 +71,11 @@ module Types ...@@ -71,5 +71,11 @@ module Types
Types::EpicIssueType.connection_type, Types::EpicIssueType.connection_type,
null: true, null: true,
resolver: Resolvers::EpicIssuesResolver resolver: Resolvers::EpicIssuesResolver
field :descendant_counts, Types::EpicDescendantCountType, null: true, complexity: 10,
description: 'Number of open and closed descendant epics and issues',
resolve: -> (epic, args, ctx) do
Epics::DescendantCountService.new(epic, ctx[:current_user])
end
end end
end end
...@@ -61,6 +61,7 @@ module EE ...@@ -61,6 +61,7 @@ module EE
scope :for_ids, -> (ids) { where(id: ids) } scope :for_ids, -> (ids) { where(id: ids) }
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) } scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
scope :inc_group, -> { includes(:group) } scope :inc_group, -> { includes(:group) }
scope :in_selected_groups, -> (groups) { where(group_id: groups) }
scope :in_milestone, -> (milestone_id) { joins(:issues).where(issues: { milestone_id: milestone_id }) } scope :in_milestone, -> (milestone_id) { joins(:issues).where(issues: { milestone_id: milestone_id }) }
scope :in_issues, -> (issues) { joins(:epic_issues).where(epic_issues: { issue_id: issues }).distinct } scope :in_issues, -> (issues) { joins(:epic_issues).where(epic_issues: { issue_id: issues }).distinct }
scope :has_parent, -> { where.not(parent_id: nil) } scope :has_parent, -> { where.not(parent_id: nil) }
...@@ -93,6 +94,8 @@ module EE ...@@ -93,6 +94,8 @@ module EE
scope :start_date_inherited, -> { where(start_date_is_fixed: [nil, false]) } scope :start_date_inherited, -> { where(start_date_is_fixed: [nil, false]) }
scope :due_date_inherited, -> { where(due_date_is_fixed: [nil, false]) } scope :due_date_inherited, -> { where(due_date_is_fixed: [nil, false]) }
scope :counts_by_state, -> { group(:state_id).count }
MAX_HIERARCHY_DEPTH = 5 MAX_HIERARCHY_DEPTH = 5
def etag_caching_enabled? def etag_caching_enabled?
...@@ -191,6 +194,15 @@ module EE ...@@ -191,6 +194,15 @@ module EE
def deepest_relationship_level def deepest_relationship_level
::Gitlab::ObjectHierarchy.new(self.where(parent_id: nil)).max_descendants_depth ::Gitlab::ObjectHierarchy.new(self.where(parent_id: nil)).max_descendants_depth
end end
def groups_user_can_read_epics(epics, user)
groups = ::Group.where(id: epics.select(:group_id))
groups = ::Gitlab::GroupPlansPreloader.new.preload(groups)
DeclarativePolicy.user_scope do
groups.select { |g| Ability.allowed?(user, :read_epic, g) }
end
end
end end
def resource_parent def resource_parent
...@@ -276,6 +288,10 @@ module EE ...@@ -276,6 +288,10 @@ module EE
hierarchy.descendants hierarchy.descendants
end end
def base_and_descendants
hierarchy.base_and_descendants
end
def has_ancestor?(epic) def has_ancestor?(epic)
ancestors.exists?(epic.id) ancestors.exists?(epic.id)
end end
......
...@@ -19,6 +19,11 @@ module EE ...@@ -19,6 +19,11 @@ module EE
scope :order_created_at_desc, -> { reorder(created_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) }
scope :service_desk, -> { where(author: ::User.support_bot) } scope :service_desk, -> { where(author: ::User.support_bot) }
scope :in_epics, ->(epics) do
issue_ids = EpicIssue.where(epic_id: epics).select(:issue_id)
id_in(issue_ids)
end
has_one :epic_issue has_one :epic_issue
has_one :epic, through: :epic_issue has_one :epic, through: :epic_issue
has_many :designs, class_name: "DesignManagement::Design", inverse_of: :issue has_many :designs, class_name: "DesignManagement::Design", inverse_of: :issue
......
# frozen_string_literal: true
module Epics
class DescendantCountService
include Gitlab::Utils::StrongMemoize
attr_reader :epic, :current_user
def initialize(epic, current_user)
@epic = epic
@current_user = current_user
end
def opened_epics
epics_count.fetch(Epic.state_ids[:opened], 0)
end
def closed_epics
epics_count.fetch(Epic.state_ids[:closed], 0)
end
def opened_issues
issues_count.fetch('opened', 0)
end
def closed_issues
issues_count.fetch('closed', 0)
end
private
def epics_count
strong_memoize(:epics_count) do
accessible_epics.id_not_in(epic.id).counts_by_state
end
end
def issues_count
strong_memoize(:issue_counts) do
IssuesFinder.new(current_user).execute.in_epics(accessible_epics).counts_by_state
end
end
def accessible_epics
strong_memoize(:epics) do
epics = epic.base_and_descendants
groups = Epic.groups_user_can_read_epics(epics, current_user)
epics.in_selected_groups(groups)
end
end
end
end
---
title: Expose number of sub-epics and epic issues in GraphQL API
merge_request: 19450
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['EpicDescendantCount'] do
it { expect(described_class.graphql_name).to eq('EpicDescendantCount') }
it 'has specific fields' do
%i[opened_epics closed_epics opened_issues closed_issues].each do |field_name|
expect(described_class).to have_graphql_field(field_name)
end
end
end
...@@ -11,6 +11,7 @@ describe GitlabSchema.types['Epic'] do ...@@ -11,6 +11,7 @@ describe GitlabSchema.types['Epic'] do
closed_at created_at updated_at children has_children has_issues closed_at created_at updated_at children has_children has_issues
web_path web_url relation_path reference issues user_permissions web_path web_url relation_path reference issues user_permissions
notes discussions relative_position subscribed participants notes discussions relative_position subscribed participants
descendant_counts
] ]
end end
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
require 'spec_helper' require 'spec_helper'
describe Epic do describe Epic do
set(:group) { create(:group) } let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:project) { create(:project, group: group) } let(:project) { create(:project, group: group) }
describe 'associations' do describe 'associations' do
...@@ -100,6 +101,46 @@ describe Epic do ...@@ -100,6 +101,46 @@ describe Epic do
end end
end end
describe '.groups_user_can_read_epics' do
let_it_be(:private_group) { create(:group, :private) }
let_it_be(:epic) { create(:epic, group: private_group) }
subject do
epics = described_class.where(id: epic.id)
described_class.groups_user_can_read_epics(epics, user)
end
it 'does not return inaccessible groups' do
expect(subject).to be_empty
end
context 'with authorized user' do
before do
private_group.add_developer(user)
end
context 'with epics enabled' do
before do
stub_licensed_features(epics: true)
end
it 'returns epic groups user can access' do
expect(subject).to eq [private_group]
end
end
context 'with epics are disabled' do
before do
stub_licensed_features(epics: false)
end
it 'returns an empty list' do
expect(subject).to be_empty
end
end
end
end
describe '#valid_parent?' do describe '#valid_parent?' do
context 'basic checks' do context 'basic checks' do
let(:epic) { build(:epic, group: group) } let(:epic) { build(:epic, group: group) }
...@@ -352,7 +393,6 @@ describe Epic do ...@@ -352,7 +393,6 @@ describe Epic do
end end
describe '#issues_readable_by' do describe '#issues_readable_by' do
let(:user) { create(:user) }
let(:group) { create(:group, :private) } let(:group) { create(:group, :private) }
let(:project) { create(:project, group: group) } let(:project) { create(:project, group: group) }
let(:project2) { create(:project, group: group) } let(:project2) { create(:project, group: group) }
...@@ -400,7 +440,6 @@ describe Epic do ...@@ -400,7 +440,6 @@ describe Epic do
end end
describe '#reopen' do describe '#reopen' do
let(:user) { create(:user) }
subject(:epic) { create(:epic, state: 'closed', closed_at: Time.now, closed_by: user) } subject(:epic) { create(:epic, state: 'closed', closed_at: Time.now, closed_by: user) }
it 'sets closed_at to nil when an epic is reopend' do it 'sets closed_at to nil when an epic is reopend' do
......
...@@ -66,6 +66,22 @@ describe Issue do ...@@ -66,6 +66,22 @@ describe Issue do
expect(described_class.service_desk).not_to include(regular_issue) expect(described_class.service_desk).not_to include(regular_issue)
end end
end end
describe '.in_epics' do
let_it_be(:epic1) { create(:epic) }
let_it_be(:epic2) { create(:epic) }
let_it_be(:epic_issue1) { create(:epic_issue, epic: epic1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2) }
before do
stub_licensed_features(epics: true)
end
it 'returns only issues in selected epics' do
expect(described_class.count).to eq 2
expect(described_class.in_epics([epic1])).to eq [epic_issue1.issue]
end
end
end end
describe 'validations' do describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
describe Epics::DescendantCountService do
let_it_be(:group) { create(:group, :public)}
let_it_be(:subgroup) { create(:group, :private, parent: group)}
let_it_be(:user) { create(:user) }
let_it_be(:parent_epic) { create(:epic, group: group) }
let_it_be(:epic1) { create(:epic, group: subgroup, parent: parent_epic, state: :opened) }
let_it_be(:epic2) { create(:epic, group: subgroup, parent: parent_epic, state: :closed) }
let_it_be(:project) { create(:project, :private, group: group)}
let_it_be(:issue1) { create(:issue, project: project, state: :opened) }
let_it_be(:issue2) { create(:issue, project: project, state: :closed) }
let_it_be(:issue3) { create(:issue, project: project, state: :opened) }
let_it_be(:issue4) { create(:issue, project: project, state: :closed) }
let_it_be(:epic_issue1) { create(:epic_issue, epic: parent_epic, issue: issue1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: parent_epic, issue: issue2) }
let_it_be(:epic_issue3) { create(:epic_issue, epic: epic1, issue: issue3) }
let_it_be(:epic_issue4) { create(:epic_issue, epic: epic2, issue: issue4) }
subject { described_class.new(parent_epic, user) }
shared_examples 'descendants state count' do |method, expected_count|
before do
stub_licensed_features(epics: true)
end
it 'does not count inaccessible epics' do
expect(subject.public_send(method)).to eq 0
end
context 'when authorized' do
before do
subgroup.add_developer(user)
project.add_developer(user)
end
it 'returns correct number of epics' do
expect(subject.public_send(method)).to eq expected_count
end
end
end
describe '#opened_epics' do
it_behaves_like 'descendants state count', :opened_epics, 1
end
describe '#closed_epics' do
it_behaves_like 'descendants state count', :closed_epics, 1
end
describe '#opened_issues' do
it_behaves_like 'descendants state count', :opened_issues, 2
end
describe '#closed_issues' do
it_behaves_like 'descendants state count', :closed_issues, 2
end
end
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