From 38bbf7cfa416384d429fc48e4dbf350c1d9c1866 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 1/12] Move the statistics on the issues list to their own (inner) section This will make re-using the JS in easier as it won't rely on something that is specific to that page. Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/static/issues_stats.js b/pagure/static/issues_stats.js index 6ec0baf..4724c64 100644 --- a/pagure/static/issues_stats.js +++ b/pagure/static/issues_stats.js @@ -2,7 +2,7 @@ issues_history_stats_plot = function(url, _b, _s) { var svg = d3.select("svg"), margin = {top: 20, right: 20, bottom: 30, left: 50}, - width = $('#tags').width() - margin.left - margin.right, + width = $('#stats').width() - margin.left - margin.right, height = +svg.attr("height") - margin.top - margin.bottom, g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"); diff --git a/pagure/templates/issues.html b/pagure/templates/issues.html index b0392f0..9fb915b 100644 --- a/pagure/templates/issues.html +++ b/pagure/templates/issues.html @@ -127,8 +127,10 @@
- - +
+ + +
From 716362a982f4a2b5cd6c75d6c5fe30662508e2ad Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 2/12] Add a stats endpoint to project This endpoint provides two types of statistics at the moment: - number of commits per person - number of tickets open over time (same graph as on the issues list) Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/internal/__init__.py b/pagure/internal/__init__.py index 2df97c2..ee156b2 100644 --- a/pagure/internal/__init__.py +++ b/pagure/internal/__init__.py @@ -27,6 +27,7 @@ import pagure.exceptions # noqa: E402 import pagure.forms # noqa: E402 import pagure.lib # noqa: E402 import pagure.lib.git # noqa: E402 +import pagure.lib.tasks # noqa: E402 import pagure.ui.fork # noqa: E402 @@ -570,3 +571,59 @@ def get_branches_head(): 'heads': heads, } ) + + +@PV.route('/task/', methods=['GET']) +def task_info(taskid): + """ Return the results of the specified task or a 418 if the task is + still being processed. + """ + task = pagure.lib.tasks.get_result(taskid) + + if task.ready(): + result = task.get(timeout=0, propagate=False) + return flask.jsonify({'results': result}) + else: + flask.abort(418) + + +@PV.route('/stats/commits/authors', methods=['POST']) +def get_stats_commits(): + """ Return statistics about the commits made on the specified repo. + + """ + form = pagure.forms.ConfirmationForm() + if not form.validate_on_submit(): + response = flask.jsonify({ + 'code': 'ERROR', + 'message': 'Invalid input submitted', + }) + response.status_code = 400 + return response + + repo = pagure.get_authorized_project( + pagure.SESSION, + flask.request.form.get('repo', '').strip() or None, + namespace=flask.request.form.get('namespace', '').strip() or None, + user=flask.request.form.get('repouser', '').strip() or None) + + if not repo: + response = flask.jsonify({ + 'code': 'ERROR', + 'message': 'No repo found with the information provided', + }) + response.status_code = 404 + return response + + repopath = os.path.join(pagure.APP.config['GIT_FOLDER'], repo.path) + + task = pagure.lib.tasks.commits_author_stats.delay(repopath) + + return flask.jsonify( + { + 'code': 'OK', + 'message': 'Stats asked', + 'url': flask.url_for('internal_ns.task_info', taskid=task.id), + 'task_id': task.id, + } + ) diff --git a/pagure/lib/tasks.py b/pagure/lib/tasks.py index 5fda99d..c70e0f5 100644 --- a/pagure/lib/tasks.py +++ b/pagure/lib/tasks.py @@ -8,6 +8,7 @@ """ +import collections import gc import hashlib import os @@ -722,3 +723,31 @@ def update_checksums_file(folder, filenames): for algo in algos: stream.write('%s (%s) = %s\n' % ( algo.upper(), filename, algos[algo].hexdigest())) + + +@conn.task +def commits_author_stats(repopath): + """ Returns some statistics about commits made against the specified + git repository. + """ + if not os.path.exists(repopath): + raise ValueError('Git repository not found.') + + repo_obj = pygit2.Repository(repopath) + + stats = collections.defaultdict(int) + cnt = 0 + authors_email = set() + for commit in repo_obj.walk( + repo_obj.head.get_object().oid.hex, pygit2.GIT_SORT_TIME): + cnt += 1 + email = commit.author.email + author = commit.author.name + stats[(author, email)] += 1 + authors_email.add(email) + + out_stats = collections.defaultdict(list) + for authors, val in stats.items(): + out_stats[val].append(authors) + + return (cnt, out_stats, len(authors_email), commit.commit_time) diff --git a/pagure/static/issues_stats.js b/pagure/static/issues_stats.js index 4724c64..847913e 100644 --- a/pagure/static/issues_stats.js +++ b/pagure/static/issues_stats.js @@ -58,3 +58,45 @@ issues_history_stats_plot = function(url, _b, _s) { }); }; + +wait_for_task = function(url, callback){ + $.get(url) + .done(function(data){ + callback(data); + }) + .fail(function(){ + window.setTimeout(wait_for_task(url, callback), 1000); + }) +} + +show_commits_authors = function(data) { + var _b = $("#data_stats"); + var _s = $("#data_stats_spinner"); + var html = '

