├── tests ├── __init__.py ├── util │ ├── __init__.py │ ├── iter_test.py │ ├── time_test.py │ ├── discovery_test.py │ └── subprocess_test.py ├── metrics │ ├── __init__.py │ ├── todo_test.py │ ├── lines_in_init_test.py │ ├── lines_test.py │ ├── curse_test.py │ ├── symlink_count_test.py │ ├── submodule_count_test.py │ ├── binary_file_count_test.py │ ├── base_test.py │ └── imports_test.py ├── server │ ├── __init__.py │ ├── servlets │ │ ├── __init__.py │ │ ├── status_test.py │ │ ├── changes_test.py │ │ ├── commit_test.py │ │ ├── index_test.py │ │ ├── widget_test.py │ │ └── graph_test.py │ ├── presentation │ │ ├── __init__.py │ │ ├── delta_test.py │ │ └── commit_delta_test.py │ ├── app_test.py │ ├── logic_test.py │ ├── conftest.py │ └── metric_config_test.py ├── testing │ ├── __init__.py │ ├── assertions │ │ ├── __init__.py │ │ └── response_test.py │ └── utilities │ │ ├── __init__.py │ │ └── client_test.py ├── list_metrics_test.py ├── generate_config_test.py ├── logic_test.py ├── discovery_test.py ├── repo_parser_test.py ├── conftest.py ├── generate_test.py └── file_diff_stat_test.py ├── testing ├── __init__.py ├── assertions │ ├── __init__.py │ └── response.py ├── utilities │ ├── __init__.py │ ├── cwd.py │ ├── auto_namedtuple.py │ └── client.py └── testing_package │ ├── __init__.py │ ├── package_a │ ├── __init__.py │ └── base.py │ └── package_b │ ├── __init__.py │ └── derived.py ├── git_code_debt ├── __init__.py ├── metrics │ ├── __init__.py │ ├── common.py │ ├── todo.py │ ├── lines_in_init.py │ ├── imports.py │ ├── binary_file_count.py │ ├── symlink_count.py │ ├── submodule_count.py │ ├── lines.py │ ├── curse.py │ ├── base.py │ └── curse_words.py ├── server │ ├── __init__.py │ ├── servlets │ │ ├── __init__.py │ │ ├── status.py │ │ ├── commit.py │ │ ├── changes.py │ │ ├── widget.py │ │ ├── graph.py │ │ └── index.py │ ├── presentation │ │ ├── __init__.py │ │ ├── delta.py │ │ └── commit_delta.py │ ├── static │ │ ├── img │ │ │ └── loading.gif │ │ ├── js │ │ │ ├── widget_frame.js │ │ │ ├── index.js │ │ │ ├── graph.js │ │ │ ├── widget.js │ │ │ ├── jquery.flot.selection.min.js │ │ │ └── jquery.flot.time.min.js │ │ └── css │ │ │ └── git_code_debt.css │ ├── templates │ │ ├── widget.mako │ │ ├── widget_frame.mako │ │ ├── base.mako │ │ ├── changes.mako │ │ ├── graph.mako │ │ ├── commit.mako │ │ └── index.mako │ ├── render_mako.py │ ├── metric_config.sample.yaml │ ├── metric_config.py │ ├── app.py │ └── logic.py ├── util │ ├── __init__.py │ ├── yaml.py │ ├── time.py │ ├── subprocess.py │ ├── iter.py │ └── discovery.py ├── metric.py ├── schema │ ├── metric_names.sql │ ├── metric_changes.sql │ └── metric_data.sql ├── options.py ├── logic.py ├── generate_config.py ├── write_logic.py ├── list_metrics.py ├── discovery.py ├── repo_parser.py ├── file_diff_stat.py └── generate.py ├── .gitignore ├── requirements-dev.txt ├── setup.py ├── img ├── debt_screen_1.png └── debt_screen_2.png ├── generate_config.yaml ├── .github ├── actions │ └── pre-test │ │ └── action.yml └── workflows │ └── main.yml ├── tox.ini ├── LICENSE ├── .pre-commit-config.yaml ├── metric_config.yaml ├── sample_widget_consumer.htm ├── setup.cfg └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/assertions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/servlets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/testing_package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testing/assertions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testing/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_code_debt/server/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/testing_package/package_a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/testing_package/package_b/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.egg-info 3 | *.py[co] 4 | /.coverage 5 | /.tox 6 | /dist 7 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pyquery 4 | pytest 5 | pytest-env 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /img/debt_screen_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/git-code-debt/HEAD/img/debt_screen_1.png -------------------------------------------------------------------------------- /img/debt_screen_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/git-code-debt/HEAD/img/debt_screen_2.png -------------------------------------------------------------------------------- /generate_config.yaml: -------------------------------------------------------------------------------- 1 | database: database.db 2 | metric_package_names: [] 3 | repo: git@github.com:asottile/git-code-debt 4 | -------------------------------------------------------------------------------- /testing/testing_package/package_a/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Base: 5 | pass 6 | -------------------------------------------------------------------------------- /git_code_debt/server/static/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/git-code-debt/HEAD/git_code_debt/server/static/img/loading.gif -------------------------------------------------------------------------------- /git_code_debt/server/templates/widget.mako: -------------------------------------------------------------------------------- 1 | <%namespace name="commit" file="commit.mako" /> 2 |
3 | ${commit.render_debt_stats(commit_deltas)} 4 |
5 | -------------------------------------------------------------------------------- /git_code_debt/metric.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | 6 | class Metric(NamedTuple): 7 | name: str 8 | value: int 9 | -------------------------------------------------------------------------------- /testing/testing_package/package_b/derived.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from testing.testing_package.package_a.base import Base 4 | 5 | 6 | class Base2(Base): 7 | pass 8 | -------------------------------------------------------------------------------- /git_code_debt/schema/metric_names.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metric_names ( 2 | id INTEGER PRIMARY KEY ASC, 3 | name CHAR(255) NOT NULL, 4 | has_data INTEGER DEFAULT 0, 5 | description BLOB 6 | ); 7 | -------------------------------------------------------------------------------- /tests/server/servlets/status_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | 6 | def test_healthcheck(server): 7 | server.client.get(flask.url_for('status.healthcheck')) 8 | -------------------------------------------------------------------------------- /git_code_debt/schema/metric_changes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metric_changes ( 2 | sha CHAR(40) NOT NULL, 3 | metric_id INTEGER NOT NULL, 4 | value INTEGER NOT NULL, 5 | PRIMARY KEY (sha, metric_id) 6 | ); 7 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/widget_frame.mako: -------------------------------------------------------------------------------- 1 | <%inherit file="base.mako" /> 2 | 3 | 4 | <%block name="scripts"> 5 | ${parent.scripts()} 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/actions/pre-test/action.yml: -------------------------------------------------------------------------------- 1 | inputs: 2 | env: 3 | default: ${{ matrix.env }} 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: asottile/workflows/.github/actions/latest-git@v1.2.0 8 | if: inputs.env == 'py37' 9 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | 6 | status = flask.Blueprint('status', __name__) 7 | 8 | 9 | @status.route('/status/healthcheck') 10 | def healthcheck() -> str: 11 | return '' 12 | -------------------------------------------------------------------------------- /testing/utilities/cwd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import os 5 | 6 | 7 | @contextlib.contextmanager 8 | def cwd(path): 9 | original_cwd = os.getcwd() 10 | os.chdir(path) 11 | try: 12 | yield 13 | finally: 14 | os.chdir(original_cwd) 15 | -------------------------------------------------------------------------------- /git_code_debt/util/yaml.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | 5 | import yaml 6 | 7 | 8 | Loader = getattr(yaml, 'CSafeLoader', yaml.SafeLoader) 9 | load = functools.partial(yaml.load, Loader=Loader) 10 | Dumper = getattr(yaml, 'CSafeDumper', yaml.SafeDumper) 11 | dump = functools.partial(yaml.dump, Dumper=Dumper) 12 | -------------------------------------------------------------------------------- /git_code_debt/schema/metric_data.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metric_data ( 2 | sha CHAR(40) NOT NULL, 3 | metric_id INTEGER NOT NULL, 4 | timestamp INTEGER NOT NULL, 5 | running_value INTEGER NOT NULL, 6 | PRIMARY KEY (sha, metric_id) 7 | ); 8 | 9 | CREATE INDEX metric_data__timestamp_idx ON metric_data (timestamp); 10 | CREATE INDEX metric_data__sha_idx ON metric_data (sha); 11 | -------------------------------------------------------------------------------- /git_code_debt/metrics/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from identify import identify 4 | 5 | UNKNOWN = 'unknown' 6 | IGNORED_TAGS = frozenset(( 7 | identify.DIRECTORY, identify.SYMLINK, identify.FILE, 8 | identify.EXECUTABLE, identify.NON_EXECUTABLE, 9 | identify.TEXT, identify.BINARY, 10 | )) 11 | ALL_TAGS = frozenset((identify.ALL_TAGS - IGNORED_TAGS) | {UNKNOWN}) 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main-latest-git: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py310"]' 14 | main: 15 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 16 | with: 17 | env: '["py310", "py311", "3.12"]' 18 | -------------------------------------------------------------------------------- /git_code_debt/metrics/todo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.metrics.base import SimpleLineCounterBase 5 | 6 | 7 | class TODOCount(SimpleLineCounterBase): 8 | def line_matches_metric( 9 | self, 10 | line: bytes, 11 | file_diff_stat: FileDiffStat, 12 | ) -> bool: 13 | return b'TODO' in line 14 | -------------------------------------------------------------------------------- /testing/utilities/auto_namedtuple.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | 5 | 6 | def auto_namedtuple(classname='auto_namedtuple', **kwargs): 7 | """Returns an automatic namedtuple object. 8 | 9 | Args: 10 | classname - The class name for the returned object. 11 | **kwargs - Properties to give the returned object. 12 | """ 13 | return collections.namedtuple(classname, kwargs.keys())(**kwargs) 14 | -------------------------------------------------------------------------------- /tests/server/presentation/delta_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.server.presentation.delta import Delta 4 | 5 | 6 | def test_delta_classname_negative(): 7 | delta = Delta('url', -9001) 8 | assert delta.classname == 'metric-down' 9 | 10 | 11 | def test_delta_classname_zero(): 12 | delta = Delta('url', 0) 13 | assert delta.classname == 'metric-none' 14 | 15 | 16 | def test_delta_classname_positive(): 17 | delta = Delta('url', 9001) 18 | assert delta.classname == 'metric-up' 19 | -------------------------------------------------------------------------------- /git_code_debt/util/time.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import calendar 4 | import datetime 5 | 6 | 7 | def to_timestamp(dt: datetime.datetime) -> int: 8 | return calendar.timegm(dt.utctimetuple()) 9 | 10 | 11 | def data_points_for_time_range( 12 | start_timestamp: int, 13 | end_timestamp: int, 14 | data_points: int = 25, 15 | ) -> tuple[int, ...]: 16 | interval = ((end_timestamp - start_timestamp) // data_points) or 1 17 | return tuple(range(start_timestamp, end_timestamp + interval, interval)) 18 | -------------------------------------------------------------------------------- /git_code_debt/server/presentation/delta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | 6 | SIGNS_TO_CLASSNAMES = { 7 | 0: 'metric-none', 8 | 1: 'metric-up', 9 | -1: 'metric-down', 10 | } 11 | 12 | 13 | class Delta(NamedTuple): 14 | url: str 15 | value: int 16 | 17 | @property 18 | def classname(self) -> str: 19 | if not self.value: 20 | sign = 0 21 | else: 22 | sign = self.value // abs(self.value) 23 | 24 | return SIGNS_TO_CLASSNAMES[sign] 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | 19 | [pytest] 20 | env = 21 | GIT_AUTHOR_NAME=test 22 | GIT_COMMITTER_NAME=test 23 | GIT_AUTHOR_EMAIL=test@example.com 24 | GIT_COMMITTER_EMAIL=test@example.com 25 | -------------------------------------------------------------------------------- /git_code_debt/metrics/lines_in_init.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.metrics.base import SimpleLineCounterBase 5 | 6 | 7 | class Python__init__LineCount(SimpleLineCounterBase): 8 | def should_include_file(self, file_diff_stat: FileDiffStat) -> bool: 9 | return file_diff_stat.filename == b'__init__.py' 10 | 11 | def line_matches_metric( 12 | self, 13 | line: bytes, 14 | file_diff_stat: FileDiffStat, 15 | ) -> bool: 16 | # All lines in __init__.py match 17 | return True 18 | -------------------------------------------------------------------------------- /testing/assertions/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import urllib.parse 4 | 5 | 6 | def assert_no_response_errors(response): 7 | # TODO: improve this assertion 8 | assert response.response.status_code == 200 9 | 10 | 11 | def assert_redirect(response, path, query, redirect_status_code=302): 12 | assert response.response.status_code == redirect_status_code 13 | parsed_redirect = urllib.parse.urlparse(response.response.location) 14 | assert parsed_redirect.path == path 15 | parsed_query_string = urllib.parse.parse_qs(parsed_redirect.query) 16 | assert parsed_query_string == query 17 | -------------------------------------------------------------------------------- /tests/util/iter_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from git_code_debt.util.iter import chunk_iter 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ('input_list', 'n', 'expected_output'), ( 10 | ([], 1, []), 11 | ([1, 2], 1, [(1,), (2,)]), 12 | ([1, 2], 2, [(1, 2)]), 13 | ([1, 2, 3, 4], 2, [(1, 2), (3, 4)]), 14 | ([1, 2, 3, 4, 5, 6], 3, [(1, 2, 3), (4, 5, 6)]), 15 | ([1, 2], 5, [(1, 2)]), 16 | ), 17 | ) 18 | def test_chunk_iter(input_list, n, expected_output): 19 | output = list(chunk_iter(input_list, n)) 20 | assert output == expected_output 21 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/base.mako: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%block name="css"> 5 | 6 | 7 | <%block name="title" /> 8 | 9 | 10 | ${self.body()} 11 |
12 |
13 | Home | 14 | Powered by 15 | 16 | git-code-debt 17 | 18 |
19 | <%block name="scripts"> 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/util/time_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | from git_code_debt.util.time import data_points_for_time_range 6 | from git_code_debt.util.time import to_timestamp 7 | 8 | 9 | def test_to_timestamp(): 10 | dt = datetime.datetime(2013, 1, 2, 3, 4, 5) 11 | ret = to_timestamp(dt) 12 | assert ret == 1357095845 13 | 14 | 15 | def test_data_points_for_time_range(): 16 | ret = data_points_for_time_range(1, 25, 5) 17 | assert ret == (1, 5, 9, 13, 17, 21, 25) 18 | 19 | 20 | def test_data_points_for_time_range_gives_data_for_empty_range(): 21 | ret = data_points_for_time_range(1, 1, 5) 22 | assert ret == (1,) 23 | -------------------------------------------------------------------------------- /git_code_debt/options.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | 5 | from git_code_debt.generate_config import DEFAULT_GENERATE_CONFIG_FILENAME 6 | 7 | 8 | def add_color(argparser: argparse.ArgumentParser) -> None: 9 | argparser.add_argument( 10 | '--color', 11 | default='auto', 12 | choices=('always', 'never', 'auto'), 13 | help='Whether to use color in output.', 14 | ) 15 | 16 | 17 | def add_generate_config_filename(argparser: argparse.ArgumentParser) -> None: 18 | argparser.add_argument( 19 | '-C', '--config-filename', default=DEFAULT_GENERATE_CONFIG_FILENAME, 20 | help='Path to generate config.', 21 | ) 22 | -------------------------------------------------------------------------------- /git_code_debt/server/static/js/widget_frame.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $(window).on('message', function (e) { 3 | $.ajax({ 4 | url: '/widget/data', 5 | method: 'POST', 6 | data: {diff: e.originalEvent.data.diff}, 7 | dataType: 'json', 8 | success: function (data) { 9 | parent.postMessage( 10 | { 11 | metrics: data.metrics, 12 | elementId: e.originalEvent.data.elementId 13 | }, 14 | '*' 15 | ); 16 | } 17 | }); 18 | }); 19 | 20 | parent.postMessage({ready: true}, '*'); 21 | }); 22 | -------------------------------------------------------------------------------- /git_code_debt/util/subprocess.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from typing import Any 5 | 6 | 7 | class CalledProcessError(RuntimeError): 8 | pass 9 | 10 | 11 | def cmd_output_b(*cmd: str, **kwargs: Any) -> bytes: 12 | proc = subprocess.Popen( 13 | cmd, 14 | stdout=subprocess.PIPE, 15 | stderr=subprocess.PIPE, 16 | **kwargs, 17 | ) 18 | stdout, stderr = proc.communicate() 19 | 20 | if proc.returncode: 21 | raise CalledProcessError(cmd, proc.returncode, stdout, stderr) 22 | 23 | return stdout 24 | 25 | 26 | def cmd_output(*cmd: str, **kwargs: Any) -> str: 27 | return cmd_output_b(*cmd, **kwargs).decode() 28 | -------------------------------------------------------------------------------- /git_code_debt/server/presentation/commit_delta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | from git_code_debt.server.presentation.delta import Delta 6 | 7 | 8 | class CommitDelta(NamedTuple): 9 | metric_name: str 10 | classname: str 11 | delta: Delta 12 | 13 | @classmethod 14 | def from_data( 15 | cls, 16 | metric_name: str, 17 | delta: Delta, 18 | color_overrides: set[str], 19 | ) -> CommitDelta: 20 | return cls( 21 | metric_name, 22 | # TODO: duplicated in Metric 23 | 'color-override' if metric_name in color_overrides else '', 24 | delta, 25 | ) 26 | -------------------------------------------------------------------------------- /tests/server/presentation/commit_delta_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import mock 4 | 5 | from git_code_debt.server.presentation.commit_delta import CommitDelta 6 | 7 | 8 | def test_commit_delta_not_overriden(): 9 | overrides = {'OtherMetric'} 10 | ret = CommitDelta.from_data('MyMetric', mock.sentinel.delta, overrides) 11 | assert ret == CommitDelta('MyMetric', '', mock.sentinel.delta) 12 | 13 | 14 | def test_commit_delta_with_overrides(): 15 | overrides = {'OtherMetric'} 16 | ret = CommitDelta.from_data('OtherMetric', mock.sentinel.delta, overrides) 17 | assert ret == CommitDelta( 18 | 'OtherMetric', 'color-override', mock.sentinel.delta, 19 | ) 20 | -------------------------------------------------------------------------------- /tests/metrics/todo_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import Status 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.metrics.todo import TODOCount 7 | from git_code_debt.repo_parser import BLANK_COMMIT 8 | 9 | 10 | def test_parser(): 11 | parser = TODOCount() 12 | input_stats = ( 13 | FileDiffStat( 14 | b'foo/bar.py', 15 | [b'# TO' + b'DO: herp all the derps', b'womp'], 16 | [], 17 | Status.ALREADY_EXISTING, 18 | ), 19 | ) 20 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 21 | assert metric == Metric('TODOCount', 1) 22 | -------------------------------------------------------------------------------- /tests/metrics/lines_in_init_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import Status 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.metrics.lines_in_init import Python__init__LineCount 7 | from git_code_debt.repo_parser import BLANK_COMMIT 8 | 9 | 10 | def test_lines_in_init(): 11 | parser = Python__init__LineCount() 12 | input_stats = ( 13 | FileDiffStat( 14 | b'testing/__init__.py', 15 | [b'from .foo import bar'], 16 | [], 17 | Status.ADDED, 18 | ), 19 | ) 20 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 21 | assert metric == Metric('Python__init__LineCount', 1) 22 | -------------------------------------------------------------------------------- /testing/utilities/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import flask.testing 6 | import pyquery 7 | 8 | 9 | class Response: 10 | """A Response wraps a response from a testing Client.""" 11 | 12 | def __init__(self, response): 13 | self.response = response 14 | 15 | @property 16 | def text(self): 17 | return self.response.text 18 | 19 | @property 20 | def pq(self): 21 | return pyquery.PyQuery(self.text) 22 | 23 | @property 24 | def json(self): 25 | return json.loads(self.text) 26 | 27 | 28 | class Client(flask.testing.FlaskClient): 29 | """A Client wraps the client given by flask to add other utilities.""" 30 | 31 | def open(self, *args, **kwargs): 32 | return Response(super().open(*args, **kwargs)) 33 | -------------------------------------------------------------------------------- /tests/server/servlets/changes_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | import pyquery 5 | 6 | from testing.assertions.response import assert_no_response_errors 7 | 8 | 9 | def test_changes_endpoint(server_with_data): 10 | resp = server_with_data.server.client.get( 11 | flask.url_for( 12 | 'changes.show', 13 | metric_name='PythonImportCount', 14 | start_ts=0, 15 | # Some sufficiently large number to include all the data 16 | end_ts=2 ** 62, 17 | ), 18 | ) 19 | assert_no_response_errors(resp) 20 | pq = pyquery.PyQuery(resp.json['body']) 21 | # Should have a table in output 22 | assert len(pq.find('table')) == 1 23 | # Should show the metric went up by 2 24 | assert pq.find('.metric-up').text() == '2' 25 | -------------------------------------------------------------------------------- /tests/list_metrics_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.list_metrics import color 4 | from git_code_debt.list_metrics import CYAN 5 | from git_code_debt.list_metrics import main 6 | from git_code_debt.list_metrics import NORMAL 7 | 8 | 9 | def test_list_metrics_smoke(capsys): 10 | # This test is just to make sure that it doesn't fail catastrophically 11 | main([]) 12 | assert capsys.readouterr().out 13 | 14 | 15 | def test_list_metrics_no_color_smoke(capsys): 16 | main(['--color', 'never']) 17 | out, err = capsys.readouterr() 18 | assert '\033' not in out 19 | assert '\033' not in err 20 | 21 | 22 | def test_color_no_color(): 23 | ret = color('foo', 'bar', False) 24 | assert ret == 'foo' 25 | 26 | 27 | def test_colored(): 28 | ret = color('foo', CYAN, True) 29 | assert ret == CYAN + 'foo' + NORMAL 30 | -------------------------------------------------------------------------------- /tests/util/discovery_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import testing.testing_package.package_a 4 | import testing.testing_package.package_b 5 | from git_code_debt.util.discovery import discover 6 | from testing.testing_package.package_a.base import Base 7 | 8 | 9 | def test_discover_classes(): 10 | # Note: package_a basically just contains a module base with: 11 | # class Base(object): pass 12 | ret = discover( 13 | testing.testing_package.package_a, 14 | lambda cls: True, 15 | ) 16 | assert ret == {Base} 17 | 18 | 19 | def test_discover_excludes_imported_classes(): 20 | # Note: package_b has a module derived which 21 | # imports Base from package_a.base and has 22 | # class Base2(Base): pass 23 | ret = discover( 24 | testing.testing_package.package_b, 25 | lambda cls: True, 26 | ) 27 | assert Base not in ret 28 | -------------------------------------------------------------------------------- /tests/server/servlets/commit_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | from testing.assertions.response import assert_no_response_errors 6 | 7 | 8 | def test_it_loads(server_with_data): 9 | resp = server_with_data.server.client.get( 10 | flask.url_for( 11 | 'commit.show', 12 | sha=server_with_data.cloneable_with_commits.commits[3].sha, 13 | ), 14 | ) 15 | assert_no_response_errors(resp) 16 | import_row = resp.pq.find('th:contains("PythonImportCount")').parent() 17 | assert import_row.find('td').text() == '2' 18 | 19 | 20 | def test_it_loads_for_first_commit(server_with_data): 21 | resp = server_with_data.server.client.get( 22 | flask.url_for( 23 | 'commit.show', 24 | sha=server_with_data.cloneable_with_commits.commits[0].sha, 25 | ), 26 | ) 27 | assert_no_response_errors(resp) 28 | -------------------------------------------------------------------------------- /tests/util/subprocess_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from git_code_debt.util.subprocess import CalledProcessError 6 | from git_code_debt.util.subprocess import cmd_output 7 | from git_code_debt.util.subprocess import cmd_output_b 8 | 9 | 10 | def test_subprocess_encoding(): 11 | # Defaults to utf-8 12 | ret = cmd_output('echo', '☃') 13 | assert type(ret) is str 14 | assert ret == '☃\n' 15 | 16 | 17 | def test_no_encoding_gives_bytes(): 18 | ret = cmd_output_b('echo', '☃') 19 | assert ret == '☃\n'.encode() 20 | 21 | 22 | def test_raises_on_nonzero(): 23 | cmd = ('sh', '-c', 'echo "stderr" >&2 && echo "stdout" && exit 1') 24 | with pytest.raises(CalledProcessError) as exc_info: 25 | cmd_output(*cmd) 26 | 27 | assert exc_info.value.args == ( 28 | cmd, 29 | 1, 30 | b'stdout\n', 31 | b'stderr\n', 32 | ) 33 | -------------------------------------------------------------------------------- /git_code_debt/util/iter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from collections.abc import Generator 5 | from collections.abc import Iterable 6 | from typing import TypeVar 7 | 8 | T = TypeVar('T') 9 | 10 | 11 | def chunk_iter( 12 | iterable: Iterable[T], 13 | n: int, 14 | ) -> Generator[tuple[T, ...]]: 15 | """Yields an iterator in chunks 16 | 17 | For example you can do 18 | 19 | for a, b in chunk_iter([1, 2, 3, 4, 5, 6], 2): 20 | print('{} {}'.format(a, b)) 21 | 22 | # Prints 23 | # 1 2 24 | # 3 4 25 | # 5 6 26 | 27 | Args: 28 | iterable - Some iterable 29 | n - Chunk size (must be greater than 0) 30 | """ 31 | assert n > 0 32 | iterable = iter(iterable) 33 | 34 | chunk = tuple(itertools.islice(iterable, n)) 35 | while chunk: 36 | yield chunk 37 | chunk = tuple(itertools.islice(iterable, n)) 38 | -------------------------------------------------------------------------------- /tests/metrics/lines_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import Status 5 | from git_code_debt.metrics.lines import LinesOfCodeParser 6 | from git_code_debt.repo_parser import BLANK_COMMIT 7 | 8 | 9 | def test_lines_of_code_parser(): 10 | parser = LinesOfCodeParser() 11 | input_stats = ( 12 | FileDiffStat(b'test.py', [b'a'], [], Status.ADDED), 13 | FileDiffStat( 14 | b'womp.yaml', [b'a', b'b', b'c'], [b'hi'], Status.ALREADY_EXISTING, 15 | ), 16 | ) 17 | 18 | metrics = set(parser.get_metrics_from_stat(BLANK_COMMIT, input_stats)) 19 | 20 | expected_value = { 21 | 'TotalLinesOfCode': 3, 22 | 'TotalLinesOfCode_python': 1, 23 | 'TotalLinesOfCode_yaml': 2, 24 | } 25 | for metric in metrics: 26 | assert metric.value == expected_value.get(metric.name, 0) 27 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/changes.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | import flask 3 | 4 | from git_code_debt.util.iter import chunk_iter 5 | %> 6 | 7 | %for changes_chunk in chunk_iter(changes, 10): 8 |
9 | 10 | 11 | ${changes_rows(changes_chunk)} 12 |
TimeShaChange
13 |
14 | %endfor 15 | 16 | 17 | <%def name="changes_rows(changes)"> 18 | %for date_time, sha, change in changes: 19 | 20 | ${date_time} 21 | 22 | 23 | ${sha[:8]} 24 | 25 | 26 | 27 | ${change.delta.value} 28 | 29 | 30 | %endfor 31 | 32 | -------------------------------------------------------------------------------- /tests/metrics/curse_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import Status 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.metrics.curse import CurseWordsParser 7 | from git_code_debt.repo_parser import BLANK_COMMIT 8 | 9 | 10 | def test_curse_words_parser(): 11 | parser = CurseWordsParser() 12 | input_stats = ( 13 | FileDiffStat( 14 | b'some/file.rb', 15 | [b'#man seriously, fuck ruby'], 16 | [], 17 | Status.ADDED, 18 | ), 19 | FileDiffStat( 20 | b'cmds/foo.py', 21 | [b"# I'm clean I swear"], 22 | [], 23 | Status.ADDED, 24 | ), 25 | ) 26 | metrics = set(parser.get_metrics_from_stat(BLANK_COMMIT, input_stats)) 27 | assert metrics == { 28 | Metric('TotalCurseWords', 1), Metric('TotalCurseWords_ruby', 1), 29 | } 30 | -------------------------------------------------------------------------------- /tests/server/app_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from git_code_debt.server.app import create_metric_config_if_not_exists 6 | from git_code_debt.server.app import main 7 | 8 | 9 | def test_file_does_not_exist(): 10 | with pytest.raises(SystemExit) as excinfo: 11 | main(argv=['i_dont_exist.db']) 12 | assert excinfo.value.code == 1 13 | 14 | 15 | def test_create_metric_config_if_not_exists_existing(tmpdir): 16 | metric_config = tmpdir.join('metric_config.yaml') 17 | with tmpdir.as_cwd(): 18 | metric_config.write('Groups: []\nColorOverrides: []\n') 19 | 20 | create_metric_config_if_not_exists() 21 | 22 | after_contents = metric_config.read() 23 | assert after_contents == 'Groups: []\nColorOverrides: []\n' 24 | 25 | 26 | def test_create_metric_config_if_not_exists_not_existing(tmpdir): 27 | with tmpdir.as_cwd(): 28 | create_metric_config_if_not_exists() 29 | assert tmpdir.join('metric_config.yaml').exists() 30 | -------------------------------------------------------------------------------- /tests/server/servlets/index_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | from git_code_debt.server.servlets.index import Metric 6 | from testing.assertions.response import assert_no_response_errors 7 | 8 | 9 | def _test_it_loads(server): 10 | response = server.client.get(flask.url_for('index.show')) 11 | assert_no_response_errors(response) 12 | # Should have a nonzero number of links to things 13 | assert response.pq.find('a[href]') 14 | return response 15 | 16 | 17 | def test_it_loads_no_data(server): 18 | _test_it_loads(server) 19 | 20 | 21 | def test_it_loads_with_data(server_with_data): 22 | resp = _test_it_loads(server_with_data.server) 23 | assert 'CurseWords' not in resp.text 24 | 25 | 26 | def test_metric_classname_overriden(): 27 | metric = Metric('metric', True, 0, (), '') 28 | assert metric.classname == 'color-override' 29 | 30 | 31 | def test_metric_classname_normal(): 32 | metric = Metric('metric', False, 0, (), '') 33 | assert metric.classname == '' 34 | -------------------------------------------------------------------------------- /git_code_debt/server/static/js/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var STORAGE_KEY = 'hiddenGroups', 3 | hiddenGroups = JSON.parse( 4 | window.localStorage.getItem(STORAGE_KEY) || '{}' 5 | ); 6 | 7 | // Hide any initially hidden groups 8 | (function () { 9 | var i, 10 | hidden = Object.keys(hiddenGroups); 11 | for (var i = 0, length = hidden.length; i < length; i += 1) { 12 | $('[data-group="' + hidden[i] + '"]').addClass('collapsed'); 13 | } 14 | } ()); 15 | 16 | $('.group-name').click(function () { 17 | var $this = $(this), 18 | tbodyParent = $this.parents('tbody'); 19 | 20 | tbodyParent.toggleClass('collapsed'); 21 | 22 | if (tbodyParent.is('.collapsed')) { 23 | hiddenGroups[tbodyParent.data('group')] = true; 24 | } else { 25 | delete hiddenGroups[tbodyParent.data('group')]; 26 | } 27 | 28 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify(hiddenGroups)); 29 | }); 30 | } ()); 31 | -------------------------------------------------------------------------------- /tests/testing/utilities/client_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import mock 4 | 5 | import flask.testing 6 | import pytest 7 | 8 | from testing.utilities.auto_namedtuple import auto_namedtuple 9 | from testing.utilities.client import Client 10 | from testing.utilities.client import Response 11 | 12 | 13 | @pytest.fixture 14 | def client_open_mock(): 15 | with mock.patch.object(flask.testing.FlaskClient, 'open') as open_mock: 16 | yield open_mock 17 | 18 | 19 | def test_return_value_is_testing_response(client_open_mock): 20 | instance = Client(None) 21 | ret = instance.open('/') 22 | assert isinstance(ret, Response) 23 | 24 | 25 | def test_pq(): 26 | response = auto_namedtuple('Response', text='

