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
scope :public_only, -> { where(confidential: false) }
scope :confidential_only, -> { where(confidential: true) }
scope :counts_by_state, -> { reorder(nil).group(:state).count }
after_commit :expire_etag_cache
after_save :ensure_metrics, unless: :imported?
......
......@@ -1124,6 +1124,11 @@ type Epic implements Noteable {
): EpicConnection
closedAt: Time
createdAt: Time
"""
Number of open and closed descendant epics and issues
"""
descendantCounts: EpicDescendantCount
description: String
"""
......@@ -1304,6 +1309,28 @@ type EpicConnection {
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.
"""
......
......@@ -3518,6 +3518,20 @@
"isDeprecated": false,
"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",
"description": null,
......@@ -9619,6 +9633,75 @@
"enumValues": 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",
"name": "ProjectStatistics",
......
......@@ -220,6 +220,16 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `relationPath` | String | |
| `reference` | String! | |
| `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
......
# 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
Types::EpicIssueType.connection_type,
null: true,
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
......@@ -61,6 +61,7 @@ module EE
scope :for_ids, -> (ids) { where(id: ids) }
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
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_issues, -> (issues) { joins(:epic_issues).where(epic_issues: { issue_id: issues }).distinct }
scope :has_parent, -> { where.not(parent_id: nil) }
......@@ -93,6 +94,8 @@ module EE
scope :start_date_inherited, -> { where(start_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
def etag_caching_enabled?
......@@ -191,6 +194,15 @@ module EE
def deepest_relationship_level
::Gitlab::ObjectHierarchy.new(self.where(parent_id: nil)).max_descendants_depth
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
def resource_parent
......@@ -276,6 +288,10 @@ module EE
hierarchy.descendants
end
def base_and_descendants
hierarchy.base_and_descendants
end
def has_ancestor?(epic)
ancestors.exists?(epic.id)
end
......
......@@ -19,6 +19,11 @@ module EE
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
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, through: :epic_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
closed_at created_at updated_at children has_children has_issues
web_path web_url relation_path reference issues user_permissions
notes discussions relative_position subscribed participants
descendant_counts
]
end
......
......@@ -3,7 +3,8 @@
require 'spec_helper'
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) }
describe 'associations' do
......@@ -100,6 +101,46 @@ describe Epic do
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
context 'basic checks' do
let(:epic) { build(:epic, group: group) }
......@@ -352,7 +393,6 @@ describe Epic do
end
describe '#issues_readable_by' do
let(:user) { create(:user) }
let(:group) { create(:group, :private) }
let(:project) { create(:project, group: group) }
let(:project2) { create(:project, group: group) }
......@@ -400,7 +440,6 @@ describe Epic do
end
describe '#reopen' do
let(:user) { create(: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
......
......@@ -66,6 +66,22 @@ describe Issue do
expect(described_class.service_desk).not_to include(regular_issue)
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
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