Since ' + data.results[3] + ' there has been ' + + data.results[0] + ' commits found in this repo, from ' + + data.results[2] + ' contributors

\n' + + '
\n'; + for (key in data.results[1]){ + for (key2 in data.results[1][key]){ + entry = data.results[1][key][key2] + html += ' ' + + entry[0] + + '
' + key + ' commits
' + + '
\n'; + } + } + html += '
'; + _b.html(html); + _b.show(); + _s.hide(); +} + +commits_authors = function(url, _data) { + $.post( url, _data ) + .done(function(data) { + wait_for_task(data.url, show_commits_authors); + }) + .fail(function(data) { + }) +}; diff --git a/pagure/templates/repo_stats.html b/pagure/templates/repo_stats.html new file mode 100644 index 0000000..f22e2ca --- /dev/null +++ b/pagure/templates/repo_stats.html @@ -0,0 +1,106 @@ +{% extends "repo_master.html" %} + +{% block title %}{{ select.capitalize() }} - {{ + g.repo.namespace + '/' if g.repo.namespace }}{{ g.repo.name }}{% endblock %} +{% set tag = "home" %} + + +{% block repo %} + +
+ +
+
+ + +
+
+
+{% endblock %} + +{% block jscripts %} +{{ super() }} + + + + +{% endblock %} diff --git a/pagure/ui/repo.py b/pagure/ui/repo.py index ae6ab16..9b4b514 100644 --- a/pagure/ui/repo.py +++ b/pagure/ui/repo.py @@ -2806,3 +2806,23 @@ def project_dowait(repo, username=None, namespace=None): name=repo, namespace=namespace, user=username).id return pagure.wait_for_task(taskid) + + +@APP.route('//stats/') +@APP.route('//stats') +@APP.route('///stats/') +@APP.route('///stats') +@APP.route('/fork///stats/') +@APP.route('/fork///stats') +@APP.route('/fork////stats/') +@APP.route('/fork////stats') +def view_stats(repo, username=None, namespace=None): + """ Displays some statistics about the specified repo. + """ + return flask.render_template( + 'repo_stats.html', + select='stats', + username=username, + repo=flask.g.repo, + form=pagure.forms.ConfirmationForm(), + ) From c1607b0d488ea44ca32d7b161620e32776804ba7 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 3/12] Fix the logic to find out the number of tickets open over time Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/lib/__init__.py b/pagure/lib/__init__.py index 16e6c42..47694da 100644 --- a/pagure/lib/__init__.py +++ b/pagure/lib/__init__.py @@ -4582,19 +4582,25 @@ def issues_history_stats(session, project): output = {} for week in range(53): start = tomorrow - datetime.timedelta(days=(week * 7)) - query = session.query( + closed_ticket = session.query( model.Issue ).filter( model.Issue.project_id == project.id ).filter( - sqlalchemy.or_( - model.Issue.closed_at == None, # noqa - model.Issue.closed_at <= start - ) + model.Issue.closed_at >= start + ).filter( + model.Issue.date_created <= start + ) + open_ticket = session.query( + model.Issue + ).filter( + model.Issue.project_id == project.id + ).filter( + model.Issue.status == 'Open' ).filter( model.Issue.date_created <= start ) - cnt = query.count() - to_ignore + cnt = open_ticket.count() + closed_ticket.count() - to_ignore if cnt < 0: cnt = 0 output[start.isoformat()] = cnt diff --git a/tests/test_pagure_flask_api_issue.py b/tests/test_pagure_flask_api_issue.py index 2ceac32..d7a354d 100644 --- a/tests/test_pagure_flask_api_issue.py +++ b/tests/test_pagure_flask_api_issue.py @@ -2992,7 +2992,7 @@ class PagureFlaskApiIssuetests(tests.Modeltests): self.assertEqual(len(data), 1) self.assertEqual(len(data['stats']), 53) last_key = sorted(data['stats'].keys())[-1] - self.assertEqual(data['stats'][last_key], 8) + self.assertEqual(data['stats'][last_key], 7) for k in sorted(data['stats'].keys())[:-1]: self.assertEqual(data['stats'][k], 0) From 1828a2bd3a6a0677035c16936910ee4df214415c Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 4/12] Add a graph of the evolution of the commits on the repo over the last year Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/internal/__init__.py b/pagure/internal/__init__.py index ee156b2..c33cd24 100644 --- a/pagure/internal/__init__.py +++ b/pagure/internal/__init__.py @@ -627,3 +627,45 @@ def get_stats_commits(): 'task_id': task.id, } ) + + +@PV.route('/stats/commits/trend', methods=['POST']) +def get_stats_commits_trend(): + """ Return evolution of the commits made on the specified repo. + + """ + form = pagure.forms.ConfirmationForm() + if not form.validate_on_submit(): + response = flask.jsonify({ + 'code': 'ERROR', + 'message': 'Invalid input submitted', + }) + response.status_code = 400 + return response + + repo = pagure.get_authorized_project( + pagure.SESSION, + flask.request.form.get('repo', '').strip() or None, + namespace=flask.request.form.get('namespace', '').strip() or None, + user=flask.request.form.get('repouser', '').strip() or None) + + if not repo: + response = flask.jsonify({ + 'code': 'ERROR', + 'message': 'No repo found with the information provided', + }) + response.status_code = 404 + return response + + repopath = os.path.join(pagure.APP.config['GIT_FOLDER'], repo.path) + + task = pagure.lib.tasks.commits_history_stats.delay(repopath) + + return flask.jsonify( + { + 'code': 'OK', + 'message': 'Stats asked', + 'url': flask.url_for('internal_ns.task_info', taskid=task.id), + 'task_id': task.id, + } + ) diff --git a/pagure/lib/tasks.py b/pagure/lib/tasks.py index c70e0f5..c558c9c 100644 --- a/pagure/lib/tasks.py +++ b/pagure/lib/tasks.py @@ -9,6 +9,7 @@ """ import collections +import datetime import gc import hashlib import os @@ -19,6 +20,7 @@ import time from celery import Celery from celery.result import AsyncResult +import arrow import pygit2 import tempfile import six @@ -751,3 +753,25 @@ def commits_author_stats(repopath): out_stats[val].append(authors) return (cnt, out_stats, len(authors_email), commit.commit_time) + + +@conn.task +def commits_history_stats(repopath): + """ Returns the evolution of the commits made against the specified + git repository. + """ + if not os.path.exists(repopath): + raise ValueError('Git repository not found.') + + repo_obj = pygit2.Repository(repopath) + + dates = collections.defaultdict(int) + for commit in repo_obj.walk( + repo_obj.head.get_object().oid.hex, pygit2.GIT_SORT_TIME): + delta = datetime.datetime.utcnow() \ + - arrow.get(commit.commit_time).naive + if delta.days > 365: + break + dates[arrow.get(commit.commit_time).date().isoformat()] += 1 + + return [(key, dates[key]) for key in sorted(dates)] diff --git a/pagure/static/issues_stats.js b/pagure/static/issues_stats.js index 847913e..999fc24 100644 --- a/pagure/static/issues_stats.js +++ b/pagure/static/issues_stats.js @@ -100,3 +100,74 @@ commits_authors = function(url, _data) { .fail(function(data) { }) }; + + +show_commits_history = function(data) { + var _b = $("#data_stats"); + var _s = $("#data_stats_spinner"); + + var parseTime = d3.timeParse("%Y-%m-%d"); + + var _out = data.results.map(function(x){ + var t = {}; + t.date = parseTime(x[0]); + t.value = x[1]; + return t; + }) + + var svg = d3.select("svg"), + margin = {top: 20, right: 20, bottom: 30, left: 50}, + width = $('#stats').width() - margin.left - margin.right, + height = +svg.attr("height") - margin.top - margin.bottom, + g = svg.append("g").attr( + "transform", "translate(" + margin.left + "," + margin.top + ")"); + + var x = d3.scaleTime() + .rangeRound([0, width]); + + var y = d3.scaleLinear() + .rangeRound([height, 0]); + + var area = d3.area() + .x(function(d) { return x(d.date); }) + .y1(function(d) { return y(d.value); }); + + function draw_graph(data) { + + x.domain(d3.extent(data, function(d) { return d.date; })); + y.domain([0, d3.max(data, function(d) { return d.value; })]); + area.y0(y(0)); + + g.append("path") + .datum(data) + .attr("fill", "steelblue") + .attr("d", area); + + g.append("g") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(x)); + + g.append("g") + .call(d3.axisLeft(y)) + .append("text") + .attr("fill", "#000") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", "0.71em") + .attr("text-anchor", "end") + .text("Number of commits"); + }; + + draw_graph(_out); + _b.show(); + _s.hide(); +} + +commits_history = function(url, _data) { + $.post( url, _data ) + .done(function(data) { + wait_for_task(data.url, show_commits_history); + }) + .fail(function(data) { + }) +}; diff --git a/pagure/templates/repo_stats.html b/pagure/templates/repo_stats.html index f22e2ca..4ae0b29 100644 --- a/pagure/templates/repo_stats.html +++ b/pagure/templates/repo_stats.html @@ -11,7 +11,7 @@
@@ -88,6 +88,27 @@ commits_authors_call = function(){ commits_authors(_stats_url, data); }; +commits_history_call = function(){ + var _stats_url = "{{ url_for('internal_ns.get_stats_commits_trend') }}"; + var _b = $("#data_stats"); + var _s = $("#data_stats_spinner"); + _s.html( + "" + ) + _s.show(); + _b.html( + "

Evolution of the number of commits over the last year

" + + "" + ); + var data = { + csrf_token: "{{ form.csrf_token.current_token }}", + repo: "{{ g.repo.name }}", + username: "{{ username or '' }}", + namespace: "{{ g.repo.namespace or '' }}", + } + commits_history(_stats_url, data); +}; + $(document).ready(function() { @@ -96,11 +117,15 @@ $(document).ready(function() { _b.hide(); if ($(this).attr('name') == 'issues') { issues_history_stats_plot_call(); + } else if ($(this).attr('name') == 'authors') { + commits_authors_call(); } else if ($(this).attr('name') == 'commits') { - commits_stats_call(); + commits_history_call(); } }); + commits_history_call(); + }); {% endblock %} From 019cbc7e474059a64b00b69c1159a01a453b1ccf Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 5/12] Add a link to the stats page in the navigation bar Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/templates/repo_master.html b/pagure/templates/repo_master.html index ea2f5af..27d3f27 100644 --- a/pagure/templates/repo_master.html +++ b/pagure/templates/repo_master.html @@ -287,6 +287,20 @@ {% endif %} + + {% if repo and repo.forks %} - {% if repo and repo.forks %} - - {% endif %} - {% if authenticated %} {% if g.repo_admin %}
@@ -36,6 +42,43 @@ +
+

Forks list

+ {% if repo.forks %} +
+ {% for fork in repo.forks %} + + + {% endfor %} +
+ {% else %} +

+ This project has not been forked. +

+ {% endif %} +
{% endblock %} @@ -112,23 +155,27 @@ commits_history_call = function() { commits_history(_stats_url, data); }; +toggle_forks = function() { + $("#forks_list").show(); +} $(document).ready(function() { $('.stats_btn').click(function(ev){ var _b = $("#data_stats"); _b.hide(); + $("#forks_list").hide(); if ($(this).attr('name') == 'issues') { issues_history_stats_plot_call(); } else if ($(this).attr('name') == 'authors') { commits_authors_call(); } else if ($(this).attr('name') == 'commits') { commits_history_call(); + } else if ($(this).attr('name') == 'forks') { + toggle_forks(); } }); - commits_history_call(); - }); {% endblock %} diff --git a/pagure/ui/repo.py b/pagure/ui/repo.py index 9b4b514..6a0168f 100644 --- a/pagure/ui/repo.py +++ b/pagure/ui/repo.py @@ -898,27 +898,6 @@ def view_tree(repo, identifier=None, username=None, namespace=None): ) -@APP.route('//forks/') -@APP.route('//forks') -@APP.route('///forks/') -@APP.route('///forks') -@APP.route('/fork///forks/') -@APP.route('/fork///forks') -@APP.route('/fork////forks/') -@APP.route('/fork////forks') -def view_forks(repo, username=None, namespace=None): - """ Presents all the forks of the project. - """ - repo = flask.g.repo - - return flask.render_template( - 'forks.html', - select='forks', - username=username, - repo=repo, - ) - - @APP.route('//releases/') @APP.route('//releases') @APP.route('///releases/') From 1a80864a0d7ae375d405a03c4501b011ee0b7241 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Nov 02 2017 09:41:39 +0000 Subject: [PATCH 12/12] Adjusts the tests now that the forks are in the stats tab Signed-off-by: Pierre-Yves Chibon --- diff --git a/tests/test_pagure_flask_ui_repo.py b/tests/test_pagure_flask_ui_repo.py index 0269d35..6494c03 100644 --- a/tests/test_pagure_flask_ui_repo.py +++ b/tests/test_pagure_flask_ui_repo.py @@ -1290,13 +1290,13 @@ class PagureFlaskRepotests(tests.Modeltests): def test_view_forks(self): """ Test the view_forks endpoint. """ - output = self.app.get('/foo/forks', follow_redirects=True) + output = self.app.get('/foo/stats', follow_redirects=True) self.assertEqual(output.status_code, 404) tests.create_projects(self.session) tests.create_projects_git(os.path.join(self.path, 'repos'), bare=True) - output = self.app.get('/test/forks', follow_redirects=True) + output = self.app.get('/test/stats', follow_redirects=True) self.assertEqual(output.status_code, 200) self.assertTrue('This project has not been forked.' in output.data)