Oh hai!

') 27 | instance = Response(response) 28 | assert instance.pq.__html__() == response.text 29 | 30 | 31 | def test_json(): 32 | response = auto_namedtuple('Response', text='{"foo": "bar"}') 33 | instance = Response(response) 34 | assert instance.json == {'foo': 'bar'} 35 | -------------------------------------------------------------------------------- /git_code_debt/server/static/js/graph.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | var graph = $('#graph'), 3 | changesContainer = $('.changes-container'), 4 | changesContainerAjaxUrl = changesContainer.data('ajax-url'); 5 | 6 | $.plot( 7 | graph, 8 | [window.metrics], 9 | { 10 | xaxis: {mode: 'time', timeformat: '%Y-%m-%d'}, 11 | series: {lines: {show: true, fill: true}}, 12 | selection: { 13 | mode: 'x' 14 | } 15 | } 16 | ); 17 | 18 | graph.bind('plotselected', function (e, ranges) { 19 | window.location.href = [ 20 | window.location.pathname, 21 | '?', 22 | 'start=', 23 | (ranges.xaxis.from / 1000).toFixed(0).toString(10), 24 | '&end=', 25 | (ranges.xaxis.to / 1000).toFixed(0).toString(10) 26 | ].join(''); 27 | }); 28 | 29 | // Ajax load the changes container 30 | $.getJSON(changesContainerAjaxUrl, function (resp) { 31 | changesContainer.empty().append($(resp.body)); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/commit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | from git_code_debt.server import logic 6 | from git_code_debt.server.presentation.commit_delta import CommitDelta 7 | from git_code_debt.server.presentation.delta import Delta 8 | from git_code_debt.server.render_mako import render_template 9 | 10 | 11 | commit = flask.Blueprint('commit', __name__) 12 | 13 | 14 | @commit.route('/commit/') 15 | def show(sha: str) -> str: 16 | changes = logic.get_metric_changes(flask.g.db, sha) 17 | 18 | commit_deltas = sorted( 19 | CommitDelta.from_data( 20 | metric_name, Delta('javascript:;', change), 21 | color_overrides=flask.g.config.color_overrides, 22 | ) 23 | for metric_name, change in changes 24 | ) 25 | 26 | links = [ 27 | (link_name, link.format(sha=sha)) 28 | for link_name, link in flask.g.config.commit_links 29 | ] 30 | 31 | return render_template( 32 | 'commit.mako', 33 | sha=sha, 34 | short_sha=sha[:8], 35 | commit_deltas=commit_deltas, 36 | links=links, 37 | ) 38 | -------------------------------------------------------------------------------- /tests/generate_config_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import cfgv 6 | import pytest 7 | 8 | from git_code_debt.generate_config import GenerateOptions 9 | 10 | 11 | def test_empty_config_invalid(): 12 | with pytest.raises(cfgv.ValidationError): 13 | GenerateOptions.from_yaml({}) 14 | 15 | 16 | def test_with_all_options_specified(): 17 | ret = GenerateOptions.from_yaml({ 18 | 'skip_default_metrics': True, 19 | 'metric_package_names': ['my_package'], 20 | 'repo': '.', 21 | 'database': 'database.db', 22 | 'exclude': '^vendor/', 23 | }) 24 | assert ret == GenerateOptions( 25 | skip_default_metrics=True, 26 | metric_package_names=['my_package'], 27 | repo='.', 28 | database='database.db', 29 | exclude=re.compile(b'^vendor/'), 30 | ) 31 | 32 | 33 | def test_minimal_defaults(): 34 | ret = GenerateOptions.from_yaml({'repo': './', 'database': 'database.db'}) 35 | assert ret == GenerateOptions( 36 | skip_default_metrics=False, 37 | metric_package_names=[], 38 | repo='./', 39 | database='database.db', 40 | exclude=re.compile(b'^$'), 41 | ) 42 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/graph.mako: -------------------------------------------------------------------------------- 1 | <%inherit file="base.mako" /> 2 | 3 | <%! 4 | import flask 5 | import markdown_code_blocks 6 | import markupsafe 7 | 8 | from git_code_debt.util.iter import chunk_iter 9 | %> 10 | 11 | <%block name="title">Graph - ${metric_name} 12 | 13 | <%block name="scripts"> 14 | ${parent.scripts()} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 |

