Commit f1f279be authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'weimeng-burndown-timezone' into 'master'

Make burndown chart timezone aware

Closes #4737

See merge request gitlab-org/gitlab-ee!10328
parents 7479e1eb 99090899
...@@ -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