Initial commit
Add the initial proof of concept engagement-stats utility and sufficient boilerplate to produce and test a Python package of it. Change-Id: I43e962ee9c11c830ef503675f5ca3bc5da927262
This commit is contained in:
parent
7477753eec
commit
fd6cd1da6c
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Add patterns in here to exclude files created by tools integrated with this
|
||||||
|
# repository, such as test frameworks from the project's recommended workflow,
|
||||||
|
# rendered documentation and package builds.
|
||||||
|
#
|
||||||
|
# Don't add patterns to exclude files created by preferred personal tools
|
||||||
|
# (editors, IDEs, your operating system itself even). These should instead be
|
||||||
|
# maintained outside the repository, for example in a ~/.gitignore file added
|
||||||
|
# with:
|
||||||
|
#
|
||||||
|
# git config --global core.excludesfile '~/.gitignore'
|
||||||
|
|
||||||
|
# Bytecompiled Python
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
*.egg*
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
eggs
|
||||||
|
parts
|
||||||
|
bin
|
||||||
|
var
|
||||||
|
sdist
|
||||||
|
develop-eggs
|
||||||
|
.installed.cfg
|
||||||
|
lib
|
||||||
|
lib64
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
cover/
|
||||||
|
.coverage*
|
||||||
|
!.coveragerc
|
||||||
|
.tox
|
||||||
|
nosetests.xml
|
||||||
|
.testrepository
|
||||||
|
.stestr
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Complexity
|
||||||
|
output/*.html
|
||||||
|
output/*/index.html
|
||||||
|
|
||||||
|
# Sphinx
|
||||||
|
doc/build
|
||||||
|
|
||||||
|
# pbr generates these
|
||||||
|
AUTHORS
|
||||||
|
ChangeLog
|
||||||
|
|
||||||
|
# Files created by releasenotes build
|
||||||
|
releasenotes/build
|
9
.zuul.yaml
Normal file
9
.zuul.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
- project:
|
||||||
|
templates:
|
||||||
|
- publish-opendev-tox-docs
|
||||||
|
check:
|
||||||
|
jobs:
|
||||||
|
- tox-linters
|
||||||
|
gate:
|
||||||
|
jobs:
|
||||||
|
- tox-linters
|
6
README.rst
Normal file
6
README.rst
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
OpenDev Engagement
|
||||||
|
==================
|
||||||
|
|
||||||
|
Tooling to generate coarse-grained reports of aggregate
|
||||||
|
collaboration activity from publicly available APIs and archives
|
||||||
|
provided by OpenDev hosted services.
|
1
doc/requirements.txt
Normal file
1
doc/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
sphinx
|
86
doc/source/_static/opendev.svg
Normal file
86
doc/source/_static/opendev.svg
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 486.98252 289.775"
|
||||||
|
height="289.77499"
|
||||||
|
width="486.98251"
|
||||||
|
xml:space="preserve"
|
||||||
|
version="1.1"
|
||||||
|
id="svg2"><metadata
|
||||||
|
id="metadata8"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs6"><clipPath
|
||||||
|
id="clipPath66"
|
||||||
|
clipPathUnits="userSpaceOnUse"><path
|
||||||
|
id="path68"
|
||||||
|
d="m 533,244.125 72,0 0,71.875 -72,0 0,-71.875 z" /></clipPath><clipPath
|
||||||
|
id="clipPath82"
|
||||||
|
clipPathUnits="userSpaceOnUse"><path
|
||||||
|
id="path84"
|
||||||
|
d="m 471.926,323 27.074,0 0,28 -27.074,0 0,-28 z" /></clipPath><clipPath
|
||||||
|
id="clipPath92"
|
||||||
|
clipPathUnits="userSpaceOnUse"><path
|
||||||
|
id="path94"
|
||||||
|
d="m 502,323 27,0 0,37.035 -27,0 0,-37.035 z" /></clipPath><clipPath
|
||||||
|
id="clipPath110"
|
||||||
|
clipPathUnits="userSpaceOnUse"><path
|
||||||
|
id="path112"
|
||||||
|
d="m 645,324 21.719,0 0,26 -21.719,0 0,-26 z" /></clipPath></defs><g
|
||||||
|
transform="matrix(1.25,0,0,1.25,-589.9075,-160.26875)"
|
||||||
|
id="g10"><g
|
||||||
|
transform="matrix(2,0,0,2,-471.926,-360.035)"
|
||||||
|
id="g62"><g
|
||||||
|
clip-path="url(#clipPath66)"
|
||||||
|
id="g64"><path
|
||||||
|
id="path70"
|
||||||
|
style="fill:#ee265e;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 604.781,279.582 c 0,-19.582 -15.875,-35.457 -35.457,-35.457 -19.582,0 -35.457,15.875 -35.457,35.457 0,19.582 15.875,35.457 35.457,35.457 19.582,0 35.457,-15.875 35.457,-35.457" /><path
|
||||||
|
id="path72"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 546.879,284.004 c 0,-3 2.441,-5.442 5.441,-5.442 3,0 5.442,2.442 5.442,5.442 0,3.004 -2.442,5.441 -5.442,5.441 -3,0 -5.441,-2.437 -5.441,-5.441 m 13.004,0 c 0,-4.168 -3.391,-7.563 -7.563,-7.563 -4.172,0 -7.562,3.395 -7.562,7.563 0,4.172 3.39,7.566 7.562,7.566 4.172,0 7.563,-3.394 7.563,-7.566" /><path
|
||||||
|
id="path74"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 583.555,289.449 -5.442,0 c -3,0 -5.441,-2.441 -5.441,-5.445 0,-3 2.441,-5.442 5.441,-5.442 l 5.442,0 0,10.887 z m 1.062,-13.008 -6.504,0 c -4.172,0 -7.562,3.395 -7.562,7.563 0,4.172 3.39,7.566 7.562,7.566 l 6.504,0 c 0.586,0 1.059,-0.476 1.059,-1.062 l 0,-13.008 c 0,-0.586 -0.473,-1.059 -1.059,-1.059" /><path
|
||||||
|
id="path76"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 590.906,285.203 c -0.664,0 -1.199,-0.539 -1.199,-1.199 0,-0.66 0.535,-1.195 1.199,-1.195 0.66,0 1.196,0.535 1.196,1.195 0,0.66 -0.536,1.199 -1.196,1.199 m 0,10.465 c 0.66,0 1.196,0.539 1.196,1.199 0,0.66 -0.536,1.199 -1.196,1.199 -0.664,0 -1.199,-0.539 -1.199,-1.199 0,-0.66 0.535,-1.199 1.199,-1.199 m -52.019,-15.242 c 0.222,-0.227 0.527,-0.352 0.843,-0.352 0.321,0 0.622,0.125 0.848,0.352 0.469,0.469 0.469,1.23 0,1.695 -0.453,0.453 -1.242,0.453 -1.695,0 -0.465,-0.465 -0.465,-1.226 0.004,-1.695 m 10.359,-7.742 c -0.453,0.453 -1.242,0.453 -1.695,0 -0.469,-0.469 -0.469,-1.227 0,-1.696 0.234,-0.234 0.539,-0.351 0.847,-0.351 0.309,0 0.614,0.117 0.848,0.351 0.465,0.469 0.465,1.227 0,1.696 m 45.566,-13.707 -10.195,0 c -0.586,0 -1.062,0.476 -1.062,1.062 l 0,10.043 -5.512,0 c -5.828,0 -10.828,3.609 -12.898,8.707 -0.676,-1.652 -1.68,-3.195 -3.02,-4.535 -2.887,-2.887 -6.797,-4.313 -10.836,-4.02 -0.148,-0.265 -0.32,-0.519 -0.543,-0.746 -1.293,-1.293 -3.398,-1.293 -4.695,0 -1.293,1.293 -1.293,3.399 0,4.696 0.629,0.625 1.461,0.972 2.347,0.972 0.887,0 1.719,-0.347 2.348,-0.972 0.52,-0.52 0.82,-1.176 0.922,-1.852 3.344,-0.176 6.566,1.031 8.957,3.422 4.602,4.601 4.602,12.09 0,16.691 -4.602,4.602 -12.086,4.602 -16.687,0 -2.137,-2.136 -3.34,-4.945 -3.438,-7.949 0.594,-0.141 1.137,-0.434 1.578,-0.875 1.293,-1.293 1.293,-3.398 0,-4.695 -1.254,-1.254 -3.441,-1.25 -4.691,0 -1.297,1.297 -1.297,3.402 -0.004,4.695 0.293,0.289 0.629,0.512 0.988,0.672 0.051,3.648 1.481,7.066 4.067,9.652 2.714,2.715 6.277,4.071 9.843,4.071 3.567,0 7.129,-1.356 9.844,-4.071 1.363,-1.363 2.379,-2.937 3.059,-4.621 2.093,5.043 7.066,8.602 12.859,8.602 l 9.73,0 c 0.446,1.308 1.676,2.262 3.133,2.262 1.828,0 3.317,-1.493 3.317,-3.321 0,-1.832 -1.489,-3.32 -3.317,-3.32 -1.457,0 -2.687,0.949 -3.133,2.258 l -9.73,0 c -6.508,0 -11.801,-5.293 -11.801,-11.801 0,-6.508 5.293,-11.801 11.801,-11.801 l 6.574,0 c 0.586,0 1.059,-0.473 1.059,-1.058 l 0,-10.047 8.078,0 0,2.722 -2.852,0.004 c -0.582,0 -1.058,0.477 -1.058,1.059 l 0,15.992 c -1.309,0.445 -2.258,1.672 -2.258,3.129 0,1.832 1.488,3.32 3.32,3.32 1.828,0 3.317,-1.488 3.317,-3.32 0,-1.457 -0.95,-2.684 -2.258,-3.129 l 0,-14.93 2.847,-0.004 c 0.586,0 1.059,-0.476 1.059,-1.058 l 0,-4.844 c 0,-0.586 -0.473,-1.062 -1.059,-1.062" /></g></g><g
|
||||||
|
transform="matrix(2,0,0,2,-471.926,-360.035)"
|
||||||
|
id="g78"><g
|
||||||
|
clip-path="url(#clipPath82)"
|
||||||
|
id="g80"><path
|
||||||
|
id="path86"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 494.559,336.965 c 0,5.172 -4.184,9.379 -9.325,9.379 -5.172,0 -9.379,-4.207 -9.379,-9.379 0,-5.141 4.207,-9.324 9.379,-9.324 5.141,0 9.325,4.183 9.325,9.324 m -9.325,-13.305 c -7.336,0 -13.308,5.969 -13.308,13.305 0,7.34 5.972,13.308 13.308,13.308 7.336,0 13.305,-5.968 13.305,-13.308 0,-7.336 -5.969,-13.305 -13.305,-13.305" /></g></g><g
|
||||||
|
transform="matrix(2,0,0,2,-471.926,-360.035)"
|
||||||
|
id="g88"><g
|
||||||
|
clip-path="url(#clipPath92)"
|
||||||
|
id="g90"><path
|
||||||
|
id="path96"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 524.68,336.965 c 0,5.172 -4.184,9.379 -9.328,9.379 -5.168,0 -9.375,-4.207 -9.375,-9.379 0,-5.141 4.207,-9.324 9.375,-9.324 5.144,0 9.328,4.183 9.328,9.324 m -9.328,-13.305 c -7.336,0 -13.305,5.969 -13.305,13.332 l 0,21.946 c 0,0.656 0.441,1.097 1.101,1.097 l 1.676,0 c 0.66,0 1.102,-0.441 1.102,-1.097 l 0,-12.622 c 2.5,2.52 5.91,3.957 9.426,3.957 7.339,0 13.308,-5.968 13.308,-13.308 0,-7.336 -5.969,-13.305 -13.308,-13.305" /></g></g><g
|
||||||
|
transform="matrix(2,0,0,2,-471.926,-360.035)"
|
||||||
|
id="g98"><path
|
||||||
|
id="path100"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 600.582,347.086 c -5.582,0 -10.121,-4.539 -10.121,-10.121 0,-5.61 4.539,-10.172 10.121,-10.172 5.609,0 10.172,4.562 10.172,10.172 0,5.582 -4.563,10.121 -10.172,10.121 m 12.207,-32.395 -1.68,0 c -0.222,0 -0.304,0.082 -0.304,0.305 l 0,14.637 -1.059,-1.145 c -2.375,-2.562 -5.715,-4.035 -9.164,-4.035 -6.898,0 -12.512,5.613 -12.512,12.512 0,6.898 5.614,12.512 12.512,12.512 6.898,0 12.512,-5.614 12.512,-12.512 l -0.035,-0.949 0.035,0 0,-21.02 c 0,-0.223 -0.082,-0.305 -0.305,-0.305" /><path
|
||||||
|
id="path102"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 535.961,334.949 c 0.859,-4.933 5.016,-7.515 8.734,-7.515 4.258,0 7.996,3.218 8.785,7.515 l -17.519,0 z m 8.734,-11.289 c -7.351,0 -12.898,5.524 -12.898,12.848 0,6.765 4.957,13.765 13.254,13.765 3.851,0 7.457,-1.539 10.179,-4.359 0.403,-0.461 0.395,-1.019 0,-1.469 l -1.027,-1.281 c -0.277,-0.352 -0.574,-0.441 -0.765,-0.453 -0.45,-0.047 -0.704,0.242 -0.762,0.305 -1.332,1.222 -4.094,3.277 -7.625,3.277 -4.567,0 -8.278,-3.27 -9.09,-7.973 l 20.687,0 c 0.688,0 1.098,-0.41 1.098,-1.101 l 0,-0.039 c -0.406,-7.961 -5.773,-13.52 -13.051,-13.52" /><path
|
||||||
|
id="path104"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 621.234,335.043 c 0.844,-5.516 5.446,-8.402 9.555,-8.402 4.715,0 8.844,3.609 9.606,8.398 l 0.113,0.707 -19.379,0 0.105,-0.703 z m 9.914,14.434 c 3.629,0 7.032,-1.454 9.579,-4.086 0.132,-0.157 0.132,-0.27 -0.024,-0.446 l -1.031,-1.289 c -0.086,-0.109 -0.156,-0.148 -0.195,-0.152 -0.067,-0.004 -0.106,0.035 -0.172,0.098 -1.426,1.312 -4.356,3.484 -8.157,3.484 -5.039,0 -9.117,-3.641 -9.914,-8.859 l -0.105,-0.704 21.613,0 c 0.246,0 0.305,-0.054 0.305,-0.304 -0.387,-7.504 -5.426,-12.766 -12.258,-12.766 -6.898,0 -12.101,5.184 -12.101,12.055 0,6.375 4.66,12.969 12.46,12.969" /></g><g
|
||||||
|
transform="matrix(2,0,0,2,-471.926,-360.035)"
|
||||||
|
id="g106"><g
|
||||||
|
clip-path="url(#clipPath110)"
|
||||||
|
id="g108"><path
|
||||||
|
id="path114"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
d="m 655.48,349.07 1.067,0 c 0.265,0 0.32,-0.156 0.344,-0.215 l 9.824,-23.972 c 0,-0.008 0.004,-0.012 0.004,-0.02 -0.012,0 -0.031,0 -0.051,0 l -2.188,0 c -0.261,0 -0.32,0.16 -0.339,0.211 l -8.106,20.637 -8.043,-20.629 c -0.023,-0.059 -0.082,-0.219 -0.344,-0.219 l -2.238,0 c -0.019,0 -0.035,0 -0.051,0 0.004,0.012 0.008,0.028 0.016,0.043 l 9.754,23.934 c 0.031,0.074 0.086,0.23 0.351,0.23" /></g></g><path
|
||||||
|
d="m 688.37,338.105 z m 3.454,1.586 c 1.32,0 2.204,-0.882 2.204,-2.194 l 0,-28.688 c 0,-11.852 -9.642,-21.508 -21.524,-21.516 l -0.25,-0.008 c -11.844,0 -21.484,9.656 -21.484,21.524 l 0,28.688 c 0,1.312 0.882,2.194 2.202,2.194 l 3.462,0 c 1.312,0 2.194,-0.882 2.194,-2.194 l 0,-28.688 c 0,-7.586 6.18,-13.766 13.774,-13.766 7.594,0 13.766,6.18 13.766,13.766 l 0,28.688 c 0,1.312 0.882,2.194 2.202,2.194 l 3.454,0 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
id="path118" /></g></svg>
|
After Width: | Height: | Size: 9.4 KiB |
13
doc/source/conf.py
Normal file
13
doc/source/conf.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath('.'))
|
||||||
|
source_suffix = '.rst'
|
||||||
|
master_doc = 'index'
|
||||||
|
project = 'OpenDev Engagement'
|
||||||
|
copyright = ('OpenDev Contributors')
|
||||||
|
exclude_patterns = ['_build']
|
||||||
|
pygments_style = 'sphinx'
|
||||||
|
html_static_path = ['_static/']
|
||||||
|
html_theme = 'alabaster'
|
||||||
|
html_theme_options = {'logo': 'opendev.svg'}
|
1
doc/source/index.rst
Normal file
1
doc/source/index.rst
Normal file
@ -0,0 +1 @@
|
|||||||
|
.. include:: ../../README.rst
|
0
engagement/__init__.py
Normal file
0
engagement/__init__.py
Normal file
583
engagement/stats.py
Executable file
583
engagement/stats.py
Executable file
@ -0,0 +1,583 @@
|
|||||||
|
# Copyright OpenDev Contributors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an "AS
|
||||||
|
# IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
|
||||||
|
# express or implied. See the License for the specific language
|
||||||
|
# governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import html.parser
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def requester(
|
||||||
|
url, params={}, headers={}, mode='live', recording=None, verbose=0):
|
||||||
|
"""A requests wrapper to consistently retry HTTPS queries"""
|
||||||
|
|
||||||
|
# We key recordings of queries based on a tuple of their URL and parameters
|
||||||
|
# (this may not be stable in Python<3.6 due to lack of dict ordering)
|
||||||
|
if mode == 'replay':
|
||||||
|
# In replay mode, use recorded results for all queries
|
||||||
|
text = recording[(url, params)]
|
||||||
|
else:
|
||||||
|
# In live or record modes, actually use the remote API instead
|
||||||
|
retry = requests.Session()
|
||||||
|
retry.mount("https://", requests.adapters.HTTPAdapter(max_retries=3))
|
||||||
|
response = retry.get(url=url, params=params, headers=headers)
|
||||||
|
text = response.text
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Queried: %s" % response.url)
|
||||||
|
if mode == 'record':
|
||||||
|
# In record mode, also save a copy of the query results to replay
|
||||||
|
recording[(url, params)] = text
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def decode_json(raw):
|
||||||
|
"""Trap JSON decoding failures and provide more detailed errors"""
|
||||||
|
|
||||||
|
# Gerrit's REST API prepends a JSON-breaker to avoid XSS vulnerabilities
|
||||||
|
if raw.startswith(")]}'"):
|
||||||
|
trimmed = raw[4:]
|
||||||
|
else:
|
||||||
|
trimmed = raw
|
||||||
|
|
||||||
|
# Try to decode and bail with much detail if it fails
|
||||||
|
try:
|
||||||
|
decoded = json.loads(trimmed)
|
||||||
|
except Exception:
|
||||||
|
print('\nrequest returned %s error to query:\n\n %s\n'
|
||||||
|
'\nwith detail:\n\n %s\n' % (raw, raw.url, trimmed),
|
||||||
|
file=sys.stderr)
|
||||||
|
raise
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def query_gerrit(method, params={}, mode='live', recording=None, verbose=0):
|
||||||
|
"""Query the Gerrit REST API and make or replay a recording"""
|
||||||
|
|
||||||
|
url = 'https://review.opendev.org/%s' % method
|
||||||
|
result = requester(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
mode=mode,
|
||||||
|
recording=recording,
|
||||||
|
verbose=verbose)
|
||||||
|
return decode_json(result)
|
||||||
|
|
||||||
|
|
||||||
|
def from_gerrit_time(when):
|
||||||
|
"""Translate a Gerrit date/time string into a naive datetime object."""
|
||||||
|
|
||||||
|
return datetime.datetime.strptime(when.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
def to_gerrit_time(when):
|
||||||
|
"""Translate a datetime object into a Gerrit date/time string."""
|
||||||
|
|
||||||
|
return when.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
def get_projects(recording=None, verbose=0):
|
||||||
|
"""Return a sorted list of all namespaced code projects in Gerrit"""
|
||||||
|
|
||||||
|
all_projects = query_gerrit(
|
||||||
|
'projects/', params={'type': 'code'}, recording=recording,
|
||||||
|
verbose=verbose)
|
||||||
|
projects = list()
|
||||||
|
for (project, details) in all_projects.items():
|
||||||
|
if '/' in project:
|
||||||
|
projects.append(project)
|
||||||
|
return sorted(projects)
|
||||||
|
|
||||||
|
|
||||||
|
def usage_error():
|
||||||
|
"""Write a generic usage message to stderr and exit nonzero"""
|
||||||
|
|
||||||
|
sys.stderr.write(
|
||||||
|
'ERROR: specify report period like YEAR, YEAR-H[1-2], YEAR-Q[1-4],\n'
|
||||||
|
' or YEAR-[01-12], optionally prefixed by record- or replay-\n'
|
||||||
|
' if you want to make a recording or reuse a prior recording\n')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_report_period(when):
|
||||||
|
"""Parse a supplied report period string, returning a tuple of
|
||||||
|
after and before datetime objects"""
|
||||||
|
|
||||||
|
monthly = re.compile(r'^(\d{4})-(\d{2})$')
|
||||||
|
quarterly = re.compile(r'^(\d{4})-q([1-4])$', re.IGNORECASE)
|
||||||
|
halfyearly = re.compile(r'^(\d{4})-h([1-4])$', re.IGNORECASE)
|
||||||
|
yearly = re.compile(r'^\d{4}$')
|
||||||
|
if monthly.match(when):
|
||||||
|
start_year = int(monthly.match(when).group(1))
|
||||||
|
start_month = int(monthly.match(when).group(2))
|
||||||
|
end_year = start_year + start_month // 12
|
||||||
|
end_month = 1 + start_month % 12
|
||||||
|
elif quarterly.match(when):
|
||||||
|
start_year = int(quarterly.match(when).group(1))
|
||||||
|
start_month = 1 + 3 * (int(quarterly.match(when).group(2)) - 1)
|
||||||
|
end_year = start_year + (start_month + 2) // 12
|
||||||
|
end_month = 1 + (start_month + 2) % 12
|
||||||
|
elif halfyearly.match(when):
|
||||||
|
start_year = int(halfyearly.match(when).group(1))
|
||||||
|
start_month = 1 + 6 * (int(halfyearly.match(when).group(2)) - 1)
|
||||||
|
end_year = start_year + (start_month + 5) // 12
|
||||||
|
end_month = 1 + (start_month + 5) % 12
|
||||||
|
elif yearly.match(when):
|
||||||
|
start_year = int(yearly.match(when).group())
|
||||||
|
start_month = 1
|
||||||
|
end_year = start_year + 1
|
||||||
|
end_month = 1
|
||||||
|
else:
|
||||||
|
usage_error()
|
||||||
|
after = datetime.datetime(start_year, start_month, 1)
|
||||||
|
before = datetime.datetime(end_year, end_month, 1)
|
||||||
|
return after, before
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command_line():
|
||||||
|
"""Parse the command line to obtain the report period, then return it"""
|
||||||
|
|
||||||
|
if len(sys.argv) == 2:
|
||||||
|
return sys.argv[1]
|
||||||
|
else:
|
||||||
|
usage_error()
|
||||||
|
|
||||||
|
|
||||||
|
def report_times(report, after, before):
|
||||||
|
"""Add timestamp values to provided report"""
|
||||||
|
|
||||||
|
report['times'] = dict()
|
||||||
|
report['times']['after'] = to_gerrit_time(after)
|
||||||
|
report['times']['before'] = to_gerrit_time(before)
|
||||||
|
report['times']['generated'] = to_gerrit_time(datetime.datetime.utcnow())
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def get_ml_index(verbose=0):
|
||||||
|
sites = yaml.safe_load(
|
||||||
|
requester('http://lists.opendev.org/archives.yaml', verbose=verbose))
|
||||||
|
return sites
|
||||||
|
|
||||||
|
|
||||||
|
def get_ml_archive(listname, site, yearmonth, verbose=0):
|
||||||
|
year, month = yearmonth
|
||||||
|
monthname = datetime.date(1, month, 1).strftime('%B')
|
||||||
|
return requester('http://%s/pipermail/%s/%s-%s.txt' % (
|
||||||
|
site,
|
||||||
|
listname,
|
||||||
|
year,
|
||||||
|
monthname,
|
||||||
|
), verbose=verbose)
|
||||||
|
|
||||||
|
|
||||||
|
def add_ml_activity(ml_activity, site, archive):
|
||||||
|
if archive:
|
||||||
|
for line in archive.split('\n'):
|
||||||
|
# Take care to avoid incorrectly matching on lines which
|
||||||
|
# begin with the word From inside the message body
|
||||||
|
fromline = re.match(
|
||||||
|
r'From ([^ ]+) at ([0-9A-Za-z\.-]+\.[0-9A-Za-z\.-]+) '
|
||||||
|
r'(Sun|Mon|Tue|Wed|Thu|Fri|Sat) '
|
||||||
|
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) '
|
||||||
|
r'[ 123][0-9] [012][0-9]:[0-9]{2}:[0-9]{2} [0-9]{4}$',
|
||||||
|
line)
|
||||||
|
if fromline:
|
||||||
|
localpart, domainpart = fromline.groups()[:2]
|
||||||
|
domainpart = domainpart.lower()
|
||||||
|
address = '%s@%s' % (localpart, domainpart)
|
||||||
|
if address.lower() in (
|
||||||
|
'build.starlingx@gmail.com',
|
||||||
|
'hudson@openstack.org',
|
||||||
|
'info@bitergia.com',
|
||||||
|
'infra-root@openstack.org',
|
||||||
|
'jenkins@openstack.org',
|
||||||
|
'no-reply@openstack.org',
|
||||||
|
'readthedocs@readthedocs.org',
|
||||||
|
'review@openstack.org',
|
||||||
|
'zuul@opendev.org',
|
||||||
|
'zuul@openstack.org',
|
||||||
|
) or domainpart in (
|
||||||
|
'bugs.launchpad.net',
|
||||||
|
'lists.airshipit.org',
|
||||||
|
'lists.katacontainers.io',
|
||||||
|
'lists.opendev.org',
|
||||||
|
'lists.openinfra.dev',
|
||||||
|
'lists.openstack.org',
|
||||||
|
'lists.starlingx.io',
|
||||||
|
'lists.zuul-ci.org',
|
||||||
|
'review.opendev.org',
|
||||||
|
'review.openstack.org',
|
||||||
|
'storyboard.openstack.org',
|
||||||
|
'storyboard.opendev.org',
|
||||||
|
'zuul.opendev.org',
|
||||||
|
'zuul.openstack.org',
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if address in ml_activity[site]:
|
||||||
|
ml_activity[site][address] += 1
|
||||||
|
else:
|
||||||
|
ml_activity[site][address] = 1
|
||||||
|
if address in ml_activity['_total']:
|
||||||
|
ml_activity['_total'][address] += 1
|
||||||
|
else:
|
||||||
|
ml_activity['_total'][address] = 1
|
||||||
|
|
||||||
|
|
||||||
|
def add_all_ml_activity(ml_activity, sites, yearmonth, verbose=0):
|
||||||
|
for site in sites:
|
||||||
|
if site not in ml_activity:
|
||||||
|
ml_activity[site] = {}
|
||||||
|
for listname in sites[site]:
|
||||||
|
archive = get_ml_archive(
|
||||||
|
listname, site, yearmonth, verbose=verbose)
|
||||||
|
add_ml_activity(ml_activity, site, archive)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsListParser(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
self.channels = list()
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'a' and attrs[0][1].startswith('%23'):
|
||||||
|
self.channels.append(urllib.parse.unquote(attrs[0][1].strip('/')))
|
||||||
|
|
||||||
|
|
||||||
|
def get_channels_list(verbose=0):
|
||||||
|
parser = ChannelsListParser()
|
||||||
|
parser.feed(
|
||||||
|
requester('https://meetings.opendev.org/irclogs/', verbose=verbose))
|
||||||
|
return parser.channels
|
||||||
|
|
||||||
|
|
||||||
|
class LogsListParser(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
self.logs = list()
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'a' and attrs[0][1].startswith('%23'):
|
||||||
|
self.logs.append(attrs[0][1])
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_logs(channel, yearmonth, verbose=0):
|
||||||
|
year, month = yearmonth
|
||||||
|
channel = urllib.parse.quote(channel)
|
||||||
|
logs = ''
|
||||||
|
parser = LogsListParser()
|
||||||
|
parser.feed(requester(
|
||||||
|
'https://meetings.opendev.org/irclogs/%s/' % channel, verbose=verbose))
|
||||||
|
for day in range(1, 32):
|
||||||
|
if '%s.%d-%02d-%02d.log.html' % (
|
||||||
|
channel, year, month, day) not in parser.logs:
|
||||||
|
continue
|
||||||
|
result = requester(
|
||||||
|
'https://meetings.opendev.org/irclogs/%s/%s.%d-%02d-%02d.log' % (
|
||||||
|
channel,
|
||||||
|
channel,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
), verbose=verbose)
|
||||||
|
if result:
|
||||||
|
logs += result
|
||||||
|
return logs.split('\n')
|
||||||
|
|
||||||
|
|
||||||
|
def add_chat_activity(chat_activity, logs, namespace, verbose=0):
|
||||||
|
if logs:
|
||||||
|
for line in logs.split('\n'):
|
||||||
|
logline = re.match(r'[0-9T:-]{19} <([^ ]+)> ', line)
|
||||||
|
if logline:
|
||||||
|
nick = logline.group(1).strip('@')
|
||||||
|
if nick in (
|
||||||
|
'opendevmeet',
|
||||||
|
'opendevreview',
|
||||||
|
'opendevstatus',
|
||||||
|
'openstack',
|
||||||
|
'openstackgerrit',
|
||||||
|
'openstackstatus',
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if namespace not in chat_activity:
|
||||||
|
chat_activity[namespace] = {}
|
||||||
|
if verbose >= 1:
|
||||||
|
print("Adding namespace: %s" % namespace)
|
||||||
|
if nick in chat_activity['_all_channels']:
|
||||||
|
chat_activity['_all_channels'][nick] += 1
|
||||||
|
else:
|
||||||
|
chat_activity['_all_channels'][nick] = 1
|
||||||
|
if verbose >= 1:
|
||||||
|
print("Found chat nick: %s" % nick)
|
||||||
|
if nick in chat_activity[namespace]:
|
||||||
|
chat_activity[namespace][nick] += 1
|
||||||
|
else:
|
||||||
|
chat_activity[namespace][nick] = 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(verbose=0):
|
||||||
|
"""Utility entry point"""
|
||||||
|
|
||||||
|
argument = parse_command_line()
|
||||||
|
if argument.startswith('record-'):
|
||||||
|
mode = 'record'
|
||||||
|
argument = argument[len(mode)+1:]
|
||||||
|
elif argument.startswith('replay-'):
|
||||||
|
mode = 'replay'
|
||||||
|
argument = argument[len(mode)+1:]
|
||||||
|
else:
|
||||||
|
mode = 'live'
|
||||||
|
|
||||||
|
recordfn = 'recordings/%s.yaml' % argument
|
||||||
|
if mode == 'record':
|
||||||
|
recording = {}
|
||||||
|
elif mode == 'replay':
|
||||||
|
recording = yaml.load(open(recordfn), loader=yaml.loader.safeLoader)
|
||||||
|
else:
|
||||||
|
recording = None
|
||||||
|
|
||||||
|
after, before = parse_report_period(argument)
|
||||||
|
changes = dict()
|
||||||
|
|
||||||
|
# Shard querying by project, to help with the inherent instability of
|
||||||
|
# result pagination from the Gerrit API
|
||||||
|
for project in get_projects(recording=recording, verbose=verbose):
|
||||||
|
if verbose >= 1:
|
||||||
|
print("Checking project: %s" % project)
|
||||||
|
offset = 0
|
||||||
|
# Loop due to unavoidable query result pagination
|
||||||
|
while offset >= 0:
|
||||||
|
# We only constrain the query by the after date, as changes created
|
||||||
|
# between the before and after date may have been updated more
|
||||||
|
# recently with a new revision or comment
|
||||||
|
new_changes = query_gerrit('changes/', params={
|
||||||
|
'q': 'project:%s after:{%s}' % (
|
||||||
|
project, to_gerrit_time(after)),
|
||||||
|
'no-limit': '1',
|
||||||
|
'start': offset,
|
||||||
|
'o': ['ALL_REVISIONS', 'MESSAGES', 'SKIP_DIFFSTAT'],
|
||||||
|
}, recording=recording, verbose=verbose)
|
||||||
|
# Since we redundantly query ranges with offsets to help combat
|
||||||
|
# pagination instability, we must deduplicate results
|
||||||
|
for change in new_changes:
|
||||||
|
if change['id'] not in changes:
|
||||||
|
changes[change['id']] = change
|
||||||
|
# Offset additional pages by half the returned entry count to help
|
||||||
|
# avoid missing changes due to pagination instability
|
||||||
|
if new_changes and new_changes[-1].get('_more_changes', False):
|
||||||
|
offset += len(new_changes)/2
|
||||||
|
else:
|
||||||
|
offset = -1
|
||||||
|
|
||||||
|
report = {
|
||||||
|
'chat_namespaces': dict(),
|
||||||
|
'ml_sites': dict(),
|
||||||
|
'repo_namespaces': dict(),
|
||||||
|
}
|
||||||
|
report_times(report, after, before)
|
||||||
|
committers = dict()
|
||||||
|
projects_active = dict()
|
||||||
|
reviewers = dict()
|
||||||
|
for change in changes.values():
|
||||||
|
namespace = change['project'].split("/")[0]
|
||||||
|
if namespace not in report['repo_namespaces']:
|
||||||
|
report['repo_namespaces'][namespace] = {
|
||||||
|
'changes_created': 0,
|
||||||
|
'changes_merged': 0,
|
||||||
|
'review_automated': 0,
|
||||||
|
'reviewer_messages': 0,
|
||||||
|
'revisions_pushed': 0,
|
||||||
|
}
|
||||||
|
if namespace not in projects_active:
|
||||||
|
projects_active[namespace] = set()
|
||||||
|
if after < from_gerrit_time(change['created']) < before:
|
||||||
|
# Note that the changes are not returned in chronological
|
||||||
|
# order, so we have to test all of them and can't short-circuit
|
||||||
|
# after the first change which was created too late
|
||||||
|
report['repo_namespaces'][namespace]['changes_created'] += 1
|
||||||
|
projects_active[namespace].add(change['project'])
|
||||||
|
if namespace not in committers:
|
||||||
|
committers[namespace] = set()
|
||||||
|
committers[namespace].add(change['owner']['_account_id'])
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Found created change: %s" % change['_number'])
|
||||||
|
if ('submitted' in change and after < from_gerrit_time(
|
||||||
|
change['submitted']) < before):
|
||||||
|
report['repo_namespaces'][namespace]['changes_merged'] += 1
|
||||||
|
projects_active[namespace].add(change['project'])
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Found merged change: %s" % change['_number'])
|
||||||
|
for revision in change['revisions'].values():
|
||||||
|
if after < from_gerrit_time(revision['created']) < before:
|
||||||
|
report['repo_namespaces'][namespace]['revisions_pushed'] += 1
|
||||||
|
projects_active[namespace].add(change['project'])
|
||||||
|
if namespace not in committers:
|
||||||
|
committers[namespace] = set()
|
||||||
|
committers[namespace].add(revision['uploader']['_account_id'])
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Found change revision: %s,%s" % (
|
||||||
|
change['_number'], revision['_number']))
|
||||||
|
for message in change['messages']:
|
||||||
|
if after < from_gerrit_time(message['date']) < before:
|
||||||
|
if ('tag' in message and message['tag'].startswith(
|
||||||
|
'autogenerated:')):
|
||||||
|
report['repo_namespaces'][namespace][
|
||||||
|
'review_automated'] += 1
|
||||||
|
projects_active[namespace].add(change['project'])
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Found automated comment: %s,%s,%s (%s)" % (
|
||||||
|
change['_number'],
|
||||||
|
message['_revision_number'],
|
||||||
|
message['id'],
|
||||||
|
message['date']))
|
||||||
|
elif not message['message'].startswith(
|
||||||
|
'Uploaded patch set'):
|
||||||
|
report['repo_namespaces'][namespace][
|
||||||
|
'reviewer_messages'] += 1
|
||||||
|
projects_active[namespace].add(change['project'])
|
||||||
|
if namespace not in reviewers:
|
||||||
|
reviewers[namespace] = set()
|
||||||
|
reviewers[namespace].add(message['author']['_account_id'])
|
||||||
|
if verbose >= 2:
|
||||||
|
print("Found reviewer comment: %s,%s,%s (%s)" % (
|
||||||
|
change['_number'],
|
||||||
|
message['_revision_number'],
|
||||||
|
message['id'],
|
||||||
|
message['date']))
|
||||||
|
all_committers = set()
|
||||||
|
for namespace in committers:
|
||||||
|
report['repo_namespaces'][namespace]['committers'] = len(
|
||||||
|
committers[namespace])
|
||||||
|
all_committers = all_committers.union(committers[namespace])
|
||||||
|
all_reviewers = set()
|
||||||
|
for namespace in reviewers:
|
||||||
|
report['repo_namespaces'][namespace]['reviewers'] = len(
|
||||||
|
reviewers[namespace])
|
||||||
|
all_reviewers = all_reviewers.union(reviewers[namespace])
|
||||||
|
for namespace in projects_active:
|
||||||
|
report['repo_namespaces'][namespace]['projects_active'] = len(
|
||||||
|
projects_active[namespace])
|
||||||
|
|
||||||
|
ml_activity = {'_total': {}}
|
||||||
|
for scalar_month in range(
|
||||||
|
after.year * 12 + after.month,
|
||||||
|
before.year * 12 + before.month):
|
||||||
|
yearmonth = ((scalar_month - 1) // 12, scalar_month % 12 or 12)
|
||||||
|
add_all_ml_activity(
|
||||||
|
ml_activity, get_ml_index(), yearmonth, verbose=verbose)
|
||||||
|
report['ml_sites'] = {}
|
||||||
|
for site in ml_activity:
|
||||||
|
report['ml_sites'][site] = {'posts': 0, 'senders': 0}
|
||||||
|
for posts in ml_activity[site].values():
|
||||||
|
report['ml_sites'][site]['posts'] += posts
|
||||||
|
report['ml_sites'][site]['senders'] += 1
|
||||||
|
|
||||||
|
chat_activity = {'_all_channels': {}}
|
||||||
|
channels = get_channels_list(verbose=verbose)
|
||||||
|
for channel in channels:
|
||||||
|
namespace = channel.split('-')[0].strip('#')
|
||||||
|
for scalar_month in range(
|
||||||
|
after.year * 12 + after.month,
|
||||||
|
before.year * 12 + before.month):
|
||||||
|
yearmonth = ((scalar_month - 1) // 12, scalar_month % 12 or 12)
|
||||||
|
for logs in get_channel_logs(channel, yearmonth, verbose=verbose):
|
||||||
|
add_chat_activity(
|
||||||
|
chat_activity, logs, namespace, verbose=verbose)
|
||||||
|
for namespace in chat_activity:
|
||||||
|
report['chat_namespaces'][namespace] = {
|
||||||
|
'messages': sum(chat_activity[namespace].values()),
|
||||||
|
'nicks': len(chat_activity[namespace]),
|
||||||
|
}
|
||||||
|
|
||||||
|
report['totals'] = dict()
|
||||||
|
report['totals']['active_repo_namespaces'] = len(report['repo_namespaces'])
|
||||||
|
report['totals']['committers'] = len(all_committers)
|
||||||
|
report['totals']['reviewers'] = len(all_reviewers)
|
||||||
|
additive_keys = (
|
||||||
|
'changes_created',
|
||||||
|
'changes_merged',
|
||||||
|
'projects_active',
|
||||||
|
'review_automated',
|
||||||
|
'reviewer_messages',
|
||||||
|
'revisions_pushed',
|
||||||
|
)
|
||||||
|
for key in additive_keys:
|
||||||
|
report['totals'][key] = 0
|
||||||
|
# Operate on a copy of the keys since we'll be altering the dict
|
||||||
|
for namespace in list(report['repo_namespaces'].keys()):
|
||||||
|
# Cull inactive namespaces from the report
|
||||||
|
if not report['repo_namespaces'][namespace]['projects_active']:
|
||||||
|
del report['repo_namespaces'][namespace]
|
||||||
|
continue
|
||||||
|
# Summation key totals
|
||||||
|
for key in additive_keys:
|
||||||
|
report['totals'][key] += report['repo_namespaces'][namespace][key]
|
||||||
|
|
||||||
|
report['totals']['mailing_list_posts'] = (
|
||||||
|
report['ml_sites']['_total']['posts'])
|
||||||
|
report['totals']['mailing_list_senders'] = (
|
||||||
|
report['ml_sites']['_total']['senders'])
|
||||||
|
del report['ml_sites']['_total']
|
||||||
|
report['totals']['mailing_list_sites'] = len(report['ml_sites'])
|
||||||
|
|
||||||
|
report['totals']['chat_messages_logged'] = sum(
|
||||||
|
chat_activity['_all_channels'].values())
|
||||||
|
report['totals']['chat_nicknames_logged'] = len(
|
||||||
|
chat_activity['_all_channels'])
|
||||||
|
del report['chat_namespaces']['_all_channels']
|
||||||
|
report['totals']['chat_channel_namespaces'] = len(
|
||||||
|
report['chat_namespaces'])
|
||||||
|
|
||||||
|
# Write a recording if requested
|
||||||
|
if mode == 'record':
|
||||||
|
os.makedirs(os.path.dirname(recordfn), exist_ok=True)
|
||||||
|
open(recordfn, 'w').write(yaml.dump(recording))
|
||||||
|
|
||||||
|
# Write the full YAML structured data report
|
||||||
|
os.makedirs('reports', exist_ok=True)
|
||||||
|
open('reports/%s.yaml' % argument, 'w').write(yaml.dump(report))
|
||||||
|
|
||||||
|
# Write the one-dimensional CSV tabular reports
|
||||||
|
for tabname in ('times', 'totals'):
|
||||||
|
table = [[argument, tabname]]
|
||||||
|
for rowname in report[tabname]:
|
||||||
|
table.append([rowname, report[tabname][rowname]])
|
||||||
|
csv.writer(open('reports/%s_%s.csv' % (
|
||||||
|
argument, tabname), 'w')).writerows(table)
|
||||||
|
|
||||||
|
# Write the two-dimensional CSV tabular reports
|
||||||
|
for tabname in ('chat_namespaces', 'ml_sites', 'repo_namespaces'):
|
||||||
|
table = [[argument]]
|
||||||
|
for colname in report[tabname]:
|
||||||
|
table[0].append(colname)
|
||||||
|
for rowname in report[tabname][colname]:
|
||||||
|
row_updated = False
|
||||||
|
for row in table[1:]:
|
||||||
|
if row[0] == rowname:
|
||||||
|
row.append(report[tabname][colname][rowname])
|
||||||
|
row_updated = True
|
||||||
|
break
|
||||||
|
if not row_updated:
|
||||||
|
table.append(
|
||||||
|
[rowname, report[tabname][colname][rowname]])
|
||||||
|
csv.writer(open('reports/%s_%s.csv' % (
|
||||||
|
argument, tabname), 'w')).writerows(table)
|
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["pbr>=5.8.0", "setuptools>=36.6.0", "wheel"]
|
||||||
|
build-backend = "pbr.build"
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pyyaml
|
||||||
|
requests
|
44
setup.cfg
Normal file
44
setup.cfg
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
[metadata]
|
||||||
|
name = opendev-engagement
|
||||||
|
summary = Engagement statistics for OpenDev services
|
||||||
|
long_description = file: README.rst
|
||||||
|
long_description_content_type = text/x-rst; charset=UTF-8
|
||||||
|
author = OpenDev Contributors
|
||||||
|
author_email = service-discuss@lists.opendev.org
|
||||||
|
url = https://docs.opendev.org/opendev/engagement/
|
||||||
|
project_urls =
|
||||||
|
Browse Source = https://opendev.org/opendev/engagement
|
||||||
|
Bug Reporting = https://storyboard.openstack.org/#!/project/opendev/engagement
|
||||||
|
Documentation = https://docs.opendev.org/opendev/engagement/
|
||||||
|
Git Clone URL = https://opendev.org/opendev/engagement
|
||||||
|
License Texts = https://opendev.org/opendev/engagement/src/branch/master/LICENSE
|
||||||
|
keywords = contributor statistics
|
||||||
|
license = Apache License, Version 2.0
|
||||||
|
platforms = POSIX/Unix
|
||||||
|
classifier =
|
||||||
|
Development Status :: 5 - Production/Stable
|
||||||
|
Environment :: Console
|
||||||
|
Intended Audience :: Developers
|
||||||
|
Intended Audience :: Information Technology
|
||||||
|
License :: OSI Approved :: Apache Software License
|
||||||
|
Operating System :: POSIX :: Linux
|
||||||
|
Programming Language :: Python :: 3
|
||||||
|
Programming Language :: Python :: 3.6
|
||||||
|
Programming Language :: Python :: 3.7
|
||||||
|
Programming Language :: Python :: 3.8
|
||||||
|
Programming Language :: Python :: 3.9
|
||||||
|
Programming Language :: Python :: 3.10
|
||||||
|
Programming Language :: Python :: Implementation :: CPython
|
||||||
|
Topic :: Software Development :: Testing
|
||||||
|
Topic :: Utilities
|
||||||
|
|
||||||
|
[options]
|
||||||
|
python_requires = >=3.6
|
||||||
|
|
||||||
|
[files]
|
||||||
|
packages =
|
||||||
|
engagement
|
||||||
|
|
||||||
|
[entry_points]
|
||||||
|
console_scripts =
|
||||||
|
engagement-stats = engagement.stats:main
|
19
setup.py
Executable file
19
setup.py
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright OpenDev Contributors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
# implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(pbr=True)
|
1
test-requirements.txt
Normal file
1
test-requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
flake8
|
31
tox.ini
Normal file
31
tox.ini
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[tox]
|
||||||
|
minversion = 3.1
|
||||||
|
envlist = linters, py3
|
||||||
|
skipdist = True
|
||||||
|
ignore_basepython_conflict = True
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
basepython = python3
|
||||||
|
usedevelop = True
|
||||||
|
deps = -r{toxinidir}/test-requirements.txt
|
||||||
|
# TODO(fungi): work out a representative replay call with a suitable but
|
||||||
|
# small test payload and target results comparison
|
||||||
|
commands =
|
||||||
|
python setup.py test --slowest --testr-args='{posargs}'
|
||||||
|
|
||||||
|
[testenv:linters]
|
||||||
|
commands = flake8
|
||||||
|
|
||||||
|
[testenv:docs]
|
||||||
|
whitelist_externals = rm
|
||||||
|
deps = -r{toxinidir}/doc/requirements.txt
|
||||||
|
commands =
|
||||||
|
rm -fr doc/build
|
||||||
|
sphinx-build -W -b html doc/source doc/build/html
|
||||||
|
|
||||||
|
[testenv:stats]
|
||||||
|
commands = engagement-stats {posargs}
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
show-source = True
|
||||||
|
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build
|
Loading…
x
Reference in New Issue
Block a user