${metric_name}

26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 | ${markupsafe.Markup(markdown_code_blocks.highlight(description))} 35 |
36 | 37 |

Changes

38 |
39 | Loading... 40 |
41 | -------------------------------------------------------------------------------- /git_code_debt/logic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | 5 | 6 | def get_metric_mapping(db: sqlite3.Connection) -> dict[str, int]: 7 | """Gets a mapping from metric_name to metric_id.""" 8 | results = db.execute('SELECT name, id FROM metric_names').fetchall() 9 | return dict(results) 10 | 11 | 12 | def get_metric_has_data(db: sqlite3.Connection) -> dict[int, bool]: 13 | res = db.execute('SELECT id, has_data FROM metric_names').fetchall() 14 | return {k: bool(v) for k, v in res} 15 | 16 | 17 | def get_previous_sha(db: sqlite3.Connection) -> str | None: 18 | """Gets the latest inserted SHA.""" 19 | result = db.execute( 20 | # Use ROWID as a free, auto-incrementing, primary key. 21 | 'SELECT sha FROM metric_data ORDER BY ROWID DESC LIMIT 1', 22 | ).fetchone() 23 | 24 | return result[0] if result else None 25 | 26 | 27 | def get_metric_values(db: sqlite3.Connection, sha: str) -> dict[int, int]: 28 | """Gets the metric values from a specific commit. 29 | 30 | :param db: Database object 31 | :param text sha: A sha representing a single commit 32 | """ 33 | results = db.execute( 34 | 'SELECT metric_id, running_value FROM metric_data WHERE sha = ?', 35 | (sha,), 36 | ) 37 | return dict(results) 38 | -------------------------------------------------------------------------------- /git_code_debt/generate_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from re import Pattern 5 | from typing import Any 6 | from typing import NamedTuple 7 | 8 | import cfgv 9 | 10 | 11 | DEFAULT_GENERATE_CONFIG_FILENAME = 'generate_config.yaml' 12 | SCHEMA = cfgv.Map( 13 | 'Config', 'repo', 14 | 15 | cfgv.Required('repo', cfgv.check_string), 16 | cfgv.Required('database', cfgv.check_string), 17 | cfgv.Optional('skip_default_metrics', cfgv.check_bool, False), 18 | cfgv.Optional( 19 | 'metric_package_names', cfgv.check_array(cfgv.check_string), [], 20 | ), 21 | cfgv.Optional('exclude', cfgv.check_regex, '^$'), 22 | ) 23 | 24 | 25 | class GenerateOptions(NamedTuple): 26 | skip_default_metrics: bool 27 | metric_package_names: list[str] 28 | repo: str 29 | database: str 30 | exclude: Pattern[bytes] 31 | 32 | @classmethod 33 | def from_yaml(cls, dct: dict[str, Any]) -> GenerateOptions: 34 | dct = cfgv.apply_defaults(cfgv.validate(dct, SCHEMA), SCHEMA) 35 | return cls( 36 | skip_default_metrics=dct['skip_default_metrics'], 37 | metric_package_names=dct['metric_package_names'], 38 | repo=dct['repo'], 39 | database=dct['database'], 40 | exclude=re.compile(dct['exclude'].encode()), 41 | ) 42 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/commit.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from git_code_debt.util.iter import chunk_iter 3 | %> 4 | 5 | <%inherit file="base.mako" /> 6 | 7 | <%block name="title">Code Debt - Commit ${short_sha} 8 | 9 |

Commit - ${short_sha}

10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
${render_debt_stats(commit_deltas)}${render_links(links)}
19 | 20 | 21 | <%def name="render_debt_stats(commit_deltas)"> 22 | 23 | 24 | 25 | 26 | 27 | %for commit_delta in commit_deltas: 28 | 29 | 30 | 33 | 34 | %endfor 35 | 36 |

Debt stats

${commit_delta.metric_name} 31 | ${commit_delta.delta.value} 32 |
37 | 38 | 39 | 40 | <%def name="render_links(links)"> 41 |

Links

42 | 47 | 48 | -------------------------------------------------------------------------------- /git_code_debt/util/discovery.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import pkgutil 5 | from collections.abc import Callable 6 | from types import ModuleType 7 | from typing import Any 8 | 9 | 10 | def discover( 11 | package: ModuleType, 12 | cls_match_func: Callable[[type[Any]], bool], 13 | ) -> set[type[Any]]: 14 | """Returns a set of classes in the directory matched by cls_match_func 15 | 16 | Args: 17 | path - A Python package 18 | cls_match_func - Function taking a class and returning true if the 19 | class is to be included in the output. 20 | """ 21 | matched_classes = set() 22 | 23 | for _, module_name, _ in pkgutil.walk_packages( 24 | package.__path__, 25 | prefix=package.__name__ + '.', 26 | ): 27 | module = __import__(module_name, fromlist=['__trash'], level=0) 28 | 29 | # Check all the classes in that module 30 | for _, imported_class in inspect.getmembers(module, inspect.isclass): 31 | # Don't include things that are only there due to a side-effect of 32 | # importing 33 | if imported_class.__module__ != module.__name__: 34 | continue 35 | 36 | if cls_match_func(imported_class): 37 | matched_classes.add(imported_class) 38 | 39 | return matched_classes 40 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v3.1.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.16.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py310-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v4.0.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.21.2 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py310-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.3.2 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.3.0 36 | hooks: 37 | - id: flake8 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.19.1 40 | hooks: 41 | - id: mypy 42 | additional_dependencies: [flask, types-setuptools, types-pyyaml] 43 | -------------------------------------------------------------------------------- /git_code_debt/server/render_mako.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.resources 4 | from typing import Any 5 | 6 | import mako.lookup 7 | from mako.template import Template 8 | 9 | 10 | class ImportlibResourcesLookup(mako.lookup.TemplateCollection): 11 | def __init__(self, mod: str) -> None: 12 | self.mod = mod 13 | self._cache: dict[str, Template] = {} 14 | 15 | def get_template( 16 | self, 17 | uri: str, 18 | relativeto: str | None = None, 19 | ) -> Template: 20 | if relativeto is not None: 21 | raise NotImplementedError(f'{relativeto=}') 22 | 23 | try: 24 | return self._cache[uri] 25 | except KeyError: 26 | pth = importlib.resources.files(self.mod).joinpath(uri) 27 | with importlib.resources.as_file(pth) as pth: 28 | return Template( 29 | filename=str(pth), 30 | lookup=self, 31 | default_filters=['html_escape'], 32 | imports=['from mako.filters import html_escape'], 33 | ) 34 | 35 | 36 | template_lookup = ImportlibResourcesLookup('git_code_debt.server.templates') 37 | 38 | 39 | def render_template(template_name: str, **env: Any) -> str: 40 | template = template_lookup.get_template(template_name) 41 | return template.render(**env) 42 | -------------------------------------------------------------------------------- /git_code_debt/metrics/imports.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.metrics.base import SimpleLineCounterBase 5 | 6 | 7 | def is_python_import(line: bytes) -> bool: 8 | line = line.lstrip() 9 | if line.startswith(b'import'): 10 | return True 11 | if line.startswith(b'from') and b'import' in line: 12 | return True 13 | return False 14 | 15 | 16 | def is_template_import(line: bytes) -> bool: 17 | line = line.lstrip() 18 | return ( 19 | line.startswith(b'#') and 20 | is_python_import(line[1:]) 21 | ) 22 | 23 | 24 | class PythonImportCount(SimpleLineCounterBase): 25 | def should_include_file(self, file_diff_stat: FileDiffStat) -> bool: 26 | return file_diff_stat.extension == b'.py' 27 | 28 | def line_matches_metric( 29 | self, 30 | line: bytes, 31 | file_diff_stat: FileDiffStat, 32 | ) -> bool: 33 | return is_python_import(line) 34 | 35 | 36 | class CheetahTemplateImportCount(SimpleLineCounterBase): 37 | def should_include_file(self, file_diff_stat: FileDiffStat) -> bool: 38 | return file_diff_stat.extension == b'.tmpl' 39 | 40 | def line_matches_metric( 41 | self, 42 | line: bytes, 43 | file_diff_stat: FileDiffStat, 44 | ) -> bool: 45 | return is_template_import(line) 46 | -------------------------------------------------------------------------------- /git_code_debt/write_logic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.repo_parser import Commit 7 | 8 | 9 | def insert_metric_values( 10 | db: sqlite3.Connection, 11 | metric_values: dict[int, int], 12 | has_data: dict[int, bool], 13 | commit: Commit, 14 | ) -> None: 15 | values = [ 16 | (commit.sha, metric_id, commit.date, value) 17 | for metric_id, value in metric_values.items() 18 | if has_data[metric_id] 19 | ] 20 | db.executemany( 21 | 'INSERT INTO metric_data (sha, metric_id, timestamp, running_value)\n' 22 | 'VALUES (?, ?, ?, ?)\n', 23 | values, 24 | ) 25 | 26 | 27 | def insert_metric_changes( 28 | db: sqlite3.Connection, 29 | metrics: tuple[Metric, ...], 30 | metric_mapping: dict[str, int], 31 | commit: Commit, 32 | ) -> None: 33 | """Insert into the metric_changes tables. 34 | 35 | :param metrics: `list` of `Metric` objects 36 | :param dict metric_mapping: Maps metric names to ids 37 | :param Commit commit: 38 | """ 39 | values = [ 40 | [commit.sha, metric_mapping[metric.name], metric.value] 41 | for metric in metrics 42 | if metric.value != 0 43 | ] 44 | db.executemany( 45 | 'INSERT INTO metric_changes (sha, metric_id, value) VALUES (?, ?, ?)', 46 | values, 47 | ) 48 | -------------------------------------------------------------------------------- /git_code_debt/metrics/binary_file_count.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | 5 | from git_code_debt.file_diff_stat import FileDiffStat 6 | from git_code_debt.file_diff_stat import SpecialFileType 7 | from git_code_debt.file_diff_stat import Status 8 | from git_code_debt.metric import Metric 9 | from git_code_debt.metrics.base import DiffParserBase 10 | from git_code_debt.metrics.base import MetricInfo 11 | from git_code_debt.repo_parser import Commit 12 | 13 | 14 | class BinaryFileCount(DiffParserBase): 15 | """Counts the number of _files_ considered to be binary by `git`.""" 16 | 17 | def get_metrics_from_stat( 18 | self, _: Commit, 19 | file_diff_stats: tuple[FileDiffStat, ...], 20 | ) -> Generator[Metric]: 21 | binary_delta = 0 22 | 23 | for file_diff_stat in file_diff_stats: 24 | if ( 25 | file_diff_stat.special_file is not None and ( 26 | file_diff_stat.special_file.file_type is 27 | SpecialFileType.BINARY 28 | ) 29 | ): 30 | if file_diff_stat.status is Status.ADDED: 31 | binary_delta += 1 32 | elif file_diff_stat.status is Status.DELETED: 33 | binary_delta -= 1 34 | 35 | if binary_delta: 36 | yield Metric(type(self).__name__, binary_delta) 37 | 38 | def get_metrics_info(self) -> list[MetricInfo]: 39 | return [MetricInfo.from_class(type(self))] 40 | -------------------------------------------------------------------------------- /git_code_debt/metrics/symlink_count.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | 5 | from git_code_debt.file_diff_stat import FileDiffStat 6 | from git_code_debt.file_diff_stat import SpecialFileType 7 | from git_code_debt.file_diff_stat import Status 8 | from git_code_debt.metric import Metric 9 | from git_code_debt.metrics.base import DiffParserBase 10 | from git_code_debt.metrics.base import MetricInfo 11 | from git_code_debt.repo_parser import Commit 12 | 13 | 14 | class SymlinkCount(DiffParserBase): 15 | """Counts the number of symlinks in the repository.""" 16 | 17 | def get_metrics_from_stat( 18 | self, 19 | _: Commit, 20 | file_diff_stats: tuple[FileDiffStat, ...], 21 | ) -> Generator[Metric]: 22 | symlink_delta = 0 23 | 24 | for file_diff_stat in file_diff_stats: 25 | if ( 26 | file_diff_stat.special_file is not None and ( 27 | file_diff_stat.special_file.file_type is 28 | SpecialFileType.SYMLINK 29 | ) 30 | ): 31 | if file_diff_stat.status is Status.ADDED: 32 | symlink_delta += 1 33 | elif file_diff_stat.status is Status.DELETED: 34 | symlink_delta -= 1 35 | 36 | if symlink_delta: 37 | yield Metric(type(self).__name__, symlink_delta) 38 | 39 | def get_metrics_info(self) -> list[MetricInfo]: 40 | return [MetricInfo.from_class(type(self))] 41 | -------------------------------------------------------------------------------- /git_code_debt/metrics/submodule_count.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | 5 | from git_code_debt.file_diff_stat import FileDiffStat 6 | from git_code_debt.file_diff_stat import SpecialFileType 7 | from git_code_debt.file_diff_stat import Status 8 | from git_code_debt.metric import Metric 9 | from git_code_debt.metrics.base import DiffParserBase 10 | from git_code_debt.metrics.base import MetricInfo 11 | from git_code_debt.repo_parser import Commit 12 | 13 | 14 | class SubmoduleCount(DiffParserBase): 15 | """Counts the number of git submodules in a repository.""" 16 | 17 | def get_metrics_from_stat( 18 | self, 19 | _: Commit, 20 | file_diff_stats: tuple[FileDiffStat, ...], 21 | ) -> Generator[Metric]: 22 | submodule_delta = 0 23 | 24 | for file_diff_stat in file_diff_stats: 25 | if ( 26 | file_diff_stat.special_file is not None and ( 27 | file_diff_stat.special_file.file_type is 28 | SpecialFileType.SUBMODULE 29 | ) 30 | ): 31 | if file_diff_stat.status is Status.ADDED: 32 | submodule_delta += 1 33 | elif file_diff_stat.status is Status.DELETED: 34 | submodule_delta -= 1 35 | 36 | if submodule_delta: 37 | yield Metric(type(self).__name__, submodule_delta) 38 | 39 | def get_metrics_info(self) -> list[MetricInfo]: 40 | return [MetricInfo.from_class(type(self))] 41 | -------------------------------------------------------------------------------- /git_code_debt/server/metric_config.sample.yaml: -------------------------------------------------------------------------------- 1 | # This is a sample metric config 2 | 3 | # Sample Groups definition: 4 | # Groups: 5 | # Group1: 6 | # metrics: 7 | # - FooMetric 8 | # - BarMetric 9 | # metric_expressions: 10 | # - ^.*Baz.*$ 11 | # 12 | # This defines a group named Group1 which explicitly includes the metrics 13 | # FooMetric and BarMetric and also includes all metrics which match ^.*Baz.*$. 14 | # NOTE: metrics and metric_expressions may be omitted 15 | 16 | Groups: 17 | - Python: 18 | metric_expressions: 19 | - (?i)^.*Python.*$ 20 | - CurseWords: 21 | metric_expressions: 22 | - ^TotalCurseWords.*$ 23 | - LinesOfCode: 24 | metric_expressions: 25 | - ^TotalLinesOfCode.*$ 26 | 27 | 28 | # Sample ColorOverrides definition: 29 | # ColorOverrides: 30 | # - PartialTemplateCount 31 | # 32 | # This indicates that PartialTemplateCount should be colored the opposite of 33 | # the usual coloring (the usual coloring is up = red, down = green). 34 | ColorOverrides: [] 35 | 36 | 37 | # Sample CommitLinks for Commit page: 38 | # CommitLinks: 39 | # View on Github: https://github.com/asottile/git-code-debt/commit/{sha} 40 | # ... 41 | # 42 | # These links will have the following variables substituted when displayed: 43 | # {sha} - Points to the full (40-character) sha of the commit 44 | CommitLinks: 45 | View on Github: https://github.com/FIXME_USER/FIXME_PROJECT/commit/{sha} 46 | 47 | 48 | # These denote the metrics to show in the widget. 49 | # (Currently the values are intentionally empty maps) 50 | WidgetMetrics: 51 | TotalLinesOfCode: {} 52 | -------------------------------------------------------------------------------- /tests/metrics/symlink_count_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import SpecialFile 5 | from git_code_debt.file_diff_stat import SpecialFileType 6 | from git_code_debt.file_diff_stat import Status 7 | from git_code_debt.metric import Metric 8 | from git_code_debt.metrics.symlink_count import SymlinkCount 9 | from git_code_debt.repo_parser import BLANK_COMMIT 10 | 11 | 12 | def test_symlink_count_detects_added(): 13 | parser = SymlinkCount() 14 | input_stats = ( 15 | FileDiffStat( 16 | b'foo', [], [], Status.ADDED, 17 | special_file=SpecialFile(SpecialFileType.SYMLINK, b'bar', None), 18 | ), 19 | ) 20 | 21 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 22 | assert metric == Metric('SymlinkCount', 1) 23 | 24 | 25 | def test_symlink_count_detects_deleted(): 26 | parser = SymlinkCount() 27 | input_stats = ( 28 | FileDiffStat( 29 | b'foo', [], [], Status.DELETED, 30 | special_file=SpecialFile(SpecialFileType.SYMLINK, None, b'bar'), 31 | ), 32 | ) 33 | 34 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 35 | assert metric == Metric('SymlinkCount', -1) 36 | 37 | 38 | def test_symlink_count_detects_ignores_moved(): 39 | parser = SymlinkCount() 40 | input_stats = ( 41 | FileDiffStat( 42 | b'foo', [], [], Status.ALREADY_EXISTING, 43 | special_file=SpecialFile(SpecialFileType.SYMLINK, b'bar', b'baz'), 44 | ), 45 | ) 46 | 47 | assert not tuple(parser.get_metrics_from_stat(BLANK_COMMIT, input_stats)) 48 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/changes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | 6 | import flask 7 | 8 | from git_code_debt.server import logic 9 | from git_code_debt.server.presentation.commit_delta import CommitDelta 10 | from git_code_debt.server.presentation.delta import Delta 11 | from git_code_debt.server.render_mako import render_template 12 | 13 | 14 | changes = flask.Blueprint('changes', __name__) 15 | 16 | 17 | @changes.route('/changes///') 18 | def show(metric_name: str, start_ts: int, end_ts: int) -> str: 19 | metric_info = logic.get_metric_info(flask.g.db, metric_name) 20 | 21 | metric_changes_from_db = sorted( 22 | logic.get_major_changes_for_metric( 23 | flask.g.db, start_ts, end_ts, metric_info.id, 24 | ), 25 | ) 26 | metric_changes = [ 27 | ( 28 | datetime.datetime.fromtimestamp(timestamp).strftime( 29 | '%Y-%m-%d %H:%M:%S', 30 | ), 31 | sha, 32 | CommitDelta.from_data( 33 | metric_name, Delta('javascript:;', value), 34 | color_overrides=flask.g.config.color_overrides, 35 | ), 36 | ) 37 | for timestamp, sha, value in metric_changes_from_db 38 | ] 39 | 40 | override_classname = ( 41 | 'color-override' 42 | if metric_name in flask.g.config.color_overrides 43 | else '' 44 | ) 45 | 46 | rendered_template = render_template( 47 | 'changes.mako', 48 | changes=metric_changes, 49 | override_classname=override_classname, 50 | ) 51 | 52 | return json.dumps({'body': rendered_template}) 53 | -------------------------------------------------------------------------------- /tests/metrics/submodule_count_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import SpecialFile 5 | from git_code_debt.file_diff_stat import SpecialFileType 6 | from git_code_debt.file_diff_stat import Status 7 | from git_code_debt.metric import Metric 8 | from git_code_debt.metrics.submodule_count import SubmoduleCount 9 | from git_code_debt.repo_parser import BLANK_COMMIT 10 | 11 | 12 | def test_submodule_count_detects_added(): 13 | parser = SubmoduleCount() 14 | input_stats = ( 15 | FileDiffStat( 16 | b'foo', [], [], Status.ADDED, 17 | special_file=SpecialFile(SpecialFileType.SUBMODULE, b'bar', None), 18 | ), 19 | ) 20 | 21 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 22 | assert metric == Metric('SubmoduleCount', 1) 23 | 24 | 25 | def test_submodule_count_detects_deleted(): 26 | parser = SubmoduleCount() 27 | input_stats = ( 28 | FileDiffStat( 29 | b'foo', [], [], Status.DELETED, 30 | special_file=SpecialFile(SpecialFileType.SUBMODULE, None, b'bar'), 31 | ), 32 | ) 33 | 34 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 35 | assert metric == Metric('SubmoduleCount', -1) 36 | 37 | 38 | def test_submodule_count_detects_ignores_moved(): 39 | parser = SubmoduleCount() 40 | input_stats = ( 41 | FileDiffStat( 42 | b'foo', [], [], Status.ALREADY_EXISTING, 43 | special_file=SpecialFile(SpecialFileType.SUBMODULE, b'br', b'bz'), 44 | ), 45 | ) 46 | 47 | assert not tuple(parser.get_metrics_from_stat(BLANK_COMMIT, input_stats)) 48 | -------------------------------------------------------------------------------- /metric_config.yaml: -------------------------------------------------------------------------------- 1 | # This is a sample metric config 2 | 3 | # Sample Groups definition: 4 | # Groups: 5 | # Group1: 6 | # metrics: 7 | # - FooMetric 8 | # - BarMetric 9 | # metric_expressions: 10 | # - ^.*Baz.*$ 11 | # 12 | # This defines a group named Group1 which explicitly includes the metrics 13 | # FooMetric and BarMetric and also includes all metrics which match ^.*Baz.*$. 14 | # NOTE: metrics and metric_expressions may be omitted 15 | 16 | Groups: 17 | - Python: 18 | metric_expressions: 19 | - (?i)^.*Python.*$ 20 | - CurseWords: 21 | metric_expressions: 22 | - ^TotalCurseWords.*$ 23 | - LinesOfCode: 24 | metric_expressions: 25 | - ^TotalLinesOfCode.*$ 26 | 27 | 28 | # Sample ColorOverrides definition: 29 | # ColorOverrides: 30 | # - PartialTemplateCount 31 | # 32 | # This indicates that PartialTemplateCount should be colored the opposite of 33 | # the usual coloring (the usual coloring is up = red, down = green). 34 | ColorOverrides: [] 35 | 36 | 37 | # These links will have the following variables substituted when displayed: 38 | # {sha} - Points to the full (40-character) sha of the commit 39 | CommitLinks: 40 | View on Github: https://github.com/asottile/git-code-debt/commit/{sha} 41 | 42 | 43 | # TODO: these are currently empty arrays because I plan to expand this with 44 | # future functionality. 45 | 46 | # These denote the metrics to show in the widget. 47 | WidgetMetrics: 48 | TotalLinesOfCode: {} 49 | TotalLinesOfCode_css: {} 50 | TotalLinesOfCode_python: {} 51 | TotalLinesOfCode_javascript: {} 52 | TotalLinesOfCode_plain-text: {} 53 | TotalLinesOfCode_yaml: {} 54 | -------------------------------------------------------------------------------- /tests/metrics/binary_file_count_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import SpecialFile 5 | from git_code_debt.file_diff_stat import SpecialFileType 6 | from git_code_debt.file_diff_stat import Status 7 | from git_code_debt.metric import Metric 8 | from git_code_debt.metrics.binary_file_count import BinaryFileCount 9 | from git_code_debt.repo_parser import BLANK_COMMIT 10 | 11 | 12 | def test_binary_file_count_detects_added(): 13 | parser = BinaryFileCount() 14 | input_stats = ( 15 | FileDiffStat( 16 | b'foo', [], [], Status.ADDED, 17 | special_file=SpecialFile(SpecialFileType.BINARY, b'foo', None), 18 | ), 19 | ) 20 | 21 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 22 | assert metric == Metric('BinaryFileCount', 1) 23 | 24 | 25 | def test_binary_file_count_detects_deleted(): 26 | parser = BinaryFileCount() 27 | input_stats = ( 28 | FileDiffStat( 29 | b'foo', [], [], Status.DELETED, 30 | special_file=SpecialFile(SpecialFileType.BINARY, None, b'foo'), 31 | ), 32 | ) 33 | 34 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 35 | assert metric == Metric('BinaryFileCount', -1) 36 | 37 | 38 | def test_binary_file_count_detects_ignores_moved(): 39 | parser = BinaryFileCount() 40 | input_stats = ( 41 | FileDiffStat( 42 | b'foo', [], [], Status.ALREADY_EXISTING, 43 | special_file=SpecialFile(SpecialFileType.BINARY, b'foo', b'foo'), 44 | ), 45 | ) 46 | 47 | assert not tuple(parser.get_metrics_from_stat(BLANK_COMMIT, input_stats)) 48 | -------------------------------------------------------------------------------- /tests/logic_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.discovery import get_metric_parsers 4 | from git_code_debt.generate import get_metrics_info 5 | from git_code_debt.logic import get_metric_mapping 6 | from git_code_debt.logic import get_metric_values 7 | from git_code_debt.logic import get_previous_sha 8 | from git_code_debt.repo_parser import Commit 9 | from git_code_debt.write_logic import insert_metric_values 10 | 11 | 12 | def test_get_metric_mapping(sandbox): 13 | with sandbox.db() as db: 14 | ret = get_metric_mapping(db) 15 | 16 | expected = {m.name for m in get_metrics_info(get_metric_parsers())} 17 | assert set(ret) == expected 18 | 19 | 20 | def test_get_previous_sha_no_previous_sha(sandbox): 21 | with sandbox.db() as db: 22 | ret = get_previous_sha(db) 23 | assert ret is None 24 | 25 | 26 | def insert_fake_metrics(db): 27 | metric_mapping = get_metric_mapping(db) 28 | has_data = dict.fromkeys(metric_mapping.values(), True) 29 | for v, sha_part in enumerate('abc', 1): 30 | metric_values = dict.fromkeys(metric_mapping.values(), v) 31 | commit = Commit(sha_part * 40, 1) 32 | insert_metric_values(db, metric_values, has_data, commit) 33 | 34 | 35 | def test_get_previous_sha_previous_existing_sha(sandbox): 36 | with sandbox.db() as db: 37 | insert_fake_metrics(db) 38 | ret = get_previous_sha(db) 39 | assert ret == 'c' * 40 40 | 41 | 42 | def test_insert_and_get_metric_values(sandbox): 43 | with sandbox.db() as db: 44 | fake_metrics = dict.fromkeys(get_metric_mapping(db).values(), 2) 45 | insert_fake_metrics(db) 46 | assert fake_metrics == get_metric_values(db, 'b' * 40) 47 | -------------------------------------------------------------------------------- /sample_widget_consumer.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

This page represents a sample widget consumer.

7 |

To start this page run:

8 |
$ git-code-debt-server --port 9001 database.db
9 |
$ firefox sample_widget_consumer.htm &
10 |

Below this should be a table that says "TotalLinesOfCode: 1"

11 |

And below that should be a table that says "TotalLinesOfCode: -1"

12 | 13 | 24 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import flask 6 | 7 | from git_code_debt.discovery import get_metric_parsers_from_args 8 | from git_code_debt.generate import get_metrics 9 | from git_code_debt.generate_config import GenerateOptions 10 | from git_code_debt.repo_parser import BLANK_COMMIT 11 | from git_code_debt.server.presentation.commit_delta import CommitDelta 12 | from git_code_debt.server.presentation.delta import Delta 13 | from git_code_debt.server.render_mako import render_template 14 | from git_code_debt.util import yaml 15 | 16 | 17 | widget = flask.Blueprint('widget', __name__) 18 | 19 | 20 | @widget.route('/widget/frame') 21 | def frame() -> str: 22 | return render_template('widget_frame.mako') 23 | 24 | 25 | @widget.route('/widget/data', methods=['POST']) 26 | def data() -> str: 27 | metric_names = frozenset(flask.g.config.widget_metrics) 28 | diff = flask.request.form['diff'].encode('UTF-8') 29 | 30 | metric_config = GenerateOptions.from_yaml( 31 | yaml.load(open('generate_config.yaml').read()), 32 | ) 33 | parsers = get_metric_parsers_from_args( 34 | metric_config.metric_package_names, skip_defaults=False, 35 | ) 36 | metrics = get_metrics(BLANK_COMMIT, diff, parsers, metric_config.exclude) 37 | metrics = tuple( 38 | metric for metric in metrics 39 | if metric.value and metric.name in metric_names 40 | ) 41 | 42 | commit_deltas = sorted( 43 | CommitDelta.from_data( 44 | metric.name, Delta('javascript:;', metric.value), 45 | color_overrides=flask.g.config.color_overrides, 46 | ) 47 | for metric in metrics 48 | ) 49 | return json.dumps({ 50 | 'metrics': render_template('widget.mako', commit_deltas=commit_deltas), 51 | }) 52 | -------------------------------------------------------------------------------- /git_code_debt/list_metrics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | from collections.abc import Sequence 5 | 6 | from git_code_debt import options 7 | from git_code_debt.discovery import get_metric_parsers_from_args 8 | from git_code_debt.generate import get_options_from_config 9 | 10 | 11 | CYAN = '\033[1;36m' 12 | NORMAL = '\033[0m' 13 | 14 | 15 | def color(text: str, color_value: str, color_setting: bool) -> str: 16 | if not color_setting: 17 | return text 18 | else: 19 | return f'{color_value}{text}{NORMAL}' 20 | 21 | 22 | def main(argv: Sequence[str] | None = None) -> int: 23 | parser = argparse.ArgumentParser(description='List metric parsers') 24 | # optional 25 | options.add_color(parser) 26 | options.add_generate_config_filename(parser) 27 | parsed_args = parser.parse_args(argv) 28 | 29 | color_setting = parsed_args.color in ('always', 'auto') 30 | args = get_options_from_config(parsed_args.config_filename) 31 | 32 | metric_parsers = get_metric_parsers_from_args( 33 | args.metric_package_names, 34 | args.skip_default_metrics, 35 | ) 36 | 37 | metric_parsers_sorted = sorted( 38 | metric_parsers, 39 | key=lambda cls: cls.__module__ + cls.__name__, 40 | ) 41 | 42 | for metric_parser_cls in metric_parsers_sorted: 43 | print( 44 | '{} {}'.format( 45 | color(metric_parser_cls.__module__, CYAN, color_setting), 46 | metric_parser_cls.__name__, 47 | ), 48 | ) 49 | for name, description in metric_parser_cls().get_metrics_info(): 50 | description = f': {description}' if description else '' 51 | print(f' {name}{description}') 52 | return 0 53 | 54 | 55 | if __name__ == '__main__': 56 | raise SystemExit(main()) 57 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = git_code_debt 3 | version = 1.2.0 4 | description = A dashboard for monitoring code debt in a git repository. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/git-code-debt 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: Implementation :: CPython 16 | Programming Language :: Python :: Implementation :: PyPy 17 | 18 | [options] 19 | packages = find: 20 | install_requires = 21 | cfgv 22 | flask 23 | identify 24 | mako 25 | markdown-code-blocks 26 | pyyaml 27 | python_requires = >=3.10 28 | 29 | [options.packages.find] 30 | exclude = 31 | tests* 32 | testing* 33 | 34 | [options.entry_points] 35 | console_scripts = 36 | git-code-debt-generate = git_code_debt.generate:main 37 | git-code-debt-list-metrics = git_code_debt.list_metrics:main 38 | git-code-debt-server = git_code_debt.server.app:main 39 | 40 | [options.package_data] 41 | git_code_debt = 42 | schema/*.sql 43 | git_code_debt.server = 44 | templates/*.mako 45 | static/css/*.css 46 | static/img/*.gif 47 | static/js/*.js 48 | metric_config.sample.yaml 49 | 50 | [bdist_wheel] 51 | universal = True 52 | 53 | [coverage:run] 54 | plugins = covdefaults 55 | omit = *_mako 56 | 57 | [mypy] 58 | check_untyped_defs = true 59 | disallow_any_generics = true 60 | disallow_incomplete_defs = true 61 | disallow_untyped_defs = true 62 | warn_redundant_casts = true 63 | warn_unused_ignores = true 64 | 65 | [mypy-testing.*] 66 | disallow_untyped_defs = false 67 | 68 | [mypy-tests.*] 69 | disallow_untyped_defs = false 70 | -------------------------------------------------------------------------------- /tests/metrics/base_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.file_diff_stat import FileDiffStat 4 | from git_code_debt.file_diff_stat import Status 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.metrics.base import MetricInfo 7 | from git_code_debt.metrics.base import SimpleLineCounterBase 8 | from git_code_debt.metrics.base import TextLineCounterBase 9 | from git_code_debt.repo_parser import BLANK_COMMIT 10 | 11 | 12 | def test_simple_base_counter(): 13 | """Smoke test for SimpleLineCounterBase""" 14 | class TestCounter(SimpleLineCounterBase): 15 | """This is the test counter!""" 16 | 17 | def should_include_file(self, file_diff_stat): 18 | return True 19 | 20 | def line_matches_metric(self, line, file_diff_stat): 21 | return True 22 | 23 | parser = TestCounter() 24 | info, = parser.get_metrics_info() 25 | assert info == MetricInfo('TestCounter', 'This is the test counter!') 26 | 27 | input_stats = ( 28 | FileDiffStat( 29 | b'test.py', 30 | [b'a', b'b', b'c'], 31 | [b'd'], 32 | Status.ALREADY_EXISTING, 33 | ), 34 | ) 35 | 36 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 37 | assert metric == Metric('TestCounter', 2) 38 | 39 | 40 | def test_includes_file_by_default(): 41 | counter = SimpleLineCounterBase() 42 | file_diff_stat = FileDiffStat(b'filename', [], [], Status.ADDED) 43 | assert counter.should_include_file(file_diff_stat) 44 | 45 | 46 | def test_text_line_counter_base(): 47 | class Counter(TextLineCounterBase): 48 | def text_line_matches_metric(self, line, file_diff_stat): 49 | return 'foo' in line 50 | 51 | parser = Counter() 52 | stat = FileDiffStat(b'test.py', [b'hi', b'foo world'], [], Status.ADDED) 53 | 54 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, (stat,)) 55 | assert metric == Metric('Counter', 1) 56 | -------------------------------------------------------------------------------- /tests/server/servlets/widget_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from unittest import mock 5 | 6 | import flask 7 | import pyquery 8 | 9 | from git_code_debt.server.app import AppContext 10 | from git_code_debt.server.metric_config import Config 11 | from testing.assertions.response import assert_no_response_errors 12 | from tests import file_diff_stat_test 13 | 14 | 15 | def test_widget_frame_loads(server): 16 | response = server.client.get(flask.url_for('widget.frame')) 17 | assert_no_response_errors(response) 18 | assert response.pq.find('script') 19 | 20 | 21 | @contextlib.contextmanager 22 | def metrics_enabled(widget_metrics): 23 | config = Config.from_data({ 24 | 'Groups': [], 25 | 'CommitLinks': {}, 26 | 'ColorOverrides': [], 27 | 'WidgetMetrics': widget_metrics, 28 | }) 29 | with mock.patch.object(AppContext, 'config', config): 30 | yield 31 | 32 | 33 | def test_widget_data(server): 34 | with metrics_enabled({'TotalLinesOfCode': {}}): 35 | response = server.client.post( 36 | flask.url_for('widget.data'), 37 | data={'diff': file_diff_stat_test.SAMPLE_OUTPUT}, 38 | ) 39 | response_pq = pyquery.PyQuery(response.json['metrics']) 40 | assert 'TotalLinesOfCode 1' in ' '.join(response_pq.text().split()) 41 | # Should not find any metrics with no data 42 | assert not response_pq.find('.metric-none') 43 | # Should not have metrics we didn't specify 44 | assert 'TotalLinesOfCode_Text' not in response_pq.text() 45 | 46 | 47 | def test_widget_data_multiple_values(server): 48 | with metrics_enabled( 49 | {'TotalLinesOfCode': {}, 'TotalLinesOfCode_plain-text': {}}, 50 | ): 51 | response = server.client.post( 52 | flask.url_for('widget.data'), 53 | data={'diff': file_diff_stat_test.SAMPLE_OUTPUT}, 54 | ) 55 | response_pq = pyquery.PyQuery(response.json['metrics']) 56 | assert 'TotalLinesOfCode_plain-text' in response_pq.text() 57 | -------------------------------------------------------------------------------- /git_code_debt/server/servlets/graph.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | 6 | import flask 7 | from werkzeug.wrappers import Response 8 | 9 | from git_code_debt.server import logic 10 | from git_code_debt.server.render_mako import render_template 11 | from git_code_debt.util.time import data_points_for_time_range 12 | from git_code_debt.util.time import to_timestamp 13 | 14 | 15 | graph = flask.Blueprint('graph', __name__) 16 | 17 | 18 | @graph.route('/graph/') 19 | def show(metric_name: str) -> str: 20 | start_timestamp = int(flask.request.args['start']) 21 | end_timestamp = int(flask.request.args['end']) 22 | 23 | metric_info = logic.get_metric_info(flask.g.db, metric_name) 24 | 25 | data_points = data_points_for_time_range( 26 | start_timestamp, 27 | end_timestamp, 28 | data_points=250, 29 | ) 30 | metrics_for_dates = logic.metrics_for_dates(metric_info.id, data_points) 31 | 32 | metrics_for_js = sorted({ 33 | (m.date * 1000, m.value) for m in metrics_for_dates 34 | }) 35 | 36 | return render_template( 37 | 'graph.mako', 38 | description=metric_info.description, 39 | metric_name=metric_name, 40 | metrics=json.dumps(metrics_for_js), 41 | start_ts=start_timestamp, 42 | end_ts=end_timestamp, 43 | changes_url=flask.url_for( 44 | 'changes.show', 45 | metric_name=metric_name, 46 | start_ts=start_timestamp, 47 | end_ts=end_timestamp, 48 | ), 49 | ) 50 | 51 | 52 | @graph.route('/graph//all_data') 53 | def all_data(metric_name: str) -> Response: 54 | earliest_timestamp = logic.get_first_data_timestamp(metric_name) 55 | now = datetime.datetime.today() 56 | 57 | return flask.redirect( 58 | flask.url_for( 59 | 'graph.show', 60 | metric_name=metric_name, 61 | start=str(earliest_timestamp), 62 | end=str(to_timestamp(now)), 63 | ), 64 | ) 65 | -------------------------------------------------------------------------------- /tests/discovery_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt.discovery import get_metric_parsers 4 | from git_code_debt.discovery import get_modules 5 | from git_code_debt.discovery import is_metric_cls 6 | from git_code_debt.metrics.base import DiffParserBase 7 | from git_code_debt.util.discovery import discover 8 | 9 | 10 | def test_is_metric_parser_is_DiffParserBase(): 11 | assert not is_metric_cls(DiffParserBase) 12 | 13 | 14 | def test_is_metric_parser_not_DiffParserBase(): 15 | class Foo(DiffParserBase): 16 | pass 17 | 18 | assert is_metric_cls(Foo) 19 | 20 | 21 | def test_is_metric_parser_definitely_isnt_DiffParserBase(): 22 | class Bar: 23 | pass 24 | 25 | assert not is_metric_cls(Bar) 26 | 27 | 28 | def test_is_metric_parser_is_a_DiffParserBase_but_has__metric__False(): 29 | class Baz(DiffParserBase): 30 | __metric__ = False 31 | assert not is_metric_cls(Baz) 32 | 33 | 34 | class MetricParserInTests(DiffParserBase): 35 | pass 36 | 37 | 38 | def test_returns_no_metrics_when_defaults_are_off(): 39 | assert set() == get_metric_parsers(include_defaults=False) 40 | 41 | 42 | def test_get_metric_parsers_returns_something(): 43 | assert len(get_metric_parsers()) > 0 44 | 45 | 46 | def test_returns_metrics_defined_in_tests_when_specified(): 47 | import tests 48 | metrics_in_tests = discover(tests, is_metric_cls) 49 | if not metrics_in_tests: 50 | raise AssertionError( 51 | 'Expected at least one metric in `tests` but found none', 52 | ) 53 | 54 | assert ( 55 | metrics_in_tests == 56 | get_metric_parsers((tests,), include_defaults=False) 57 | ) 58 | 59 | 60 | def test_get_modules_no_modules(): 61 | ret = get_modules([]) 62 | assert ret == [] 63 | 64 | 65 | def test_get_modules_some_modules(): 66 | module_names = ['git_code_debt.metrics', 'git_code_debt.generate'] 67 | ret = get_modules(module_names) 68 | assert len(ret) == 2 69 | assert [mod.__name__ for mod in ret] == module_names 70 | -------------------------------------------------------------------------------- /git_code_debt/metrics/lines.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | from collections.abc import Generator 5 | 6 | from identify import identify 7 | 8 | from git_code_debt.file_diff_stat import FileDiffStat 9 | from git_code_debt.metric import Metric 10 | from git_code_debt.metrics.base import DiffParserBase 11 | from git_code_debt.metrics.base import MetricInfo 12 | from git_code_debt.metrics.common import ALL_TAGS 13 | from git_code_debt.metrics.common import UNKNOWN 14 | from git_code_debt.repo_parser import Commit 15 | 16 | 17 | class LinesOfCodeParser(DiffParserBase): 18 | """Counts lines of code in a repository, overall and by file types.""" 19 | 20 | def get_metrics_from_stat( 21 | self, 22 | _: Commit, 23 | file_diff_stats: tuple[FileDiffStat, ...], 24 | ) -> Generator[Metric]: 25 | total_lines = 0 26 | lines_by_file_type: dict[str, int] = collections.defaultdict(int) 27 | 28 | for file_diff_stat in file_diff_stats: 29 | lines_changed = ( 30 | len(file_diff_stat.lines_added) - 31 | len(file_diff_stat.lines_removed) 32 | ) 33 | 34 | # Track total overall 35 | total_lines += lines_changed 36 | 37 | filename = file_diff_stat.filename.decode('UTF-8') 38 | tags = identify.tags_from_filename(filename) or {UNKNOWN} 39 | 40 | for tag in tags: 41 | lines_by_file_type[tag] += lines_changed 42 | 43 | # Yield overall metric and one per type of expected mapping types 44 | yield Metric('TotalLinesOfCode', total_lines) 45 | for tag, val in lines_by_file_type.items(): 46 | if tag in ALL_TAGS and val: 47 | yield Metric(f'TotalLinesOfCode_{tag}', val) 48 | 49 | def get_metrics_info(self) -> list[MetricInfo]: 50 | metric_names = [f'TotalLinesOfCode_{tag}' for tag in ALL_TAGS] 51 | metric_names.append('TotalLinesOfCode') 52 | return [MetricInfo(metric_name) for metric_name in metric_names] 53 | -------------------------------------------------------------------------------- /tests/server/logic_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from git_code_debt import write_logic 4 | from git_code_debt.logic import get_metric_mapping 5 | from git_code_debt.metric import Metric 6 | from git_code_debt.repo_parser import Commit 7 | from git_code_debt.server.logic import get_first_data_timestamp 8 | 9 | 10 | def test_no_data_returns_zero(sandbox): 11 | with sandbox.db() as db: 12 | assert get_first_data_timestamp('PythonImportCount', db=db) == 0 13 | 14 | 15 | def insert(db, sha, timestamp, value, has_data=True): 16 | metric_mapping = get_metric_mapping(db) 17 | write_logic.insert_metric_values( 18 | db, 19 | {metric_mapping['PythonImportCount']: value}, 20 | {metric_mapping['PythonImportCount']: has_data}, 21 | Commit(sha, timestamp), 22 | ) 23 | 24 | 25 | def insert_metric_changes(db, sha, change): 26 | metric_mapping = get_metric_mapping(db) 27 | write_logic.insert_metric_changes( 28 | db, 29 | (Metric('PythonImportCount', change),), 30 | metric_mapping, 31 | Commit(sha, 0), 32 | ) 33 | 34 | 35 | def test_some_data_returns_first_timestamp(sandbox): 36 | with sandbox.db() as db: 37 | insert(db, '1' * 40, 1111, 0, has_data=False) 38 | assert get_first_data_timestamp('PythonImportCount', db=db) == 0 39 | 40 | 41 | def test_some_data_returns_last_zero_before_data(sandbox): 42 | with sandbox.db() as db: 43 | insert(db, '1' * 40, 1111, 0, has_data=False) 44 | insert(db, '2' * 40, 2222, 0, has_data=False) 45 | insert(db, '3' * 40, 3333, 1) 46 | insert_metric_changes(db, '3' * 40, 1) 47 | assert get_first_data_timestamp('PythonImportCount', db=db) == 3333 48 | 49 | 50 | def test_first_commit_introduces_data(sandbox): 51 | with sandbox.db() as db: 52 | insert(db, '1' * 40, 1111, 1) 53 | insert_metric_changes(db, '1' * 40, 1) 54 | insert(db, '2' * 40, 2222, 2) 55 | insert_metric_changes(db, '2' * 40, 1) 56 | assert get_first_data_timestamp('PythonImportCount', db=db) == 1111 57 | -------------------------------------------------------------------------------- /tests/server/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from git_code_debt.generate import main 9 | from git_code_debt.server.app import app 10 | from git_code_debt.server.app import AppContext 11 | from git_code_debt.server.metric_config import Config 12 | from testing.utilities.auto_namedtuple import auto_namedtuple 13 | from testing.utilities.client import Client 14 | 15 | 16 | class GitCodeDebtServer: 17 | def __init__(self, client, sandbox): 18 | self.client = client 19 | self.sandbox = sandbox 20 | 21 | 22 | @contextlib.contextmanager 23 | def _patch_app_with_client(application): 24 | with mock.patch.object(application, 'test_client_class', Client): 25 | # Make the app always debug so it throws exceptions 26 | with mock.patch.object( 27 | type(application), 'debug', mock.PropertyMock(return_value=True), 28 | ): 29 | yield 30 | 31 | 32 | @contextlib.contextmanager 33 | def _in_testing_app_context(application): 34 | with application.test_request_context(): 35 | with application.test_client() as client: 36 | yield client 37 | 38 | 39 | @pytest.fixture 40 | def server(sandbox): 41 | with _patch_app_with_client(app), _in_testing_app_context(app) as client: 42 | with mock.patch.object(AppContext, 'database_path', sandbox.db_path): 43 | config = Config.from_data({ 44 | 'Groups': [{'Python': {'metric_expressions': ['Python']}}], 45 | 'ColorOverrides': [], 46 | 'CommitLinks': {}, 47 | 'WidgetMetrics': {}, 48 | }) 49 | with mock.patch.object(AppContext, 'config', config): 50 | yield GitCodeDebtServer(client, sandbox) 51 | 52 | 53 | @pytest.fixture 54 | def server_with_data(server, cloneable_with_commits): 55 | cfg = server.sandbox.gen_config(repo=cloneable_with_commits.path) 56 | main(('-C', cfg)) 57 | yield auto_namedtuple( 58 | server=server, 59 | cloneable_with_commits=cloneable_with_commits, 60 | ) 61 | -------------------------------------------------------------------------------- /git_code_debt/server/templates/index.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from git_code_debt.server.servlets.index import DATE_NAMES_TO_TIMEDELTAS 3 | %> 4 | 5 | <%inherit file="base.mako" /> 6 | 7 | <%block name="title">Code Debt - Index 8 | 9 | <%block name="scripts"> 10 | ${parent.scripts()} 11 | 12 | 13 | 14 |

Technical Debt

15 | 16 | 17 | 18 | 19 | 20 | 21 | % for time_name, _ in DATE_NAMES_TO_TIMEDELTAS: 22 | 23 | % endfor 24 | 25 | % for group in groups: 26 | <% first = True %> 27 | 28 | % for metric in group.metrics: 29 | 30 | % if first: 31 | <% first = False %> 32 | 35 | % endif 36 | 37 | 38 | % for delta in metric.historic_deltas: 39 | 44 | % endfor 45 | 46 | % endfor 47 | 48 | 51 | 56 | 57 | 58 | % endfor 59 |
MetricCurrent Value${time_name}
33 | ${group.name} 34 | ${metric.name}${metric.current_value} 40 | 41 | ${delta.value} 42 | 43 |
49 | ${group.name} 50 |
60 | -------------------------------------------------------------------------------- /git_code_debt/discovery.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Sequence 4 | from types import ModuleType 5 | from typing import Any 6 | 7 | from git_code_debt.metrics.base import DiffParserBase 8 | from git_code_debt.util.discovery import discover 9 | 10 | 11 | def is_metric_cls(cls: type[Any]) -> bool: 12 | """A metric class is defined as follows: 13 | 14 | - It inherits DiffParserBase 15 | - It is not DiffParserBase 16 | - It does not have __metric__ = False 17 | """ 18 | return ( 19 | cls is not DiffParserBase and 20 | cls.__dict__.get('__metric__', True) and 21 | issubclass(cls, DiffParserBase) 22 | ) 23 | 24 | 25 | def get_metric_parsers( 26 | metric_packages: Sequence[ModuleType] = (), 27 | include_defaults: bool = True, 28 | ) -> set[type[DiffParserBase]]: 29 | """Gets all of the metric parsers. 30 | 31 | Args: 32 | metric_packages - Defaults to no extra packages. An iterable of 33 | metric containing packages. A metric inherits DiffParserBase 34 | and does not have __metric__ = False 35 | A metric package must be imported using import a.b.c 36 | include_defaults - Whether to include the generic metric parsers 37 | """ 38 | metric_parsers = set() 39 | 40 | if include_defaults: 41 | import git_code_debt.metrics 42 | metric_parsers.update(discover(git_code_debt.metrics, is_metric_cls)) 43 | 44 | for metric_package in metric_packages: 45 | metric_parsers.update(discover(metric_package, is_metric_cls)) 46 | return metric_parsers 47 | 48 | 49 | def get_modules(module_names: list[str]) -> list[ModuleType]: 50 | """Returns module objects for each module name. Has the side effect of 51 | importing each module. 52 | 53 | Args: 54 | module_names - iterable of module names 55 | 56 | Returns: 57 | Module objects for each module specified in module_names 58 | """ 59 | return [ 60 | __import__(module_name, fromlist=['__trash__']) 61 | for module_name in module_names 62 | ] 63 | 64 | 65 | def get_metric_parsers_from_args( 66 | metric_package_names: list[str], 67 | skip_defaults: bool, 68 | ) -> set[type[DiffParserBase]]: 69 | packages = get_modules(metric_package_names) 70 | return get_metric_parsers( 71 | metric_packages=packages, include_defaults=not skip_defaults, 72 | ) 73 | -------------------------------------------------------------------------------- /tests/testing/assertions/response_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from testing.assertions.response import assert_no_response_errors 8 | from testing.assertions.response import assert_redirect 9 | from testing.utilities.auto_namedtuple import auto_namedtuple 10 | 11 | 12 | def test_raises_for_response_error(): 13 | with pytest.raises(AssertionError): 14 | assert_no_response_errors( 15 | auto_namedtuple(response=auto_namedtuple(status_code=201)), 16 | ) 17 | 18 | 19 | def test_ok_for_200(): 20 | assert_no_response_errors( 21 | auto_namedtuple(response=auto_namedtuple(status_code=200)), 22 | ) 23 | 24 | 25 | def test_redirect_not_a_redirect(): 26 | with pytest.raises(AssertionError): 27 | assert_redirect( 28 | auto_namedtuple(response=auto_namedtuple(status_code=200)), 29 | mock.sentinel.expected_path, 30 | mock.sentinel.expected_query, 31 | ) 32 | 33 | 34 | def test_redirect_custom_status_code(): 35 | assert_redirect( 36 | auto_namedtuple( 37 | response=auto_namedtuple( 38 | status_code=303, 39 | location='/foo', 40 | ), 41 | ), 42 | '/foo', 43 | {}, 44 | redirect_status_code=303, 45 | ) 46 | 47 | 48 | def test_redirect_wrong_path(): 49 | with pytest.raises(AssertionError): 50 | assert_redirect( 51 | auto_namedtuple( 52 | response=auto_namedtuple( 53 | status_code=302, 54 | location='/foo', 55 | ), 56 | ), 57 | '/bar', 58 | {}, 59 | ) 60 | 61 | 62 | def test_redirect_wrong_query(): 63 | with pytest.raises(AssertionError): 64 | assert_redirect( 65 | auto_namedtuple( 66 | response=auto_namedtuple( 67 | status_code=302, 68 | location='/foo?bar=baz', 69 | ), 70 | ), 71 | '/foo', 72 | {'bar': ['biz']}, 73 | ) 74 | 75 | 76 | def test_correct_redirect(): 77 | assert_redirect( 78 | auto_namedtuple( 79 | response=auto_namedtuple( 80 | status_code=302, 81 | location='/foo?bar=baz', 82 | ), 83 | ), 84 | '/foo', 85 | {'bar': ['baz']}, 86 | ) 87 | -------------------------------------------------------------------------------- /git_code_debt/metrics/curse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | from collections.abc import Generator 5 | 6 | from identify import identify 7 | 8 | from git_code_debt.file_diff_stat import FileDiffStat 9 | from git_code_debt.metric import Metric 10 | from git_code_debt.metrics.base import DiffParserBase 11 | from git_code_debt.metrics.base import MetricInfo 12 | from git_code_debt.metrics.common import ALL_TAGS 13 | from git_code_debt.metrics.common import UNKNOWN 14 | from git_code_debt.metrics.curse_words import word_list 15 | from git_code_debt.repo_parser import Commit 16 | 17 | 18 | def count_curse_words(lines: list[bytes]) -> int: 19 | curses = 0 20 | for line in lines: 21 | for word in line.split(): 22 | if word in word_list: 23 | curses += 1 24 | return curses 25 | 26 | 27 | class CurseWordsParser(DiffParserBase): 28 | """Counts curse words in a repository, overall and by file type""" 29 | 30 | def get_metrics_from_stat( 31 | self, 32 | _: Commit, 33 | file_diff_stats: tuple[FileDiffStat, ...], 34 | ) -> Generator[Metric]: 35 | total_curses = 0 36 | curses_by_file_type: dict[str, int] = collections.defaultdict(int) 37 | 38 | for file_diff_stat in file_diff_stats: 39 | curses_added = count_curse_words(file_diff_stat.lines_added) 40 | curses_removed = count_curse_words(file_diff_stat.lines_removed) 41 | curses_changed = curses_added - curses_removed 42 | 43 | # Track total overall 44 | total_curses = total_curses + curses_changed 45 | 46 | # Track by file extension -> type mapping 47 | filename = file_diff_stat.filename.decode('UTF-8') 48 | tags = identify.tags_from_filename(filename) or {UNKNOWN} 49 | 50 | for tag in tags: 51 | curses_by_file_type[tag] += curses_changed 52 | 53 | # Yield overall metric and one per type of expected mapping types 54 | yield Metric('TotalCurseWords', total_curses) 55 | for tag, value in curses_by_file_type.items(): 56 | if tag in ALL_TAGS and value: 57 | yield Metric(f'TotalCurseWords_{tag}', value) 58 | 59 | def get_metrics_info(self) -> list[MetricInfo]: 60 | metric_names = [f'TotalCurseWords_{tag}' for tag in ALL_TAGS] 61 | metric_names.append('TotalCurseWords') 62 | return [MetricInfo(metric_name) for metric_name in metric_names] 63 | -------------------------------------------------------------------------------- /tests/metrics/imports_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from git_code_debt.file_diff_stat import FileDiffStat 6 | from git_code_debt.file_diff_stat import Status 7 | from git_code_debt.metric import Metric 8 | from git_code_debt.metrics.imports import CheetahTemplateImportCount 9 | from git_code_debt.metrics.imports import is_python_import 10 | from git_code_debt.metrics.imports import is_template_import 11 | from git_code_debt.metrics.imports import PythonImportCount 12 | from git_code_debt.repo_parser import BLANK_COMMIT 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ('line', 'expected'), ( 17 | (b'import collections', True), 18 | (b'from collections import defaultdict', True), 19 | (b'from foo import bar as baz', True), 20 | (b'import bar as baz', True), 21 | (b'#import foo as bar', False), 22 | (b'from with nothing', False), 23 | (b' import foo', True), 24 | (b'herpderp', False), 25 | ), 26 | ) 27 | def test_python_imports(line, expected): 28 | assert is_python_import(line) == expected 29 | 30 | 31 | @pytest.mark.parametrize( 32 | ('line', 'expected'), ( 33 | (b'#import foo', True), 34 | (b'#from foo import bar', True), 35 | (b'#from foo import bar as baz', True), 36 | (b'#import bar as baz', True), 37 | (b' #import foo', True), 38 | (b'## Nothing to import from here', False), 39 | (b'herpderp', False), 40 | ), 41 | ) 42 | def test_template_imports(line, expected): 43 | assert is_template_import(line) == expected 44 | 45 | 46 | def test_python_import_parser(): 47 | parser = PythonImportCount() 48 | input_stats = ( 49 | FileDiffStat( 50 | b'test.py', 51 | [b'import collections', b'from os import path'], 52 | [b'import os.path', b'nothing'], 53 | Status.ALREADY_EXISTING, 54 | ), 55 | ) 56 | 57 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 58 | assert metric == Metric('PythonImportCount', 1) 59 | 60 | 61 | def test_template_import_parser(): 62 | parser = CheetahTemplateImportCount() 63 | input_stats = ( 64 | FileDiffStat( 65 | b'test.tmpl', 66 | [b'#import collections', b'#from os import path'], 67 | [b'#import os.path', b'nothing'], 68 | Status.ALREADY_EXISTING, 69 | ), 70 | ) 71 | 72 | metric, = parser.get_metrics_from_stat(BLANK_COMMIT, input_stats) 73 | assert metric == Metric('CheetahTemplateImportCount', 1) 74 | -------------------------------------------------------------------------------- /git_code_debt/server/metric_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from re import Pattern 5 | from typing import Any 6 | from typing import NamedTuple 7 | 8 | 9 | class Group(NamedTuple): 10 | name: str 11 | metrics: frozenset[str] 12 | metric_expressions: tuple[Pattern[str], ...] 13 | 14 | def contains(self, metric_name: str) -> bool: 15 | return ( 16 | metric_name in self.metrics or 17 | any(expr.search(metric_name) for expr in self.metric_expressions) 18 | ) 19 | 20 | @classmethod 21 | def from_yaml( 22 | cls, 23 | name: str, 24 | metrics: list[str], 25 | metric_expressions: list[str], 26 | ) -> Group: 27 | if not metrics and not metric_expressions: 28 | raise TypeError( 29 | 'Group {} must define at least one of ' 30 | '`metrics` or `metric_expressions`'.format(name), 31 | ) 32 | return cls( 33 | name, 34 | frozenset(metrics), 35 | tuple(re.compile(expr) for expr in metric_expressions), 36 | ) 37 | 38 | 39 | def _get_groups_from_yaml(yaml: list[dict[str, Any]]) -> tuple[Group, ...]: 40 | # A group dict maps it's name to a dict containing metrics and 41 | # metric_expressions 42 | # Here's an example yaml: 43 | # [{'Bar': {'metrics': ['Foo', 'Bar'], 'metric_expressions': ['^Baz']}}] 44 | return tuple( 45 | Group.from_yaml( 46 | next(iter(group_dict.keys())), 47 | next(iter(group_dict.values())).get('metrics', []), 48 | next(iter(group_dict.values())).get('metric_expressions', []), 49 | ) 50 | for group_dict in yaml 51 | ) 52 | 53 | 54 | def _get_commit_links_from_yaml( 55 | yaml: dict[str, str], 56 | ) -> tuple[tuple[str, str], ...]: 57 | # The CommitLinks will look like 58 | # LinkName: 'link_value' 59 | # OtherLinkName: 'other_link_value' 60 | # Here we'll alphabetize these and return a tuple of (LinkName, link_value) 61 | return tuple(sorted(yaml.items())) 62 | 63 | 64 | class Config(NamedTuple): 65 | color_overrides: frozenset[str] 66 | commit_links: tuple[tuple[str, str], ...] 67 | groups: tuple[Group, ...] 68 | widget_metrics: list[str] 69 | 70 | @classmethod 71 | def from_data(cls, data: dict[str, Any]) -> Config: 72 | return cls( 73 | color_overrides=frozenset(data['ColorOverrides']), 74 | commit_links=_get_commit_links_from_yaml(data['CommitLinks']), 75 | groups=_get_groups_from_yaml(data['Groups']), 76 | widget_metrics=data['WidgetMetrics'], 77 | ) 78 | -------------------------------------------------------------------------------- /tests/repo_parser_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os.path 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from git_code_debt import repo_parser 9 | from testing.utilities.auto_namedtuple import auto_namedtuple 10 | 11 | 12 | def test_repo_checked_out(cloneable): 13 | parser = repo_parser.RepoParser(cloneable) 14 | assert parser.tempdir is None 15 | 16 | with parser.repo_checked_out(): 17 | assert parser.tempdir is not None 18 | 19 | tempdir_path = parser.tempdir 20 | assert os.path.exists(tempdir_path) 21 | assert os.path.exists(os.path.join(tempdir_path, '.git')) 22 | 23 | assert parser.tempdir is None 24 | assert not os.path.exists(tempdir_path) 25 | 26 | 27 | @pytest.fixture 28 | def checked_out_repo(cloneable_with_commits): 29 | parser = repo_parser.RepoParser(cloneable_with_commits.path) 30 | with parser.repo_checked_out(): 31 | yield auto_namedtuple( 32 | repo_parser=parser, 33 | cloneable_with_commits=cloneable_with_commits, 34 | ) 35 | 36 | 37 | def test_get_commits_all_of_them(checked_out_repo): 38 | with mock.patch.object(repo_parser, 'cmd_output') as cmd_output_mock: 39 | commit = repo_parser.Commit('sha', 123) 40 | cmd_output_mock.return_value = '\n'.join( 41 | str(part) for part in commit 42 | ) + '\n' 43 | all_commits = checked_out_repo.repo_parser.get_commits() 44 | assert all_commits == [commit] 45 | 46 | 47 | def test_get_commits_after_date(checked_out_repo): 48 | with mock.patch.object(repo_parser, 'cmd_output') as cmd_output_mock: 49 | previous_sha = '29d0d321f43950fd2aa1d1df9fc81dee0e9046b3' 50 | commit = repo_parser.Commit(previous_sha, 123) 51 | cmd_output_mock.return_value = '\n'.join( 52 | str(part) for part in commit 53 | ) + '\n' 54 | checked_out_repo.repo_parser.get_commits(previous_sha) 55 | assert ( 56 | f'{previous_sha}..HEAD' in 57 | cmd_output_mock.call_args[0] 58 | ) 59 | 60 | 61 | def test_get_commits_since_commit_includes_that_commit(checked_out_repo): 62 | previous_sha = checked_out_repo.cloneable_with_commits.commits[0].sha 63 | all_commits = checked_out_repo.repo_parser.get_commits(previous_sha) 64 | shas = [commit.sha for commit in all_commits] 65 | assert previous_sha in shas 66 | assert len(shas) == len(set(shas)) 67 | 68 | 69 | def test_get_commit(checked_out_repo): 70 | # Smoke test 71 | first_commit = checked_out_repo.cloneable_with_commits.commits[0] 72 | sha = first_commit.sha 73 | ret = checked_out_repo.repo_parser.get_commit(sha) 74 | assert ret == first_commit 75 | -------------------------------------------------------------------------------- /git_code_debt/repo_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import subprocess 5 | import tempfile 6 | from collections.abc import Generator 7 | from typing import NamedTuple 8 | 9 | from git_code_debt.util.iter import chunk_iter 10 | from git_code_debt.util.subprocess import cmd_output 11 | from git_code_debt.util.subprocess import cmd_output_b 12 | 13 | 14 | class Commit(NamedTuple): 15 | sha: str 16 | date: int 17 | 18 | 19 | BLANK_COMMIT = Commit('0' * 40, 0) 20 | 21 | COMMIT_FORMAT = '--format=%H%n%ct' 22 | 23 | 24 | class RepoParser: 25 | 26 | def __init__(self, git_repo: str) -> None: 27 | self.git_repo = git_repo 28 | self.tempdir: str | None = None 29 | 30 | @contextlib.contextmanager 31 | def repo_checked_out(self) -> Generator[None]: 32 | assert not self.tempdir 33 | with tempfile.TemporaryDirectory(suffix='temp-repo') as self.tempdir: 34 | try: 35 | subprocess.check_call(( 36 | 'git', 'clone', 37 | '--no-checkout', '--quiet', '--shared', 38 | self.git_repo, self.tempdir, 39 | )) 40 | yield 41 | finally: 42 | self.tempdir = None 43 | 44 | def get_commit(self, sha: str) -> Commit: 45 | output = cmd_output( 46 | 'git', 'show', COMMIT_FORMAT, sha, cwd=self.tempdir, 47 | ) 48 | sha, date = output.splitlines()[:2] 49 | 50 | return Commit(sha, int(date)) 51 | 52 | def get_commits(self, since_sha: str | None = None) -> list[Commit]: 53 | """Returns a list of Commit objects. 54 | 55 | Args: 56 | since_sha - (optional) A sha to search from 57 | """ 58 | assert self.tempdir 59 | 60 | cmd = ['git', 'log', '--first-parent', '--reverse', COMMIT_FORMAT] 61 | if since_sha: 62 | commits = [self.get_commit(since_sha)] 63 | cmd.append(f'{since_sha}..HEAD') 64 | else: 65 | commits = [] 66 | cmd.append('HEAD') 67 | 68 | output = cmd_output(*cmd, cwd=self.tempdir) 69 | 70 | for sha, date in chunk_iter(output.splitlines(), 2): 71 | commits.append(Commit(sha, int(date))) 72 | 73 | return commits 74 | 75 | def get_original_commit(self, sha: str) -> bytes: 76 | assert self.tempdir 77 | return cmd_output_b('git', 'show', sha, cwd=self.tempdir) 78 | 79 | def get_commit_diff(self, previous_sha: str, sha: str) -> bytes: 80 | assert self.tempdir 81 | return cmd_output_b( 82 | 'git', 'diff', previous_sha, sha, '--no-renames', 83 | cwd=self.tempdir, 84 | ) 85 | -------------------------------------------------------------------------------- /git_code_debt/server/static/js/widget.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | var thisScript = $('script[src$="/static/js/widget.js"]'), 3 | debtDomain = thisScript[0].src.split('/static/js/widget.js')[0], 4 | iframe = $('