Commit 99090899 authored by Wei-Meng Lee's avatar Wei-Meng Lee Committed by Bob Van Landuyt

Render the burndown chart on the frontend

This adds an events API to the milestone API endpoints. These
endpoints are used by the burndown charts so they can be rendered on
the frontend.

Rendering on the frontend makes sure that the viewer's time zone is
taking into account.
parent 7479e1eb
...@@ -136,3 +136,18 @@ Parameters: ...@@ -136,3 +136,18 @@ Parameters:
- `milestone_id` (required) - The ID of a group milestone - `milestone_id` (required) - The ID of a group milestone
[ce-12819]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12819 [ce-12819]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12819
## Get all burndown chart events for a single milestone **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4737) in GitLab 12.1
Get all burndown chart events for a single milestone.
```
GET /groups/:id/milestones/:milestone_id/burndown_events
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a group milestone
...@@ -147,3 +147,18 @@ Parameters: ...@@ -147,3 +147,18 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a project milestone - `milestone_id` (required) - The ID of a project milestone
## Get all burndown chart events for a single milestone **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4737) in GitLab 12.1
Gets all burndown chart events for a single milestone.
```
GET /projects/:id/milestones/:milestone_id/burndown_events
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a project milestone
import dateFormat from 'dateformat';
export default class BurndownChartData {
constructor(burndownEvents, startDate, dueDate) {
this.dateFormatMask = 'yyyy-mm-dd';
this.burndownEvents = this.convertEventsToLocalTimezone(burndownEvents);
this.startDate = startDate;
this.dueDate = dueDate;
// determine when to stop burndown chart
const today = dateFormat(new Date(), this.dateFormatMask);
this.endDate = today < this.dueDate ? today : this.dueDate;
}
generate() {
let openIssuesCount = 0;
let openIssuesWeight = 0;
const chartData = [];
for (
let date = new Date(this.startDate);
date <= new Date(this.endDate);
date.setDate(date.getDate() + 1)
) {
const dateString = dateFormat(date, this.dateFormatMask);
const openedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
(event.action === 'created' || event.action === 'reopened'),
);
const closedIssuesToday = this.filterAndSummarizeBurndownEvents(
event => event.created_at === dateString && event.action === 'closed',
);
openIssuesCount += openedIssuesToday.count - closedIssuesToday.count;
openIssuesWeight += openedIssuesToday.weight - closedIssuesToday.weight;
chartData.push([dateString, openIssuesCount, openIssuesWeight]);
}
return chartData;
}
convertEventsToLocalTimezone(events) {
return events.map(event => ({
...event,
created_at: dateFormat(event.created_at, this.dateFormatMask),
}));
}
filterAndSummarizeBurndownEvents(filter) {
const issues = this.burndownEvents.filter(filter);
return {
count: issues.length,
weight: issues.reduce((total, issue) => total + issue.weight, 0),
};
}
}
import $ from 'jquery'; import $ from 'jquery';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import BurndownChart from './burndown_chart'; import BurndownChart from './burndown_chart';
import BurndownChartData from './burndown_chart_data';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export default () => { export default () => {
...@@ -13,49 +16,61 @@ export default () => { ...@@ -13,49 +16,61 @@ export default () => {
// generate burndown chart (if data available) // generate burndown chart (if data available)
const container = '.burndown-chart'; const container = '.burndown-chart';
const $chartElm = $(container); const $chartEl = $(container);
if ($chartElm.length) { if ($chartEl.length) {
const startDate = $chartElm.data('startDate'); const startDate = $chartEl.data('startDate');
const dueDate = $chartElm.data('dueDate'); const dueDate = $chartEl.data('dueDate');
const chartData = $chartElm.data('chartData'); const burndownEventsPath = $chartEl.data('burndownEventsPath');
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]); axios
.get(burndownEventsPath)
const chart = new BurndownChart({ container, startDate, dueDate }); .then(response => {
const burndownEvents = response.data;
let currentView = 'count'; const chartData = new BurndownChartData(burndownEvents, startDate, dueDate).generate();
chart.setData(openIssuesCount, { label: s__('BurndownChartLabel|Open issues'), animate: true });
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
$('.js-burndown-data-selector').on('click', 'button', function switchData() { const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
const $this = $(this);
const show = $this.data('show'); const chart = new BurndownChart({ container, startDate, dueDate });
if (currentView !== show) {
currentView = show; let currentView = 'count';
$this chart.setData(openIssuesCount, {
.removeClass('btn-inverted') label: s__('BurndownChartLabel|Open issues'),
.siblings() animate: true,
.addClass('btn-inverted'); });
switch (show) {
case 'count': $('.js-burndown-data-selector').on('click', 'button', function switchData() {
chart.setData(openIssuesCount, { const $this = $(this);
label: s__('BurndownChartLabel|Open issues'), const show = $this.data('show');
animate: true, if (currentView !== show) {
}); currentView = show;
break; $this
case 'weight': .removeClass('btn-inverted')
chart.setData(openIssuesWeight, { .siblings()
label: s__('BurndownChartLabel|Open issue weight'), .addClass('btn-inverted');
animate: true, switch (show) {
}); case 'count':
break; chart.setData(openIssuesCount, {
default: label: s__('BurndownChartLabel|Open issues'),
break; animate: true,
} });
} break;
}); case 'weight':
chart.setData(openIssuesWeight, {
window.addEventListener('resize', () => chart.animateResize(1)); label: s__('BurndownChartLabel|Open issue weight'),
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2)); animate: true,
});
break;
default:
break;
}
}
});
window.addEventListener('resize', () => chart.animateResize(1));
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2));
})
.catch(() => new Flash('Error loading burndown chart data'));
} }
}; };
...@@ -3,23 +3,8 @@ ...@@ -3,23 +3,8 @@
class Burndown class Burndown
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
class Issue attr_reader :start_date, :due_date, :end_date, :accurate, :milestone, :current_user
attr_reader :closed_at, :weight, :state
def initialize(closed_at, weight, state)
@closed_at = closed_at
@weight = weight
@state = state
end
def reopened?
@state == 'opened' && @closed_at.present?
end
end
attr_reader :start_date, :due_date, :end_date, :accurate, :legacy_data, :milestone, :current_user
alias_method :accurate?, :accurate alias_method :accurate?, :accurate
alias_method :empty?, :legacy_data
def initialize(milestone, current_user) def initialize(milestone, current_user)
@milestone = milestone @milestone = milestone
...@@ -29,132 +14,97 @@ class Burndown ...@@ -29,132 +14,97 @@ class Burndown
@end_date = @milestone.due_date @end_date = @milestone.due_date
@end_date = Date.today if @end_date.present? && @end_date > Date.today @end_date = Date.today if @end_date.present? && @end_date > Date.today
@accurate = milestone_issues.all?(&:closed_at) @accurate = true
@legacy_data = milestone_issues.any? && milestone_issues.none?(&:closed_at)
end end
# Returns the chart data in the following format: # Returns an array of milestone issue event data in the following format:
# [date, issue count, issue weight] eg: [["2017-03-01", 33, 127], ["2017-03-02", 35, 73], ["2017-03-03", 28, 50]...] # [{"created_at":"2019-03-10T16:00:00.039Z", "weight":null, "action":"closed" }, ... ]
def as_json(opts = nil) def as_json(opts = nil)
return [] unless valid? return [] unless valid?
open_issues_count = 0 burndown_events
open_issues_weight = 0 end
start_date.upto(end_date).each_with_object([]) do |date, chart_data|
closed, reopened = closed_and_reopened_issues_by(date)
closed_issues_count = closed.count
closed_issues_weight = sum_issues_weight(closed)
issues_created = opened_issues_on(date)
open_issues_count += issues_created.count
open_issues_weight += sum_issues_weight(issues_created)
open_issues_count -= closed_issues_count
open_issues_weight -= closed_issues_weight
chart_data << [date.strftime("%Y-%m-%d"), open_issues_count, open_issues_weight]
reopened_count = reopened.count
reopened_weight = sum_issues_weight(reopened)
open_issues_count += reopened_count def empty?
open_issues_weight += reopened_weight burndown_events.any? && legacy_data?
end
end end
def valid? def valid?
start_date && due_date start_date && due_date
end end
# If all closed issues have no closed events, mark burndown chart as containing legacy data
def legacy_data?
strong_memoize(:legacy_data) do
closed_events = milestone_issues.select(&:closed?)
closed_events.any? && !Event.closed.where(target: closed_events, action: Event::CLOSED).exists?
end
end
private private
def opened_issues_on(date) def burndown_events
return {} if opened_issues_grouped_by_date.empty? milestone_issues
.map { |issue| burndown_events_for(issue) }
.flatten
end
date = date.to_date def burndown_events_for(issue)
[
transformed_create_event_for(issue),
transformed_action_events_for(issue),
transformed_legacy_closed_event_for(issue)
].compact
end
def milestone_issues
return [] unless valid?
# If issues.created_at < milestone.start_date strong_memoize(:milestone_issues) do
# we consider all of them created at milestone.start_date @milestone
if date == start_date .issues_visible_to_user(current_user)
first_issue_created_at = opened_issues_grouped_by_date.keys.first .where('issues.created_at <= ?', end_date.end_of_day)
days_before_start_date = start_date.downto(first_issue_created_at).to_a
opened_issues_grouped_by_date.values_at(*days_before_start_date).flatten.compact
else
opened_issues_grouped_by_date[date] || []
end end
end end
def opened_issues_grouped_by_date def milestone_events_per_issue
strong_memoize(:opened_issues_grouped_by_date) do return [] unless valid?
issues =
@milestone strong_memoize(:milestone_events_per_issue) do
.issues_visible_to_user(current_user) Event
.where('issues.created_at <= ?', end_date) .where(target: milestone_issues, action: [Event::CLOSED, Event::REOPENED])
.reorder(nil) .where('created_at <= ?', end_date.end_of_day)
.order('issues.created_at').to_a .group_by(&:target_id)
issues.group_by do |issue|
issue.created_at.to_date
end
end end
end end
def sum_issues_weight(issues) # Use issue creation date as the source of truth for created events
issues.map(&:weight).compact.sum def transformed_create_event_for(issue)
build_burndown_event(issue.created_at, issue.weight, 'created')
end end
def closed_and_reopened_issues_by(date) # Use issue events as the source of truth for events other than 'created'
current_date = date.to_date def transformed_action_events_for(issue)
events_for_issue = milestone_events_per_issue[issue.id]
return [] unless events_for_issue
closed = events_for_issue.map do |event|
milestone_issues.select do |issue| build_burndown_event(event.created_at, issue.weight, Event::ACTIONS.key(event.action).to_s)
(issue.closed_at&.to_date || start_date) == current_date end
end end
# If issue is closed but has no closed events, treat it as though closed on milestone start date
def transformed_legacy_closed_event_for(issue)
return [] unless issue.closed?
return [] if milestone_events_per_issue[issue.id]&.any?(&:closed_action?)
reopened = closed.select(&:reopened?) # Mark burndown chart as inaccurate
@accurate = false
[closed, reopened] build_burndown_event(milestone.start_date.beginning_of_day, issue.weight, 'closed')
end end
def milestone_issues def build_burndown_event(created_at, issue_weight, action)
@milestone_issues ||= { created_at: created_at, weight: issue_weight, action: action }
begin
# We make use of `events` table to get the closed_at timestamp.
# `issues.closed_at` can't be used once it's nullified if the issue is
# reopened.
internal_clause =
@milestone.issues_visible_to_user(current_user)
.joins("LEFT OUTER JOIN events e ON issues.id = e.target_id AND e.target_type = 'Issue' AND e.action = #{Event::CLOSED}")
.where("state = 'closed' OR (state = 'opened' AND e.action = #{Event::CLOSED})") # rubocop:disable GitlabSecurity/SqlInjection
rel =
if Gitlab::Database.postgresql?
::Issue
.select("*")
.from(internal_clause.select('DISTINCT ON (issues.id) issues.id, issues.state, issues.weight, e.created_at AS closed_at'))
.order('closed_at ASC')
.pluck('closed_at, weight, state')
else
# In rails 5 mysql's `only_full_group_by` option is enabled by default,
# this means that `GROUP` clause must include all columns used in `SELECT`
# clause. Adding all columns to `GROUP` means that we have now
# duplicates (by issue ID) in records. To get rid of these, we unify them
# on ruby side by issue id. Finally we drop the issue id attribute from records
# because this is not accepted when creating Issue object.
::Issue
.select("*")
.from(internal_clause.select('issues.id, issues.state, issues.weight, e.created_at AS closed_at'))
.group(:id, :closed_at, :weight, :state)
.having('closed_at = MIN(closed_at) OR closed_at IS NULL')
.order('closed_at ASC')
.pluck('id, closed_at, weight, state')
.uniq(&:first)
.map { |attrs| attrs.drop(1) }
end
rel.map { |attrs| Issue.new(*attrs) }
end
end end
end end
- milestone = local_assigns[:milestone] - milestone = local_assigns[:milestone]
- burndown = burndown_chart(milestone) - burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown) - warning = data_warning_for(burndown)
- burndown_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burndown_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burndown_events_path(id: milestone.project.id, milestone_id: milestone.milestoneish_id)
= warning = warning
...@@ -13,7 +14,9 @@ ...@@ -13,7 +14,9 @@
Issues Issues
%button.btn.btn-sm.btn-primary.btn-inverted{ data: { show: 'weight' } } %button.btn.btn-sm.btn-primary.btn-inverted{ data: { show: 'weight' } }
Issue weight Issue weight
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), due_date: burndown.due_date.strftime("%Y-%m-%d"), chart_data: burndown.to_json } } .burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
burndown_events_path: expose_url(burndown_endpoint) } }
- elsif show_burndown_placeholder?(milestone, warning) - elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid .burndown-hint.content-block.container-fluid
......
---
title: Make burndown chart timezone aware
merge_request: 10328
author:
type: fixed
# frozen_string_literal: true
module EE
module API
module GroupMilestones
extend ActiveSupport::Concern
prepended do
include EE::API::MilestoneResponses # rubocop: disable Cop/InjectEnterpriseEditionModule
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of burndown events' do
detail 'This feature was introduced in GitLab 12.1.'
end
get ':id/milestones/:milestone_id/burndown_events' do
authorize! :read_group, user_group
milestone_burndown_events_for(user_group)
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module API
module MilestoneResponses
extend ActiveSupport::Concern
included do
helpers do
def milestone_burndown_events_for(parent)
milestone = parent.milestones.find(params[:milestone_id])
if milestone.supports_burndown_charts?
present Burndown.new(milestone, current_user).as_json
else
render_api_error!("Milestone does not support burndown chart", 405)
end
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module API
module ProjectMilestones
extend ActiveSupport::Concern
prepended do
include EE::API::MilestoneResponses # rubocop: disable Cop/InjectEnterpriseEditionModule
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of burndown events' do
detail 'This feature was introduced in GitLab 12.1.'
end
get ':id/milestones/:milestone_id/burndown_events' do
authorize! :read_milestone, user_project
milestone_burndown_events_for(user_project)
end
end
end
end
end
end
import dateFormat from 'dateformat';
import BurndownChartData from 'ee/burndown_chart/burndown_chart_data';
describe('BurndownChartData', () => {
const milestoneEvents = [
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.190Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.478Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-01T00:00:00.597Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.767Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-03T00:00:00.260Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.152Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.572Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-03T00:00:00.450Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.352Z', weight: 2, action: 'reopened' },
];
const startDate = '2017-03-01';
const dueDate = '2017-03-03';
let burndownChartData;
beforeEach(() => {
burndownChartData = new BurndownChartData(milestoneEvents, startDate, dueDate);
});
describe('generate', () => {
it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([
['2017-03-01', 5, 10],
['2017-03-02', 5, 10],
['2017-03-03', 4, 8],
]);
});
describe('when viewing before due date', () => {
beforeAll(() => {
const today = new Date(2017, 2, 2);
// eslint-disable-next-line no-global-assign
Date = class extends Date {
constructor(date) {
super(date || today);
}
};
});
it('counts until today if milestone due date > date today', () => {
const chartData = burndownChartData.generate();
expect(dateFormat(new Date(), 'yyyy-mm-dd')).toEqual('2017-03-02');
expect(chartData[chartData.length - 1][0]).toEqual('2017-03-02');
});
});
});
});
This diff is collapsed.
...@@ -8,12 +8,13 @@ describe API::GroupMilestones do ...@@ -8,12 +8,13 @@ describe API::GroupMilestones do
let(:project) { create(:project, namespace: group) } let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, group: group, user: user) } let!(:group_member) { create(:group_member, group: group, user: user) }
let!(:closed_milestone) { create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone') } let!(:closed_milestone) { create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone') }
let!(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone') } let!(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone', start_date: Date.today, due_date: Date.today + 3.days) }
let(:issue) { create(:issue, created_at: Date.today.beginning_of_day, weight: 2, project: project) }
let(:issues_route) { "/groups/#{group.id}/milestones/#{milestone.id}/issues" } let(:issues_route) { "/groups/#{group.id}/milestones/#{milestone.id}/issues" }
before do before do
project.add_developer(user) project.add_developer(user)
milestone.issues << create(:issue, project: project) milestone.issues << issue
end end
it 'matches V4 EE-specific response schema for a list of issues' do it 'matches V4 EE-specific response schema for a list of issues' do
...@@ -22,4 +23,8 @@ describe API::GroupMilestones do ...@@ -22,4 +23,8 @@ describe API::GroupMilestones do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee') expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee')
end end
it_behaves_like 'group and project milestone burndowns', '/groups/:id/milestones/:milestone_id/burndown_events' do
let(:route) { "/groups/#{group.id}/milestones" }
end
end end
...@@ -5,12 +5,13 @@ require 'spec_helper' ...@@ -5,12 +5,13 @@ require 'spec_helper'
describe API::ProjectMilestones do describe API::ProjectMilestones do
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:project) { create(:project, namespace: user.namespace ) } let!(:project) { create(:project, namespace: user.namespace ) }
let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') } let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone', start_date: Date.today, due_date: Date.today + 3.days) }
let(:issue) { create(:issue, created_at: Date.today.beginning_of_day, weight: 2, project: project) }
let(:issues_route) { "/projects/#{project.id}/milestones/#{milestone.id}/issues" } let(:issues_route) { "/projects/#{project.id}/milestones/#{milestone.id}/issues" }
before do before do
project.add_developer(user) project.add_developer(user)
milestone.issues << create(:issue, project: project) milestone.issues << issue
end end
it 'matches V4 EE-specific response schema for a list of issues' do it 'matches V4 EE-specific response schema for a list of issues' do
...@@ -19,4 +20,8 @@ describe API::ProjectMilestones do ...@@ -19,4 +20,8 @@ describe API::ProjectMilestones do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee') expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee')
end end
it_behaves_like 'group and project milestone burndowns', '/projects/:id/milestones/:milestone_id/burndown_events' do
let(:route) { "/projects/#{project.id}/milestones" }
end
end end
# frozen_string_literal: true
shared_examples_for 'group and project milestone burndowns' do |route_definition|
let(:resource_route) { "#{route}/#{milestone.id}/burndown_events" }
describe "GET #{route_definition}" do
it 'returns burndown events list' do
get api(resource_route, user)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first['created_at'].to_time).to eq(Date.today.beginning_of_day)
expect(json_response.first['weight']).to eq(2)
expect(json_response.first['action']).to eq('created')
end
it 'returns 404 when user is not authorized to read milestone' do
outside_user = create(:user)
get api(resource_route, outside_user)
expect(response).to have_gitlab_http_status(404)
end
end
end
...@@ -95,3 +95,5 @@ module API ...@@ -95,3 +95,5 @@ module API
end end
end end
end end
API::GroupMilestones.prepend(EE::API::GroupMilestones)
...@@ -116,3 +116,5 @@ module API ...@@ -116,3 +116,5 @@ module API
end end
end end
end end
API::ProjectMilestones.prepend(EE::API::ProjectMilestones)
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