diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9dae20c9..63126916 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,27 +1,13 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile { - "name": "Existing Dockerfile", - "build": { - // Sets the run context to one level up instead of the .devcontainer folder. - "context": "..", - "dockerfile": "../Dockerfile" - }, + "name": "Existing Dockerfile", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, - // Features to add to the dev container. More info: https://containers.dev/features. - "features": { - "ghcr.io/devcontainers/features/common-utils:2": {} - }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {} + }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "echo hello", - - // Configure tool-specific properties. - // "customizations": {}, - - // Connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. - "remoteUser": "devcontainer" + "remoteUser": "devcontainer" } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1f934fb3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Application specific files +test_*.py + +# Python +*.pyc +__pycache__/ +*.pyo +*.pyd + +# Common +*.md +docker-compose.yml +Dockerfile* +.env* +Makefile + +# Logs +logs +*.log + +# IDEs +.vscode/ +.idea/ + +# Dependency directories +node_modules/ +.venv/ + +## Cache directories +.parcel-cache + +# git +.git +.gitattributes +.gitignore +.github/ diff --git a/.env-example b/.env-example index 03429dd8..ebc938d0 100644 --- a/.env-example +++ b/.env-example @@ -1,4 +1,18 @@ -GH_TOKEN = " " -SEARCH_QUERY = "repo:owner/repo is:open is:issue" -LABELS_TO_MEASURE = "waiting-for-review,waiting-for-manager" +GH_APP_ID="" +GH_APP_INSTALLATION_ID="" +GH_APP_PRIVATE_KEY="" +GITHUB_APP_ENTERPRISE_ONLY="" +GH_ENTERPRISE_URL = "" +GH_TOKEN = "" +HIDE_AUTHOR = "false" +HIDE_ITEMS_CLOSED_COUNT="false" +HIDE_LABEL_METRICS = "false" +HIDE_TIME_TO_ANSWER = "false" +HIDE_TIME_TO_CLOSE = "false" +HIDE_TIME_TO_FIRST_RESPONSE = "false" IGNORE_USERS = "user1,user2" +LABELS_TO_MEASURE = "waiting-for-review,waiting-for-manager" +NON_MENTIONING_LINKS = "false" +OUTPUT_FILE = "" +REPORT_TITLE = "Issue Metrics" +SEARCH_QUERY = "repo:owner/repo is:open is:issue" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e5f6f752..a506e1eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @zkoppert +* @github/ospo-github-actions diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..a0e96428 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +--- +name: Bug report +description: Create a report to help us improve +labels: + - bug +body: + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + attributes: + label: To Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + + - type: textarea + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + validations: + required: false + + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f9eb1679 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false + +contact_links: + - name: Ask a question + url: https://github.com/github/issue-metrics/discussions/new + about: Ask a question or start a discussion + - name: GitHub OSPO GitHub Action Overall Issue + url: https://github.com/github/github-ospo/issues/new + about: File issue for multiple GitHub OSPO GitHub Actions diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..6bfc5a7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +--- +name: Feature request +description: Suggest an idea for this project +labels: + - enhancement +body: + - type: textarea + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is. Please describe. + placeholder: | + Ex. I'm always frustrated when [...] + validations: + required: false + + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..fd519888 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,30 @@ +# Copilot Instructions + +This is a GitHub Action that searches for issues/pull requests/discussions in a repository, measures several metrics, and generates a report in form of a GitHub issue. The issues/pull requests/discussions to search for can be filtered by using a search query. + +## Code Standards + +### Required Before Each Commit + +- Run `make lint` before committing any changes to ensure proper code linting and formatting. + +### Development Flow + +- Lint: `make lint` +- Test: `make test` + +## Repository Structure + +- `Makefile`: Contains commands for linting, testing, and other tasks +- `requirements.txt`: Python dependencies for the project +- `requirements-test.txt`: Python dependencies for testing +- `README.md`: Project documentation and setup instructions +- `setup.py`: Python package setup configuration +- `test_*.py`: Python test files matching the naming convention for test discovery + +## Key Guidelines + +1. Follow Python best practices and idiomatic patterns +2. Maintain existing code structure and organization +3. Write unit tests for new functionality. +4. Document changes to environment variables in the `README.md` file. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0782e612..c136da44 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,39 @@ --- -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "daily" + interval: "weekly" + commit-message: + prefix: "chore(deps)" + groups: + dependencies: + applies-to: version-updates + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" + commit-message: + prefix: "chore(deps)" + groups: + dependencies: + applies-to: version-updates + update-types: + - "minor" + - "patch" - package-ecosystem: "docker" directory: "/" schedule: - interval: "daily" + interval: "weekly" + commit-message: + prefix: "chore(deps)" + groups: + dependencies: + applies-to: version-updates + update-types: + - "minor" + - "patch" diff --git a/.github/linters/.flake8 b/.github/linters/.flake8 new file mode 100644 index 00000000..de0917a5 --- /dev/null +++ b/.github/linters/.flake8 @@ -0,0 +1,5 @@ +[flake8] +exclude = venv,.venv,.git,__pycache__ +extend-ignore = C901, E203 +max-line-length = 150 +statistics = True diff --git a/.github/linters/.isort.cfg b/.github/linters/.isort.cfg new file mode 100644 index 00000000..f238bf7e --- /dev/null +++ b/.github/linters/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile = black diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json new file mode 100644 index 00000000..475c2ca1 --- /dev/null +++ b/.github/linters/.jscpd.json @@ -0,0 +1,26 @@ +{ + "exitCode": 0, + "ignore": [ + "**/.venv/**", + "**/.coverage*", + "**/.devcontainer/**", + "**/.git/**", + "**/.gitignore", + "**/.github/**", + "**/.mypy_cache/**", + "**/.pytest_cache/**", + "**/__pycache__/**", + "**/build/**", + "**/dist/**", + "**/docs/**", + "**/migrations/**", + "**/node_modules/**", + "**/report/**", + "**/test_*.py", + "**/venv/**", + "**/*.md", + "**/Dockerfile", + "**/LICENSE", + "**/Makefile" + ] +} diff --git a/.github/linters/.mypy.ini b/.github/linters/.mypy.ini new file mode 100644 index 00000000..f0d4703d --- /dev/null +++ b/.github/linters/.mypy.ini @@ -0,0 +1,5 @@ +[mypy] +disable_error_code = attr-defined, import-not-found + +[mypy-github3.*] +ignore_missing_imports = True diff --git a/.github/linters/.python-lint b/.github/linters/.python-lint new file mode 100644 index 00000000..758f92c8 --- /dev/null +++ b/.github/linters/.python-lint @@ -0,0 +1,645 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.11 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=bad-inline-option, + deprecated-pragma, + duplicate-code, + locally-disabled, + file-ignored, + import-error, + line-too-long, + raw-checker-failed, + suppressed-message, + too-few-public-methods, + too-many-arguments, + too-many-function-args, + too-many-branches, + too-many-locals, + too-many-positional-arguments, + too-many-statements, + useless-suppression, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + wrong-import-order + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: text, parseable, colorized, +# json2 (improved json format), json (old json format) and msvs (visual +# studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/.github/pull_request-template.md b/.github/pull_request-template.md deleted file mode 100644 index 3df7a0b4..00000000 --- a/.github/pull_request-template.md +++ /dev/null @@ -1,17 +0,0 @@ - - - - - -## Proposed Changes - - -## Readiness Checklist - -### Author/Contributor -- [ ] If documentation is needed for this change, has that been included in this pull request -- [ ] run `make lint` and fix any issues that you have introduced -- [ ] run `make test` and ensure you have test coverage for the lines you are introducing - -### Reviewer -- [ ] Label as either `bug`, `documentation`, `enhancement`, `infrastructure`, or `breaking` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e0a63f42 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +# Pull Request + + + +## Proposed Changes + + + +## Readiness Checklist + +### Author/Contributor + +- [ ] If documentation is needed for this change, has that been included in this pull request +- [ ] run `make lint` and fix any issues that you have introduced +- [ ] run `make test` and ensure you have test coverage for the lines you are introducing +- [ ] If publishing new data to the public (scorecards, security scan results, code quality results, live dashboards, etc.), please request review from `@jeffrey-luszcz` + +### Reviewer + +- [ ] Label as either `fix`, `documentation`, `enhancement`, `infrastructure`, `maintenance`, or `breaking` diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 24300e17..f786f739 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,41 +1,72 @@ --- -name-template: 'v$RESOLVED_VERSION' -tag-template: 'v$RESOLVED_VERSION' +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" template: | # Changelog $CHANGES - - See details of [all code changes](https://github.com/github/issue-metrics/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) since previous release - + + See details of [all code changes](https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) since previous release + categories: - - title: '🚀 Features' + - title: "🚀 Features" labels: - - 'feature' - - 'enhancement' - - title: '🐛 Bug Fixes' + - "feature" + - "enhancement" + - title: "🐛 Bug Fixes" labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: '🧰 Maintenance' + - "fix" + - "bugfix" + - "bug" + - title: "🧰 Maintenance" labels: - - 'infrastructure' - - 'automation' - - 'documentation' - - 'dependencies' - - title: '🏎 Performance' - label: 'performance' -change-template: '- $TITLE @$AUTHOR (#$NUMBER)' + - "infrastructure" + - "automation" + - "documentation" + - "dependencies" + - "maintenance" + - "revert" + - title: "🏎 Performance" + label: "performance" +change-template: "- $TITLE @$AUTHOR (#$NUMBER)" version-resolver: major: labels: - - 'breaking' + - "breaking" + - "major" minor: labels: - - 'enhancement' + - "enhancement" + - "feature" + - "minor" patch: labels: - - 'bug' - - 'maintenance' - - 'documentation' + - "documentation" + - "fix" + - "maintenance" + - "patch" default: patch +autolabeler: + - label: "automation" + title: + - "/^(build|ci|perf|refactor|test).*/i" + - label: "enhancement" + title: + - "/^(style).*/i" + - label: "documentation" + title: + - "/^(docs).*/i" + - label: "feature" + title: + - "/^(feat).*/i" + - label: "fix" + title: + - "/^(fix).*/i" + - label: "infrastructure" + title: + - "/^(infrastructure).*/i" + - label: "maintenance" + title: + - "/^(chore|maintenance).*/i" + - label: "revert" + title: + - "/^(revert).*/i" diff --git a/.github/scripts/env_vars_check.sh b/.github/scripts/env_vars_check.sh new file mode 100755 index 00000000..ecee7bed --- /dev/null +++ b/.github/scripts/env_vars_check.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Find all test_*.py files +files=$(find . -name "test_*.py") +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Loop through each file +for file in $files; do + # Search for instances of get_env_vars() with no arguments + result=$(grep -n "get_env_vars()" "$file") + + # If any instances are found, print the file name and line number + if [ -n "$result" ]; then + echo "Found in $file:" + echo "$result" + echo -e "${RED}ERROR: get_env_vars() should always set test=True in test*.py files.${NC}" + exit 1 + fi +done +echo -e " ${GREEN}PASS:${NC} All test*.py files call get_env_vars() with test=True." diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml new file mode 100644 index 00000000..48d4f70c --- /dev/null +++ b/.github/workflows/auto-labeler.yml @@ -0,0 +1,18 @@ +--- +name: Auto Labeler +on: + # pull_request_target event is required for autolabeler to support all PRs including forks + pull_request_target: + types: [opened, reopened, edited, synchronize] +permissions: + contents: read +jobs: + main: + permissions: + contents: write + pull-requests: write + uses: github/ospo-reusable-workflows/.github/workflows/auto-labeler.yaml@ebb4e218b75c6043139fd69a4c9bb5a465fb696b + with: + config-name: release-drafter.yml + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 18dc5d2f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,68 +0,0 @@ ---- -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '27 19 * * 5' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/contributor_report.yaml b/.github/workflows/contributor_report.yaml index 80e796d7..123abb10 100644 --- a/.github/workflows/contributor_report.yaml +++ b/.github/workflows/contributor_report.yaml @@ -2,7 +2,7 @@ name: Monthly contributor report on: workflow_dispatch: schedule: - - cron: '3 2 1 * *' + - cron: "3 2 1 * *" permissions: issues: write @@ -13,33 +13,32 @@ jobs: runs-on: ubuntu-latest steps: - - - name: Get dates for last month - shell: bash - run: | - # Calculate the first day of the previous month - start_date=$(date -d "last month" +%Y-%m-01) - - # Calculate the last day of the previous month - end_date=$(date -d "$start_date +1 month -1 day" +%Y-%m-%d) - - #Set an environment variable with the date range - echo "START_DATE=$start_date" >> "$GITHUB_ENV" - echo "END_DATE=$end_date" >> "$GITHUB_ENV" - - - name: Run contributor action - uses: github/contributors@v1 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - START_DATE: ${{ env.START_DATE }} - END_DATE: ${{ env.END_DATE }} - REPOSITORY: github/issue-metrics - SPONSOR_INFO: "true" - - - name: Create issue - uses: peter-evans/create-issue-from-file@v5 - with: - title: Monthly contributor report - token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./contributors.md - assignees: zkoppert + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + start_date=$(date -d "last month" +%Y-%m-01) + + # Calculate the last day of the previous month + end_date=$(date -d "$start_date +1 month -1 day" +%Y-%m-%d) + + #Set an environment variable with the date range + echo "START_DATE=$start_date" >> "$GITHUB_ENV" + echo "END_DATE=$end_date" >> "$GITHUB_ENV" + + - name: Run contributor action + uses: github/contributors@69e531b620b7e5b0fad2e9823681607b54db447a # v1.5.11 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + START_DATE: ${{ env.START_DATE }} + END_DATE: ${{ env.END_DATE }} + REPOSITORY: github/issue-metrics + SPONSOR_INFO: "true" + + - name: Create issue + uses: peter-evans/create-issue-from-file@e8ef132d6df98ed982188e460ebb3b5d4ef3a9cd # v5.0.1 + with: + title: Monthly contributor report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./contributors.md + assignees: zkoppert diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..cddb8d9d --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,38 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +# Set the permissions to the lowest permissions possible needed for your steps. +# Copilot will be given its own token for its operations. +permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + + - name: Set up Python + uses: actions/setup-python@v5.6.0 + with: + python-version: 3.12 + + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-test.txt diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 22604a23..9f65a353 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -3,17 +3,17 @@ name: Docker Image CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] -jobs: +permissions: + contents: read +jobs: build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build the Docker image - run: docker build . --file Dockerfile --platform linux/amd64 --tag issue-metrics:"$(date +%s)" + - uses: actions/checkout@v5.0.0 + - name: Build the Docker image + run: docker build . --file Dockerfile --platform linux/amd64 --tag issue-metrics:"$(date +%s)" diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml new file mode 100644 index 00000000..fe5ec0d6 --- /dev/null +++ b/.github/workflows/linter.yaml @@ -0,0 +1,37 @@ +--- +name: Lint Code Base + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint Code Base + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + statuses: write + steps: + - name: Checkout Code + uses: actions/checkout@v5.0.0 + with: + # Full git history is needed to get a proper + # list of changed files within `super-linter` + fetch-depth: 0 + - uses: actions/setup-python@v5.6.0 + with: + python-version: "3.12" + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-test.txt + - name: Lint Code Base + uses: super-linter/super-linter@5119dcd8011e92182ce8219d9e9efc82f16fddb6 # v8.0.0 + env: + DEFAULT_BRANCH: main + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTIONS_COMMAND_ARGS: -shellcheck= diff --git a/.github/workflows/major-version-updater.yml b/.github/workflows/major-version-updater.yml deleted file mode 100644 index 4ace19e0..00000000 --- a/.github/workflows/major-version-updater.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- - name: Major Version Updater - # Whenever a new release is made, push a major version tag - on: - release: - types: [ published ] - - jobs: - update-major-version-tag: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: version - id: version - run: | - tag=${GITHUB_REF/refs\/tags\//} - version=${tag#v} - major=${version%%.*} - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "major=${major}" >> "$GITHUB_OUTPUT" - - - name: force update major tag - run: | - git tag v${{ steps.version.outputs.major }} - git push origin refs/tags/v${{ steps.version.outputs.major }} -f \ No newline at end of file diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..77afc54b --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,17 @@ +## Reference: https://github.com/amannn/action-semantic-pull-request +--- +name: "Lint PR Title" +on: + pull_request_target: + types: [opened, reopened, edited, synchronize] +permissions: + contents: read +jobs: + main: + permissions: + contents: read + pull-requests: read + statuses: write + uses: github/ospo-reusable-workflows/.github/workflows/pr-title.yaml@ebb4e218b75c6043139fd69a4c9bb5a465fb696b + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 43c3727e..07a7ff7b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -6,32 +6,32 @@ name: Python package on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python-version: [3.11, 3.12 ] + python-version: [3.11, 3.12] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pylint pytest pytest-cov - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 and pylint - run: | - make lint - - name: Test with pytest - run: | - make test + - uses: actions/checkout@v5.0.0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-test.txt + - name: Lint with flake8 and pylint + run: | + make lint + - name: Test with pytest + run: | + make test diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 14e0b9b5..00000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: Release Drafter - -on: - push: - # branches to consider in the event; optional, defaults to all - branches: - - main - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5cc0ca03 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +--- +name: Release +on: + workflow_dispatch: + pull_request_target: + types: [closed] + branches: [main] +permissions: + contents: read +jobs: + release: + permissions: + contents: write + pull-requests: read + uses: github/ospo-reusable-workflows/.github/workflows/release.yaml@ebb4e218b75c6043139fd69a4c9bb5a465fb696b + with: + publish: true + release-config-name: release-drafter.yml + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + release_image: + needs: release + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: github/ospo-reusable-workflows/.github/workflows/release-image.yaml@ebb4e218b75c6043139fd69a4c9bb5a465fb696b + with: + image-name: ${{ github.repository_owner }}/issue_metrics + full-tag: ${{ needs.release.outputs.full-tag }} + short-tag: ${{ needs.release.outputs.short-tag }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + image-registry: ghcr.io + image-registry-username: ${{ github.actor }} + image-registry-password: ${{ secrets.GITHUB_TOKEN }} + release_discussion: + needs: release + permissions: + contents: read + discussions: write + uses: github/ospo-reusable-workflows/.github/workflows/release-discussion.yaml@ebb4e218b75c6043139fd69a4c9bb5a465fb696b + with: + full-tag: ${{ needs.release.outputs.full-tag }} + body: ${{ needs.release.outputs.body }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + discussion-repository-id: ${{ secrets.RELEASE_DISCUSSION_REPOSITORY_ID }} + discussion-category-id: ${{ secrets.RELEASE_DISCUSSION_CATEGORY_ID }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..c9ca2c6e --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,47 @@ +--- +name: Scorecard supply-chain security +on: + workflow_dispatch: + # For Branch-Protection check (for repo branch protection or rules). + # Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: "29 11 * * 6" + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Merge to Main Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + + steps: + - name: "Checkout code" + uses: actions/checkout@v5.0.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: "Upload artifact" + uses: actions/upload-artifact@v4.6.2 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.24.9 + with: + sarif_file: results.sarif diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index bad2aeba..78e8210b 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -1,7 +1,7 @@ -name: 'Close stale issues' +name: "Close stale issues" on: schedule: - - cron: '30 1 * * *' + - cron: "30 1 * * *" permissions: issues: write @@ -11,10 +11,10 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v9.1.0 with: - stale-issue-message: 'This issue is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.' - close-issue-message: 'This issue was closed because it has been stalled for 35 days with no activity.' + stale-issue-message: "This issue is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days." + close-issue-message: "This issue was closed because it has been stalled for 35 days with no activity." days-before-stale: 21 days-before-close: 14 days-before-pr-close: -1 diff --git a/.gitignore b/.gitignore index 0f01f758..4f454b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Output files -issue_metrics.md +issue_metrics*.md issue_metrics.json # Byte-compiled / optimized / DLL files @@ -10,6 +10,9 @@ __pycache__/ # C extensions *.so +# JSCPD +report/ + # Distribution / packaging .Python build/ diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index a6db1df5..00000000 --- a/.pylintrc +++ /dev/null @@ -1,16 +0,0 @@ -[MAIN] -ignore=.git, - __pycache__, - venv, - .venv, - - -[MESSAGES CONTROL] -disable= - redefined-argument-from-local, - too-many-arguments, - too-few-public-methods, - duplicate-code, - too-many-locals, - too-many-branches, - line-too-long, diff --git a/.vscode/settings.json b/.vscode/settings.json index 60561a45..705ed9b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,9 @@ { - "python.testing.pytestArgs": [ - "." - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" - }, - "python.formatting.provider": "none" -} \ No newline at end of file + "python.testing.pytestArgs": ["."], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08770fe1..3299ec78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,6 @@ + # Contributing to issue-metrics First off, thanks for taking the time to contribute! :heart: @@ -7,6 +8,7 @@ First off, thanks for taking the time to contribute! :heart: All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us project owners and smooth out the experience for all involved. The team looks forward to your contributions. :tada: + ## Table of Contents - [I Have a Question](#i-have-a-question) @@ -23,18 +25,20 @@ If you then still feel the need to ask a question and need clarification, we rec - Open an [Issue](https://github.com/github/issue-metrics/issues/new). - Provide as much context as you can about what you're running into. -- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. +- Provide project and platform versions (Node.js, npm, etc), depending on what seems relevant. We will then take care of the issue as soon as possible. ## I Want To Contribute > ### Legal Notice +> > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. ## Reporting Bugs + ### Before Submitting a Bug Report A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. @@ -50,26 +54,17 @@ A good bug report shouldn't leave others needing to chase you up for more inform - Can you reliably reproduce the issue? And can you also reproduce it with older versions? -### How Do I Submit a Good Bug Report? - -We use GitHub issues to track bugs and errors. If you run into an issue with the project: - -- Open an [Issue](https://github.com/github/issue-metrics/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) -- Explain the behavior you would expect and the actual behavior. -- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. -- Provide the information you collected in the previous section. -Once it's filed: +### How Do I Submit a Good Bug Report? -- The project team will label the issue accordingly. -- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. -- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be implemented by someone. +Please submit a bug report using our [GitHub Issues template](https://github.com/github/issue-metrics/issues/new?template=bug_report.yml). ## Suggesting Enhancements This section guides you through submitting an enhancement suggestion for issue-metrics, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + ### Before Submitting an Enhancement - Make sure that you are using the latest version. @@ -78,28 +73,17 @@ This section guides you through submitting an enhancement suggestion for issue-m - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature or to develop the feature yourself and contribute it to the project. + ### How Do I Submit a Good Enhancement Suggestion? -Enhancement suggestions are tracked as [GitHub issues](https://github.com/github/issue-metrics/issues). +Please submit an enhancement suggestion using our [GitHub Issues template](https://github.com/github/issue-metrics/issues/new?template=feature_request.yml). -- Use a **clear and descriptive title** for the issue to identify the suggestion. -- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. -- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. -- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. -- **Explain why this enhancement would be useful** to most issue-metrics users. +### Pull Request Standards + +We are using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to standardize our pull request titles. This allows us to automatically generate labels and changelogs and follow semantic versioning. Please follow the commit message format when creating a pull request. What pull request title prefixes are expected are in the [pull_request_template.md](.github/pull_request_template.md) that is shown when creating a pull request. ## Releases -To release a new version, maintainers are to release new versions following semantic versioning and via GitHub Releases. -Once the code is ready to release please do the following -1. Create a [GitHub release](https://github.com/github/issue-metrics/releases) based off the current draft and review release notes -2. Ensure that the versioning is correct given the content of the release -3. Check the box to release it to the GitHub Marketplace -4. Publish the release -5. Clone the repository at the release tag locally or in a codespace -6. Authenticate to ghcr.io using [these instructions](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry) -7. `docker build -t ghcr.io/github/issue-metrics:v1 .` where v1 is the current major version number -8. `docker build -t ghcr.io/github/issue-metrics:v1.0.0 .` where v1.0.0 is the full version number -9. `docker push ghcr.io/github/issue-metrics:v1` where v1 is the current major version number -10. `docker push ghcr.io/github/issue-metrics:v1.0.0` where v1.0.0 is the full version number -11. Update the `action.yml` and `README.md` instructions to point to the new docker container if its a major version number change +Releases are automated if a pull request is labelled with our [SemVer related labels](.github/release-drafter.yml) or with the `vuln` or `release` labels. + +You can also manually initiate a release you can do so through the GitHub Actions UI. If you have permissions to do so, you can navigate to the [Actions tab](https://github.com/github/issue-metrics/actions/workflows/release.yml) and select the `Run workflow` button. This will allow you to select the branch to release from and the version to release. diff --git a/Dockerfile b/Dockerfile index c47ad10f..b12d6ce5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM python:3.12-slim +#checkov:skip=CKV_DOCKER_2 +#checkov:skip=CKV_DOCKER_3 +FROM python:3.13-slim@sha256:27f90d79cc85e9b7b2560063ef44fa0e9eaae7a7c3f5a9f74563065c5477cc24 LABEL com.github.actions.name="issue-metrics" \ com.github.actions.description="Gather metrics on issues/prs/discussions such as time to first response, count of issues opened, closed, etc." \ com.github.actions.icon="check-square" \ @@ -15,8 +17,7 @@ COPY requirements.txt *.py /action/workspace/ RUN python3 -m pip install --no-cache-dir -r requirements.txt \ && apt-get -y update \ - && apt-get -y install --no-install-recommends git-all=1:2.39.2-1.1 \ - && apt-get install build-essential -y --no-install-recommends \ + && apt-get -y install --no-install-recommends git=1:2.47.2-0.2 \ && rm -rf /var/lib/apt/lists/* CMD ["/action/workspace/issue_metrics.py"] diff --git a/Makefile b/Makefile index 0437bed3..c94fa87c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test test: pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=80 --cov-report term-missing + .github/scripts/env_vars_check.sh .PHONY: clean clean: @@ -9,7 +10,10 @@ clean: .PHONY: lint lint: # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=venv,.venv,.git,__pycache__ + flake8 . --config=.github/linters/.flake8 --count --select=E9,F63,F7,F82 --show-source # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=15 --max-line-length=127 --statistics --exclude=venv,.venv,.git,__pycache__ - pylint --rcfile=.pylintrc --fail-under=9.0 *.py + flake8 . --config=.github/linters/.flake8 --count --exit-zero --max-complexity=15 --max-line-length=127 + isort --settings-file=.github/linters/.isort.cfg . + pylint --rcfile=.github/linters/.python-lint --fail-under=9.0 *.py + mypy --config-file=.github/linters/.mypy.ini *.py + black . diff --git a/README.md b/README.md index 9a72180d..baddaba5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Issue Metrics Action -[![CodeQL](https://github.com/github/issue-metrics/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/codeql-analysis.yml) [![Docker Image CI](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml) [![Python package](https://github.com/github/issue-metrics/actions/workflows/python-package.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/python-package.yml) +[![CodeQL](https://github.com/github/issue-metrics/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/github-code-scanning/codeql) +[![Docker Image CI](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml) +[![Python package](https://github.com/github/issue-metrics/actions/workflows/python-package.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/python-package.yml) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/github/issue-metrics/badge)](https://scorecard.dev/viewer/?uri=github.com/github/issue-metrics) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9501/badge)](https://www.bestpractices.dev/projects/9501) This is a GitHub Action that searches for issues/pull requests/discussions in a repository, measures several metrics, and generates a report in form of a GitHub issue. The issues/pull requests/discussions to search for can be filtered by using a search query. @@ -10,16 +14,17 @@ Feel free to inquire about its usage by creating an issue in this repository. ## Available Metrics -| Metric | Description | -|--------|-------------| -|Time to First Response | The duration from creation to the initial comment or review.*| -|Time to Close | The period from creation to closure.*| -|Time to Answer (Discussions Only) | The time from creation to an answer.| -|Time in Label | The duration from label application to removal, requires `LABELS_TO_MEASURE` env variable.| +| Metric | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------ | +| Time to First Response | The duration from creation to the initial comment or review.\* | +| Time to Close | The period from creation to closure.\* | +| Time to Answer (Discussions Only) | The time from creation to an answer. | +| Time in Label | The duration from label application to removal, requires `LABELS_TO_MEASURE` env variable. | +| Time in Draft (PRs Only) | The duration from creation to the PR being marked as ready for review. | -*For pull requests, these metrics exclude the time the PR was in draft mode. +\*For pull requests, these metrics exclude the time the PR was in draft mode. -*For issues and pull requests, comments by issue/pull request author's and comments by bots are excluded. +\*For issues and pull requests, comments by issue/pull request author's and comments by bots are excluded. To find syntax for search queries, check out the documentation on [searching issues and pull requests](https://docs.github.com/en/issues/tracking-your-work-with-issues/filtering-and-searching-issues-and-pull-requests) or [searching discussions](https://docs.github.com/en/search-github/searching-on-github/searching-discussions). @@ -42,42 +47,44 @@ name: Monthly issue metrics on: workflow_dispatch: schedule: - - cron: '3 2 1 * *' + - cron: "3 2 1 * *" permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - - name: Get dates for last month - shell: bash - run: | - # Calculate the first day of the previous month - first_day=$(date -d "last month" +%Y-%m-01) - - # Calculate the last day of the previous month - last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) - - #Set an environment variable with the date range - echo "$first_day..$last_day" - echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - - - name: Run issue-metrics tool - uses: github/issue-metrics@v2 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' - - - name: Create issue - uses: peter-evans/create-issue-from-file@v5 - with: - title: Monthly issue metrics report - token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./issue_metrics.md + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + first_day=$(date -d "last month" +%Y-%m-01) + + # Calculate the last day of the previous month + last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) + + #Set an environment variable with the date range + echo "$first_day..$last_day" + echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" + + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' + + - name: Create issue + uses: peter-evans/create-issue-from-file@v5 + with: + title: Monthly issue metrics report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md ``` ## Example use cases @@ -91,14 +98,23 @@ jobs: If you need support using this project or have questions about it, please [open up an issue in this repository](https://github.com/github/issue-metrics/issues). Requests made directly to GitHub staff or support team will be redirected here to open an issue. GitHub SLA's and support/services contracts do not apply to this repository. +### OSPO GitHub Actions as a Whole + +All feedback regarding our GitHub Actions, as a whole, should be communicated through [issues on our github-ospo repository](https://github.com/github/github-ospo/issues/new). + ## Use as a GitHub Action 1. Create a repository to host this GitHub Action or select an existing repository. This is easiest if it is the same repository as the one you want to measure metrics on. 2. Select a best fit workflow file from the [examples directory](./docs/example-workflows.md) for your use case. 3. Copy that example into your repository (from step 1) and into the proper directory for GitHub Actions: `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/issue-metrics.yml`) 4. Edit the values (`SEARCH_QUERY`, `assignees`) from the sample workflow with your information. See the [SEARCH_QUERY](./docs/search-query.md) section for more information on how to configure the search query. -5. If you are running metrics on a repository other than the one where the workflow file is going to be, then update the value of `GH_TOKEN`. Do this by creating a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the repo and write issues. Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `GH_TOKEN` and the value of the secret the API token. Then finally update the workflow file to use that repository secret by changing `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}` to `GH_TOKEN: ${{ secrets.GH_TOKEN }}`. The name of the secret can really be anything. It just needs to match between when you create the secret name and when you refer to it in the workflow file. -6. If you want the resulting issue with the metrics in it to appear in a different repository other than the one the workflow file runs in, update the line `token: ${{ secrets.GITHUB_TOKEN }}` with your own GitHub API token stored as a repository secret. This process is the same as described in the step above. More info on creating secrets can be found [here](https://docs.github.com/en/actions/security-guides/encrypted-secrets). +5. If you are running metrics on a repository other than the one where the workflow file is going to be, then update the value of `GH_TOKEN`. + - Do this by creating a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the repository and write issues. + - Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `GH_TOKEN` and the value of the secret the API token. + - Then finally update the workflow file to use that repository secret by changing `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}` to `GH_TOKEN: ${{ secrets.GH_TOKEN }}`. The name of the secret can really be anything. It just needs to match between when you create the secret name and when you refer to it in the workflow file. + - Help on verifying your token's access to your repository [in the docs directory](docs/verify-token-access-to-repository.md) +6. If you want the resulting issue with the metrics in it to appear in a different repository other than the one the workflow file runs in, update the line `token: ${{ secrets.GITHUB_TOKEN }}` with your own GitHub API token stored as a repository secret. + - This process is the same as described in the step above. More info on creating secrets can be found [in the GitHub docs security guide on encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). 7. Commit the workflow file to the default branch (often `master` or `main`) 8. Wait for the action to trigger based on the `schedule` entry or manually trigger the workflow as shown in the [documentation](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow). @@ -106,34 +122,86 @@ If you need support using this project or have questions about it, please [open Below are the allowed configuration options: -| field | required | default | description | -|-----------------------|----------|---------|-------------| -| `GH_TOKEN` | True | | The GitHub Token used to scan the repository. Must have read access to all repository you are interested in scanning. | -| `SEARCH_QUERY` | True | | The query by which you can filter issues/prs which must contain a `repo:`, `org:`, `owner:`, or a `user:` entry. For discussions, include `type:discussions` in the query. | -| `LABELS_TO_MEASURE` | False | | A comma separated list of labels to measure how much time the label is applied. If not provided, no labels durations will be measured. Not compatible with discussions at this time. | -| `HIDE_AUTHOR` | False | | If set to any value, the author will not be displayed in the generated markdown file. | -| `HIDE_TIME_TO_FIRST_RESPONSE` | False | | If set to any value, the time to first response will not be displayed in the generated markdown file. | -| `HIDE_TIME_TO_CLOSE` | False | | If set to any value, the time to close will not be displayed in the generated markdown file. | -| `HIDE_TIME_TO_ANSWER` | False | | If set to any value, the time to answer a discussion will not be displayed in the generated markdown file. | -| `HIDE_LABEL_METRICS` | False | | If set to any value, the time in label metrics will not be displayed in the generated markdown file. | -| `IGNORE_USERS` | False | | A comma separated list of users to ignore when calculating metrics. (ie. `IGNORE_USERS: 'user1,user2'`). To ignore bots, append `[bot]` to the user (ie. `IGNORE_USERS: 'github-actions[bot]'`) | +#### Authentication + +This action can be configured to authenticate with GitHub App Installation or Personal Access Token (PAT). If all configuration options are provided, the GitHub App Installation configuration has precedence. You can choose one of the following methods to authenticate: + +##### GitHub App Installation + +| field | required | default | description | +| ---------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GH_APP_ID` | True | `""` | GitHub Application ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GH_APP_INSTALLATION_ID` | True | `""` | GitHub Application Installation ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GH_APP_PRIVATE_KEY` | True | `""` | GitHub Application Private Key. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GITHUB_APP_ENTERPRISE_ONLY` | False | false | Set this input to `true` if your app is created in GHE and communicates with GHE. | + +##### Personal Access Token (PAT) + +| field | required | default | description | +| ---------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------- | +| `GH_TOKEN` | True | `""` | The GitHub Token used to scan the repository. Must have read access to all repository you are interested in scanning. | + +#### Other Configuration Options + +| field | required | default | description | +| ----------------------------- | -------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GH_ENTERPRISE_URL` | False | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com | +| `RATE_LIMIT_BYPASS` | False | `false` | If set to `true`, the rate limit will be bypassed. This is useful if being run on an local GitHub server with rate limiting disabled. | +| `HIDE_ASSIGNEE` | False | False | If set to `true`, the assignee will not be displayed in the generated Markdown file. | +| `HIDE_AUTHOR` | False | False | If set to `true`, the author will not be displayed in the generated Markdown file. | +| `HIDE_ITEMS_CLOSED_COUNT` | False | False | If set to `true`, the number of items closed metric will not be displayed in the generated Markdown file. | +| `HIDE_LABEL_METRICS` | False | False | If set to `true`, the time in label metrics will not be displayed in the generated Markdown file. | +| `HIDE_TIME_TO_ANSWER` | False | False | If set to `true`, the time to answer a discussion will not be displayed in the generated Markdown file. | +| `HIDE_TIME_TO_CLOSE` | False | False | If set to `true`, the time to close will not be displayed in the generated Markdown file. | +| `HIDE_TIME_TO_FIRST_RESPONSE` | False | False | If set to `true`, the time to first response will not be displayed in the generated Markdown file. | +| `HIDE_STATUS` | False | True | If set to `true`, the status column will not be shown | +| `HIDE_CREATED_AT` | False | True | If set to `true`, the creation timestamp will not be displayed in the generated Markdown file. | +| `DRAFT_PR_TRACKING` | False | False | If set to `true`, draft PRs will be included in the metrics as a new column and in the summary stats. | +| `IGNORE_USERS` | False | False | A comma separated list of users to ignore when calculating metrics. (ie. `IGNORE_USERS: 'user1,user2'`). To ignore bots, append `[bot]` to the user (ie. `IGNORE_USERS: 'github-actions[bot]'`) Users in this list will also have their authored issues and pull requests removed from the Markdown table. | +| `ENABLE_MENTOR_COUNT` | False | False | If set to 'TRUE' count number of comments users left on discussions, issues and PRs and display number of active mentors | +| `MIN_MENTOR_COMMENTS` | False | 10 | Minimum number of comments to count as a mentor | +| `MAX_COMMENTS_EVAL` | False | 20 | Maximum number of comments per thread to evaluate for mentor stats | +| `HEAVILY_INVOLVED_CUTOFF` | False | 3 | Cutoff after which a mentor's comments in one issue are no longer counted against their total score | +| `LABELS_TO_MEASURE` | False | `""` | A comma separated list of labels to measure how much time the label is applied. If not provided, no labels durations will be measured. Not compatible with discussions at this time. | +| `NON_MENTIONING_LINKS` | False | False | If set to `true`, will use non-mentioning GitHub links to avoid linking to the generated issue from the source repository. Links of the form `https://www.github.com` will be used. | +| `OUTPUT_FILE` | False | `issue_metrics.md` or `issue_metrics.json` | Output filename. | +| `REPORT_TITLE` | False | `"Issue Metrics"` | Title to have on the report issue. | +| `SEARCH_QUERY` | True | `""` | The query by which you can filter issues/PRs which must contain a `repo:`, `org:`, `owner:`, or a `user:` entry. For discussions, include `type:discussions` in the query. | ## Further Documentation - [Example workflows](./docs/example-workflows.md) - [Measuring time spent in labels](./docs/measure-time.md) - [Assigning teams instead of individuals](./docs/assign-team-instead-of-individual.md) -- [Example using the JSON output instead of the markdown output](./docs/example-using-json-instead-markdown-output.md) +- [Example using the JSON output instead of the Markdown output](./docs/example-using-json-instead-markdown-output.md) - [Configuring the `SEARCH_QUERY`](./docs/search-query.md) - [Local usage without Docker](./docs/local-usage-without-docker.md) +- [Authenticating with GitHub App Installation](./docs/authenticating-with-github-app-installation.md) +- [Dealing with large issue_metrics.md files](./docs/dealing-with-large-issue-metrics.md) ## Contributions + We would â¤ī¸ contributions to improve this action. Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for how to get involved. +### Development Setup + +- Ensure you have python `3.10+` installed +- Clone this repository and cd into `issue-metrics` +- Create python virtual env + `python3 -m venv .venv` +- Activate virtual env + `source .venv/bin/activate` +- Install dependencies + `pip install -r requirements.txt -r requirements-test.txt` +- Run tests + `make test` +- Run linter + `make lint` + ## License [MIT](LICENSE) ## More OSPO Tools -Looking for more resources for your open source program office (OSPO)? Check out the [`github-ospo`](https://github.com/github/github-ospo) repo for a variety of tools designed to support your needs. +Looking for more resources for your open source program office (OSPO)? Check out the [`github-ospo`](https://github.com/github/github-ospo) repository for a variety of tools designed to support your needs. diff --git a/action.yml b/action.yml index 3e5aa541..1a5179a0 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,10 @@ --- -name: 'issue-metrics' -author: 'github' -description: 'A GitHub Action to report out issue metrics' +name: "issue-metrics" +author: "github" +description: "A GitHub Action to report out issue metrics" runs: - using: 'docker' - image: 'docker://ghcr.io/github/issue_metrics:v2' + using: "docker" + image: "docker://ghcr.io/github/issue_metrics:v3" branding: - icon: 'check-square' - color: 'white' + icon: "check-square" + color: "white" diff --git a/auth.py b/auth.py new file mode 100644 index 00000000..aa95f0d7 --- /dev/null +++ b/auth.py @@ -0,0 +1,83 @@ +"""This is the module that contains functions related to authenticating to GitHub with a personal access token.""" + +import github3 +import requests + + +def auth_to_github( + token: str, + gh_app_id: int | None, + gh_app_installation_id: int | None, + gh_app_private_key_bytes: bytes, + ghe: str, + gh_app_enterprise_only: bool, +) -> github3.GitHub: + """ + Connect to GitHub.com or GitHub Enterprise, depending on env variables. + + Args: + token (str): the GitHub personal access token + gh_app_id (int | None): the GitHub App ID + gh_app_installation_id (int | None): the GitHub App Installation ID + gh_app_private_key_bytes (bytes): the GitHub App Private Key + ghe (str): the GitHub Enterprise URL + gh_app_enterprise_only (bool): Set this to true if the GH APP is created + on GHE and needs to communicate with GHE api only + + Returns: + github3.GitHub: the GitHub connection object + """ + if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + if ghe and gh_app_enterprise_only: + gh = github3.github.GitHubEnterprise(url=ghe) + else: + gh = github3.github.GitHub() + gh.login_as_app_installation( + gh_app_private_key_bytes, gh_app_id, gh_app_installation_id + ) + github_connection = gh + elif ghe and token: + github_connection = github3.github.GitHubEnterprise(url=ghe, token=token) + elif token: + github_connection = github3.login(token=token) + else: + raise ValueError( + "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, \ + GH_APP_PRIVATE_KEY] environment variables are not set" + ) + + if not github_connection: + raise ValueError("Unable to authenticate to GitHub") + return github_connection # type: ignore + + +def get_github_app_installation_token( + ghe: str, + gh_app_id: str, + gh_app_private_key_bytes: bytes, + gh_app_installation_id: str, +) -> str | None: + """ + Get a GitHub App Installation token. + API: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation # noqa: E501 + + Args: + ghe (str): the GitHub Enterprise endpoint + gh_app_id (str): the GitHub App ID + gh_app_private_key_bytes (bytes): the GitHub App Private Key + gh_app_installation_id (str): the GitHub App Installation ID + + Returns: + str: the GitHub App token + """ + jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id) + api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" + url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens" + + try: + response = requests.post(url, headers=jwt_headers, json=None, timeout=5) + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + return None + return response.json().get("token") diff --git a/classes.py b/classes.py index 3867f261..d24f4301 100644 --- a/classes.py +++ b/classes.py @@ -13,15 +13,22 @@ class IssueWithMetrics: title (str): The title of the issue. html_url (str): The URL of the issue on GitHub. author (str): The author of the issue. + assignee (str, optional): The primary assignee of the issue. + assignees (list, optional): All assignees of the issue. time_to_first_response (timedelta, optional): The time it took to get the first response to the issue. time_to_close (timedelta, optional): The time it took to close the issue. time_to_answer (timedelta, optional): The time it took to answer the discussions in the issue. + time_in_draft (timedelta, optional): The time the PR was in draft state. label_metrics (dict, optional): A dictionary containing the label metrics - + mentor_activity (dict, optional): A dictionary containing active mentors + created_at (datetime, optional): The time the issue was created. + status (str, optional): The status of the issue, e.g., "open", "closed as completed", """ + # pylint: disable=too-many-instance-attributes + def __init__( self, title, @@ -30,12 +37,24 @@ def __init__( time_to_first_response=None, time_to_close=None, time_to_answer=None, + time_in_draft=None, labels_metrics=None, + mentor_activity=None, + created_at=None, + assignee=None, + assignees=None, + status=None, ): self.title = title self.html_url = html_url self.author = author + self.assignee = assignee + self.assignees = assignees or [] self.time_to_first_response = time_to_first_response self.time_to_close = time_to_close self.time_to_answer = time_to_answer + self.time_in_draft = time_in_draft self.label_metrics = labels_metrics + self.mentor_activity = mentor_activity + self.created_at = created_at + self.status = status diff --git a/config.py b/config.py index a1f05420..475aa34d 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """A module for managing environment variables used in GitHub metrics calculation. -This module defines a class for encapsulating environment variables and a function to retrieve these variables. +This module defines a class for encapsulating environment variables +and a function to retrieve these variables. Classes: EnvVars: Represents the collection of environment variables used in the script. @@ -8,9 +9,13 @@ Functions: get_env_vars: Retrieves and returns an instance of EnvVars populated with environment variables. """ + import os +from os.path import dirname, join from typing import List +from dotenv import load_dotenv + class EnvVars: # pylint: disable=too-many-instance-attributes @@ -18,76 +23,259 @@ class EnvVars: Environment variables Attributes: - search_query (str): Search query used to filter issues/prs/discussions on GitHub - gh_token (str): GitHub personal access token (PAT) for API authentication - labels_to_measure (List[str]): List of labels to measure how much time the lable is applied + gh_app_id (int | None): The GitHub App ID to use for authentication + gh_app_installation_id (int | None): The GitHub App Installation ID to use for + authentication + gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for + authentication + gh_token (str | None): GitHub personal access token (PAT) for API authentication + ghe (str): The GitHub Enterprise URL to use for authentication + hide_assignee (bool): If true, the assignee's information is hidden in the output + hide_author (bool): If true, the author's information is hidden in the output + hide_items_closed_count (bool): If true, the number of items closed metric is hidden + in the output + hide_label_metrics (bool): If true, the label metrics are hidden in the output + hide_time_to_answer (bool): If true, the time to answer discussions is hidden in the output + hide_time_to_close (bool): If true, the time to close metric is hidden in the output + hide_time_to_first_response (bool): If true, the time to first response metric is hidden + in the output + hide_created_at (bool): If true, the created at timestamp is hidden in the output + hide_status (bool): If true, the status column is hidden in the output ignore_users (List[str]): List of usernames to ignore when calculating metrics - github_server_url (str): URL of GitHub server (Github.com or Github Enterprise) - hide_author (str): If set, the author's information is hidden in the output - hide_time_to_first_response (str): If set, the time to first response metric is hidden in the output - hide_time_to_close (str): If set, the time to close metric is hidden in the output - hide_time_to_answer (str): If set, the time to answer discussions is hidden in the output - hide_label_metrics (str): If set, the label metrics are hidden in the output + labels_to_measure (List[str]): List of labels to measure how much time the label is applied + enable_mentor_count (bool): If set to TRUE, compute number of mentors + min_mentor_comments (str): If set, defines the minimum number of comments for mentors + max_comments_eval (str): If set, defines the maximum number of comments to look + at for mentor evaluation + heavily_involved_cutoff (str): If set, defines the cutoff after which heavily + involved commentors in + search_query (str): Search query used to filter issues/prs/discussions on GitHub + non_mentioning_links (bool): If set to TRUE, links do not cause a notification + in the destination repository + report_title (str): The title of the report + output_file (str): The name of the file to write the report to + rate_limit_bypass (bool): If set to TRUE, bypass the rate limit for the GitHub API + draft_pr_tracking (bool): If set to TRUE, track PR time in draft state + in addition to other metrics """ - def __init__(self, search_query: str, gh_token: str, labels_to_measure: List[str], ignore_user: List[str], - github_server_url: str, hide_author: str, hide_time_to_first_response: str, - hide_time_to_close: str, hide_time_to_answer: str, hide_label_metrics: str): - self.search_query = search_query + + def __init__( + self, + gh_app_id: int | None, + gh_app_installation_id: int | None, + gh_app_private_key_bytes: bytes, + gh_app_enterprise_only: bool, + gh_token: str | None, + ghe: str | None, + hide_assignee: bool, + hide_author: bool, + hide_items_closed_count: bool, + hide_label_metrics: bool, + hide_time_to_answer: bool, + hide_time_to_close: bool, + hide_time_to_first_response: bool, + hide_created_at: bool, + hide_status: bool, + ignore_user: List[str], + labels_to_measure: List[str], + enable_mentor_count: bool, + min_mentor_comments: str, + max_comments_eval: str, + heavily_involved_cutoff: str, + search_query: str, + non_mentioning_links: bool, + report_title: str, + output_file: str, + rate_limit_bypass: bool = False, + draft_pr_tracking: bool = False, + ): + self.gh_app_id = gh_app_id + self.gh_app_installation_id = gh_app_installation_id + self.gh_app_private_key_bytes = gh_app_private_key_bytes + self.gh_app_enterprise_only = gh_app_enterprise_only self.gh_token = gh_token - self.labels_to_measure = labels_to_measure + self.ghe = ghe self.ignore_users = ignore_user - self.github_server_url = github_server_url + self.labels_to_measure = labels_to_measure + self.hide_assignee = hide_assignee self.hide_author = hide_author - self.hide_time_to_first_response = hide_time_to_first_response - self.hide_time_to_close = hide_time_to_close - self.hide_time_to_answer = hide_time_to_answer + self.hide_items_closed_count = hide_items_closed_count self.hide_label_metrics = hide_label_metrics + self.hide_time_to_answer = hide_time_to_answer + self.hide_time_to_close = hide_time_to_close + self.hide_time_to_first_response = hide_time_to_first_response + self.hide_created_at = hide_created_at + self.hide_status = hide_status + self.enable_mentor_count = enable_mentor_count + self.min_mentor_comments = min_mentor_comments + self.max_comments_eval = max_comments_eval + self.heavily_involved_cutoff = heavily_involved_cutoff + self.search_query = search_query + self.non_mentioning_links = non_mentioning_links + self.report_title = report_title + self.output_file = output_file + self.rate_limit_bypass = rate_limit_bypass + self.draft_pr_tracking = draft_pr_tracking + + def __repr__(self): + return ( + f"EnvVars(" + f"{self.gh_app_id}," + f"{self.gh_app_installation_id}," + f"{self.gh_app_private_key_bytes}," + f"{self.gh_app_enterprise_only}," + f"{self.gh_token}," + f"{self.ghe}," + f"{self.hide_assignee}," + f"{self.hide_author}," + f"{self.hide_items_closed_count})," + f"{self.hide_label_metrics}," + f"{self.hide_time_to_answer}," + f"{self.hide_time_to_close}," + f"{self.hide_time_to_first_response}," + f"{self.hide_created_at}," + f"{self.hide_status}," + f"{self.ignore_users}," + f"{self.labels_to_measure}," + f"{self.enable_mentor_count}," + f"{self.min_mentor_comments}," + f"{self.max_comments_eval}," + f"{self.heavily_involved_cutoff}," + f"{self.search_query}" + f"{self.non_mentioning_links}" + f"{self.report_title}" + f"{self.output_file}" + f"{self.rate_limit_bypass}" + f"{self.draft_pr_tracking}" + ) + + +def get_bool_env_var(env_var_name: str, default: bool = False) -> bool: + """Get a boolean environment variable. + Args: + env_var_name: The name of the environment variable to retrieve. + default: The default value to return if the environment variable is not set. -def get_env_vars() -> EnvVars: + Returns: + The value of the environment variable as a boolean. + """ + ev = os.environ.get(env_var_name, "") + if ev == "" and default: + return default + return ev.strip().lower() == "true" + + +def get_int_env_var(env_var_name: str) -> int | None: + """Get an integer environment variable. + + Args: + env_var_name: The name of the environment variable to retrieve. + + Returns: + The value of the environment variable as an integer or None. + """ + env_var = os.environ.get(env_var_name) + if env_var is None or not env_var.strip(): + return None + try: + return int(env_var) + except ValueError: + return None + + +def get_env_vars(test: bool = False) -> EnvVars: """ Get the environment variables for use in the script. Returns EnvVars object with all environment variables """ + if not test: + dotenv_path = join(dirname(__file__), ".env") + load_dotenv(dotenv_path) + search_query = os.getenv("SEARCH_QUERY") if not search_query: raise ValueError("SEARCH_QUERY environment variable not set") + gh_app_id = get_int_env_var("GH_APP_ID") + gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8") + gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID") + gh_app_enterprise_only = get_bool_env_var("GITHUB_APP_ENTERPRISE_ONLY") + + if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id): + raise ValueError( + "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set" + ) + gh_token = os.getenv("GH_TOKEN") - if not gh_token: - raise ValueError("GITHUB_TOKEN environment variable not set") + if ( + not gh_app_id + and not gh_app_private_key_bytes + and not gh_app_installation_id + and not gh_token + ): + raise ValueError("GH_TOKEN environment variable not set") + + ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip() - labels_to_measure = os.getenv("LABELS_TO_MEASURE") + labels_to_measure_list: List[str] = [] + labels_to_measure: str | None = os.getenv("LABELS_TO_MEASURE") if labels_to_measure: - labels_to_measure = labels_to_measure.split(",") - else: - labels_to_measure = [] + labels_to_measure_list = labels_to_measure.split(",") - ignore_users = os.getenv("IGNORE_USERS") + ignore_users_list: List[str] = [] + ignore_users: str | None = os.getenv("IGNORE_USERS") if ignore_users: - ignore_users = ignore_users.split(",") - else: - ignore_users = [] + ignore_users_list = ignore_users.split(",") - github_server_url = os.getenv("GITHUB_SERVER_URL") + report_title = os.getenv("REPORT_TITLE", "Issue Metrics") + output_file = os.getenv("OUTPUT_FILE", "") + rate_limit_bypass = get_bool_env_var("RATE_LIMIT_BYPASS", False) + draft_pr_tracking = get_bool_env_var("DRAFT_PR_TRACKING", False) # Hidden columns - hide_author = os.getenv("HIDE_AUTHOR") - hide_time_to_first_response = os.getenv("HIDE_TIME_TO_FIRST_RESPONSE") - hide_time_to_close = os.getenv("HIDE_TIME_TO_CLOSE") - hide_time_to_answer = os.getenv("HIDE_TIME_TO_ANSWER") - hide_label_metrics = os.getenv("HIDE_LABEL_METRICS") + hide_assignee = get_bool_env_var("HIDE_ASSIGNEE", False) + hide_author = get_bool_env_var("HIDE_AUTHOR", False) + hide_items_closed_count = get_bool_env_var("HIDE_ITEMS_CLOSED_COUNT", False) + hide_label_metrics = get_bool_env_var("HIDE_LABEL_METRICS", False) + hide_time_to_answer = get_bool_env_var("HIDE_TIME_TO_ANSWER", False) + hide_time_to_close = get_bool_env_var("HIDE_TIME_TO_CLOSE", False) + hide_time_to_first_response = get_bool_env_var("HIDE_TIME_TO_FIRST_RESPONSE", False) + hide_created_at = get_bool_env_var("HIDE_CREATED_AT", True) + hide_status = get_bool_env_var("HIDE_STATUS", True) + enable_mentor_count = get_bool_env_var("ENABLE_MENTOR_COUNT", False) + min_mentor_comments = os.getenv("MIN_MENTOR_COMMENTS", "10") + max_comments_eval = os.getenv("MAX_COMMENTS_EVAL", "20") + heavily_involved_cutoff = os.getenv("HEAVILY_INVOLVED_CUTOFF", "3") + non_mentioning_links = get_bool_env_var("NON_MENTIONING_LINKS", False) return EnvVars( - search_query, + gh_app_id, + gh_app_installation_id, + gh_app_private_key_bytes, + gh_app_enterprise_only, gh_token, - labels_to_measure, - ignore_users, - github_server_url, + ghe, + hide_assignee, hide_author, - hide_time_to_first_response, - hide_time_to_close, + hide_items_closed_count, + hide_label_metrics, hide_time_to_answer, - hide_label_metrics + hide_time_to_close, + hide_time_to_first_response, + hide_created_at, + hide_status, + ignore_users_list, + labels_to_measure_list, + enable_mentor_count, + min_mentor_comments, + max_comments_eval, + heavily_involved_cutoff, + search_query, + non_mentioning_links, + report_title, + output_file, + rate_limit_bypass, + draft_pr_tracking, ) diff --git a/discussions.py b/discussions.py index 28b03753..b3ab1d0f 100644 --- a/discussions.py +++ b/discussions.py @@ -6,24 +6,25 @@ Get a list of discussions in a GitHub repository that match the search query. """ + import requests -def get_discussions(token: str, search_query: str): +def get_discussions(token: str, search_query: str, ghe: str): """Get a list of discussions in a GitHub repository that match the search query. Args: token (str): A personal access token for GitHub. search_query (str): The search query to filter discussions by. + ghe (str): GitHub Enterprise URL if applicable, or None for github.com. Returns: list: A list of discussions in the repository that match the search query. - """ - # Construct the GraphQL query + # Construct the GraphQL query with pagination query = """ - query($query: String!) { - search(query: $query, type: DISCUSSION, first: 100) { + query($query: String!, $cursor: String) { + search(query: $query, type: DISCUSSION, first: 100, after: $cursor) { edges { node { ... on Discussion { @@ -40,33 +41,57 @@ def get_discussions(token: str, search_query: str): } } } + pageInfo { + hasNextPage + endCursor + } } } """ # Remove the type:discussions filter from the search query search_query = search_query.replace("type:discussions ", "") - # Set the variables for the GraphQL query - variables = {"query": search_query} # Send the GraphQL request + api_endpoint = f"{ghe}/api" if ghe else "https://api.github.com" headers = {"Authorization": f"Bearer {token}"} - response = requests.post( - "https://api.github.com/graphql", - json={"query": query, "variables": variables}, - headers=headers, - timeout=60, - ) - # Check for errors in the GraphQL response - if response.status_code != 200 or "errors" in response.json(): - raise ValueError("GraphQL query failed") + discussions = [] + cursor = None - data = response.json()["data"] + while True: + # Set the variables for the GraphQL query + variables = {"query": search_query, "cursor": cursor} - # Extract the discussions from the GraphQL response - discussions = [] - for edge in data["search"]["edges"]: - discussions.append(edge["node"]) + # Send the GraphQL request + response = requests.post( + f"{api_endpoint}/graphql", + json={"query": query, "variables": variables}, + headers=headers, + timeout=60, + ) + + # Check for errors in the GraphQL response + if response.status_code != 200: + raise ValueError( + f"GraphQL query failed with status code {response.status_code}" + ) + + response_json = response.json() + if "errors" in response_json: + raise ValueError(f"GraphQL query failed: {response_json['errors']}") + + data = response_json["data"] + + # Extract the discussions from the current page + for edge in data["search"]["edges"]: + discussions.append(edge["node"]) + + # Check if there are more pages + page_info = data["search"]["pageInfo"] + if not page_info["hasNextPage"]: + break + + cursor = page_info["endCursor"] return discussions diff --git a/docs/assign-team-instead-of-individual.md b/docs/assign-team-instead-of-individual.md index 6b2b6e6c..11bdf349 100644 --- a/docs/assign-team-instead-of-individual.md +++ b/docs/assign-team-instead-of-individual.md @@ -1,8 +1,14 @@ # Assigning teams instead of individuals -The assignee part of this workflow action comes from [a different GitHub action](https://github.com/peter-evans/create-issue-from-file) and currently GitHub issues don't support assigning groups. +The assignee part of this workflow action comes from [a different GitHub Action](https://github.com/peter-evans/create-issue-from-file) and currently GitHub issues don't support assigning groups. -By way of work around, you could use the [GitHub API to retrieve the members of the team](https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#list-team-members) and then put them in a comma separated string that you provide as the assignee. This requires setting up a new GitHub API token (referred to below as `CUSTOM_TOKEN`) which has `read:org` permissions assigned and single sign on authorization as needed. To do this, create a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the org (`read:org`). Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `CUSTOM_TOKEN` and the value of the secret the API token. +By way of work around, you could use the [GitHub API to retrieve the members of the team](https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#list-team-members) and then put them in a comma separated string that you provide as the assignee. + +This requires setting up a new GitHub API token (referred to below as `CUSTOM_TOKEN`) which has `read:org` permissions assigned and single sign on authorization as needed. + +To do this, create a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the org (`read:org`). + +Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `CUSTOM_TOKEN` and the value of the secret the API token. That might look something like the workflow below where `ORG` is your organization name and `TEAM_SLUG` is the name of the team: @@ -14,13 +20,15 @@ on: - cron: '3 2 1 * *' permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: @@ -38,7 +46,7 @@ jobs: echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - name: Run issue-metrics tool - uses: github/issue-metrics@v2 + uses: github/issue-metrics@v3 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' diff --git a/docs/authenticating-with-github-app-installation.md b/docs/authenticating-with-github-app-installation.md new file mode 100644 index 00000000..767d8588 --- /dev/null +++ b/docs/authenticating-with-github-app-installation.md @@ -0,0 +1,62 @@ +# Authenticating with a GitHub App Installation + +Authenticating as an app installation lets your app access resources that are owned by the user or organization that installed the app. Authenticating as an app installation is ideal for automation workflows that don't involve user input. + +[Documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app#authentication-as-an-app-installation) for more details. + +```yaml +name: Monthly issue metrics +on: + workflow_dispatch: + schedule: + - cron: '3 2 1 * *' + +permissions: + contents: read + +jobs: + build: + name: issue metrics + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + + steps: + + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + first_day=$(date -d "last month" +%Y-%m-01) + + # Calculate the last day of the previous month + last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) + + #Set an environment variable with the date range + echo "$first_day..$last_day" + echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" + + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_INSTALLATION_ID: ${{ secrets.GH_APP_INSTALLATION_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' + + - name: Get user names from team + run: | + teamMembers="$(gh api /orgs/ORG/teams/TEAM_SLUG/members | jq -r '.[].login' | paste -sd, -)" + echo 'TEAM_MEMBERS='$teamMembers >> $GITHUB_ENV + env: + GITHUB_TOKEN: ${{ secrets.CUSTOM_TOKEN }} + + - name: Create issue + uses: peter-evans/create-issue-from-file@v4 + with: + title: Monthly issue metrics report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md + assignees: ${{ env.TEAM_MEMBERS }} +``` diff --git a/docs/dealing-with-large-issue-metrics.md b/docs/dealing-with-large-issue-metrics.md new file mode 100644 index 00000000..f41337e7 --- /dev/null +++ b/docs/dealing-with-large-issue-metrics.md @@ -0,0 +1,18 @@ +# Dealing with large issue metrics Markdown files + +When working with lots of issues/pull requests/discussion results, the resulting issue_metrics.md file can become very large. This can cause the GitHub API to return an error when trying to create an issue with the contents of the file. + +```shell +Pull request creation failed. Validation failed: Body is too long (maximum is 65536 characters) +``` + +To work around this limitation, the issue-metrics action detects the large file size and splits the issue_metrics.md file into smaller files. So instead of issue_metrics.md, you will get issue_metrics_0.md, issue_metrics_1.md, etc. +Since we don't want the action to fail, it has been designed to have the same name as usual for the first split file (issue_metrics.md) and then append a number to the name for the subsequent split files. + +You can choose one of the following strategies to deal with the split files: + +- Create multiple issues, each with using the next split file in the sequence. +- Upload the full file as an artifact and link to it in the issue body. +- Create an issue and put the content of the split files as issue comments. + +JSON output files are not split since its not anticipated that you use them as issue body content. diff --git a/docs/example-using-json-instead-markdown-output.md b/docs/example-using-json-instead-markdown-output.md index 0396b54e..4fcfc769 100644 --- a/docs/example-using-json-instead-markdown-output.md +++ b/docs/example-using-json-instead-markdown-output.md @@ -1,32 +1,33 @@ -# Example using the JSON output instead of the markdown output +# Example using the JSON output instead of the Markdown output -There is JSON output available as well. You could use it for any number of possibilities, but here is one example that demonstrates retreiving the JSON output and then printing it out. +There is JSON output available as well. You could use it for any number of possibilities, but here is one example that demonstrates retrieving the JSON output and then printing it out. ```yaml name: Monthly issue metrics on: workflow_dispatch: schedule: - - cron: '3 2 1 * *' + - cron: "3 2 1 * *" permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - - name: Run issue-metrics tool - id: issue-metrics - uses: github/issue-metrics@v2 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' - - - name: Print output of issue metrics tool - run: echo "${{ steps.issue-metrics.outputs.metrics }}" + - name: Run issue-metrics tool + id: issue-metrics + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' + - name: Print output of issue metrics tool + run: echo "${{ steps.issue-metrics.outputs.metrics }}" ``` diff --git a/docs/example-workflows.md b/docs/example-workflows.md index b4fc8ff3..31bc9e2a 100644 --- a/docs/example-workflows.md +++ b/docs/example-workflows.md @@ -9,46 +9,46 @@ name: Monthly issue metrics on: workflow_dispatch: schedule: - - cron: '3 2 1 * *' + - cron: "3 2 1 * *" permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + first_day=$(date -d "last month" +%Y-%m-01) - - name: Get dates for last month - shell: bash - run: | - # Calculate the first day of the previous month - first_day=$(date -d "last month" +%Y-%m-01) - - # Calculate the last day of the previous month - last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) - - #Set an environment variable with the date range - echo "$first_day..$last_day" - echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - - - name: Run issue-metrics tool - uses: github/issue-metrics@v2 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' - - - name: Create issue - uses: peter-evans/create-issue-from-file@v4 - with: - title: Monthly issue metrics report - token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./issue_metrics.md - assignees: + # Calculate the last day of the previous month + last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) + #Set an environment variable with the date range + echo "$first_day..$last_day" + echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" + + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'repo:owner/repo is:issue created:${{ env.last_month }} -reason:"not planned"' + + - name: Create issue + uses: peter-evans/create-issue-from-file@v4 + with: + title: Monthly issue metrics report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md + assignees: ``` ## Fixed Time Example @@ -61,30 +61,30 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' - - name: Run issue-metrics tool - uses: github/issue-metrics@v2 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' - - - name: Create issue - uses: peter-evans/create-issue-from-file@v4 - with: - title: Monthly issue metrics report - token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./issue_metrics.md - assignees: - + - name: Create issue + uses: peter-evans/create-issue-from-file@v4 + with: + title: Monthly issue metrics report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md + assignees: ``` ## Multiple Repositories Example @@ -97,13 +97,15 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - name: Get dates for last month @@ -120,7 +122,7 @@ jobs: echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - name: Get issue metrics - uses: github/issue-metrics@v2 + uses: github/issue-metrics@v3 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:owner/repo1 repo:owner/repo2 is:issue created:${{ env.last_month }} -reason:"not planned"' diff --git a/docs/measure-time.md b/docs/measure-time.md index 3547100a..8d3cae9b 100644 --- a/docs/measure-time.md +++ b/docs/measure-time.md @@ -12,31 +12,31 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: read + content: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - - - name: Run issue-metrics tool - uses: github/issue-metrics@v2 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - LABELS_TO_MEASURE: 'waiting-for-manager-approval,waiting-for-security-review' - SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' - - - name: Create issue - uses: peter-evans/create-issue-from-file@v4 - with: - title: Monthly issue metrics report - token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./issue_metrics.md - assignees: - + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LABELS_TO_MEASURE: "waiting-for-manager-approval,waiting-for-security-review" + SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"' + + - name: Create issue + uses: peter-evans/create-issue-from-file@v4 + with: + title: Monthly issue metrics report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md + assignees: ``` then the report will look like this: @@ -44,22 +44,21 @@ then the report will look like this: ```markdown # Issue Metrics -| Metric | Value | -| --- | ---: | -| Average time to first response | 0:50:44.666667 | -| Average time to close | 6 days, 7:08:52 | -| Average time to answer | 1 day | -| Average time spent in waiting-for-manager-approval | 0:00:41 | -| Average time spent in waiting-for-security-review | 2 days, 4:25:03 | -| Number of items that remain open | 2 | -| Number of items closed | 1 | -| Total number of items created | 3 | - -| Title | URL | Time to first response | Time to close | Time to answer | Time spent in waiting-for-manager-approval | Time spent in waiting-for-security-review | -| --- | --- | --- | --- | --- | --- | --- | -| Pull Request Title 1 | https://github.com/user/repo/pulls/1 | 0:05:26 | None | None | None | None | -| Issue Title 2 | https://github.com/user/repo/issues/2 | 2:26:07 | None | None | 0:00:41 | 2 days, 4:25:03 | - +| Metric | Value | +| -------------------------------------------------- | --------------: | +| Average time to first response | 0:50:44.666667 | +| Average time to close | 6 days, 7:08:52 | +| Average time to answer | 1 day | +| Average time spent in waiting-for-manager-approval | 0:00:41 | +| Average time spent in waiting-for-security-review | 2 days, 4:25:03 | +| Number of items that remain open | 2 | +| Number of items closed | 1 | +| Total number of items created | 3 | + +| Title | URL | Time to first response | Time to close | Time to answer | Time spent in waiting-for-manager-approval | Time spent in waiting-for-security-review | +| -------------------- | ------------------------------------- | ---------------------- | ------------- | -------------- | ------------------------------------------ | ----------------------------------------- | +| Pull Request Title 1 | https://github.com/user/repo/pulls/1 | 0:05:26 | None | None | None | None | +| Issue Title 2 | https://github.com/user/repo/issues/2 | 2:26:07 | None | None | 0:00:41 | 2 days, 4:25:03 | ``` ## Example issue_metrics.md output @@ -69,21 +68,20 @@ Here is the output with no hidden columns: ```markdown # Issue Metrics -| Metric | Value | -| --- | ---: | -| Average time to first response | 0:50:44.666667 | -| Average time to close | 6 days, 7:08:52 | -| Average time to answer | 1 day | -| Number of items that remain open | 2 | -| Number of items closed | 1 | -| Total number of items created | 3 | - -| Title | URL | Time to first response | Time to close | Time to answer | -| --- | --- | --- | --- | --- | -| Discussion Title 1 | https://github.com/user/repo/discussions/1 | 0:00:41 | 6 days, 7:08:52 | 1 day | -| Pull Request Title 2 | https://github.com/user/repo/pulls/2 | 0:05:26 | None | None | -| Issue Title 3 | https://github.com/user/repo/issues/3 | 2:26:07 | None | None | - +| Metric | Value | +| -------------------------------- | --------------: | +| Average time to first response | 0:50:44.666667 | +| Average time to close | 6 days, 7:08:52 | +| Average time to answer | 1 day | +| Number of items that remain open | 2 | +| Number of items closed | 1 | +| Total number of items created | 3 | + +| Title | URL | Time to first response | Time to close | Time to answer | +| -------------------- | ------------------------------------------ | ---------------------- | --------------- | -------------- | +| Discussion Title 1 | https://github.com/user/repo/discussions/1 | 0:00:41 | 6 days, 7:08:52 | 1 day | +| Pull Request Title 2 | https://github.com/user/repo/pulls/2 | 0:05:26 | None | None | +| Issue Title 3 | https://github.com/user/repo/issues/3 | 2:26:07 | None | None | ``` Here is the output with all hidable columns hidden: @@ -91,16 +89,15 @@ Here is the output with all hidable columns hidden: ```markdown # Issue Metrics -| Metric | Value | -| --- | ---: | -| Number of items that remain open | 2 | -| Number of items closed | 1 | -| Total number of items created | 3 | - -| Title | URL | -| --- | --- | -| Discussion Title 1 | https://github.com/user/repo/discussions/1 | -| Pull Request Title 2 | https://github.com/user/repo/pulls/2 | -| Issue Title 3 | https://github.com/user/repo/issues/3 | - +| Metric | Value | +| -------------------------------- | ----: | +| Number of items that remain open | 2 | +| Number of items closed | 1 | +| Total number of items created | 3 | + +| Title | URL | +| -------------------- | ------------------------------------------ | +| Discussion Title 1 | https://github.com/user/repo/discussions/1 | +| Pull Request Title 2 | https://github.com/user/repo/pulls/2 | +| Issue Title 3 | https://github.com/user/repo/issues/3 | ``` diff --git a/docs/search-query.md b/docs/search-query.md index 13d03292..64a43f64 100644 --- a/docs/search-query.md +++ b/docs/search-query.md @@ -1,7 +1,9 @@ # Configuring the `SEARCH_QUERY` Issues or Pull Requests? Open or closed? -This action can be configured to run metrics on discussions, pull requests and/or issues. It is also configurable by whether they were open or closed in the specified time window. Further query options are listed in the documentation on [searching issues and pull requests](https://docs.github.com/en/issues/tracking-your-work-with-issues/filtering-and-searching-issues-and-pull-requests) or [searching discussions](https://docs.github.com/en/search-github/searching-on-github/searching-discussions). Search results are limited to 1000 results by the GitHub API. +This action can be configured to run metrics on discussions, pull requests and/or issues. It is also configurable by whether they were open or closed in the specified time window. + +Further query options are listed in the documentation on [searching issues and pull requests](https://docs.github.com/en/issues/tracking-your-work-with-issues/filtering-and-searching-issues-and-pull-requests) or [searching discussions](https://docs.github.com/en/search-github/searching-on-github/searching-discussions). Search results are limited to 1000 results by the GitHub API. ## Examples @@ -49,18 +51,20 @@ on: - cron: '3 2 1 * *' permissions: - issues: write - pull-requests: read + contents: read jobs: build: name: issue metrics runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - name: Run issue-metrics tool for issues and prs opened in May 2023 - uses: github/issue-metrics@v2 + uses: github/issue-metrics@v3 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:owner/repo created:2023-05-01..2023-05-31 -reason:"not planned"' @@ -74,7 +78,7 @@ jobs: assignees: - name: Run issue-metrics tool for issues and prs closed in May 2023 - uses: github/issue-metrics@v2 + uses: github/issue-metrics@v3 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:owner/repo closed:2023-05-01..2023-05-31 -reason:"not planned"' diff --git a/docs/verify-token-access-to-repository.md b/docs/verify-token-access-to-repository.md new file mode 100644 index 00000000..e6d7d54a --- /dev/null +++ b/docs/verify-token-access-to-repository.md @@ -0,0 +1,65 @@ +# Verify Token Access to Repository + +GitHub PAT token access can be confusing. Here's a quick way to test if the token you're using is authorized to access your repository. + +**Remove this snippet after you've verified your token.** + +- Make sure you follow the token setup instructions [in the `README.md`](https://github.com/github/issue-metrics/tree/main?tab=readme-ov-file#use-as-a-github-action) first. + +- Replace `{owner/repo}` with your own repository information. + +- Add this snippet to your workflow.yml. + +```yml +- name: Check GitHub token permissions + run: | + curl -H "Authorization: token ${{ secrets.GH_TOKEN }}" https://api.github.com/repos/{owner/repo} +``` + +- Go to your repository Actions in GitHub and run your job. +- In the job run details, click into the results of `Check GitHub token permissions` +- You should see your token details with no errors. + +Example of the snippet in the full workflow: + +```yml +name: Monthly issue metrics +on: + workflow_dispatch: + schedule: + - cron: "3 2 1 * *" + +permissions: + contents: read + +jobs: + build: + name: issue metrics + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + + steps: + - name: Check GitHub token permissions + run: | + curl -H "Authorization: token ${{ secrets.GH_TOKEN }}" https://api.github.com/{owner/repo} + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + first_day=$(date -d "last month" +%Y-%m-01) + + # Calculate the last day of the previous month + last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d) + + #Set an environment variable with the date range + echo "$first_day..$last_day" + echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" + + - name: Run issue-metrics tool + uses: github/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + SEARCH_QUERY: "repo:{owner/repo} is:issue created:${{ env.last_month }}" +``` diff --git a/issue_metrics.py b/issue_metrics.py index 033970e2..a7dd5041 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -6,12 +6,6 @@ their metrics to a markdown file. Functions: - get_env_vars() -> EnvVars: Get the environment variables for use - in the script. - search_issues(search_query: str, github_connection: github3.GitHub) - -> github3.structs.SearchIterator: - Searches for issues in a GitHub repository that match the given search query. - auth_to_github() -> github3.GitHub: Connect to GitHub API with token authentication. get_per_issue_metrics(issues: Union[List[dict], List[github3.issues.Issue]], discussions: bool = False), labels: Union[List[str], None] = None, ignore_users: List[str] = [] -> tuple[List, int, int]: @@ -21,101 +15,41 @@ main(): Run the issue-metrics script. """ -from os.path import dirname, join -import sys +import shutil +from pathlib import Path from typing import List, Union import github3 -from dotenv import load_dotenv - +import github3.structs +from auth import auth_to_github, get_github_app_installation_token from classes import IssueWithMetrics +from config import EnvVars, get_env_vars from discussions import get_discussions from json_writer import write_to_json -from labels import get_stats_time_in_labels, get_label_metrics +from labels import get_label_metrics, get_stats_time_in_labels +from markdown_helpers import markdown_too_large_for_issue_body, split_markdown_file from markdown_writer import write_to_markdown +from most_active_mentors import count_comments_per_user, get_mentor_count +from search import get_owners_and_repositories, search_issues +from time_in_draft import get_stats_time_in_draft, measure_time_in_draft from time_to_answer import get_stats_time_to_answer, measure_time_to_answer from time_to_close import get_stats_time_to_close, measure_time_to_close -from time_to_ready_for_review import get_time_to_ready_for_review -from time_to_merge import measure_time_to_merge from time_to_first_response import ( get_stats_time_to_first_response, measure_time_to_first_response, ) -from config import get_env_vars - - -def search_issues( - search_query: str, github_connection: github3.GitHub -) -> List[github3.search.IssueSearchResult]: # type: ignore - """ - Searches for issues/prs/discussions in a GitHub repository that match - the given search query and handles errors related to GitHub API responses. - - Args: - search_query (str): The search query to use for finding issues/prs/discussions. - github_connection (github3.GitHub): A connection to the GitHub API. - - Returns: - List[github3.search.IssueSearchResult]: A list of issues that match the search query. - """ - print("Searching for issues...") - issues_iterator = github_connection.search_issues(search_query, per_page=100) - - # Print the issue titles - issues = [] - try: - for issue in issues_iterator: - print(issue.title) # type: ignore - issues.append(issue) - except github3.exceptions.ForbiddenError: - print( - "You do not have permission to view this repository; Check you API Token." - ) - sys.exit(1) - except github3.exceptions.NotFoundError: - print("The repository could not be found; Check the repository owner and name.") - sys.exit(1) - except github3.exceptions.ConnectionError: - print( - "There was a connection error; Check your internet connection or API Token." - ) - sys.exit(1) - except github3.exceptions.AuthenticationFailed: - print("Authentication failed; Check your API Token.") - sys.exit(1) - except github3.exceptions.UnprocessableEntity: - print("The search query is invalid; Check the search query.") - sys.exit(1) - - return issues - - -def auth_to_github() -> github3.GitHub: - """ - Connect to GitHub.com or GitHub Enterprise, depending on env variables. - - Returns: - github3.GitHub: A github api connection. - """ - env_vars = get_env_vars() - token = env_vars.gh_token - github_server_url = env_vars.github_server_url - - if github_server_url and github_server_url != "https://github.com": - github_connection = github3.GitHubEnterprise( - github_server_url, token=token - ) - else: - github_connection = github3.login(token=token) - - return github_connection # type: ignore +from time_to_merge import measure_time_to_merge +from time_to_ready_for_review import get_time_to_ready_for_review def get_per_issue_metrics( issues: Union[List[dict], List[github3.search.IssueSearchResult]], # type: ignore + env_vars: EnvVars, discussions: bool = False, labels: Union[List[str], None] = None, - ignore_users: List[str] = None, + ignore_users: Union[List[str], None] = None, + max_comments_to_eval: int = 20, + heavily_involved: int = 3, ) -> tuple[List, int, int]: """ Calculate the metrics for each issue/pr/discussion in a list provided. @@ -127,6 +61,7 @@ def get_per_issue_metrics( Defaults to False. labels (List[str]): A list of labels to measure time spent in. Defaults to empty list. ignore_users (List[str]): A list of users to ignore when calculating metrics. + env_vars (EnvVars): The environment variables for the script. Returns: tuple[List[IssueWithMetrics], int, int]: A tuple containing a @@ -148,75 +83,141 @@ def get_per_issue_metrics( None, None, None, + None, ) - issue_with_metrics.time_to_first_response = measure_time_to_first_response( - None, issue, ignore_users - ) - issue_with_metrics.time_to_answer = measure_time_to_answer(issue) + # Discussions typically don't have assignees in the same way as issues/PRs + issue_with_metrics.assignee = None + issue_with_metrics.assignees = [] + if env_vars.hide_time_to_first_response is False: + issue_with_metrics.time_to_first_response = ( + measure_time_to_first_response(None, issue, ignore_users) + ) + if env_vars.enable_mentor_count: + issue_with_metrics.mentor_activity = count_comments_per_user( + None, + issue, + None, + None, + ignore_users, + max_comments_to_eval, + heavily_involved, + ) + if env_vars.hide_time_to_answer is False: + issue_with_metrics.time_to_answer = measure_time_to_answer(issue) if issue["closedAt"]: - issue_with_metrics.time_to_close = measure_time_to_close(None, issue) num_issues_closed += 1 + if not env_vars.hide_time_to_close: + issue_with_metrics.time_to_close = measure_time_to_close( + None, issue + ) else: num_issues_open += 1 else: + if ignore_users and issue.user["login"] in ignore_users: # type: ignore + continue + issue_with_metrics = IssueWithMetrics( - issue.title, # type: ignore - issue.html_url, # type: ignore - issue.user["login"], # type: ignore - None, - None, - None, - None, + title=issue.title, # type: ignore + html_url=issue.html_url, # type: ignore + author=issue.user["login"], # type: ignore ) + # Extract assignee information from the issue + issue_dict = issue.issue.as_dict() # type: ignore + assignee = None + assignees = [] + + if issue_dict.get("assignee"): + assignee = issue_dict["assignee"]["login"] + + if issue_dict.get("assignees"): + assignees = [a["login"] for a in issue_dict["assignees"]] + + issue_with_metrics.assignee = assignee + issue_with_metrics.assignees = assignees + # Check if issue is actually a pull request pull_request, ready_for_review_at = None, None - if issue.issue.pull_request_urls: - pull_request = issue.issue.pull_request() - ready_for_review_at = get_time_to_ready_for_review(issue, pull_request) - - issue_with_metrics.time_to_first_response = measure_time_to_first_response( - issue, None, pull_request, ready_for_review_at, ignore_users - ) - if labels: + if issue.issue.pull_request_urls: # type: ignore + try: + pull_request = issue.issue.pull_request() # type: ignore + ready_for_review_at = get_time_to_ready_for_review( + issue, pull_request + ) + if env_vars.draft_pr_tracking: + issue_with_metrics.time_in_draft = measure_time_in_draft( + issue=issue, pull_request=pull_request + ) + except TypeError as e: + print( + f"An error occurred processing review comments. Perhaps the review contains a ghost user. {e}" + ) + + if env_vars.hide_time_to_first_response is False: + issue_with_metrics.time_to_first_response = ( + measure_time_to_first_response( + issue, None, pull_request, ready_for_review_at, ignore_users + ) + ) + if env_vars.enable_mentor_count: + issue_with_metrics.mentor_activity = count_comments_per_user( + issue, + None, + pull_request, + ready_for_review_at, + ignore_users, + max_comments_to_eval, + heavily_involved, + ) + if labels and env_vars.hide_label_metrics is False: issue_with_metrics.label_metrics = get_label_metrics(issue, labels) if issue.state == "closed": # type: ignore - if pull_request: - issue_with_metrics.time_to_close = measure_time_to_merge(pull_request, ready_for_review_at) - else: - issue_with_metrics.time_to_close = measure_time_to_close(issue, None) num_issues_closed += 1 + if not env_vars.hide_time_to_close: + if pull_request: + issue_with_metrics.time_to_close = measure_time_to_merge( + pull_request, ready_for_review_at + ) + else: + issue_with_metrics.time_to_close = measure_time_to_close( + issue, None + ) + if not env_vars.hide_status: + issue_with_metrics.status = f"{issue.issue.state} as {issue.issue.state_reason}" # type: ignore elif issue.state == "open": # type: ignore num_issues_open += 1 + if not env_vars.hide_status: + issue_with_metrics.status = f"{issue.issue.state}" # type: ignore + if not env_vars.hide_created_at: + if isinstance(issue, github3.search.IssueSearchResult): # type: ignore + issue_with_metrics.created_at = issue.issue.created_at # type: ignore + elif isinstance(issue, dict): # type: ignore + issue_with_metrics.created_at = issue["createdAt"] # type: ignore issues_with_metrics.append(issue_with_metrics) return issues_with_metrics, num_issues_open, num_issues_closed -def get_owner( - search_query: str, -) -> Union[str, None]: - """Get the owner from the search query. - - Args: - search_query (str): The search query used to search for issues. - - Returns: - Union[str, None]: The owner. - +def evaluate_markdown_file_size(output_file: str) -> None: """ - search_query_split = search_query.split(" ") - owner = None - for item in search_query_split: - if "repo:" in item and "/" in item: - owner = item.split(":")[1].split("/")[0] - if "org:" in item or "owner:" in item or "user:" in item: - owner = item.split(":")[1] - - return owner + Evaluate the size of the markdown file and split it, if it is too large. + """ + output_file_name = output_file if output_file else "issue_metrics.md" + file_name_without_extension = Path(output_file_name).stem + max_char_count = 65535 + if markdown_too_large_for_issue_body(output_file_name, max_char_count): + split_markdown_file(output_file_name, max_char_count) + shutil.move(output_file_name, f"{file_name_without_extension}_full.md") + shutil.move(f"{file_name_without_extension}_0.md", output_file_name) + print( + f"Issue metrics markdown file is too large for GitHub issue body and has been \ +split into multiple files. ie. {output_file_name}, {file_name_without_extension}_1.md, etc. \ +The full file is saved as {file_name_without_extension}_full.md\n\ +See https://github.com/github/issue-metrics/blob/main/docs/dealing-with-large-issue-metrics.md" + ) -def main(): +def main(): # pragma: no cover """Run the issue-metrics script. This function authenticates to GitHub, searches for issues/prs/discussions @@ -231,29 +232,56 @@ def main(): print("Starting issue-metrics search...") - # Load env variables from file - dotenv_path = join(dirname(__file__), ".env") - load_dotenv(dotenv_path) - - # Auth to GitHub.com - github_connection = auth_to_github() - # Get the environment variables for use in the script env_vars = get_env_vars() search_query = env_vars.search_query token = env_vars.gh_token ignore_users = env_vars.ignore_users + hide_items_closed_count = env_vars.hide_items_closed_count + hide_label_metrics = env_vars.hide_label_metrics + non_mentioning_links = env_vars.non_mentioning_links + report_title = env_vars.report_title + output_file = env_vars.output_file + rate_limit_bypass = env_vars.rate_limit_bypass + + ghe = env_vars.ghe + gh_app_id = env_vars.gh_app_id + gh_app_installation_id = env_vars.gh_app_installation_id + gh_app_private_key_bytes = env_vars.gh_app_private_key_bytes + gh_app_enterprise_only = env_vars.gh_app_enterprise_only - # Get the repository owner and name from the search query - owner = get_owner(search_query) + # Auth to GitHub.com + github_connection = auth_to_github( + token, + gh_app_id, + gh_app_installation_id, + gh_app_private_key_bytes, + ghe, + gh_app_enterprise_only, + ) - if owner is None: - raise ValueError( - "The search query must include a repository owner and name \ - (ie. repo:owner/repo), an organization (ie. org:organization), \ - a user (ie. user:login) or an owner (ie. owner:user-or-organization)" + if not token and gh_app_id and gh_app_installation_id and gh_app_private_key_bytes: + token = get_github_app_installation_token( + ghe, gh_app_id, gh_app_private_key_bytes, gh_app_installation_id ) + enable_mentor_count = env_vars.enable_mentor_count + min_mentor_count = int(env_vars.min_mentor_comments) + max_comments_eval = int(env_vars.max_comments_eval) + heavily_involved_cutoff = int(env_vars.heavily_involved_cutoff) + + # Get the owners and repositories from the search query + owners_and_repositories = get_owners_and_repositories(search_query) + + # Every search query must include a repository owner for each repository, organization, or user + for item in owners_and_repositories: + if item["owner"] is None: + raise ValueError( + "The search query must include a repository owner and name \ + (ie. repo:owner/repo), an organization (ie. org:organization), \ + a user (ie. user:login) or an owner (ie. owner:user-or-organization)" + ) + # Determine if there are label to measure labels = env_vars.labels_to_measure @@ -264,16 +292,56 @@ def main(): raise ValueError( "The search query for discussions cannot include labels to measure" ) - issues = get_discussions(token, search_query) + issues = get_discussions(token, search_query, ghe) if len(issues) <= 0: print("No discussions found") - write_to_markdown(None, None, None, None, None, None, None) + write_to_markdown( + issues_with_metrics=None, + average_time_to_first_response=None, + average_time_to_close=None, + average_time_to_answer=None, + average_time_in_draft=None, + average_time_in_labels=None, + num_issues_opened=None, + num_issues_closed=None, + num_mentor_count=None, + labels=None, + search_query=search_query, + hide_label_metrics=False, + hide_items_closed_count=False, + enable_mentor_count=enable_mentor_count, + non_mentioning_links=False, + report_title=report_title, + output_file=output_file, + ghe=ghe, + ) return else: - issues = search_issues(search_query, github_connection) + issues = search_issues( + search_query, github_connection, owners_and_repositories, rate_limit_bypass + ) if len(issues) <= 0: print("No issues found") - write_to_markdown(None, None, None, None, None, None, None) + write_to_markdown( + issues_with_metrics=None, + average_time_to_first_response=None, + average_time_to_close=None, + average_time_to_answer=None, + average_time_in_draft=None, + average_time_in_labels=None, + num_issues_opened=None, + num_issues_closed=None, + num_mentor_count=None, + labels=None, + search_query=search_query, + hide_label_metrics=False, + hide_items_closed_count=False, + enable_mentor_count=enable_mentor_count, + non_mentioning_links=False, + report_title=report_title, + output_file=output_file, + ghe=ghe, + ) return # Get all the metrics @@ -282,16 +350,22 @@ def main(): discussions="type:discussions" in search_query, labels=labels, ignore_users=ignore_users, + max_comments_to_eval=max_comments_eval, + heavily_involved=heavily_involved_cutoff, + env_vars=env_vars, ) - stats_time_to_first_response = get_stats_time_to_first_response( - issues_with_metrics - ) + stats_time_to_first_response = get_stats_time_to_first_response(issues_with_metrics) stats_time_to_close = None if num_issues_closed > 0: stats_time_to_close = get_stats_time_to_close(issues_with_metrics) stats_time_to_answer = get_stats_time_to_answer(issues_with_metrics) + stats_time_in_draft = get_stats_time_in_draft(issues_with_metrics) + + num_mentor_count = 0 + if enable_mentor_count: + num_mentor_count = get_mentor_count(issues_with_metrics, min_mentor_count) # Get stats describing the time in label for each label and store it in a dictionary # where the key is the label and the value is the average time @@ -299,27 +373,42 @@ def main(): # Write the results to json and a markdown file write_to_json( - issues_with_metrics, - stats_time_to_first_response, - stats_time_to_close, - stats_time_to_answer, - stats_time_in_labels, - num_issues_open, - num_issues_closed, - search_query, + issues_with_metrics=issues_with_metrics, + stats_time_to_first_response=stats_time_to_first_response, + stats_time_to_close=stats_time_to_close, + stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, + stats_time_in_labels=stats_time_in_labels, + num_issues_opened=num_issues_open, + num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, + search_query=search_query, + output_file=output_file, ) + write_to_markdown( - issues_with_metrics, - stats_time_to_first_response, - stats_time_to_close, - stats_time_to_answer, - stats_time_in_labels, - num_issues_open, - num_issues_closed, - labels, - search_query, + issues_with_metrics=issues_with_metrics, + average_time_to_first_response=stats_time_to_first_response, + average_time_to_close=stats_time_to_close, + average_time_to_answer=stats_time_to_answer, + average_time_in_draft=stats_time_in_draft, + average_time_in_labels=stats_time_in_labels, + num_issues_opened=num_issues_open, + num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, + labels=labels, + search_query=search_query, + hide_label_metrics=hide_label_metrics, + hide_items_closed_count=hide_items_closed_count, + enable_mentor_count=enable_mentor_count, + non_mentioning_links=non_mentioning_links, + report_title=report_title, + output_file=output_file, + ghe=ghe, ) + evaluate_markdown_file_size(output_file) + if __name__ == "__main__": main() diff --git a/json_writer.py b/json_writer.py index cc4d12df..11285601 100644 --- a/json_writer.py +++ b/json_writer.py @@ -2,23 +2,26 @@ Functions: write_to_json( - issues_with_metrics: List[IssueWithMetrics], - average_time_to_first_response: timedelta, - average_time_to_close: timedelta, - average_time_to_answer: timedelta, - num_issues_opened: int, - num_issues_closed: int, + issues_with_metrics: Union[List[IssueWithMetrics], None], + stats_time_to_first_response: Union[dict[str, timedelta], None], + stats_time_to_close: Union[dict[str, timedelta], None], + stats_time_to_answer: Union[dict[str, timedelta], None], + stats_time_in_draft: Union[dict[str, timedelta], None], + stats_time_in_labels: Union[dict[str, dict[str, timedelta]], None], + num_issues_opened: Union[int, None], + num_issues_closed: Union[int, None], + num_mentor_count: Union[int, None], search_query: str, + output_file: str, ) -> str: Write the issues with metrics to a json file. """ - import json -from datetime import timedelta import os -from typing import List, Union +from datetime import timedelta +from typing import Any, List, Union from classes import IssueWithMetrics @@ -28,46 +31,61 @@ def write_to_json( stats_time_to_first_response: Union[dict[str, timedelta], None], stats_time_to_close: Union[dict[str, timedelta], None], stats_time_to_answer: Union[dict[str, timedelta], None], + stats_time_in_draft: Union[dict[str, timedelta], None], stats_time_in_labels: Union[dict[str, dict[str, timedelta]], None], num_issues_opened: Union[int, None], num_issues_closed: Union[int, None], + num_mentor_count: Union[int, None], search_query: str, + output_file: str, ) -> str: """ Write the issues with metrics to a JSON file called issue_metrics.json. json structure is like following { - "average_time_to_first_response": "2 days, 12:00:00", - "average_time_to_close": "5 days, 0:00:00", - "average_time_to_answer": "1 day, 0:00:00", + "average_time_to_first_response": "None", + "average_time_to_close": "None", + "average_time_to_answer": "None", + "average_time_in_draft": "None", + "average_time_in_labels": {}, + "median_time_to_first_response": "None", + "median_time_to_close": "None", + "median_time_to_answer": "None", + "median_time_in_draft": "None", + "median_time_in_labels": {}, + "90_percentile_time_to_first_response": "None", + "90_percentile_time_to_close": "None", + "90_percentile_time_to_answer": "None", + "90_percentile_time_in_draft": "None", + "90_percentile_time_in_labels": {}, "num_items_opened": 2, - "num_items_closed": 1, + "num_items_closed": 0, + "num_mentor_count": 5, "total_item_count": 2, "issues": [ { "title": "Issue 1", "html_url": "https://github.com/owner/repo/issues/1", - "author": "author", - "time_to_first_response": "3 days, 0:00:00", - "time_to_close": "6 days, 0:00:00", + "author": "alice", + "time_to_first_response": "None", + "time_to_close": "None", "time_to_answer": "None", - "label_metrics": { - "bug": "1 day, 16:24:12" - } + "time_in_draft": "None", + "label_metrics": {} }, { "title": "Issue 2", "html_url": "https://github.com/owner/repo/issues/2", - "author": "author", - "time_to_first_response": "2 days, 0:00:00", - "time_to_close": "4 days, 0:00:00", - "time_to_answer": "1 day, 0:00:00", - "label_metrics": { - } - }, + "author": "bob", + "time_to_first_response": "None", + "time_to_close": "None", + "time_to_answer": "None", + "time_in_draft": "None", + "label_metrics": {} + } ], - "search_query": "is:issue is:open repo:owner/repo" + "search_query": "is:issue repo:owner/repo" } """ @@ -81,54 +99,69 @@ def write_to_json( med_time_to_first_response = None p90_time_to_first_response = None if stats_time_to_first_response is not None: - average_time_to_first_response = stats_time_to_first_response['avg'] - med_time_to_first_response = stats_time_to_first_response['med'] - p90_time_to_first_response = stats_time_to_first_response['90p'] + average_time_to_first_response = stats_time_to_first_response["avg"] + med_time_to_first_response = stats_time_to_first_response["med"] + p90_time_to_first_response = stats_time_to_first_response["90p"] # time to close average_time_to_close = None med_time_to_close = None p90_time_to_close = None if stats_time_to_close is not None: - average_time_to_close = stats_time_to_close['avg'] - med_time_to_close = stats_time_to_close['med'] - p90_time_to_close = stats_time_to_close['90p'] + average_time_to_close = stats_time_to_close["avg"] + med_time_to_close = stats_time_to_close["med"] + p90_time_to_close = stats_time_to_close["90p"] # time to answer average_time_to_answer = None med_time_to_answer = None p90_time_to_answer = None if stats_time_to_answer is not None: - average_time_to_answer = stats_time_to_answer['avg'] - med_time_to_answer = stats_time_to_answer['med'] - p90_time_to_answer = stats_time_to_answer['90p'] - + average_time_to_answer = stats_time_to_answer["avg"] + med_time_to_answer = stats_time_to_answer["med"] + p90_time_to_answer = stats_time_to_answer["90p"] + + # time in draft + average_time_in_draft = None + med_time_in_draft = None + p90_time_in_draft = None + if stats_time_in_draft is not None: + average_time_in_draft = stats_time_in_draft["avg"] + med_time_in_draft = stats_time_in_draft["med"] + p90_time_in_draft = stats_time_in_draft["90p"] + + # time in labels average_time_in_labels = {} med_time_in_labels = {} p90_time_in_labels = {} - for label, time in stats_time_in_labels['avg'].items(): - average_time_in_labels[label] = str(time) - for label, time in stats_time_in_labels['med'].items(): - med_time_in_labels[label] = str(time) - for label, time in stats_time_in_labels['90p'].items(): - p90_time_in_labels[label] = str(time) + if stats_time_in_labels is not None: + for label, time in stats_time_in_labels["avg"].items(): + average_time_in_labels[label] = str(time) + for label, time in stats_time_in_labels["med"].items(): + med_time_in_labels[label] = str(time) + for label, time in stats_time_in_labels["90p"].items(): + p90_time_in_labels[label] = str(time) # Create a dictionary with the metrics - metrics = { + metrics: dict[str, Any] = { "average_time_to_first_response": str(average_time_to_first_response), "average_time_to_close": str(average_time_to_close), "average_time_to_answer": str(average_time_to_answer), + "average_time_in_draft": str(average_time_in_draft), "average_time_in_labels": average_time_in_labels, "median_time_to_first_response": str(med_time_to_first_response), "median_time_to_close": str(med_time_to_close), "median_time_to_answer": str(med_time_to_answer), + "median_time_in_draft": str(med_time_in_draft), "median_time_in_labels": med_time_in_labels, "90_percentile_time_to_first_response": str(p90_time_to_first_response), "90_percentile_time_to_close": str(p90_time_to_close), "90_percentile_time_to_answer": str(p90_time_to_answer), + "90_percentile_time_in_draft": str(p90_time_in_draft), "90_percentile_time_in_labels": p90_time_in_labels, "num_items_opened": num_issues_opened, "num_items_closed": num_issues_closed, + "num_mentor_count": num_mentor_count, "total_item_count": len(issues_with_metrics), } @@ -144,10 +177,14 @@ def write_to_json( "title": issue.title, "html_url": issue.html_url, "author": issue.author, + "assignee": issue.assignee, + "assignees": issue.assignees, "time_to_first_response": str(issue.time_to_first_response), "time_to_close": str(issue.time_to_close), "time_to_answer": str(issue.time_to_answer), + "time_in_draft": str(issue.time_in_draft), "label_metrics": formatted_label_metrics, + "created_at": str(issue.created_at), } ) @@ -165,7 +202,8 @@ def write_to_json( print(f"metrics={metrics_json}", file=file_handle) # Write the metrics to a JSON file - with open("issue_metrics.json", "w", encoding="utf-8") as file: + output_file_name = output_file if output_file else "issue_metrics.json" + with open(output_file_name, "w", encoding="utf-8") as file: json.dump(metrics, file, indent=4) return metrics_json diff --git a/labels.py b/labels.py index 091a5a0f..ee8656b5 100644 --- a/labels.py +++ b/labels.py @@ -1,11 +1,11 @@ -""" Functions for calculating time spent in labels. """ +"""Functions for calculating time spent in labels.""" + from datetime import datetime, timedelta from typing import List import github3 import numpy import pytz - from classes import IssueWithMetrics @@ -41,8 +41,9 @@ def get_label_metrics(issue: github3.issues.Issue, labels: List[str]) -> dict: Returns: dict: A dictionary containing the time spent in each label or None. """ - label_metrics = {} + label_metrics: dict = {} label_events = get_label_events(issue, labels) + label_last_event_type: dict = {} for label in labels: label_metrics[label] = None @@ -55,6 +56,12 @@ def get_label_metrics(issue: github3.issues.Issue, labels: List[str]) -> dict: # Calculate the time to add or subtract to the time spent in label based on the label events for event in label_events: + # Skip labeling events that have occurred past issue close time + if issue.closed_at is not None and ( + event.created_at >= datetime.fromisoformat(issue.closed_at) + ): + continue + if event.event == "labeled": labeled[event.label["name"]] = True if event.label["name"] in labels: @@ -63,6 +70,7 @@ def get_label_metrics(issue: github3.issues.Issue, labels: List[str]) -> dict: label_metrics[ event.label["name"] ] -= event.created_at - datetime.fromisoformat(issue.created_at) + label_last_event_type[event.label["name"]] = "labeled" elif event.event == "unlabeled": unlabeled[event.label["name"]] = True if event.label["name"] in labels: @@ -71,16 +79,23 @@ def get_label_metrics(issue: github3.issues.Issue, labels: List[str]) -> dict: label_metrics[ event.label["name"] ] += event.created_at - datetime.fromisoformat(issue.created_at) + label_last_event_type[event.label["name"]] = "unlabeled" for label in labels: - # if the label is still on there, add the time from the last event to now - if label in labeled and label not in unlabeled: + if label in labeled: # if the issue is closed, add the time from the issue creation to the closed_at time if issue.state == "closed": + # Only add the final (closed_at - created_at) span if the label was still applied at closure. + if label_last_event_type.get(label) != "labeled": + continue label_metrics[label] += datetime.fromisoformat( issue.closed_at ) - datetime.fromisoformat(issue.created_at) else: + # skip label if last labeling event is 'unlabeled' and issue is still open + if label_last_event_type[label] == "unlabeled": + continue + # if the issue is open, add the time from the issue creation to now label_metrics[label] += datetime.now(pytz.utc) - datetime.fromisoformat( issue.created_at @@ -91,8 +106,8 @@ def get_label_metrics(issue: github3.issues.Issue, labels: List[str]) -> dict: def get_stats_time_in_labels( issues_with_metrics: List[IssueWithMetrics], - labels: List[str], -) -> dict[str, timedelta]: + labels: dict[str, timedelta], +) -> dict[str, dict[str, timedelta | None]]: """Calculate stats describing time spent in each label.""" time_in_labels = {} for issue in issues_with_metrics: @@ -107,9 +122,9 @@ def get_stats_time_in_labels( issue.label_metrics[label].total_seconds() ) - average_time_in_labels = {} - med_time_in_labels = {} - ninety_percentile_in_labels = {} + average_time_in_labels: dict[str, timedelta | None] = {} + med_time_in_labels: dict[str, timedelta | None] = {} + ninety_percentile_in_labels: dict[str, timedelta | None] = {} for label, time_list in time_in_labels.items(): average_time_in_labels[label] = timedelta( seconds=numpy.round(numpy.average(time_list)) diff --git a/markdown_helpers.py b/markdown_helpers.py new file mode 100644 index 00000000..c06a0692 --- /dev/null +++ b/markdown_helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for working with markdown files.""" + + +def markdown_too_large_for_issue_body(file_path: str, max_char_count: int) -> bool: + """ + Check if the markdown file is too large to fit into a github issue. + + Inputs: + file_path: str - the path to the markdown file to check + max_char_count: int - the maximum number of characters allowed in a github issue body + + Returns: + bool - True if the file is too large, False otherwise + + """ + with open(file_path, "r", encoding="utf-8") as file: + file_contents = file.read() + return len(file_contents) > max_char_count + + +def split_markdown_file(file_path: str, max_char_count: int) -> None: + """ + Split the markdown file into smaller files. + + Inputs: + file_path: str - the path to the markdown file to split + max_char_count: int - the maximum number of characters allowed before splitting markdown file + + """ + with open(file_path, "r", encoding="utf-8") as file: + file_contents = file.read() + contents_list = [ + file_contents[i : i + max_char_count] + for i in range(0, len(file_contents), max_char_count) + ] + for i, content in enumerate(contents_list): + with open(f"{file_path[:-3]}_{i}.md", "w", encoding="utf-8") as new_file: + new_file.write(content) diff --git a/markdown_writer.py b/markdown_writer.py index ba496229..67cc2417 100644 --- a/markdown_writer.py +++ b/markdown_writer.py @@ -7,13 +7,21 @@ Functions: write_to_markdown( - issues_with_metrics: List[IssueWithMetrics], - average_time_to_first_response: timedelta, - average_time_to_close: timedelta, - average_time_to_answer: timedelta, - num_issues_opened: int, - num_issues_closed: int, - file: file object = None + issues_with_metrics: Union[List[IssueWithMetrics], None], + average_time_to_first_response: Union[dict[str, timedelta], None], + average_time_to_close: Union[dict[str, timedelta], None], + average_time_to_answer: Union[dict[str, timedelta], None], + average_time_in_labels: Union[dict, None], + num_issues_opened: Union[int, None], + num_issues_closed: Union[int, None], + num_mentor_count: Union[int, None], + labels: List[str], + search_query: str, + hide_label_metrics: bool, + hide_items_closed_count: bool, + non_mentioning_links: bool, + report_title: str, + output_file: str, ) -> None: Write the issues with metrics to a markdown file. get_non_hidden_columns( @@ -47,6 +55,10 @@ def get_non_hidden_columns(labels) -> List[str]: env_vars = get_env_vars() # Find the number of columns and which are to be hidden + hide_assignee = env_vars.hide_assignee + if not hide_assignee: + columns.append("Assignee") + hide_author = env_vars.hide_author if not hide_author: columns.append("Author") @@ -63,10 +75,21 @@ def get_non_hidden_columns(labels) -> List[str]: if not hide_time_to_answer: columns.append("Time to answer") + enable_time_in_draft = env_vars.draft_pr_tracking + if enable_time_in_draft: + columns.append("Time in draft") + hide_label_metrics = env_vars.hide_label_metrics if not hide_label_metrics and labels: for label in labels: columns.append(f"Time spent in {label}") + hide_created_at = env_vars.hide_created_at + if not hide_created_at: + columns.append("Created At") + + hide_status = env_vars.hide_status + if not hide_status: + columns.append("Status") return columns @@ -76,12 +99,20 @@ def write_to_markdown( average_time_to_first_response: Union[dict[str, timedelta], None], average_time_to_close: Union[dict[str, timedelta], None], average_time_to_answer: Union[dict[str, timedelta], None], + average_time_in_draft: Union[dict[str, timedelta], None], average_time_in_labels: Union[dict, None], num_issues_opened: Union[int, None], num_issues_closed: Union[int, None], + num_mentor_count: Union[int, None], labels=None, search_query=None, hide_label_metrics=False, + hide_items_closed_count=False, + enable_mentor_count=False, + non_mentioning_links=False, + report_title="", + output_file="", + ghe="", ) -> None: """Write the issues with metrics to a markdown file. @@ -91,28 +122,40 @@ def write_to_markdown( response for the issues. average_time_to_close (datetime.timedelta): The average time to close for the issues. average_time_to_answer (datetime.timedelta): The average time to answer the discussions. + average_time_in_draft (datetime.timedelta): The average time spent in draft for the issues. average_time_in_labels (dict): A dictionary containing the average time spent in each label. file (file object, optional): The file object to write to. If not provided, a file named "issue_metrics.md" will be created. num_issues_opened (int): The Number of items that remain opened. num_issues_closed (int): The number of issues that were closed. + num_mentor_count (int): The number of very active commentors. labels (List[str]): A list of the labels that are used in the issues. search_query (str): The search query used to find the issues. - hide_label_metrics (bool): Represents whether the user has chosen to hide label metrics in the output + hide_label_metrics (bool): Represents whether the user has chosen to hide label + metrics in the output + hide_items_closed_count (bool): Represents whether the user has chosen to hide + the number of items closed + non_mentioning_links (bool): Represents whether links do not cause a notification + in the destination repository + report_title (str): The title of the report + output_file (str): The name of the file to write the report to + ghe (str): the GitHub Enterprise endpoint Returns: None. """ columns = get_non_hidden_columns(labels) - with open("issue_metrics.md", "w", encoding="utf-8") as file: - file.write("# Issue Metrics\n\n") + output_file_name = output_file if output_file else "issue_metrics.md" + with open(output_file_name, "w", encoding="utf-8") as file: + file.write(f"# {report_title}\n\n") # If all the metrics are None, then there are no issues if not issues_with_metrics or len(issues_with_metrics) == 0: file.write("no issues found for the given search criteria\n\n") file.write( - "\n_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" + "\n_This report was generated with the \ +[Issue Metrics Action](https://github.com/github/issue-metrics)_\n" ) if search_query: file.write(f"Search query used to find these items: `{search_query}`\n") @@ -124,13 +167,17 @@ def write_to_markdown( average_time_to_first_response, average_time_to_close, average_time_to_answer, + average_time_in_draft, average_time_in_labels, num_issues_opened, num_issues_closed, + num_mentor_count, labels, columns, file, hide_label_metrics, + hide_items_closed_count, + enable_mentor_count, ) # Write second table with individual issue/pr/discussion metrics @@ -153,27 +200,53 @@ def write_to_markdown( # Replace any whitespace issue.title = issue.title.strip() - file.write(f"| " f"{issue.title} | " f"{issue.html_url} |") + endpoint = ghe.removeprefix("https://") if ghe else "github.com" + if non_mentioning_links: + file.write( + f"| {issue.title} | " + f"{issue.html_url}".replace( + f"https://{endpoint}", f"https://www.{endpoint}" + ) + + " |" + ) + else: + file.write(f"| {issue.title} | {issue.html_url} |") + if "Assignee" in columns: + if issue.assignees: + assignee_links = [ + f"[{assignee}](https://{endpoint}/{assignee})" + for assignee in issue.assignees + ] + file.write(f" {', '.join(assignee_links)} |") + else: + file.write(" None |") if "Author" in columns: - file.write(f" [{issue.author}](https://github.com/{issue.author}) |") + file.write(f" [{issue.author}](https://{endpoint}/{issue.author}) |") if "Time to first response" in columns: file.write(f" {issue.time_to_first_response} |") if "Time to close" in columns: file.write(f" {issue.time_to_close} |") if "Time to answer" in columns: file.write(f" {issue.time_to_answer} |") + if "Time in draft" in columns: + file.write(f" {issue.time_in_draft} |") if labels and issue.label_metrics: for label in labels: if f"Time spent in {label}" in columns: file.write(f" {issue.label_metrics[label]} |") + if "Created At" in columns: + file.write(f" {issue.created_at} |") + if "Status" in columns: + file.write(f" {issue.status} |") file.write("\n") file.write( - "\n_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" + "\n_This report was generated with the \ +[Issue Metrics Action](https://github.com/github/issue-metrics)_\n" ) if search_query: file.write(f"Search query used to find these items: `{search_query}`\n") - print("Wrote issue metrics to issue_metrics.md") + print(f"Wrote issue metrics to {output_file_name}") def write_overall_metrics_tables( @@ -181,21 +254,28 @@ def write_overall_metrics_tables( stats_time_to_first_response, stats_time_to_close, stats_time_to_answer, + average_time_in_draft, stats_time_in_labels, num_issues_opened, num_issues_closed, + num_mentor_count, labels, columns, file, hide_label_metrics, + hide_items_closed_count=False, + enable_mentor_count=False, ): """Write the overall metrics tables to the markdown file.""" - if ( - "Time to first response" in columns - or "Time to close" in columns - or "Time to answer" in columns - or (hide_label_metrics is False and len(labels) > 0) - ): + if any( + column in columns + for column in [ + "Time to first response", + "Time to close", + "Time to answer", + "Time in draft", + ] + ) or (hide_label_metrics is False and len(labels) > 0): file.write("| Metric | Average | Median | 90th percentile |\n") file.write("| --- | --- | --- | ---: |\n") if "Time to first response" in columns: @@ -228,6 +308,16 @@ def write_overall_metrics_tables( ) else: file.write("| Time to answer | None | None | None |\n") + if "Time in draft" in columns: + if average_time_in_draft is not None: + file.write( + f"| Time in draft " + f"| {average_time_in_draft['avg']} " + f"| {average_time_in_draft['med']} " + f"| {average_time_in_draft['90p']} |\n" + ) + else: + file.write("| Time in draft | None | None | None |\n") if labels and stats_time_in_labels: for label in labels: if ( @@ -240,10 +330,16 @@ def write_overall_metrics_tables( f"| {stats_time_in_labels['med'][label]} " f"| {stats_time_in_labels['90p'][label]} |\n" ) + if "Status" in columns: # Add logic for the 'status' column + file.write("| Status | | | |\n") + file.write("\n") # Write count stats to a separate table file.write("| Metric | Count |\n") file.write("| --- | ---: |\n") file.write(f"| Number of items that remain open | {num_issues_opened} |\n") - file.write(f"| Number of items closed | {num_issues_closed} |\n") + if not hide_items_closed_count: + file.write(f"| Number of items closed | {num_issues_closed} |\n") + if enable_mentor_count: + file.write(f"| Number of most active mentors | {num_mentor_count} |\n") file.write(f"| Total number of items created | {len(issues_with_metrics)} |\n\n") diff --git a/most_active_mentors.py b/most_active_mentors.py new file mode 100755 index 00000000..7a9a64e8 --- /dev/null +++ b/most_active_mentors.py @@ -0,0 +1,181 @@ +"""A module for measuring the number of very active mentors + +This module provides functions for measuring the number of active mentors on a +project. + +This is measured by number of PR comments. We are working under the assumption +that PR comments are left in good faith to move contributors further instead of +nitpicking and discouraging them. + +Open questions: + - should there be an option to limit this to certain users, e.g. core + maintainers? + - should there be a limit to how many comments per PR we consider to avoid + having the statistic dominated by contested PRs? + - should this metric count consecutive comments coming from the same user as + only one to avoid people unnessesarily splitting their comments to game the + metric? + - instead of PR comments should we count PRs on which a username was seen as + commenter? + +Functions: + collect_response_usernames( + issue: Union[github3.issues.Issue, None], + discussion: Union[dict, None], + pull_request: Union[github3.pulls.PullRequest, None], + max_comments_to_evaluate, + ) -> ____________ + Collect the number of responses per username for single item. Take only + top n comments (max_comments_to_evaluate) into consideration. + get_number_of_active_reviewers( + mentors: List [mentors with metrics) + ) -> int active_number + Count the number of mentors active at least n times + +""" + +from collections import Counter +from datetime import datetime +from typing import Dict, List, Union + +import github3 +from classes import IssueWithMetrics + + +def count_comments_per_user( + issue: Union[github3.issues.Issue, None], # type: ignore + discussion: Union[dict, None] = None, + pull_request: Union[github3.pulls.PullRequest, None] = None, + ready_for_review_at: Union[datetime, None] = None, + ignore_users: List[str] | None = None, + max_comments_to_eval=20, + heavily_involved=3, +) -> dict: + """Count the number of times a user was seen commenting on a single item. + + Args: + issue (Union[github3.issues.Issue, None]): A GitHub issue. + pull_request (Union[github3.pulls.PullRequest, None]): A GitHub pull + request. + ignore_users (List[str]): A list of GitHub usernames to ignore. + max_comments_to_eval: Maximum number of comments per item to look at. + heavily_involved: Maximum number of comments to count for one + user per issue. + + Returns: + dict: A dictionary of usernames seen and number of comments they left. + + """ + if ignore_users is None: + ignore_users = [] + mentor_count: Dict[str, int] = {} + + # Get the first comments + if issue: + comments = issue.issue.comments( + number=max_comments_to_eval, sort="created", direction="asc" + ) # type: ignore + for comment in comments: + if ignore_comment( + issue.issue.user, + comment.user, + ignore_users, + comment.created_at, + ready_for_review_at, + ): + continue + # increase the number of comments left by current user by 1 + if comment.user.login in mentor_count: + if mentor_count[comment.user.login] < heavily_involved: + mentor_count[comment.user.login] += 1 + else: + mentor_count[comment.user.login] = 1 + + # Check if the issue is actually a pull request + # so we may also get the first review comment time + if pull_request: + review_comments = pull_request.reviews(number=max_comments_to_eval) + # type: ignore + for review_comment in review_comments: + if ignore_comment( + issue.issue.user, + review_comment.user, + ignore_users, + review_comment.submitted_at, + ready_for_review_at, + ): + continue + + # increase the number of comments left by current user by 1 + if review_comment.user.login in mentor_count: + mentor_count[review_comment.user.login] += 1 + else: + mentor_count[review_comment.user.login] = 1 + + if discussion and len(discussion["comments"]["nodes"]) > 0: + for comment in discussion["comments"]["nodes"]: + if ignore_comment( + comment.user, + comment.user, + ignore_users, + comment.submitted_at, + comment.ready_for_review_at, + ): + continue + + # increase the number of comments left by current user by 1 + if comment.user.login in mentor_count: + mentor_count[comment.user.login] += 1 + else: + mentor_count[comment.user.login] = 1 + + return mentor_count + + +def ignore_comment( + issue_user: github3.users.User, + comment_user: github3.users.User, + ignore_users: List[str], + comment_created_at: datetime, + ready_for_review_at: Union[datetime, None], +) -> bool: + """Check if a comment should be ignored.""" + return bool( + # ignore comments by IGNORE_USERS + comment_user.login in ignore_users + # ignore comments by bots + or comment_user.type == "Bot" + # ignore comments by the issue creator + or comment_user.login == issue_user.login + # ignore pending reviews + or not comment_created_at + # ignore comments created before the issue was ready for review + or (ready_for_review_at and comment_created_at < ready_for_review_at) + ) + + +def get_mentor_count(issues_with_metrics: List[IssueWithMetrics], cutoff: int) -> int: + """Calculate the number of active mentors on the project. + + Args: + issues_with_metrics (List[IssueWithMetrics]): A list of issues w/ + metrics + cutoff (int: the minimum number of comments a user has to leave + to count as active mentor.) + + Returns: + int: Number of active mentors + + """ + + mentor_count: Counter[str] = Counter({}) + for issue_with_metrics in issues_with_metrics: + current_counter = Counter(issue_with_metrics.mentor_activity) + mentor_count = mentor_count + current_counter + + active_mentor_count = 0 + for count in mentor_count.values(): + if count >= cutoff: + active_mentor_count += 1 + + return active_mentor_count diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 00000000..c0be31ec --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,9 @@ +black==25.1.0 +flake8==7.3.0 +mypy==1.17.1 +mypy-extensions==1.1.0 +pylint==3.3.8 +pytest==8.4.1 +pytest-cov==6.2.1 +types-pytz==2025.2.0.20250809 +types-requests==2.32.4.20250809 diff --git a/requirements.txt b/requirements.txt index 7fe0965b..5ba9ab59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,5 @@ github3.py==4.0.1 -python-dotenv==1.0.1 -pytz==2024.1 -Requests==2.31.0 -pytest==8.1.1 -pytest-cov==4.1.0 -flake8==7.0.0 -pylint==3.1.0 -numpy==1.26.4 +numpy==2.2.4 +python-dotenv==1.1.1 +pytz==2025.2 +requests==2.32.5 diff --git a/search.py b/search.py new file mode 100644 index 00000000..21091356 --- /dev/null +++ b/search.py @@ -0,0 +1,155 @@ +"""A module to search for issues in a GitHub repository.""" + +import sys +from time import sleep +from typing import List + +import github3 +import github3.structs + + +def search_issues( + search_query: str, + github_connection: github3.GitHub, + owners_and_repositories: List[dict], + rate_limit_bypass: bool = False, +) -> List[github3.search.IssueSearchResult]: # type: ignore + """ + Searches for issues/prs/discussions in a GitHub repository that match + the given search query and handles errors related to GitHub API responses. + + Args: + search_query (str): The search query to use for finding issues/prs/discussions. + github_connection (github3.GitHub): A connection to the GitHub API. + owners_and_repositories (List[dict]): A list of dictionaries containing + the owner and repository names. + rate_limit_bypass (bool, optional): A flag to bypass the rate limit to be used + when working with GitHub server that has rate limiting turned off. Defaults to False. + + Returns: + List[github3.search.IssueSearchResult]: A list of issues that match the search query. + """ + + # Rate Limit Handling: API only allows 30 requests per minute + def wait_for_api_refresh( + iterator: github3.structs.SearchIterator, rate_limit_bypass: bool = False + ): + # If the rate limit bypass is enabled, don't wait for the API to refresh + if rate_limit_bypass: + return + + max_retries = 5 + retry_count = 0 + sleep_time = 70 + + while iterator.ratelimit_remaining < 5: + if retry_count >= max_retries: + raise RuntimeError("Exceeded maximum retries for API rate limit") + + print( + f"GitHub API Rate Limit Low, waiting {sleep_time} seconds to refresh." + ) + sleep(sleep_time) + + # Exponentially increase the sleep time for the next retry + sleep_time *= 2 + retry_count += 1 + + issues_per_page = 100 + + print("Searching for issues...") + issues_iterator = github_connection.search_issues( + search_query, per_page=issues_per_page + ) + wait_for_api_refresh(issues_iterator, rate_limit_bypass) + + issues = [] + repos_and_owners_string = "" + for item in owners_and_repositories: + repos_and_owners_string += ( + f"{item.get('owner', '')}/{item.get('repository', '')} " + ) + + # Print the issue titles and add them to the list of issues + try: + for idx, issue in enumerate(issues_iterator, 1): + print(issue.title) # type: ignore + issues.append(issue) + + # requests are sent once per page of issues + if idx % issues_per_page == 0: + wait_for_api_refresh(issues_iterator, rate_limit_bypass) + + except github3.exceptions.ForbiddenError as e: + print( + f"You do not have permission to view a repository \ +from: '{repos_and_owners_string}'; Check your API Token." + ) + print_error_messages(e) + sys.exit(1) + except github3.exceptions.NotFoundError as e: + print( + f"The repository could not be found; \ +Check the repository owner and names: '{repos_and_owners_string}" + ) + print_error_messages(e) + sys.exit(1) + except github3.exceptions.ConnectionError as e: + print( + "There was a connection error; Check your internet connection or API Token." + ) + print_error_messages(e) + sys.exit(1) + except github3.exceptions.AuthenticationFailed as e: + print("Authentication failed; Check your API Token.") + print_error_messages(e) + sys.exit(1) + except github3.exceptions.UnprocessableEntity as e: + print("The search query is invalid; Check the search query.") + print_error_messages(e) + sys.exit(1) + + return issues + + +def print_error_messages(error: github3.exceptions): + """Prints the error messages from the GitHub API response. + + Args: + Error (github3.exceptions): The error object from the GitHub API response. + + """ + if hasattr(error, "errors"): + for e in error.errors: + print(f"Error: {e.get('message')}") + + +def get_owners_and_repositories( + search_query: str, +) -> List[dict]: + """Get the owners and repositories from the search query. + + Args: + search_query (str): The search query used to search for issues. + + Returns: + List[dict]: A list of dictionaries of owners and repositories. + + """ + search_query_split = search_query.split(" ") + results_list = [] + for item in search_query_split: + result = {} + if "repo:" in item and "/" in item: + result["owner"] = item.split(":")[1].split("/")[0] + result["repository"] = item.split(":")[1].split("/")[1] + if "org:" in item or "owner:" in item or "user:" in item: + result["owner"] = item.split(":")[1] + if "user:" in item: + result["owner"] = item.split(":")[1] + if "owner:" in item: + result["owner"] = item.split(":")[1] + if result: + results_list.append(result) + + return results_list diff --git a/test_assignee_functionality.py b/test_assignee_functionality.py new file mode 100644 index 00000000..890fc62c --- /dev/null +++ b/test_assignee_functionality.py @@ -0,0 +1,225 @@ +"""Test assignee functionality added to issue metrics.""" + +import os +import unittest +from unittest.mock import patch + +from classes import IssueWithMetrics +from markdown_writer import get_non_hidden_columns + + +class TestAssigneeFunctionality(unittest.TestCase): + """Test suite for the assignee functionality.""" + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_ASSIGNEE": "false", + "HIDE_AUTHOR": "false", + }, + clear=True, + ) + def test_get_non_hidden_columns_includes_assignee_by_default(self): + """Test that assignee column is included by default.""" + columns = get_non_hidden_columns(labels=None) + self.assertIn("Assignee", columns) + self.assertIn("Author", columns) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_ASSIGNEE": "true", + "HIDE_AUTHOR": "false", + }, + clear=True, + ) + def test_get_non_hidden_columns_hides_assignee_when_env_set(self): + """Test that assignee column is hidden when HIDE_ASSIGNEE is true.""" + columns = get_non_hidden_columns(labels=None) + self.assertNotIn("Assignee", columns) + self.assertIn("Author", columns) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_ASSIGNEE": "false", + "HIDE_AUTHOR": "true", + }, + clear=True, + ) + def test_get_non_hidden_columns_shows_assignee_but_hides_author(self): + """Test that assignee can be shown while author is hidden.""" + columns = get_non_hidden_columns(labels=None) + self.assertIn("Assignee", columns) + self.assertNotIn("Author", columns) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_ASSIGNEE": "true", + "HIDE_AUTHOR": "true", + }, + clear=True, + ) + def test_get_non_hidden_columns_hides_both_assignee_and_author(self): + """Test that both assignee and author can be hidden.""" + columns = get_non_hidden_columns(labels=None) + self.assertNotIn("Assignee", columns) + self.assertNotIn("Author", columns) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_STATUS": "false", + }, + clear=True, + ) + def test_get_non_hidden_columns_includes_status_by_default(self): + """Test that status column is included by default.""" + columns = get_non_hidden_columns(labels=None) + self.assertIn("Status", columns) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_STATUS": "true", + }, + clear=True, + ) + def test_get_non_hidden_columns_hides_status_when_env_set(self): + """Test that status column is hidden when HIDE_STATUS is true.""" + columns = get_non_hidden_columns(labels=None) + self.assertNotIn("Status", columns) + + def test_assignee_column_position(self): + """Test that assignee column appears before author column.""" + with patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_ASSIGNEE": "false", + "HIDE_AUTHOR": "false", + }, + clear=True, + ): + columns = get_non_hidden_columns(labels=None) + assignee_index = columns.index("Assignee") + author_index = columns.index("Author") + self.assertLess( + assignee_index, + author_index, + "Assignee column should appear before Author column", + ) + + def test_multiple_assignees_rendering_logic(self): + """Test that multiple assignees are rendered correctly in assignee column.""" + + # Test the assignee rendering logic directly + endpoint = "github.com" + columns = ["Title", "URL", "Assignee", "Author"] + + # Initialize variables + multiple_output = "" + single_output = "" + none_output = "" + + # Test case 1: Multiple assignees + issue_multiple = IssueWithMetrics( + title="Test Issue with Multiple Assignees", + html_url="https://github.com/test/repo/issues/1", + author="testuser", + assignee="alice", + assignees=["alice", "bob", "charlie"], + ) + + # Simulate the new rendering logic + if "Assignee" in columns: + if issue_multiple.assignees: + assignee_links = [ + f"[{assignee}](https://{endpoint}/{assignee})" + for assignee in issue_multiple.assignees + ] + multiple_output = f" {', '.join(assignee_links)} |" + else: + multiple_output = " None |" + + expected_multiple = ( + " [alice](https://github.com/alice), [bob](https://github.com/bob), " + "[charlie](https://github.com/charlie) |" + ) + self.assertEqual( + multiple_output, + expected_multiple, + "Multiple assignees should be rendered as comma-separated links", + ) + + # Test case 2: Single assignee + issue_single = IssueWithMetrics( + title="Test Issue with Single Assignee", + html_url="https://github.com/test/repo/issues/2", + author="testuser", + assignee="alice", + assignees=["alice"], + ) + + if "Assignee" in columns: + if issue_single.assignees: + assignee_links = [ + f"[{assignee}](https://{endpoint}/{assignee})" + for assignee in issue_single.assignees + ] + single_output = f" {', '.join(assignee_links)} |" + else: + single_output = " None |" + + expected_single = " [alice](https://github.com/alice) |" + self.assertEqual( + single_output, + expected_single, + "Single assignee should be rendered as a single link", + ) + + # Test case 3: No assignees + issue_none = IssueWithMetrics( + title="Test Issue with No Assignees", + html_url="https://github.com/test/repo/issues/3", + author="testuser", + assignee=None, + assignees=[], + ) + + if "Assignee" in columns: + if issue_none.assignees: + assignee_links = [ + f"[{assignee}](https://{endpoint}/{assignee})" + for assignee in issue_none.assignees + ] + none_output = f" {', '.join(assignee_links)} |" + else: + none_output = " None |" + + expected_none = " None |" + self.assertEqual( + none_output, expected_none, "No assignees should be rendered as 'None'" + ) + + print(f"✅ Multiple assignees test: {expected_multiple}") + print(f"✅ Single assignee test: {expected_single}") + print(f"✅ No assignees test: {expected_none}") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_assignee_integration.py b/test_assignee_integration.py new file mode 100644 index 00000000..3495b778 --- /dev/null +++ b/test_assignee_integration.py @@ -0,0 +1,173 @@ +"""Integration test for assignee functionality.""" + +import json +import os +import tempfile +import unittest +from datetime import datetime, timedelta +from unittest.mock import patch + +from classes import IssueWithMetrics +from json_writer import write_to_json +from markdown_writer import write_to_markdown + + +class TestAssigneeIntegration(unittest.TestCase): + """Integration test for assignee functionality.""" + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "repo:test/repo is:issue", + }, + clear=True, + ) + def test_assignee_in_markdown_output(self): + """Test that assignee information appears correctly in markdown output.""" + issues_with_metrics = [ + IssueWithMetrics( + title="Test Issue 1", + html_url="https://github.com/test/repo/issues/1", + author="john", + assignee="alice", + assignees=["alice"], + time_to_first_response=timedelta(hours=2), + time_to_close=timedelta(days=1), + created_at=datetime.now() - timedelta(days=2), + ), + IssueWithMetrics( + title="Test Issue 2", + html_url="https://github.com/test/repo/issues/2", + author="jane", + assignee=None, + assignees=[], + time_to_first_response=timedelta(hours=4), + time_to_close=None, + created_at=datetime.now() - timedelta(days=1), + ), + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + output_file = f.name + + try: + write_to_markdown( + issues_with_metrics=issues_with_metrics, + average_time_to_first_response={ + "avg": timedelta(hours=3), + "med": timedelta(hours=3), + "90p": timedelta(hours=4), + }, + average_time_to_close={ + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + }, + average_time_to_answer=None, + average_time_in_draft=None, + average_time_in_labels=None, + num_issues_opened=2, + num_issues_closed=1, + num_mentor_count=0, + labels=None, + search_query="repo:test/repo is:issue", + hide_label_metrics=True, + hide_items_closed_count=False, + enable_mentor_count=False, + non_mentioning_links=False, + report_title="Test Issue Metrics", + output_file=output_file, + ghe="", + ) + + # Read and verify the markdown content + with open(output_file, "r", encoding="utf-8") as f: + content = f.read() + + # Check for assignee column header + self.assertIn("| Assignee |", content) + + # Check for assignee data - alice should be linked + self.assertIn("[alice](https://github.com/alice)", content) + + # Check for None assignee + self.assertIn("| None |", content) + + # Check that both assignee and author columns are present + self.assertIn("| Author |", content) + + finally: + os.unlink(output_file) + + def test_assignee_in_json_output(self): + """Test that assignee information appears correctly in JSON output.""" + issues_with_metrics = [ + IssueWithMetrics( + title="Test Issue 1", + html_url="https://github.com/test/repo/issues/1", + author="john", + assignee="alice", + assignees=["alice", "bob"], + time_to_first_response=timedelta(hours=2), + time_to_close=timedelta(days=1), + created_at=datetime.now() - timedelta(days=2), + ), + IssueWithMetrics( + title="Test Issue 2", + html_url="https://github.com/test/repo/issues/2", + author="jane", + assignee=None, + assignees=[], + time_to_first_response=timedelta(hours=4), + time_to_close=None, + created_at=datetime.now() - timedelta(days=1), + ), + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + output_file = f.name + + try: + json_output = write_to_json( + issues_with_metrics=issues_with_metrics, + stats_time_to_first_response={ + "avg": timedelta(hours=3), + "med": timedelta(hours=3), + "90p": timedelta(hours=4), + }, + stats_time_to_close={ + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + }, + stats_time_to_answer=None, + stats_time_in_draft=None, + stats_time_in_labels=None, + num_issues_opened=2, + num_issues_closed=1, + num_mentor_count=0, + search_query="repo:test/repo is:issue", + output_file=output_file, + ) + + # Parse the JSON output + data = json.loads(json_output) + + # Check that assignee fields are present + issue1 = data["issues"][0] + self.assertEqual(issue1["assignee"], "alice") + self.assertEqual(issue1["assignees"], ["alice", "bob"]) + self.assertEqual(issue1["author"], "john") + + issue2 = data["issues"][1] + self.assertIsNone(issue2["assignee"]) + self.assertEqual(issue2["assignees"], []) + self.assertEqual(issue2["author"], "jane") + + finally: + os.unlink(output_file) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 00000000..9788df01 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,126 @@ +"""A module containing unit tests for the auth module. + +This module contains unit tests for the functions in the auth module +that authenticate to github. + +Classes: + TestAuthToGithub: A class to test the auth_to_github function. + +""" + +import unittest +from unittest.mock import MagicMock, patch + +import github3 +import requests +from auth import auth_to_github, get_github_app_installation_token + + +class TestAuthToGithub(unittest.TestCase): + """Test the auth_to_github function.""" + + @patch("github3.github.GitHub.login_as_app_installation") + def test_auth_to_github_with_github_app(self, mock_login): + """ + Test the auth_to_github function when GitHub app + parameters provided. + """ + mock_login.return_value = MagicMock() + result = auth_to_github("", 12345, 678910, b"hello", "", False) + + self.assertIsInstance(result, github3.github.GitHub, False) + + def test_auth_to_github_with_token(self): + """ + Test the auth_to_github function when the token is provided. + """ + result = auth_to_github("token", None, None, b"", "", False) + + self.assertIsInstance(result, github3.github.GitHub, False) + + def test_auth_to_github_without_authentication_information(self): + """ + Test the auth_to_github function when authentication information is not provided. + Expect a ValueError to be raised. + """ + with self.assertRaises(ValueError): + auth_to_github("", None, None, b"", "", False) + + def test_auth_to_github_with_ghe(self): + """ + Test the auth_to_github function when the GitHub Enterprise URL is provided. + """ + result = auth_to_github( + "token", None, None, b"", "https://github.example.com", False + ) + + self.assertIsInstance(result, github3.github.GitHubEnterprise, False) + + @patch("github3.github.GitHubEnterprise") + def test_auth_to_github_with_ghe_and_ghe_app(self, mock_ghe): + """ + Test the auth_to_github function when the GitHub Enterprise URL \ + is provided and the app was created in GitHub Enterprise URL. + """ + mock = mock_ghe.return_value + mock.login_as_app_installation = MagicMock(return_value=True) + result = auth_to_github( + "", "123", "123", b"123", "https://github.example.com", True + ) + mock.login_as_app_installation.assert_called_once() + self.assertEqual(result, mock) + + @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) + @patch("requests.post") + def test_get_github_app_installation_token(self, mock_post): + """ + Test the get_github_app_installation_token function. + """ + dummy_token = "dummytoken" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"token": dummy_token} + mock_post.return_value = mock_response + mock_ghe = "" + + result = get_github_app_installation_token( + mock_ghe, b"gh_private_token", "gh_app_id", "gh_installation_id" + ) + + self.assertEqual(result, dummy_token) + + @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) + @patch("auth.requests.post") + def test_get_github_app_installation_token_request_failure(self, mock_post): + """ + Test the get_github_app_installation_token function returns None when the request fails. + """ + # Mock the post request to raise a RequestException + mock_post.side_effect = requests.exceptions.RequestException("Request failed") + + # Call the function with test data + result = get_github_app_installation_token( + ghe="https://api.github.com", + gh_app_id=12345, + gh_app_private_key_bytes=b"private_key", + gh_app_installation_id=678910, + ) + + # Assert that the result is None + self.assertIsNone(result) + + @patch("github3.login") + def test_auth_to_github_invalid_credentials(self, mock_login): + """ + Test the auth_to_github function raises correct ValueError + when credentials are present but incorrect. + """ + mock_login.return_value = None + with self.assertRaises(ValueError) as context_manager: + auth_to_github("not_a_valid_token", "", "", b"", "", False) + + the_exception = context_manager.exception + self.assertEqual( + str(the_exception), + "Unable to authenticate to GitHub", + ) diff --git a/test_column_order_fix.py b/test_column_order_fix.py new file mode 100644 index 00000000..c186ad7d --- /dev/null +++ b/test_column_order_fix.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +""" +Test to verify that the Status and Created At columns have their content aligned with headers. + +This test specifically validates the fix for issue #568 where the Status and Created At +columns had their data swapped. +""" + +import os +import unittest +from datetime import timedelta +from unittest.mock import patch + +from classes import IssueWithMetrics +from markdown_writer import get_non_hidden_columns, write_to_markdown + + +@patch.dict( + os.environ, + { + "SEARCH_QUERY": "is:open repo:user/repo", + "GH_TOKEN": "test_token", + "HIDE_CREATED_AT": "False", + "HIDE_STATUS": "False", + }, +) +class TestColumnOrderFix(unittest.TestCase): + """Test that Status and Created At columns have correct data.""" + + def test_status_and_created_at_columns_alignment(self): + """Test that Status and Created At columns show correct data values. + + This test specifically validates that: + 1. The Status column contains actual status values (not dates) + 2. The Created At column contains actual date values (not status) + """ + # Create test data with clearly distinguishable Status and Created At values + issues_with_metrics = [ + IssueWithMetrics( + title="Test Issue", + html_url="https://github.com/user/repo/issues/1", + author="testuser", + assignee="assignee1", + assignees=["assignee1"], + created_at="2023-01-01T00:00:00Z", # This should appear in Created At column + status="open", # This should appear in Status column + time_to_first_response=timedelta(days=1), + time_to_close=timedelta(days=2), + time_to_answer=timedelta(days=3), + ) + ] + + # Call the function + write_to_markdown( + issues_with_metrics=issues_with_metrics, + average_time_to_first_response=None, + average_time_to_close=None, + average_time_to_answer=None, + average_time_in_draft=None, + average_time_in_labels=None, + num_issues_opened=1, + num_issues_closed=0, + num_mentor_count=0, + labels=None, + search_query="is:issue is:open repo:user/repo", + hide_label_metrics=True, + hide_items_closed_count=False, + enable_mentor_count=False, + non_mentioning_links=False, + report_title="Test Report", + output_file="test_column_order.md", + ) + + # Read the generated markdown + with open("test_column_order.md", "r", encoding="utf-8") as file: + content = file.read() + + # The table should have the columns in the correct order + # and the data should be properly aligned + expected_header = ( + "| Title | URL | Assignee | Author | Time to first response | " + "Time to close | Time to answer | Created At | Status |" + ) + self.assertIn(expected_header, content) + + # Verify the data row has correct values in correct positions + # The Created At column should contain the date value + # The Status column should contain the status value + expected_row = ( + "| Test Issue | https://github.com/user/repo/issues/1 | " + "[assignee1](https://github.com/assignee1) | " + "[testuser](https://github.com/testuser) | 1 day, 0:00:00 | " + "2 days, 0:00:00 | 3 days, 0:00:00 | 2023-01-01T00:00:00Z | open |" + ) + self.assertIn(expected_row, content) + + # Clean up + os.remove("test_column_order.md") + + def test_get_non_hidden_columns_order(self): + """Test that get_non_hidden_columns returns columns in the correct order.""" + columns = get_non_hidden_columns(labels=None) + + # Find the indices of the Status and Created At columns + try: + created_at_index = columns.index("Created At") + status_index = columns.index("Status") + + # Status should come after Created At + self.assertGreater( + status_index, + created_at_index, + "Status column should come after Created At column", + ) + except ValueError: + # If one of the columns is hidden, that's fine, but we shouldn't get here + # given our environment variables + self.fail("Both Status and Created At columns should be present") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_config.py b/test_config.py new file mode 100644 index 00000000..49435fa6 --- /dev/null +++ b/test_config.py @@ -0,0 +1,367 @@ +"""A module containing unit tests for the config module functions. + +Classes: + TestGetIntFromEnv: A class to test the get_int_env_var function. + TestEnvVars: A class to test the get_env_vars function. + +""" + +import os +import unittest +from unittest.mock import patch + +from config import EnvVars, get_env_vars, get_int_env_var + +SEARCH_QUERY = "is:issue is:open repo:user/repo" +TOKEN = "test_token" + + +class TestGetIntFromEnv(unittest.TestCase): + """ + Test suite for the get_int_from_env function. + + ... + + Test methods: + - test_get_int_env_var: Test returns the expected integer value. + - test_get_int_env_var_with_empty_env_var: Test returns None when environment variable + is empty. + - test_get_int_env_var_with_non_integer: Test returns None when environment variable + is a non-integer. + """ + + @patch.dict(os.environ, {"INT_ENV_VAR": "12345"}) + def test_get_int_env_var(self): + """ + Test that get_int_env_var returns the expected integer value. + """ + result = get_int_env_var("INT_ENV_VAR") + self.assertEqual(result, 12345) + + @patch.dict(os.environ, {"INT_ENV_VAR": ""}) + def test_get_int_env_var_with_empty_env_var(self): + """ + This test verifies that the get_int_env_var function returns None + when the environment variable is empty. + + """ + result = get_int_env_var("INT_ENV_VAR") + self.assertIsNone(result) + + @patch.dict(os.environ, {"INT_ENV_VAR": "not_an_int"}) + def test_get_int_env_var_with_non_integer(self): + """ + Test that get_int_env_var returns None when the environment variable is + a non-integer. + + """ + result = get_int_env_var("INT_ENV_VAR") + self.assertIsNone(result) + + +class TestGetEnvVars(unittest.TestCase): + """ + Test suite for the get_env_vars function. + """ + + def setUp(self): + env_keys = [ + "GH_APP_ID", + "GH_APP_INSTALLATION_ID", + "GH_APP_PRIVATE_KEY", + "GH_TOKEN", + "GHE", + "HIDE_AUTHOR", + "HIDE_CREATED_AT", + "HIDE_ITEMS_CLOSED_COUNT", + "HIDE_LABEL_METRICS", + "HIDE_TIME_TO_ANSWER", + "HIDE_TIME_TO_CLOSE", + "HIDE_TIME_TO_FIRST_RESPONSE", + "IGNORE_USERS", + "LABELS_TO_MEASURE", + "NON_MENTIONING_LINKS", + "OUTPUT_FILE", + "REPORT_TITLE", + "SEARCH_QUERY", + "RATE_LIMIT_BYPASS", + ] + for key in env_keys: + if key in os.environ: + del os.environ[key] + + @patch.dict( + os.environ, + { + "GH_APP_ID": "12345", + "GH_APP_INSTALLATION_ID": "678910", + "GH_APP_PRIVATE_KEY": "hello", + "GH_TOKEN": "", + "GH_ENTERPRISE_URL": "", + "HIDE_AUTHOR": "", + "HIDE_ITEMS_CLOSED_COUNT": "false", + "HIDE_LABEL_METRICS": "", + "HIDE_TIME_TO_ANSWER": "", + "HIDE_TIME_TO_CLOSE": "", + "HIDE_TIME_TO_FIRST_RESPONSE": "", + "IGNORE_USERS": "", + "LABELS_TO_MEASURE": "", + "NON_MENTIONING_LINKS": "false", + "OUTPUT_FILE": "", + "REPORT_TITLE": "", + "SEARCH_QUERY": SEARCH_QUERY, + "RATE_LIMIT_BYPASS": "false", + }, + clear=True, + ) + def test_get_env_vars_with_github_app(self): + """Test that all environment variables are set correctly using GitHub App""" + expected_result = EnvVars( + gh_app_id=12345, + gh_app_installation_id=678910, + gh_app_private_key_bytes=b"hello", + gh_app_enterprise_only=False, + gh_token="", + ghe="", + hide_assignee=False, + hide_author=False, + hide_items_closed_count=False, + hide_label_metrics=False, + hide_time_to_answer=False, + hide_time_to_close=False, + hide_time_to_first_response=False, + hide_created_at=True, + hide_status=True, + ignore_user=[], + labels_to_measure=[], + enable_mentor_count=False, + min_mentor_comments="10", + max_comments_eval="20", + heavily_involved_cutoff="3", + search_query=SEARCH_QUERY, + non_mentioning_links=False, + report_title="", + output_file="", + draft_pr_tracking=False, + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_ENTERPRISE_URL": "", + "GH_TOKEN": TOKEN, + "HIDE_AUTHOR": "", + "HIDE_ITEMS_CLOSED_COUNT": "false", + "HIDE_LABEL_METRICS": "", + "HIDE_TIME_TO_ANSWER": "", + "HIDE_TIME_TO_CLOSE": "", + "HIDE_TIME_TO_FIRST_RESPONSE": "", + "IGNORE_USERS": "", + "LABELS_TO_MEASURE": "", + "NON_MENTIONING_LINKS": "false", + "OUTPUT_FILE": "", + "REPORT_TITLE": "", + "SEARCH_QUERY": SEARCH_QUERY, + }, + clear=True, + ) + def test_get_env_vars_with_token(self): + """Test that all environment variables are set correctly using a list of repositories""" + expected_result = EnvVars( + gh_app_id=None, + gh_app_installation_id=None, + gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, + gh_token=TOKEN, + ghe="", + hide_assignee=False, + hide_author=False, + hide_items_closed_count=False, + hide_label_metrics=False, + hide_time_to_answer=False, + hide_time_to_close=False, + hide_time_to_first_response=False, + hide_created_at=True, + hide_status=True, + ignore_user=[], + labels_to_measure=[], + enable_mentor_count=False, + min_mentor_comments="10", + max_comments_eval="20", + heavily_involved_cutoff="3", + search_query=SEARCH_QUERY, + non_mentioning_links=False, + report_title="", + output_file="", + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "", + "SEARCH_QUERY": SEARCH_QUERY, + "HIDE_ITEMS_CLOSED_COUNT": "false", + }, + clear=True, + ) + def test_get_env_vars_missing_token(self): + """Test that an error is raised if the TOKEN environment variables is not set""" + with self.assertRaises(ValueError): + get_env_vars(True) + + @patch.dict( + os.environ, + { + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "SEARCH_QUERY": "", + "HIDE_ITEMS_CLOSED_COUNT": "false", + }, + clear=True, + ) + def test_get_env_vars_missing_query(self): + """Test that an error is raised if the SEARCH_QUERY environment variable is not set.""" + + with self.assertRaises(ValueError): + get_env_vars(True) + + @patch.dict( + os.environ, + { + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "GH_ENTERPRISE_URL": "", + "HIDE_AUTHOR": "true", + "HIDE_ITEMS_CLOSED_COUNT": "true", + "HIDE_LABEL_METRICS": "true", + "HIDE_TIME_TO_ANSWER": "true", + "HIDE_TIME_TO_CLOSE": "true", + "HIDE_TIME_TO_FIRST_RESPONSE": "true", + "IGNORE_USERS": "", + "LABELS_TO_MEASURE": "waiting-for-review,waiting-for-manager", + "NON_MENTIONING_LINKS": "true", + "OUTPUT_FILE": "issue_metrics.md", + "REPORT_TITLE": "Issue Metrics", + "SEARCH_QUERY": SEARCH_QUERY, + "RATE_LIMIT_BYPASS": "true", + "DRAFT_PR_TRACKING": "True", + }, + ) + def test_get_env_vars_optional_values(self): + """Test that optional values are set to their default values if not provided""" + expected_result = EnvVars( + gh_app_id=None, + gh_app_installation_id=None, + gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, + gh_token=TOKEN, + ghe="", + hide_assignee=False, + hide_author=True, + hide_items_closed_count=True, + hide_label_metrics=True, + hide_time_to_answer=True, + hide_time_to_close=True, + hide_time_to_first_response=True, + hide_created_at=True, + hide_status=True, + ignore_user=[], + labels_to_measure=["waiting-for-review", "waiting-for-manager"], + enable_mentor_count=False, + min_mentor_comments=10, + max_comments_eval=20, + heavily_involved_cutoff=3, + search_query=SEARCH_QUERY, + non_mentioning_links=True, + report_title="Issue Metrics", + output_file="issue_metrics.md", + rate_limit_bypass=True, + draft_pr_tracking=True, + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "TOKEN", + "SEARCH_QUERY": SEARCH_QUERY, + }, + clear=True, + ) + def test_get_env_vars_optionals_are_defaulted(self): + """Test that optional values are set to their default values if not provided""" + expected_result = EnvVars( + gh_app_id=None, + gh_app_installation_id=None, + gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, + gh_token="TOKEN", + ghe="", + hide_assignee=False, + hide_author=False, + hide_items_closed_count=False, + hide_label_metrics=False, + hide_time_to_answer=False, + hide_time_to_close=False, + hide_time_to_first_response=False, + hide_created_at=True, + hide_status=True, + ignore_user=[], + labels_to_measure=[], + enable_mentor_count=False, + min_mentor_comments="10", + max_comments_eval="20", + heavily_involved_cutoff="3", + search_query=SEARCH_QUERY, + non_mentioning_links=False, + report_title="Issue Metrics", + output_file="", + rate_limit_bypass=False, + draft_pr_tracking=False, + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "ORGANIZATION": "my_organization", + "GH_APP_ID": "12345", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "", + "SEARCH_QUERY": SEARCH_QUERY, + }, + clear=True, + ) + def test_get_env_vars_auth_with_github_app_installation_missing_inputs(self): + """Test that an error is raised there are missing inputs for the gh app""" + with self.assertRaises(ValueError) as context_manager: + get_env_vars(True) + the_exception = context_manager.exception + self.assertEqual( + str(the_exception), + "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_config_get_bool.py b/test_config_get_bool.py new file mode 100644 index 00000000..c19f7ab4 --- /dev/null +++ b/test_config_get_bool.py @@ -0,0 +1,81 @@ +"""Test the get_get_bool_env_var function""" + +import os +import unittest +from unittest.mock import patch + +from config import get_bool_env_var + + +class TestEnv(unittest.TestCase): + """Test the get_bool_env_var function""" + + @patch.dict( + os.environ, + { + "TEST_BOOL": "true", + }, + clear=True, + ) + def test_get_bool_env_var_that_exists_and_is_true(self): + """Test that gets a boolean environment variable that exists and is true""" + result = get_bool_env_var("TEST_BOOL", False) + self.assertTrue(result) + + @patch.dict( + os.environ, + { + "TEST_BOOL": "false", + }, + clear=True, + ) + def test_get_bool_env_var_that_exists_and_is_false(self): + """Test that gets a boolean environment variable that exists and is false""" + result = get_bool_env_var("TEST_BOOL", False) + self.assertFalse(result) + + @patch.dict( + os.environ, + { + "TEST_BOOL": "nope", + }, + clear=True, + ) + def test_get_bool_env_var_that_exists_and_is_false_due_to_invalid_value(self): + """Test that gets a boolean environment variable that exists and is false + due to an invalid value + """ + result = get_bool_env_var("TEST_BOOL", False) + self.assertFalse(result) + + @patch.dict( + os.environ, + { + "TEST_BOOL": "false", + }, + clear=True, + ) + def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_true(self): + """Test that gets a boolean environment variable that does not exist + and default value returns: true + """ + result = get_bool_env_var("DOES_NOT_EXIST", True) + self.assertTrue(result) + + @patch.dict( + os.environ, + { + "TEST_BOOL": "true", + }, + clear=True, + ) + def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_false(self): + """Test that gets a boolean environment variable that does not exist + and default value returns: false + """ + result = get_bool_env_var("DOES_NOT_EXIST", False) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_discussions.py b/test_discussions.py index cc1d4606..8145aad4 100644 --- a/test_discussions.py +++ b/test_discussions.py @@ -4,76 +4,137 @@ TestGetDiscussions: A class to test the get_discussions function with mock GraphQL responses. """ + import unittest from unittest.mock import patch + from discussions import get_discussions class TestGetDiscussions(unittest.TestCase): """A class to test the get_discussions function in the discussions module.""" - @patch("requests.post") - def test_get_discussions(self, mock_post): - """Test the get_discussions function with a successful GraphQL response. - - This test mocks a successful GraphQL response and checks that the - function returns the expected discussions. - - """ - # Mock the GraphQL response - mock_response = { + def _create_mock_response( + self, discussions, has_next_page=False, end_cursor="cursor123" + ): + """Helper method to create a mock GraphQL response.""" + return { "data": { "search": { - "edges": [ - { - "node": { - "title": "Discussion 1", - "url": "https://github.com/user/repo/discussions/1", - "createdAt": "2021-01-01T00:00:00Z", - "comments": { - "nodes": [{"createdAt": "2021-01-01T00:01:00Z"}] - }, - "answerChosenAt": None, - "closedAt": None, - } - }, - { - "node": { - "title": "Discussion 2", - "url": "https://github.com/user/repo/discussions/2", - "createdAt": "2021-01-02T00:00:00Z", - "comments": { - "nodes": [{"createdAt": "2021-01-02T00:01:00Z"}] - }, - "answerChosenAt": "2021-01-03T00:00:00Z", - "closedAt": "2021-01-04T00:00:00Z", - } - }, - ] + "edges": [{"node": discussion} for discussion in discussions], + "pageInfo": {"hasNextPage": has_next_page, "endCursor": end_cursor}, } } } + + @patch("requests.post") + def test_get_discussions_single_page(self, mock_post): + """Test the get_discussions function with a single page of results.""" + # Mock data for two discussions + mock_discussions = [ + { + "title": "Discussion 1", + "url": "https://github.com/user/repo/discussions/1", + "createdAt": "2021-01-01T00:00:00Z", + "comments": {"nodes": [{"createdAt": "2021-01-01T00:01:00Z"}]}, + "answerChosenAt": None, + "closedAt": None, + }, + { + "title": "Discussion 2", + "url": "https://github.com/user/repo/discussions/2", + "createdAt": "2021-01-02T00:00:00Z", + "comments": {"nodes": [{"createdAt": "2021-01-02T00:01:00Z"}]}, + "answerChosenAt": "2021-01-03T00:00:00Z", + "closedAt": "2021-01-04T00:00:00Z", + }, + ] + mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = mock_response + mock_post.return_value.json.return_value = self._create_mock_response( + mock_discussions, has_next_page=False + ) - # Call the function with mock arguments - discussions = get_discussions("token", "repo:user/repo type:discussions query") + discussions = get_discussions( + "token", "repo:user/repo type:discussions query", "" + ) # Check that the function returns the expected discussions self.assertEqual(len(discussions), 2) self.assertEqual(discussions[0]["title"], "Discussion 1") self.assertEqual(discussions[1]["title"], "Discussion 2") + # Verify only one API call was made + self.assertEqual(mock_post.call_count, 1) + @patch("requests.post") - def test_get_discussions_error(self, mock_post): - """Test the get_discussions function with a failed GraphQL response. + def test_get_discussions_multiple_pages(self, mock_post): + """Test the get_discussions function with multiple pages of results.""" + # Mock data for pagination + page1_discussions = [ + { + "title": "Discussion 1", + "url": "https://github.com/user/repo/discussions/1", + "createdAt": "2021-01-01T00:00:00Z", + "comments": {"nodes": [{"createdAt": "2021-01-01T00:01:00Z"}]}, + "answerChosenAt": None, + "closedAt": None, + } + ] + + page2_discussions = [ + { + "title": "Discussion 2", + "url": "https://github.com/user/repo/discussions/2", + "createdAt": "2021-01-02T00:00:00Z", + "comments": {"nodes": [{"createdAt": "2021-01-02T00:01:00Z"}]}, + "answerChosenAt": None, + "closedAt": None, + } + ] + + # Configure mock to return different responses for each call + mock_post.return_value.status_code = 200 + mock_post.return_value.json.side_effect = [ + self._create_mock_response( + page1_discussions, has_next_page=True, end_cursor="cursor123" + ), + self._create_mock_response(page2_discussions, has_next_page=False), + ] - This test mocks a failed GraphQL response and checks that the function raises a ValueError. + discussions = get_discussions( + "token", "repo:user/repo type:discussions query", "" + ) - """ - # Mock a failed GraphQL response + # Check that all discussions were returned + self.assertEqual(len(discussions), 2) + self.assertEqual(discussions[0]["title"], "Discussion 1") + self.assertEqual(discussions[1]["title"], "Discussion 2") + + # Verify that two API calls were made + self.assertEqual(mock_post.call_count, 2) + + @patch("requests.post") + def test_get_discussions_error_status_code(self, mock_post): + """Test the get_discussions function with a failed HTTP response.""" mock_post.return_value.status_code = 500 - # Call the function with mock arguments and check that it raises an error - with self.assertRaises(ValueError): - get_discussions("token", "repo:user/repo type:discussions query") + with self.assertRaises(ValueError) as context: + get_discussions("token", "repo:user/repo type:discussions query", "") + + self.assertIn( + "GraphQL query failed with status code 500", str(context.exception) + ) + + @patch("requests.post") + def test_get_discussions_graphql_error(self, mock_post): + """Test the get_discussions function with GraphQL errors in response.""" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "errors": [{"message": "GraphQL Error"}] + } + + with self.assertRaises(ValueError) as context: + get_discussions("token", "repo:user/repo type:discussions query", "") + + self.assertIn("GraphQL query failed:", str(context.exception)) diff --git a/test_issue_metrics.py b/test_issue_metrics.py index 71e49088..d44b67cd 100644 --- a/test_issue_metrics.py +++ b/test_issue_metrics.py @@ -6,99 +6,39 @@ Classes: TestSearchIssues: A class to test the search_issues function. - TestAuthToGithub: A class to test the auth_to_github function. TestGetPerIssueMetrics: A class to test the get_per_issue_metrics function. TestGetEnvVars: A class to test the get_env_vars function. - TestMain: A class to test the main function. - + TestEvaluateMarkdownFileSize: A class to test the evaluate_markdown_file_size function. """ + import os import unittest from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch -import issue_metrics from issue_metrics import ( IssueWithMetrics, - auth_to_github, + evaluate_markdown_file_size, get_env_vars, get_per_issue_metrics, measure_time_to_close, measure_time_to_first_response, - search_issues, ) -class TestSearchIssues(unittest.TestCase): - """Unit tests for the search_issues function. - - This class contains unit tests for the search_issues function in the - issue_metrics module. The tests use the unittest module and the unittest.mock - module to mock the GitHub API and test the function in isolation. - - Methods: - test_search_issues: Test that search_issues returns the correct issues. - - """ - - def test_search_issues(self): - """Test that search_issues returns the correct issues.""" - # Set up the mock GitHub connection object - mock_connection = MagicMock() - mock_issues = [ - MagicMock(title="Issue 1"), - MagicMock(title="Issue 2"), - ] - mock_connection.search_issues.return_value = mock_issues - - # Call search_issues and check that it returns the correct issues - issues = search_issues("is:open", mock_connection) - self.assertEqual(issues, mock_issues) - - -class TestAuthToGithub(unittest.TestCase): - """Test the auth_to_github function.""" - - @patch("github3.login") - @patch.dict(os.environ, {"GH_TOKEN": "test_token", "SEARCH_QUERY": "is:open repo:user/repo"}) - def test_auth_to_github_with_token(self, mock_login): - """Test that auth_to_github works with a token. - - This test sets the GH_TOKEN environment variable and checks that - auth_to_github returns the expected GitHub connection. - - """ - - # Set up the mock GitHub connection - mock_gh = MagicMock() - mock_login.return_value = mock_gh - - # Call the function - github_connection = auth_to_github() - - # Check the results - self.assertEqual(github_connection, mock_gh) - mock_login.assert_called_once_with(token="test_token") - - @patch.dict(os.environ, {}, clear=True) - def test_auth_to_github_no_token(self): - """Test that auth_to_github raises a ValueError if GH_TOKEN is not set.""" - - # Call auth_to_github and check that it raises a ValueError - with self.assertRaises(ValueError): - issue_metrics.auth_to_github() - - class TestGetEnvVars(unittest.TestCase): """Test suite for the get_env_vars function.""" - @patch.dict(os.environ, {"GH_TOKEN": "test_token", "SEARCH_QUERY": "is:issue is:open repo:user/repo"}) + @patch.dict( + os.environ, + {"GH_TOKEN": "test_token", "SEARCH_QUERY": "is:issue is:open repo:user/repo"}, + ) def test_get_env_vars(self): """Test that the function correctly retrieves the environment variables.""" # Call the function and check the result - search_query = get_env_vars().search_query - gh_token = get_env_vars().gh_token + search_query = get_env_vars(test=True).search_query + gh_token = get_env_vars(test=True).gh_token gh_token_expected_result = "test_token" search_query_expected_result = "is:issue is:open repo:user/repo" self.assertEqual(gh_token, gh_token_expected_result) @@ -112,128 +52,144 @@ def test_get_env_vars_missing_query(self): # Call the function and check that it raises a ValueError with self.assertRaises(ValueError): - get_env_vars() - - -class TestMain(unittest.TestCase): - """Unit tests for the main function. + get_env_vars(test=True) - This class contains unit tests for the main function in the issue_metrics - module. The tests use the unittest module and the unittest.mock module to - mock the GitHub API and test the function in isolation. - Methods: - test_main: Test that main runs without errors. - test_main_no_issues_found: Test that main handles when no issues are found - - """ +class TestGetPerIssueMetrics(unittest.TestCase): + """Test suite for the get_per_issue_metrics function.""" - @patch("issue_metrics.auth_to_github") - @patch("issue_metrics.search_issues") - @patch("issue_metrics.measure_time_to_first_response") - @patch("issue_metrics.get_stats_time_to_first_response") @patch.dict( os.environ, { - "SEARCH_QUERY": "is:open repo:user/repo", "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_AUTHOR": "true", + "HIDE_LABEL_METRICS": "true", + "HIDE_TIME_TO_ANSWER": "true", + "HIDE_TIME_TO_CLOSE": "true", + "HIDE_TIME_TO_FIRST_RESPONSE": "true", }, ) - def test_main( - self, - mock_get_stats_time_to_first_response, - mock_measure_time_to_first_response, - mock_search_issues, - mock_auth_to_github, - ): - """Test that main runs without errors.""" - # Set up the mock GitHub connection object - mock_connection = MagicMock() - mock_auth_to_github.return_value = mock_connection - - # Set up the mock search_issues function - mock_issues = MagicMock( - items=[ - MagicMock(title="Issue 1"), - MagicMock(title="Issue 2"), - ] + def test_get_per_issue_metrics_with_hide_envs(self): + """ + Test that the function correctly calculates the metrics for + a list of GitHub issues where HIDE_* envs are set true + """ + + # Create mock data + mock_issue1 = MagicMock( + title="Issue 1", + html_url="https://github.com/user/repo/issues/1", + user={"login": "alice"}, + state="open", + comments=1, + created_at="2023-01-01T00:00:00Z", + ) + + mock_comment1 = MagicMock() + mock_comment1.created_at = datetime.fromisoformat("2023-01-02T00:00:00Z") + mock_issue1.issue.comments.return_value = [mock_comment1] + mock_issue1.issue.pull_request_urls = None + + mock_issue2 = MagicMock( + title="Issue 2", + html_url="https://github.com/user/repo/issues/2", + user={"login": "bob"}, + state="closed", + comments=1, + created_at="2023-01-01T00:00:00Z", + closed_at="2023-01-04T00:00:00Z", ) - mock_search_issues.return_value = mock_issues + mock_comment2 = MagicMock() + mock_comment2.created_at = datetime.fromisoformat("2023-01-03T00:00:00Z") + mock_issue2.issue.comments.return_value = [mock_comment2] + mock_issue2.issue.pull_request_urls = None - # Set up the mock measure_time_to_first_response function - mock_issues_with_ttfr = [ + issues = [ + mock_issue1, + mock_issue2, + ] + + # Call the function and check the result + with unittest.mock.patch( # type:ignore + "issue_metrics.measure_time_to_first_response", + measure_time_to_first_response, + ), unittest.mock.patch( # type:ignore + "issue_metrics.measure_time_to_close", measure_time_to_close + ): ( + result_issues_with_metrics, + result_num_issues_open, + result_num_issues_closed, + ) = get_per_issue_metrics( + issues, + env_vars=get_env_vars(test=True), + ) + expected_issues_with_metrics = [ + IssueWithMetrics( "Issue 1", "https://github.com/user/repo/issues/1", "alice", - timedelta(days=1, hours=2, minutes=30), + None, + None, + None, + None, ), - ( + IssueWithMetrics( "Issue 2", "https://github.com/user/repo/issues/2", "bob", - timedelta(days=3, hours=4, minutes=30), + None, + None, + None, + None, ), ] - mock_measure_time_to_first_response.return_value = mock_issues_with_ttfr - - # Set up the mock get_stats_time_to_first_response function - mock_stats_time_to_first_response = 15 - mock_get_stats_time_to_first_response.return_value = ( - mock_stats_time_to_first_response + expected_num_issues_open = 1 + expected_num_issues_closed = 1 + self.assertEqual(result_num_issues_open, expected_num_issues_open) + self.assertEqual(result_num_issues_closed, expected_num_issues_closed) + self.assertEqual( + result_issues_with_metrics[0].time_to_first_response, + expected_issues_with_metrics[0].time_to_first_response, + ) + self.assertEqual( + result_issues_with_metrics[0].time_to_close, + expected_issues_with_metrics[0].time_to_close, + ) + self.assertEqual( + result_issues_with_metrics[1].time_to_first_response, + expected_issues_with_metrics[1].time_to_first_response, + ) + self.assertEqual( + result_issues_with_metrics[1].time_to_close, + expected_issues_with_metrics[1].time_to_close, ) - # Call main and check that it runs without errors - issue_metrics.main() - - # Remove the markdown file created by main - os.remove("issue_metrics.md") - - @patch("issue_metrics.auth_to_github") - @patch("issue_metrics.search_issues") - @patch("issue_metrics.write_to_markdown") @patch.dict( os.environ, { - "SEARCH_QUERY": "is:open repo:org/repo", "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_AUTHOR": "false", + "HIDE_LABEL_METRICS": "false", + "HIDE_TIME_TO_ANSWER": "false", + "HIDE_TIME_TO_CLOSE": "false", + "HIDE_TIME_TO_FIRST_RESPONSE": "false", }, ) - def test_main_no_issues_found( - self, - mock_write_to_markdown, - mock_search_issues, - mock_auth_to_github, - ): - """Test that main writes 'No issues found' to the - console and calls write_to_markdown with None.""" - - # Set up the mock GitHub connection object - mock_connection = MagicMock() - mock_auth_to_github.return_value = mock_connection - - # Set up the mock search_issues function to return an empty list of issues - mock_issues = MagicMock(items=[]) - mock_search_issues.return_value = mock_issues - - # Call main and check that it writes 'No issues found' - issue_metrics.main() - mock_write_to_markdown.assert_called_once_with( - None, None, None, None, None, None, None - ) - - -class TestGetPerIssueMetrics(unittest.TestCase): - """Test suite for the get_per_issue_metrics function.""" + def test_get_per_issue_metrics_without_hide_envs(self): + """ + Test that the function correctly calculates the metrics for + a list of GitHub issues where HIDE_* envs are set false + """ - def test_get_per_issue_metrics(self): - """Test that the function correctly calculates the metrics for a list of GitHub issues.""" # Create mock data mock_issue1 = MagicMock( title="Issue 1", html_url="https://github.com/user/repo/issues/1", - author="alice", + user={"login": "alice"}, state="open", comments=1, created_at="2023-01-01T00:00:00Z", @@ -247,7 +203,7 @@ def test_get_per_issue_metrics(self): mock_issue2 = MagicMock( title="Issue 2", html_url="https://github.com/user/repo/issues/2", - author="bob", + user={"login": "bob"}, state="closed", comments=1, created_at="2023-01-01T00:00:00Z", @@ -275,7 +231,10 @@ def test_get_per_issue_metrics(self): result_issues_with_metrics, result_num_issues_open, result_num_issues_closed, - ) = get_per_issue_metrics(issues) + ) = get_per_issue_metrics( + issues, + env_vars=get_env_vars(test=True), + ) expected_issues_with_metrics = [ IssueWithMetrics( "Issue 1", @@ -317,6 +276,160 @@ def test_get_per_issue_metrics(self): expected_issues_with_metrics[1].time_to_close, ) + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "IGNORE_USERS": "alice", + }, + ) + def test_get_per_issue_metrics_with_ignore_users(self): + """ + Test that the function correctly filters out issues + with authors in the IGNORE_USERS variable + """ + + # Create mock data + mock_issue1 = MagicMock( + title="Issue 1", + html_url="https://github.com/user/repo/issues/1", + user={"login": "alice"}, + state="open", + comments=1, + created_at="2023-01-01T00:00:00Z", + ) + + mock_comment1 = MagicMock() + mock_comment1.created_at = datetime.fromisoformat("2023-01-02T00:00:00Z") + mock_issue1.issue.comments.return_value = [mock_comment1] + mock_issue1.issue.pull_request_urls = None + + mock_issue2 = MagicMock( + title="Issue 2", + html_url="https://github.com/user/repo/issues/2", + user={"login": "bob"}, + state="closed", + comments=1, + created_at="2023-01-01T00:00:00Z", + closed_at="2023-01-04T00:00:00Z", + ) + + mock_comment2 = MagicMock() + mock_comment2.created_at = datetime.fromisoformat("2023-01-03T00:00:00Z") + mock_issue2.issue.comments.return_value = [mock_comment2] + mock_issue2.issue.pull_request_urls = None + + issues = [ + mock_issue1, + mock_issue2, + ] + + # Call the function and check the result + with unittest.mock.patch( # type:ignore + "issue_metrics.measure_time_to_first_response", + measure_time_to_first_response, + ), unittest.mock.patch( # type:ignore + "issue_metrics.measure_time_to_close", measure_time_to_close + ): + ( + result_issues_with_metrics, + result_num_issues_open, + result_num_issues_closed, + ) = get_per_issue_metrics( + issues, + env_vars=get_env_vars(test=True), + ignore_users=["alice"], + ) + expected_issues_with_metrics = [ + IssueWithMetrics( + "Issue 2", + "https://github.com/user/repo/issues/2", + "bob", + timedelta(days=2), + timedelta(days=3), + None, + None, + ), + ] + expected_num_issues_open = 0 + expected_num_issues_closed = 1 + self.assertEqual(result_num_issues_open, expected_num_issues_open) + self.assertEqual(result_num_issues_closed, expected_num_issues_closed) + self.assertEqual( + result_issues_with_metrics[0].time_to_first_response, + expected_issues_with_metrics[0].time_to_first_response, + ) + self.assertEqual( + result_issues_with_metrics[0].time_to_close, + expected_issues_with_metrics[0].time_to_close, + ) + + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:pr is:open repo:user/repo", + }, + ) + def test_get_per_issue_metrics_with_ghost_user_pull_request(self): + """ + Test that the function handles TypeError when a pull request + contains a ghost user (deleted account) gracefully. + """ + # Create mock data for a pull request that will cause TypeError on pull_request() + mock_issue = MagicMock( + title="PR with Ghost User", + html_url="https://github.com/user/repo/pull/1", + user={"login": "existing_user"}, + state="open", + comments=0, + created_at="2023-01-01T00:00:00Z", + closed_at=None, + ) + + # Mock the issue to have pull_request_urls (indicating it's a PR) + mock_issue.issue.pull_request_urls = [ + "https://api.github.com/repos/user/repo/pulls/1" + ] + + # Make pull_request() raise TypeError (simulating ghost user scenario) + mock_issue.issue.pull_request.side_effect = TypeError( + "'NoneType' object is not subscriptable" + ) + mock_issue.issue.comments.return_value = [] + mock_issue.issue.assignee = None + mock_issue.issue.assignees = None + + issues = [mock_issue] + + # Mock the measure functions to avoid additional complexities + with unittest.mock.patch( # type: ignore + "issue_metrics.measure_time_to_first_response", + return_value=timedelta(days=1), + ), unittest.mock.patch( # type: ignore + "issue_metrics.measure_time_to_close", return_value=None + ): + # Call the function and verify it doesn't crash + ( + result_issues_with_metrics, + result_num_issues_open, + result_num_issues_closed, + ) = get_per_issue_metrics( + issues, + env_vars=get_env_vars(test=True), + ) + + # Verify the function completed successfully despite the TypeError + self.assertEqual(len(result_issues_with_metrics), 1) + self.assertEqual(result_num_issues_open, 1) + self.assertEqual(result_num_issues_closed, 0) + + # Verify the issue was processed with pull_request as None + issue_metric = result_issues_with_metrics[0] + self.assertEqual(issue_metric.title, "PR with Ghost User") + self.assertEqual(issue_metric.author, "existing_user") + class TestDiscussionMetrics(unittest.TestCase): """Test suite for the discussion_metrics function.""" @@ -326,9 +439,7 @@ def setUp(self): self.issue1 = { "title": "Issue 1", "url": "github.com/user/repo/issues/1", - "user": { - "login": "alice", - }, + "user": {"login": "alice"}, "createdAt": "2023-01-01T00:00:00Z", "comments": { "nodes": [ @@ -344,15 +455,17 @@ def setUp(self): self.issue2 = { "title": "Issue 2", "url": "github.com/user/repo/issues/2", - "user": { - "login": "bob", - }, + "user": {"login": "bob"}, "createdAt": "2023-01-01T00:00:00Z", "comments": {"nodes": [{"createdAt": "2023-01-03T00:00:00Z"}]}, "answerChosenAt": "2023-01-05T00:00:00Z", "closedAt": "2023-01-07T00:00:00Z", } + @patch.dict( + os.environ, + {"GH_TOKEN": "test_token", "SEARCH_QUERY": "is:issue is:open repo:user/repo"}, + ) def test_get_per_issue_metrics_with_discussion(self): """ Test that the function correctly calculates @@ -360,7 +473,9 @@ def test_get_per_issue_metrics_with_discussion(self): """ issues = [self.issue1, self.issue2] - metrics = get_per_issue_metrics(issues, discussions=True) + metrics = get_per_issue_metrics( + issues, discussions=True, env_vars=get_env_vars(test=True) + ) # get_per_issue_metrics returns a tuple of # (issues_with_metrics, num_issues_open, num_issues_closed) @@ -381,6 +496,106 @@ def test_get_per_issue_metrics_with_discussion(self): self.assertEqual(metrics[0][1].time_to_close, timedelta(days=6)) self.assertEqual(metrics[0][1].time_to_first_response, timedelta(days=2)) + @patch.dict( + os.environ, + { + "GH_TOKEN": "test_token", + "SEARCH_QUERY": "is:issue is:open repo:user/repo", + "HIDE_AUTHOR": "true", + "HIDE_CREATED_AT": "false", + "HIDE_LABEL_METRICS": "true", + "HIDE_TIME_TO_ANSWER": "true", + "HIDE_TIME_TO_CLOSE": "true", + "HIDE_TIME_TO_FIRST_RESPONSE": "true", + }, + ) + def test_get_per_issue_metrics_with_discussion_with_hide_envs(self): + """ + Test that the function correctly calculates + the metrics for a list of GitHub issues with discussions + and HIDE_* env vars set to True + """ + + issues = [self.issue1, self.issue2] + metrics = get_per_issue_metrics( + issues, discussions=True, env_vars=get_env_vars(test=True) + ) + + # get_per_issue_metrics returns a tuple of + # (issues_with_metrics, num_issues_open, num_issues_closed) + self.assertEqual(len(metrics), 3) + + # Check that the metrics are correct, 0 issues open, 2 issues closed + self.assertEqual(metrics[1], 0) + self.assertEqual(metrics[2], 2) + + # Check that the issues_with_metrics has 2 issues in it + self.assertEqual(len(metrics[0]), 2) + + # Check that the issues_with_metrics has the correct metrics, + self.assertEqual(metrics[0][0].time_to_answer, None) + self.assertEqual(metrics[0][0].time_to_close, None) + self.assertEqual(metrics[0][0].time_to_first_response, None) + self.assertEqual(metrics[0][1].time_to_answer, None) + self.assertEqual(metrics[0][1].time_to_close, None) + self.assertEqual(metrics[0][1].time_to_first_response, None) + + +class TestEvaluateMarkdownFileSize(unittest.TestCase): + """Test suite for the evaluate_markdown_file_size function.""" + + @patch("issue_metrics.markdown_too_large_for_issue_body") + def test_markdown_too_large_for_issue_body_called_with_empty_output_file( + self, mock_evaluate + ): + """ + Test that the function uses the output_file. + """ + mock_evaluate.return_value = False + evaluate_markdown_file_size("") + + mock_evaluate.assert_called_with("issue_metrics.md", 65535) + + @patch("issue_metrics.markdown_too_large_for_issue_body") + def test_markdown_too_large_for_issue_body_called_with_output_file( + self, mock_evaluate + ): + """ + Test that the function uses the output_file. + """ + mock_evaluate.return_value = False + evaluate_markdown_file_size("test_issue_metrics.md") + + mock_evaluate.assert_called_with("test_issue_metrics.md", 65535) + + @patch("issue_metrics.print") + @patch("shutil.move") + @patch("issue_metrics.split_markdown_file") + @patch("issue_metrics.markdown_too_large_for_issue_body") + def test_split_markdown_file_when_file_size_too_large( + self, mock_evaluate, mock_split, mock_move, mock_print + ): + """ + Test that the function is called with the output_file + environment variable. + """ + mock_evaluate.return_value = True + evaluate_markdown_file_size("test_issue_metrics.md") + + mock_split.assert_called_with("test_issue_metrics.md", 65535) + mock_move.assert_has_calls( + [ + call("test_issue_metrics.md", "test_issue_metrics_full.md"), + call("test_issue_metrics_0.md", "test_issue_metrics.md"), + ] + ) + mock_print.assert_called_with( + "Issue metrics markdown file is too large for GitHub issue body and has been \ +split into multiple files. ie. test_issue_metrics.md, test_issue_metrics_1.md, etc. \ +The full file is saved as test_issue_metrics_full.md\n\ +See https://github.com/github/issue-metrics/blob/main/docs/dealing-with-large-issue-metrics.md" + ) + if __name__ == "__main__": unittest.main() diff --git a/test_json_writer.py b/test_json_writer.py index d8070665..3ace4193 100644 --- a/test_json_writer.py +++ b/test_json_writer.py @@ -3,6 +3,7 @@ import json import unittest from datetime import timedelta + from classes import IssueWithMetrics from json_writer import write_to_json @@ -20,21 +21,28 @@ def test_write_to_json(self): title="Issue 1", html_url="https://github.com/owner/repo/issues/1", author="alice", + assignee="charlie", + assignees=["charlie"], time_to_first_response=timedelta(days=3), time_to_close=timedelta(days=6), time_to_answer=None, + time_in_draft=timedelta(days=1), labels_metrics={ "bug": timedelta(days=1, hours=16, minutes=24, seconds=12) }, + created_at=timedelta(days=-5), ), IssueWithMetrics( title="Issue 2", html_url="https://github.com/owner/repo/issues/2", author="bob", + assignee=None, + assignees=[], time_to_first_response=timedelta(days=2), time_to_close=timedelta(days=4), time_to_answer=timedelta(days=1), labels_metrics={}, + created_at=timedelta(days=-5), ), ] @@ -53,6 +61,11 @@ def test_write_to_json(self): "med": timedelta(days=2), "90p": timedelta(days=3), } + stats_time_in_draft = { + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + } stats_time_in_labels = { "avg": {"bug": timedelta(days=1, hours=16, minutes=24, seconds=12)}, "med": {"bug": timedelta(days=1, hours=16, minutes=24, seconds=12)}, @@ -60,41 +73,54 @@ def test_write_to_json(self): } num_issues_opened = 2 num_issues_closed = 1 + num_mentor_count = 5 expected_output = { "average_time_to_first_response": "2 days, 12:00:00", "average_time_to_close": "5 days, 0:00:00", "average_time_to_answer": "1 day, 0:00:00", + "average_time_in_draft": "1 day, 0:00:00", "average_time_in_labels": {"bug": "1 day, 16:24:12"}, "median_time_to_first_response": "2 days, 12:00:00", "median_time_to_close": "4 days, 0:00:00", "median_time_to_answer": "2 days, 0:00:00", + "median_time_in_draft": "1 day, 0:00:00", "median_time_in_labels": {"bug": "1 day, 16:24:12"}, "90_percentile_time_to_first_response": "1 day, 12:00:00", "90_percentile_time_to_close": "3 days, 0:00:00", "90_percentile_time_to_answer": "3 days, 0:00:00", + "90_percentile_time_in_draft": "1 day, 0:00:00", "90_percentile_time_in_labels": {"bug": "1 day, 16:24:12"}, "num_items_opened": 2, "num_items_closed": 1, + "num_mentor_count": 5, "total_item_count": 2, "issues": [ { "title": "Issue 1", "html_url": "https://github.com/owner/repo/issues/1", "author": "alice", + "assignee": "charlie", + "assignees": ["charlie"], "time_to_first_response": "3 days, 0:00:00", "time_to_close": "6 days, 0:00:00", "time_to_answer": "None", + "time_in_draft": "1 day, 0:00:00", "label_metrics": {"bug": "1 day, 16:24:12"}, + "created_at": "-5 days, 0:00:00", }, { "title": "Issue 2", "html_url": "https://github.com/owner/repo/issues/2", "author": "bob", + "assignee": None, + "assignees": [], "time_to_first_response": "2 days, 0:00:00", "time_to_close": "4 days, 0:00:00", "time_to_answer": "1 day, 0:00:00", + "time_in_draft": "None", "label_metrics": {}, + "created_at": "-5 days, 0:00:00", }, ], "search_query": "is:issue repo:owner/repo", @@ -107,10 +133,13 @@ def test_write_to_json(self): stats_time_to_first_response=stats_time_to_first_response, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, stats_time_in_labels=stats_time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, search_query="is:issue repo:owner/repo", + output_file="issue_metrics.json", ), json.dumps(expected_output), ) @@ -122,19 +151,25 @@ def test_write_to_json_with_no_response(self): title="Issue 1", html_url="https://github.com/owner/repo/issues/1", author="alice", + assignee=None, + assignees=[], time_to_first_response=None, time_to_close=None, time_to_answer=None, labels_metrics={}, + created_at=None, ), IssueWithMetrics( title="Issue 2", html_url="https://github.com/owner/repo/issues/2", author="bob", + assignee=None, + assignees=[], time_to_first_response=None, time_to_close=None, time_to_answer=None, labels_metrics={}, + created_at=None, ), ] @@ -146,43 +181,57 @@ def test_write_to_json_with_no_response(self): "med": {}, "90p": {}, } + stats_time_in_draft = None num_issues_opened = 2 num_issues_closed = 0 + num_mentor_count = 5 expected_output = { "average_time_to_first_response": "None", "average_time_to_close": "None", "average_time_to_answer": "None", + "average_time_in_draft": "None", "average_time_in_labels": {}, "median_time_to_first_response": "None", "median_time_to_close": "None", "median_time_to_answer": "None", + "median_time_in_draft": "None", "median_time_in_labels": {}, "90_percentile_time_to_first_response": "None", "90_percentile_time_to_close": "None", "90_percentile_time_to_answer": "None", + "90_percentile_time_in_draft": "None", "90_percentile_time_in_labels": {}, "num_items_opened": 2, "num_items_closed": 0, + "num_mentor_count": 5, "total_item_count": 2, "issues": [ { "title": "Issue 1", "html_url": "https://github.com/owner/repo/issues/1", "author": "alice", + "assignee": None, + "assignees": [], "time_to_first_response": "None", "time_to_close": "None", "time_to_answer": "None", + "time_in_draft": "None", "label_metrics": {}, + "created_at": "None", }, { "title": "Issue 2", "html_url": "https://github.com/owner/repo/issues/2", "author": "bob", + "assignee": None, + "assignees": [], "time_to_first_response": "None", "time_to_close": "None", "time_to_answer": "None", + "time_in_draft": "None", "label_metrics": {}, + "created_at": "None", }, ], "search_query": "is:issue repo:owner/repo", @@ -195,10 +244,13 @@ def test_write_to_json_with_no_response(self): stats_time_to_first_response=stats_time_to_first_response, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, stats_time_in_labels=stats_time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, search_query="is:issue repo:owner/repo", + output_file="issue_metrics.json", ), json.dumps(expected_output), ) diff --git a/test_labels.py b/test_labels.py index 35c07e0e..bea6edfa 100644 --- a/test_labels.py +++ b/test_labels.py @@ -1,4 +1,5 @@ -""" Unit tests for labels.py """ +"""Unit tests for labels.py""" + import unittest from datetime import datetime, timedelta from unittest.mock import MagicMock @@ -6,8 +7,7 @@ import github3 import pytz from classes import IssueWithMetrics - -from labels import get_stats_time_in_labels, get_label_events, get_label_metrics +from labels import get_label_events, get_label_metrics, get_stats_time_in_labels class TestLabels(unittest.TestCase): @@ -16,7 +16,7 @@ class TestLabels(unittest.TestCase): def setUp(self): self.issue = MagicMock() # type: ignore self.issue.issue = MagicMock(spec=github3.issues.Issue) # type: ignore - self.issue.created_at = "2020-01-01T00:00:00Z" + self.issue.created_at = "2021-01-01T00:00:00Z" self.issue.closed_at = "2021-01-05T00:00:00Z" self.issue.state = "closed" self.issue.issue.events.return_value = [ @@ -35,21 +35,33 @@ def setUp(self): label={"name": "bug"}, created_at=datetime(2021, 1, 3, tzinfo=pytz.UTC), ), + MagicMock( + event="labeled", + label={"name": "bug"}, + created_at=datetime(2021, 1, 4, tzinfo=pytz.UTC), + ), + # Label labeled after issue close date + MagicMock( + event="labeled", + label={"name": "foo"}, + created_at=datetime(2021, 1, 20, tzinfo=pytz.UTC), + ), ] def test_get_label_events(self): """Test get_label_events""" labels = ["bug"] events = get_label_events(self.issue, labels) - self.assertEqual(len(events), 2) + self.assertEqual(len(events), 3) self.assertEqual(events[0].label["name"], "bug") self.assertEqual(events[1].label["name"], "bug") + self.assertEqual(events[2].label["name"], "bug") def test_get_label_metrics_closed_issue(self): """Test get_label_metrics using a closed issue""" labels = ["bug", "feature"] metrics = get_label_metrics(self.issue, labels) - self.assertEqual(metrics["bug"], timedelta(days=2)) + self.assertEqual(metrics["bug"], timedelta(days=3)) self.assertEqual(metrics["feature"], timedelta(days=3)) def test_get_label_metrics_open_issue(self): @@ -57,7 +69,14 @@ def test_get_label_metrics_open_issue(self): self.issue.state = "open" labels = ["bug", "feature"] metrics = get_label_metrics(self.issue, labels) - self.assertEqual(metrics["bug"], timedelta(days=2)) + self.assertLessEqual( + metrics["bug"], + datetime.now(pytz.utc) - datetime(2021, 1, 2, tzinfo=pytz.UTC), + ) + self.assertGreater( + metrics["bug"], + datetime.now(pytz.utc) - datetime(2021, 1, 3, tzinfo=pytz.UTC), + ) self.assertLessEqual( metrics["feature"], datetime.now(pytz.utc) - datetime(2021, 1, 2, tzinfo=pytz.UTC), @@ -67,6 +86,188 @@ def test_get_label_metrics_open_issue(self): datetime.now(pytz.utc) - datetime(2021, 1, 4, tzinfo=pytz.UTC), ) + def test_get_label_metrics_closed_issue_labeled_past_closed_at(self): + """Test get_label_metrics using a closed issue that was labeled past issue closed_at""" + self.issue.state = "closed" + labels = ["foo"] + metrics = get_label_metrics(self.issue, labels) + self.assertEqual(metrics["foo"], None) + + def test_get_label_metrics_closed_issue_label_removed_before_closure(self): + """Test get_label_metrics for a closed issue where label was removed before closure""" + # Create a mock issue that reproduces the problem scenario: + # Issue created: day 0 (2021-01-01) + # Label added: day 5 (2021-01-06) + # Label removed: day 10 (2021-01-11) + # Issue closed: day 15 (2021-01-16) + # Expected duration: 5 days (from day 5 to day 10) + + issue = MagicMock() + issue.issue = MagicMock(spec=github3.issues.Issue) + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-01-16T00:00:00Z" # 15 days after creation + issue.state = "closed" + issue.issue.events.return_value = [ + MagicMock( + event="labeled", + label={"name": "test-label"}, + created_at=datetime(2021, 1, 6, tzinfo=pytz.UTC), # day 5 + ), + MagicMock( + event="unlabeled", + label={"name": "test-label"}, + created_at=datetime(2021, 1, 11, tzinfo=pytz.UTC), # day 10 + ), + ] + + labels = ["test-label"] + metrics = get_label_metrics(issue, labels) + + # Should be 5 days (from day 5 to day 10), not 15 days (full issue duration) + expected_duration = timedelta(days=5) + self.assertEqual(metrics["test-label"], expected_duration) + + def test_get_label_metrics_closed_issue_label_remains_through_closure(self): + """Test get_label_metrics for a closed issue where label remains applied through closure""" + # Test scenario where label is applied and never removed: + # Issue created: day 0 (2021-01-01) + # Label added: day 2 (2021-01-03) + # Issue closed: day 10 (2021-01-11) + # Expected duration: 10 days (from issue creation to closure) + + issue = MagicMock() + issue.issue = MagicMock(spec=github3.issues.Issue) + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-01-11T00:00:00Z" # 10 days after creation + issue.state = "closed" + issue.issue.events.return_value = [ + MagicMock( + event="labeled", + label={"name": "stays-applied"}, + created_at=datetime(2021, 1, 3, tzinfo=pytz.UTC), # day 2 + ), + # No unlabel event - label remains applied + ] + + labels = ["stays-applied"] + metrics = get_label_metrics(issue, labels) + + # Should be 8 days (from day 2 when label was applied to day 10 when issue closed) + expected_duration = timedelta(days=8) + self.assertEqual(metrics["stays-applied"], expected_duration) + + def test_get_label_metrics_label_applied_at_creation_and_removed_before_closure( + self, + ): + """Test get_label_metrics for a label applied at issue creation and removed before closure""" + # Test scenario where label is applied at creation and later removed: + # Issue created: day 0 (2021-01-01) with label applied + # Label removed: day 7 (2021-01-08) + # Issue closed: day 20 (2021-01-21) + # Expected duration: 7 days (from creation to removal) + + issue = MagicMock() + issue.issue = MagicMock(spec=github3.issues.Issue) + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-01-21T00:00:00Z" # 20 days after creation + issue.state = "closed" + issue.issue.events.return_value = [ + MagicMock( + event="labeled", + label={"name": "creation-label"}, + created_at=datetime(2021, 1, 1, tzinfo=pytz.UTC), # day 0 - at creation + ), + MagicMock( + event="unlabeled", + label={"name": "creation-label"}, + created_at=datetime(2021, 1, 8, tzinfo=pytz.UTC), # day 7 + ), + ] + + labels = ["creation-label"] + metrics = get_label_metrics(issue, labels) + + # Should be 7 days (from creation to removal), not 20 days (full issue duration) + expected_duration = timedelta(days=7) + self.assertEqual(metrics["creation-label"], expected_duration) + + def test_get_label_metrics_label_applied_at_creation_remains_through_closure(self): + """Test get_label_metrics for a label applied at creation and kept through closure""" + # Test scenario where label is applied at creation and never removed: + # Issue created: day 0 (2021-01-01) with label applied + # Issue closed: day 30 (2021-01-31) + # Expected duration: 30 days (full issue duration) + + issue = MagicMock() + issue.issue = MagicMock(spec=github3.issues.Issue) + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-01-31T00:00:00Z" # 30 days after creation + issue.state = "closed" + issue.issue.events.return_value = [ + MagicMock( + event="labeled", + label={"name": "permanent-label"}, + created_at=datetime(2021, 1, 1, tzinfo=pytz.UTC), # day 0 - at creation + ), + # No unlabel event - label remains applied + ] + + labels = ["permanent-label"] + metrics = get_label_metrics(issue, labels) + + # Should be 30 days (full issue duration since label was applied at creation) + expected_duration = timedelta(days=30) + self.assertEqual(metrics["permanent-label"], expected_duration) + + def test_get_label_metrics_multiple_labels_different_timeframes(self): + """Test get_label_metrics with multiple labels having different application patterns and longer timeframes""" + # Test scenario with multiple labels and longer timeframes: + # Issue created: day 0 (2021-01-01) + # Label A applied: day 0 (at creation) + # Label B applied: day 14 (2021-01-15) + # Label A removed: day 21 (2021-01-22) + # Label B removed: day 35 (2021-02-05) + # Issue closed: day 60 (2021-03-02) + # Expected: Label A = 21 days, Label B = 21 days + + issue = MagicMock() + issue.issue = MagicMock(spec=github3.issues.Issue) + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-03-02T00:00:00Z" # 60 days after creation + issue.state = "closed" + issue.issue.events.return_value = [ + MagicMock( + event="labeled", + label={"name": "label-a"}, + created_at=datetime(2021, 1, 1, tzinfo=pytz.UTC), # day 0 - at creation + ), + MagicMock( + event="labeled", + label={"name": "label-b"}, + created_at=datetime(2021, 1, 15, tzinfo=pytz.UTC), # day 14 + ), + MagicMock( + event="unlabeled", + label={"name": "label-a"}, + created_at=datetime(2021, 1, 22, tzinfo=pytz.UTC), # day 21 + ), + MagicMock( + event="unlabeled", + label={"name": "label-b"}, + created_at=datetime(2021, 2, 5, tzinfo=pytz.UTC), # day 35 + ), + ] + + labels = ["label-a", "label-b"] + metrics = get_label_metrics(issue, labels) + + # Label A: 21 days (from day 0 to day 21) + # Label B: 21 days (from day 14 to day 35) + expected_duration_a = timedelta(days=21) + expected_duration_b = timedelta(days=21) + self.assertEqual(metrics["label-a"], expected_duration_a) + self.assertEqual(metrics["label-b"], expected_duration_b) + class TestGetAverageTimeInLabels(unittest.TestCase): """Unit tests for get_stats_time_in_labels""" @@ -75,7 +276,13 @@ def setUp(self): self.issues_with_metrics = MagicMock() self.issues_with_metrics = [ IssueWithMetrics( - "issue1", "url1", "alice", None, None, None, {"bug": timedelta(days=2)} + title="issue1", + html_url="url1", + author="alice", + time_to_first_response=None, + time_to_close=None, + time_to_answer=None, + labels_metrics={"bug": timedelta(days=2)}, ), ] @@ -84,9 +291,9 @@ def test_get_stats_time_in_labels(self): labels = ["bug", "feature"] metrics = get_stats_time_in_labels(self.issues_with_metrics, labels) print(metrics) - self.assertEqual(len(metrics['avg']), 2) - self.assertEqual(metrics['avg']["bug"], timedelta(days=2)) - self.assertIsNone(metrics['avg'].get("feature")) + self.assertEqual(len(metrics["avg"]), 2) + self.assertEqual(metrics["avg"]["bug"], timedelta(days=2)) + self.assertIsNone(metrics["avg"].get("feature")) if __name__ == "__main__": diff --git a/test_markdown_helpers.py b/test_markdown_helpers.py new file mode 100644 index 00000000..591843e7 --- /dev/null +++ b/test_markdown_helpers.py @@ -0,0 +1,75 @@ +"""Unit tests for the markdown_helpers module.""" + +import os +import unittest + +from markdown_helpers import markdown_too_large_for_issue_body, split_markdown_file + + +class TestMarkdownHelpers(unittest.TestCase): + """ + Unit tests for the markdown_helpers module. + """ + + def test_markdown_too_large_for_issue_body(self): + """ + Test the markdown_too_large_for_issue_body function. + """ + # Define a sample markdown file content + max_char_count = 65535 + markdown_content = "a\n" * max_char_count + + # Write the markdown content to a temporary file + with open("temp.md", "w", encoding="utf-8") as f: + f.write(markdown_content) + + # Call the function with the temporary file + result = markdown_too_large_for_issue_body("temp.md", max_char_count) + + # remove the temporary file + os.remove("temp.md") + + # Assert that the function returns True + self.assertTrue(result) + + def test_split_markdown_file(self): + """ + Test the split_markdown_file function. + """ + + # Define a sample markdown file content with 4 times the maximum character count + multiple_of_max = 4 + max_char_count = 65535 + repeated_content = "a\n" + markdown_content = repeated_content * int( + (max_char_count * multiple_of_max) / len(repeated_content) + ) + + # Write the markdown content to a temporary file + with open("temp.md", "w", encoding="utf-8") as f: + f.write(markdown_content) + + # Call the function with the temporary file + split_markdown_file("temp.md", max_char_count) + + # Assert that the function creates two files + self.assertTrue(os.path.exists("temp_0.md")) + self.assertTrue(os.path.exists("temp_1.md")) + self.assertTrue(os.path.exists("temp_2.md")) + self.assertTrue(os.path.exists("temp_3.md")) + + # Assert that the all files have less than max characters + for i in range(0, multiple_of_max): + with open(f"temp_{i}.md", "r", encoding="utf-8") as f: + self.assertLessEqual(len(f.read()), max_char_count) + + # remove the temporary files + os.remove("temp.md") + os.remove("temp_0.md") + os.remove("temp_1.md") + os.remove("temp_2.md") + os.remove("temp_3.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_markdown_writer.py b/test_markdown_writer.py index 6988b943..c79536b5 100644 --- a/test_markdown_writer.py +++ b/test_markdown_writer.py @@ -6,6 +6,7 @@ environment variables set. """ + import os import unittest from datetime import timedelta @@ -19,7 +20,10 @@ os.environ, { "SEARCH_QUERY": "is:open repo:user/repo", - "GH_TOKEN": "test_token" + "GH_TOKEN": "test_token", + "DRAFT_PR_TRACKING": "True", + "HIDE_CREATED_AT": "False", + "HIDE_STATUS": "False", }, ) class TestWriteToMarkdown(unittest.TestCase): @@ -39,22 +43,30 @@ def test_write_to_markdown(self): # Create mock data issues_with_metrics = [ IssueWithMetrics( - "Issue 1", - "https://github.com/user/repo/issues/1", - "alice", - timedelta(days=1), - timedelta(days=2), - timedelta(days=3), - {"bug": timedelta(days=1)}, + title="Issue 1", + html_url="https://github.com/user/repo/issues/1", + author="alice", + assignee="charlie", + assignees=["charlie"], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(days=1), + time_to_close=timedelta(days=2), + time_to_answer=timedelta(days=3), + time_in_draft=timedelta(days=1), + labels_metrics={"bug": timedelta(days=4)}, ), IssueWithMetrics( - "Issue 2\r", - "https://github.com/user/repo/issues/2", - "bob", - timedelta(days=3), - timedelta(days=4), - timedelta(days=5), - {"bug": timedelta(days=2)}, + title="Issue 2\r", + html_url="https://github.com/user/repo/issues/2", + author="bob", + assignee=None, + assignees=[], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(days=3), + time_to_close=timedelta(days=4), + time_to_answer=timedelta(days=5), + time_in_draft=timedelta(days=1), + labels_metrics={"bug": timedelta(days=2)}, ), ] time_to_first_response = { @@ -72,6 +84,11 @@ def test_write_to_markdown(self): "med": timedelta(days=4), "90p": timedelta(days=4), } + time_in_draft = { + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + } time_in_labels = { "avg": {"bug": "1 day, 12:00:00"}, "med": {"bug": "1 day, 12:00:00"}, @@ -80,6 +97,7 @@ def test_write_to_markdown(self): num_issues_opened = 2 num_issues_closed = 1 + num_mentor_count = 5 # Call the function write_to_markdown( @@ -87,11 +105,16 @@ def test_write_to_markdown(self): average_time_to_first_response=time_to_first_response, average_time_to_close=time_to_close, average_time_to_answer=time_to_answer, + average_time_in_draft=time_in_draft, average_time_in_labels=time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, labels=["bug"], search_query="is:issue is:open label:bug", + report_title="Issue Metrics", + output_file="issue_metrics.md", + ghe="", ) # Check that the function writes the correct markdown file @@ -104,20 +127,23 @@ def test_write_to_markdown(self): "| Time to first response | 2 days, 0:00:00 | 2 days, 0:00:00 | 2 days, 0:00:00 |\n" "| Time to close | 3 days, 0:00:00 | 3 days, 0:00:00 | 3 days, 0:00:00 |\n" "| Time to answer | 4 days, 0:00:00 | 4 days, 0:00:00 | 4 days, 0:00:00 |\n" + "| Time in draft | 1 day, 0:00:00 | 1 day, 0:00:00 | 1 day, 0:00:00 |\n" "| Time spent in bug | 1 day, 12:00:00 | 1 day, 12:00:00 | 1 day, 12:00:00 |\n" + "| Status | | | |\n" "\n" "| Metric | Count |\n" "| --- | ---: |\n" "| Number of items that remain open | 2 |\n" "| Number of items closed | 1 |\n" "| Total number of items created | 2 |\n\n" - "| Title | URL | Author | Time to first response | Time to close |" - " Time to answer | Time spent in bug |\n" - "| --- | --- | --- | --- | --- | --- | --- |\n" - "| Issue 1 | https://github.com/user/repo/issues/1 | [alice](https://github.com/alice) | 1 day, 0:00:00 | " - "2 days, 0:00:00 | 3 days, 0:00:00 | 1 day, 0:00:00 |\n" - "| Issue 2 | https://github.com/user/repo/issues/2 | [bob](https://github.com/bob) | 3 days, 0:00:00 | " - "4 days, 0:00:00 | 5 days, 0:00:00 | 2 days, 0:00:00 |\n\n" + "| Title | URL | Assignee | Author | Time to first response | Time to close | " + "Time to answer | Time in draft | Time spent in bug | Created At | Status |\n" + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" + "| Issue 1 | https://github.com/user/repo/issues/1 | [charlie](https://github.com/charlie) | " + "[alice](https://github.com/alice) | 1 day, 0:00:00 | 2 days, 0:00:00 | 3 days, 0:00:00 | " + "1 day, 0:00:00 | 4 days, 0:00:00 | -5 days, 0:00:00 | None |\n" + "| Issue 2 | https://github.com/user/repo/issues/2 | None | [bob](https://github.com/bob) | 3 days, 0:00:00 | " + "4 days, 0:00:00 | 5 days, 0:00:00 | 1 day, 0:00:00 | 2 days, 0:00:00 | -5 days, 0:00:00 | None |\n\n" "_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" "Search query used to find these items: `is:issue is:open label:bug`\n" ) @@ -136,22 +162,30 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): # Create mock data issues_with_metrics = [ IssueWithMetrics( - "Issue 1", - "https://github.com/user/repo/issues/1", - "alice", - timedelta(days=1), - timedelta(days=2), - timedelta(days=3), - {"bug": timedelta(days=1)}, + title="Issue 1", + html_url="https://github.com/user/repo/issues/1", + author="alice", + assignee="charlie", + assignees=["charlie"], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(days=1), + time_to_close=timedelta(days=2), + time_to_answer=timedelta(days=3), + time_in_draft=timedelta(days=1), + labels_metrics={"bug": timedelta(days=1)}, ), IssueWithMetrics( - "feat| Issue 2", # title contains a vertical bar - "https://github.com/user/repo/issues/2", - "bob", - timedelta(days=3), - timedelta(days=4), - timedelta(days=5), - {"bug": timedelta(days=2)}, + title="feat| Issue 2", # title contains a vertical bar + html_url="https://github.com/user/repo/issues/2", + author="bob", + assignee=None, + assignees=[], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(days=3), + time_to_close=timedelta(days=4), + time_to_answer=timedelta(days=5), + time_in_draft=None, + labels_metrics={"bug": timedelta(days=2)}, ), ] average_time_to_first_response = { @@ -169,6 +203,11 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): "med": timedelta(days=4), "90p": timedelta(days=4), } + average_time_in_draft = { + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + } average_time_in_labels = { "avg": {"bug": "1 day, 12:00:00"}, "med": {"bug": "1 day, 12:00:00"}, @@ -177,6 +216,7 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): num_issues_opened = 2 num_issues_closed = 1 + num_mentor_count = 5 # Call the function write_to_markdown( @@ -184,10 +224,14 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): average_time_to_first_response=average_time_to_first_response, average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, + average_time_in_draft=average_time_in_draft, average_time_in_labels=average_time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, labels=["bug"], + report_title="Issue Metrics", + output_file="issue_metrics.md", ) # Check that the function writes the correct markdown file @@ -200,20 +244,24 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): "| Time to first response | 2 days, 0:00:00 | 2 days, 0:00:00 | 2 days, 0:00:00 |\n" "| Time to close | 3 days, 0:00:00 | 3 days, 0:00:00 | 3 days, 0:00:00 |\n" "| Time to answer | 4 days, 0:00:00 | 4 days, 0:00:00 | 4 days, 0:00:00 |\n" + "| Time in draft | 1 day, 0:00:00 | 1 day, 0:00:00 | 1 day, 0:00:00 |\n" "| Time spent in bug | 1 day, 12:00:00 | 1 day, 12:00:00 | 1 day, 12:00:00 |\n" + "| Status | | | |\n" "\n" "| Metric | Count |\n" "| --- | ---: |\n" "| Number of items that remain open | 2 |\n" "| Number of items closed | 1 |\n" "| Total number of items created | 2 |\n\n" - "| Title | URL | Author | Time to first response | Time to close |" - " Time to answer | Time spent in bug |\n" - "| --- | --- | --- | --- | --- | --- | --- |\n" - "| Issue 1 | https://github.com/user/repo/issues/1 | [alice](https://github.com/alice) | 1 day, 0:00:00 | " - "2 days, 0:00:00 | 3 days, 0:00:00 | 1 day, 0:00:00 |\n" - "| feat| Issue 2 | https://github.com/user/repo/issues/2 | [bob](https://github.com/bob) | 3 days, 0:00:00 | " - "4 days, 0:00:00 | 5 days, 0:00:00 | 2 days, 0:00:00 |\n\n" + "| Title | URL | Assignee | Author | Time to first response | Time to close | " + "Time to answer | Time in draft | Time spent in bug | Created At | Status |\n" + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" + "| Issue 1 | https://github.com/user/repo/issues/1 | [charlie](https://github.com/charlie) | " + "[alice](https://github.com/alice) | 1 day, 0:00:00 | 2 days, 0:00:00 | 3 days, 0:00:00 | " + "1 day, 0:00:00 | 1 day, 0:00:00 | -5 days, 0:00:00 | None |\n" + "| feat| Issue 2 | https://github.com/user/repo/issues/2 | None | " + "[bob](https://github.com/bob) | 3 days, 0:00:00 | " + "4 days, 0:00:00 | 5 days, 0:00:00 | None | 2 days, 0:00:00 | -5 days, 0:00:00 | None |\n\n" "_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" ) self.assertEqual(content, expected_content) @@ -223,7 +271,18 @@ def test_write_to_markdown_no_issues(self): """Test that write_to_markdown writes the correct markdown file when no issues are found.""" # Call the function with no issues with patch("builtins.open", mock_open()) as mock_open_file: - write_to_markdown(None, None, None, None, None, None, None) + write_to_markdown( + None, + None, + None, + None, + None, + None, + None, + None, + None, + report_title="Issue Metrics", + ) # Check that the file was written correctly expected_output = [ @@ -246,14 +305,22 @@ def test_write_to_markdown_no_issues(self): { "SEARCH_QUERY": "is:open repo:user/repo", "GH_TOKEN": "test_token", + "HIDE_CREATED_AT": "False", "HIDE_TIME_TO_FIRST_RESPONSE": "True", "HIDE_TIME_TO_CLOSE": "True", "HIDE_TIME_TO_ANSWER": "True", - "HIDE_LABEL_METRICS": "True" + "HIDE_LABEL_METRICS": "True", + "NON_MENTIONING_LINKS": "True", + "GH_ENTERPRISE_URL": "https://ghe.com", + "HIDE_STATUS": "False", }, ) class TestWriteToMarkdownWithEnv(unittest.TestCase): - """Test the write_to_markdown function with the HIDE* environment variables set.""" + """Test the write_to_markdown function with the following environment variables set: + - HIDE*, + - NON_MENTIONING_LINKS + - GH_ENTERPRISE_URL + """ def test_writes_markdown_file_with_non_hidden_columns_only(self): """ @@ -265,19 +332,26 @@ def test_writes_markdown_file_with_non_hidden_columns_only(self): issues_with_metrics = [ IssueWithMetrics( title="Issue 1", - html_url="https://github.com/user/repo/issues/1", + html_url="https://ghe.com/user/repo/issues/1", author="alice", + assignee="charlie", + assignees=["charlie"], + created_at=timedelta(days=-5), time_to_first_response=timedelta(minutes=10), time_to_close=timedelta(days=1), time_to_answer=timedelta(hours=2), + time_in_draft=timedelta(days=1), labels_metrics={ "label1": timedelta(days=1), }, ), IssueWithMetrics( title="Issue 2", - html_url="https://github.com/user/repo/issues/2", + html_url="https://ghe.com/user/repo/issues/2", author="bob", + assignee=None, + assignees=[], + created_at=timedelta(days=-5), time_to_first_response=timedelta(minutes=20), time_to_close=timedelta(days=2), time_to_answer=timedelta(hours=4), @@ -289,11 +363,14 @@ def test_writes_markdown_file_with_non_hidden_columns_only(self): average_time_to_first_response = timedelta(minutes=15) average_time_to_close = timedelta(days=1.5) average_time_to_answer = timedelta(hours=3) + average_time_in_draft = timedelta(days=1) average_time_in_labels = { "label1": timedelta(days=1), } num_issues_opened = 2 - num_issues_closed = 1 + num_issues_closed = 2 + num_mentor_count = 5 + ghe = "https://ghe.com" # Call the function write_to_markdown( @@ -302,29 +379,152 @@ def test_writes_markdown_file_with_non_hidden_columns_only(self): average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, average_time_in_labels=average_time_in_labels, + average_time_in_draft=average_time_in_draft, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, labels=["label1"], search_query="repo:user/repo is:issue", hide_label_metrics=True, + hide_items_closed_count=True, + enable_mentor_count=True, + non_mentioning_links=True, + report_title="Issue Metrics", + output_file="issue_metrics.md", + ghe=ghe, ) # Check that the function writes the correct markdown file with open("issue_metrics.md", "r", encoding="utf-8") as file: content = file.read() + expected_content = ( "# Issue Metrics\n\n" "| Metric | Count |\n" "| --- | ---: |\n" "| Number of items that remain open | 2 |\n" - "| Number of items closed | 1 |\n" + "| Number of most active mentors | 5 |\n" + "| Total number of items created | 2 |\n\n" + "| Title | URL | Assignee | Author | Created At | Status |\n" + "| --- | --- | --- | --- | --- | --- |\n" + "| Issue 1 | https://www.ghe.com/user/repo/issues/1 | [charlie](https://ghe.com/charlie) | " + "[alice](https://ghe.com/alice) | -5 days, 0:00:00 | None |\n" + "| Issue 2 | https://www.ghe.com/user/repo/issues/2 | None | [bob](https://ghe.com/bob) | -5 days, 0:00:00 | None |\n\n" + "_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" + "Search query used to find these items: `repo:user/repo is:issue`\n" + ) + self.assertEqual(content, expected_content) + os.remove("issue_metrics.md") + + @patch.dict( + os.environ, + { + "SEARCH_QUERY": "is:open repo:user/repo", + "GH_TOKEN": "test_token", + "HIDE_CREATED_AT": "False", + "HIDE_TIME_TO_FIRST_RESPONSE": "True", + "HIDE_TIME_TO_CLOSE": "True", + "HIDE_TIME_TO_ANSWER": "True", + "HIDE_LABEL_METRICS": "True", + "NON_MENTIONING_LINKS": "True", + "GH_ENTERPRISE_URL": "https://ghe.com", + "HIDE_STATUS": "True", # Status column should be hidden + }, + ) + def test_writes_markdown_file_with_hidden_status_column(self): + """ + Test that write_to_markdown writes the correct markdown file + when HIDE_STATUS is set to True, ensuring the Status column + is not present in the output. + """ + # Create mock data + issues_with_metrics = [ + IssueWithMetrics( + title="Issue 1", + html_url="https://ghe.com/user/repo/issues/1", + author="alice", + assignee="charlie", + assignees=["charlie"], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(minutes=10), + time_to_close=timedelta(days=1), + time_to_answer=timedelta(hours=2), + time_in_draft=timedelta(days=1), + labels_metrics={ + "label1": timedelta(days=1), + }, + ), + IssueWithMetrics( + title="Issue 2", + html_url="https://ghe.com/user/repo/issues/2", + author="bob", + assignee=None, + assignees=[], + created_at=timedelta(days=-5), + time_to_first_response=timedelta(minutes=20), + time_to_close=timedelta(days=2), + time_to_answer=timedelta(hours=4), + labels_metrics={ + "label1": timedelta(days=1), + }, + ), + ] + average_time_to_first_response = timedelta(minutes=15) + average_time_to_close = timedelta(days=1.5) + average_time_to_answer = timedelta(hours=3) + average_time_in_draft = timedelta(days=1) + average_time_in_labels = { + "label1": timedelta(days=1), + } + num_issues_opened = 2 + num_issues_closed = 2 + num_mentor_count = 5 + ghe = "https://ghe.com" + + # Call the function + write_to_markdown( + issues_with_metrics=issues_with_metrics, + average_time_to_first_response=average_time_to_first_response, + average_time_to_close=average_time_to_close, + average_time_to_answer=average_time_to_answer, + average_time_in_labels=average_time_in_labels, + average_time_in_draft=average_time_in_draft, + num_issues_opened=num_issues_opened, + num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, + labels=["label1"], + search_query="repo:user/repo is:issue", + hide_label_metrics=True, + hide_items_closed_count=True, + enable_mentor_count=True, + non_mentioning_links=True, + report_title="Issue Metrics", + output_file="issue_metrics.md", + ghe=ghe, + ) + + # Check that the function writes the correct markdown file + with open("issue_metrics.md", "r", encoding="utf-8") as file: + content = file.read() + + expected_content = ( + "# Issue Metrics\n\n" + "| Metric | Count |\n" + "| --- | ---: |\n" + "| Number of items that remain open | 2 |\n" + "| Number of most active mentors | 5 |\n" "| Total number of items created | 2 |\n\n" - "| Title | URL | Author |\n" - "| --- | --- | --- |\n" - "| Issue 1 | https://github.com/user/repo/issues/1 | [alice](https://github.com/alice) |\n" - "| Issue 2 | https://github.com/user/repo/issues/2 | [bob](https://github.com/bob) |\n\n" + "| Title | URL | Assignee | Author | Created At |\n" # Status column should be missing + "| --- | --- | --- | --- | --- |\n" + "| Issue 1 | https://www.ghe.com/user/repo/issues/1 | [charlie](https://ghe.com/charlie) | " + "[alice](https://ghe.com/alice) | -5 days, 0:00:00 |\n" + "| Issue 2 | https://www.ghe.com/user/repo/issues/2 | None | [bob](https://ghe.com/bob) | -5 days, 0:00:00 |\n\n" "_This report was generated with the [Issue Metrics Action](https://github.com/github/issue-metrics)_\n" "Search query used to find these items: `repo:user/repo is:issue`\n" ) self.assertEqual(content, expected_content) os.remove("issue_metrics.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_most_active_mentors.py b/test_most_active_mentors.py new file mode 100755 index 00000000..44664bdc --- /dev/null +++ b/test_most_active_mentors.py @@ -0,0 +1,121 @@ +"""A module containing unit tests for the most_active_mentors module. + +This module contains unit tests for the count_comments_per_user and +get_mentor_count functions in the most_active_mentors module. +The tests use mock GitHub issues and comments to test the functions' behavior. + +Classes: + TestCountCommentsPerUser: A class testing count_comments_per_user. + TestGetMentorCount: A class to test the + get_mentor_count function. + +""" + +import unittest +from datetime import datetime +from unittest.mock import MagicMock + +from classes import IssueWithMetrics +from most_active_mentors import count_comments_per_user, get_mentor_count + + +class TestCountCommentsPerUser(unittest.TestCase): + """Test the count_comments_per_user function.""" + + def test_count_comments_per_user_limit(self): + """Test that count_comments_per_user correctly counts user comments. + + This test mocks the GitHub connection and issue comments, and checks + that count_comments_per_user correctly considers user comments for + counting. + + """ + # Set up the mock GitHub issues + mock_issue1 = MagicMock() + mock_issue1.comments = 2 + mock_issue1.issue.user.login = "issue_owner" + mock_issue1.created_at = "2023-01-01T00:00:00Z" + + # Set up 21 mock GitHub issue comments - only 20 should be counted + mock_issue1.issue.comments.return_value = [] + for i in range(22): + mock_comment1 = MagicMock() + mock_comment1.user.login = "very_active_user" + mock_comment1.created_at = datetime.fromisoformat( + f"2023-01-02T{i:02d}:00:00Z" + ) + # pylint: disable=maybe-no-member + mock_issue1.issue.comments.return_value.append(mock_comment1) + + # Call the function + result = count_comments_per_user(mock_issue1) + expected_result = {"very_active_user": 3} + + # Check the results + self.assertEqual(result, expected_result) + + def test_count_comments_per_user_with_ignores(self): + """Test that count_comments_per_user correctly counts user comments with some users ignored.""" + # Set up the mock GitHub issues + mock_issue1 = MagicMock() + mock_issue1.comments = 2 + mock_issue1.issue.user.login = "issue_owner" + mock_issue1.created_at = "2023-01-01T00:00:00Z" + + # Set up mock GitHub issue comments by several users + mock_issue1.issue.comments.return_value = [] + for i in range(5): + mock_comment1 = MagicMock() + mock_comment1.user.login = "very_active_user" + mock_comment1.created_at = datetime.fromisoformat( + f"2023-01-02T{i:02d}:00:00Z" + ) + # pylint: disable=maybe-no-member + mock_issue1.issue.comments.return_value.append(mock_comment1) + for i in range(5): + mock_comment1 = MagicMock() + mock_comment1.user.login = "very_active_user_ignored" + mock_comment1.created_at = datetime.fromisoformat( + f"2023-01-02T{i:02d}:00:00Z" + ) + # pylint: disable=maybe-no-member + mock_issue1.issue.comments.return_value.append(mock_comment1) + + # Call the function + result = count_comments_per_user( + mock_issue1, ignore_users=["very_active_user_ignored"] + ) + # Only the comments by "very_active_user" should be counted, + # so the count should be 3 since that is the threshold for heavily involved + expected_result = {"very_active_user": 3} + + # Check the results + self.assertEqual(result, expected_result) + self.assertNotIn("very_active_user_ignored", result) + + def test_get_mentor_count(self): + """Test that get_mentor_count correctly counts comments per user.""" + mentor_activity = {"sue": 15, "bob": 10} + + # Create mock data + issues_with_metrics = [ + IssueWithMetrics( + "Issue 1", + "https://github.com/user/repo/issues/1", + "alice", + None, + mentor_activity=mentor_activity, + ), + IssueWithMetrics( + "Issue 2", + "https://github.com/user/repo/issues/2", + "bob", + None, + mentor_activity=mentor_activity, + ), + ] + + # Call the function and check the result + result = get_mentor_count(issues_with_metrics, 2) + expected_result = 2 + self.assertEqual(result, expected_result) diff --git a/test_search.py b/test_search.py new file mode 100644 index 00000000..bd1d36f9 --- /dev/null +++ b/test_search.py @@ -0,0 +1,152 @@ +"""Unit tests for the search module.""" + +import unittest +from unittest.mock import MagicMock + +from search import get_owners_and_repositories, search_issues + + +class TestSearchIssues(unittest.TestCase): + """Unit tests for the search_issues function. + + This class contains unit tests for the search_issues function in the + issue_metrics module. The tests use the unittest module and the unittest.mock + module to mock the GitHub API and test the function in isolation. + + Methods: + test_search_issues_with_owner_and_repository: + Test that search_issues with owner/repo returns the correct issues. + test_search_issues_with_just_owner_or_org: + Test that search_issues with just an owner/org returns the correct issues. + test_search_issues_with_just_owner_or_org_with_bypass: + Test that search_issues with just an owner/org returns the correct issues + with rate limit bypass enabled. + + """ + + def test_search_issues_with_owner_and_repository(self): + """Test that search_issues with owner/repo returns the correct issues.""" + + # Set up the mock GitHub connection object + mock_issues = [ + MagicMock(title="Issue 1"), + MagicMock(title="Issue 2"), + ] + + # simulating github3.structs.SearchIterator return value + mock_search_result = MagicMock() + mock_search_result.__iter__.return_value = iter(mock_issues) + mock_search_result.ratelimit_remaining = 30 + + mock_connection = MagicMock() + mock_connection.search_issues.return_value = mock_search_result + + # Call search_issues and check that it returns the correct issues + repo_with_owner = {"owner": "owner1", "repository": "repo1"} + owners_and_repositories = [repo_with_owner] + issues = search_issues("is:open", mock_connection, owners_and_repositories) + self.assertEqual(issues, mock_issues) + + def test_search_issues_with_just_owner_or_org(self): + """Test that search_issues with just an owner/org returns the correct issues.""" + + # Set up the mock GitHub connection object + mock_issues = [ + MagicMock(title="Issue 1"), + MagicMock(title="Issue 2"), + MagicMock(title="Issue 3"), + ] + + # simulating github3.structs.SearchIterator return value + mock_search_result = MagicMock() + mock_search_result.__iter__.return_value = iter(mock_issues) + mock_search_result.ratelimit_remaining = 30 + + mock_connection = MagicMock() + mock_connection.search_issues.return_value = mock_search_result + + # Call search_issues and check that it returns the correct issues + org = {"owner": "org1"} + owners = [org] + issues = search_issues("is:open", mock_connection, owners) + self.assertEqual(issues, mock_issues) + + def test_search_issues_with_just_owner_or_org_with_bypass(self): + """Test that search_issues with just an owner/org returns the correct issues.""" + + # Set up the mock GitHub connection object + mock_issues = [ + MagicMock(title="Issue 1"), + MagicMock(title="Issue 2"), + MagicMock(title="Issue 3"), + ] + + # simulating github3.structs.SearchIterator return value + mock_search_result = MagicMock() + mock_search_result.__iter__.return_value = iter(mock_issues) + mock_search_result.ratelimit_remaining = 30 + + mock_connection = MagicMock() + mock_connection.search_issues.return_value = mock_search_result + + # Call search_issues and check that it returns the correct issues + org = {"owner": "org1"} + owners = [org] + issues = search_issues( + "is:open", mock_connection, owners, rate_limit_bypass=True + ) + self.assertEqual(issues, mock_issues) + + +class TestGetOwnerAndRepository(unittest.TestCase): + """Unit tests for the get_owners_and_repositories function. + + This class contains unit tests for the get_owners_and_repositories function in the + issue_metrics module. The tests use the unittest module and the unittest.mock + module to mock the GitHub API and test the function in isolation. + + Methods: + test_get_owners_with_owner_and_repo_in_query: Test get both owner and repo. + test_get_owner_and_repositories_without_repo_in_query: Test get just owner. + test_get_owners_and_repositories_without_either_in_query: Test get neither. + test_get_owners_and_repositories_with_multiple_entries: Test get multiple entries. + test_get_owners_and_repositories_with_org: Test get org as owner. + test_get_owners_and_repositories_with_user: Test get user as owner. + """ + + def test_get_owners_with_owner_and_repo_in_query(self): + """Test get both owner and repo.""" + result = get_owners_and_repositories("repo:owner1/repo1") + self.assertEqual(result[0].get("owner"), "owner1") + self.assertEqual(result[0].get("repository"), "repo1") + + def test_get_owner_and_repositories_without_repo_in_query(self): + """Test get just owner.""" + result = get_owners_and_repositories("org:owner1") + self.assertEqual(result[0].get("owner"), "owner1") + self.assertIsNone(result[0].get("repository")) + + def test_get_owners_and_repositories_without_either_in_query(self): + """Test get neither.""" + result = get_owners_and_repositories("is:blah") + self.assertEqual(result, []) + + def test_get_owners_and_repositories_with_multiple_entries(self): + """Test get multiple entries.""" + result = get_owners_and_repositories("repo:owner1/repo1 org:owner2") + self.assertEqual(result[0].get("owner"), "owner1") + self.assertEqual(result[0].get("repository"), "repo1") + self.assertEqual(result[1].get("owner"), "owner2") + self.assertIsNone(result[1].get("repository")) + + def test_get_owners_and_repositories_with_org(self): + """Test get org as owner.""" + result = get_owners_and_repositories("org:owner1") + self.assertEqual(result[0].get("owner"), "owner1") + self.assertIsNone(result[0].get("repository")) + + def test_get_owners_and_repositories_with_user(self): + """Test get user as owner.""" + result = get_owners_and_repositories("user:owner1") + self.assertEqual(result[0].get("owner"), "owner1") + self.assertIsNone(result[0].get("repository")) diff --git a/test_time_in_draft.py b/test_time_in_draft.py new file mode 100644 index 00000000..8a4b9503 --- /dev/null +++ b/test_time_in_draft.py @@ -0,0 +1,261 @@ +"""A test suite for the measure_time_in_draft function.""" + +import unittest +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import github3 +import pytz +from time_in_draft import get_stats_time_in_draft, measure_time_in_draft + + +class TestMeasureTimeInDraft(unittest.TestCase): + """ + Unit tests for the measure_time_in_draft function. + """ + + def setUp(self): + """ + Setup common test data and mocks. + """ + self.issue = MagicMock() + self.issue.issue = MagicMock(spec=github3.issues.Issue) + self.issue.issue.state = "open" + + def test_time_in_draft_with_ready_for_review(self): + """ + Test measure_time_in_draft with one draft and review interval. + """ + self.issue.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + MagicMock( + event="ready_for_review", + created_at=datetime(2021, 1, 3, tzinfo=pytz.utc), + ), + ] + result = measure_time_in_draft(self.issue) + expected = timedelta(days=2) + self.assertEqual(result, expected, "The time in draft should be 2 days.") + + def test_time_in_draft_without_ready_for_review(self): + """ + Test measure_time_in_draft when ready_for_review_at is not provided and issue is still open. + """ + self.issue.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + ] + now = datetime(2021, 1, 4, tzinfo=pytz.utc) + with unittest.mock.patch("time_in_draft.datetime") as mock_datetime: + mock_datetime.now.return_value = now + result = measure_time_in_draft(self.issue) + expected = timedelta(days=3) + self.assertEqual(result, expected, "The time in draft should be 3 days.") + + def test_time_in_draft_multiple_intervals(self): + """ + Test measure_time_in_draft with multiple draft intervals. + """ + self.issue.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + MagicMock( + event="ready_for_review", + created_at=datetime(2021, 1, 3, tzinfo=pytz.utc), + ), + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 5, tzinfo=pytz.utc), + ), + MagicMock( + event="ready_for_review", + created_at=datetime(2021, 1, 7, tzinfo=pytz.utc), + ), + ] + result = measure_time_in_draft(self.issue) + expected = timedelta(days=4) + self.assertEqual(result, expected, "The total time in draft should be 4 days.") + + def test_time_in_draft_ongoing_draft(self): + """ + Test measure_time_in_draft with an ongoing draft interval. + """ + self.issue.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + ] + with unittest.mock.patch("time_in_draft.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime(2021, 1, 4, tzinfo=pytz.utc) + result = measure_time_in_draft(self.issue) + expected = timedelta(days=3) + self.assertEqual( + result, expected, "The ongoing draft time should be 3 days." + ) + + def test_time_in_draft_no_draft_events(self): + """ + Test measure_time_in_draft with no draft-related events. + """ + self.issue.issue.events.return_value = [] + result = measure_time_in_draft(self.issue) + self.assertIsNone( + result, "The result should be None when there are no draft events." + ) + + def test_time_in_draft_without_ready_for_review_and_closed(self): + """ + Test measure_time_in_draft for a closed issue with an ongoing draft and ready_for_review_at is not provided. + """ + self.issue.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + ] + self.issue.issue.state = "closed" + result = measure_time_in_draft(self.issue) + self.assertIsNone( + result, + "The result should be None for a closed issue with an ongoing draft.", + ) + + def test_time_in_draft_initially_created_as_draft(self): + """ + Test measure_time_in_draft with a PR initially created as draft. + """ + # Set up issue created_at time + self.issue.issue.created_at = "2021-01-01T00:00:00Z" + + # Mock events with only ready_for_review (no converted_to_draft) + self.issue.issue.events.return_value = [ + MagicMock( + event="ready_for_review", + created_at=datetime(2021, 1, 3, tzinfo=pytz.utc), + ), + ] + + # Mock pull request object + mock_pull_request = MagicMock() + + result = measure_time_in_draft(self.issue, mock_pull_request) + expected = timedelta(days=2) + self.assertEqual( + result, + expected, + "The time in draft should be 2 days for initially draft PR.", + ) + + def test_time_in_draft_initially_created_as_draft_still_open(self): + """ + Test measure_time_in_draft with a PR initially created as draft and still in draft. + """ + # Set up issue created_at time + self.issue.issue.created_at = "2021-01-01T00:00:00Z" + + # Mock events with no ready_for_review events (still draft) + self.issue.issue.events.return_value = [] + + # Mock pull request object indicating it's currently draft + mock_pull_request = MagicMock() + mock_pull_request.draft = True + + with unittest.mock.patch("time_in_draft.datetime") as mock_datetime: + # Keep the real datetime class but only mock the now() method + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.now.return_value = datetime(2021, 1, 4, tzinfo=pytz.utc) + result = measure_time_in_draft(self.issue, mock_pull_request) + expected = timedelta(days=3) + self.assertEqual( + result, + expected, + "The time in draft should be 3 days for initially draft PR still in draft.", + ) + + def test_time_in_draft_with_attribute_error_scenario(self): + """ + Test measure_time_in_draft to ensure it doesn't raise AttributeError when called + with issue structure similar to what get_per_issue_metrics passes. + This test reproduces the original bug scenario. + """ + # This simulates the actual issue structure passed from get_per_issue_metrics + issue_search_result = MagicMock() + issue_search_result.issue = MagicMock(spec=github3.issues.Issue) + issue_search_result.issue.state = "open" + issue_search_result.issue.events.return_value = [ + MagicMock( + event="converted_to_draft", + created_at=datetime(2021, 1, 1, tzinfo=pytz.utc), + ), + ] + + # This should NOT raise AttributeError: events + with unittest.mock.patch("time_in_draft.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime(2021, 1, 4, tzinfo=pytz.utc) + result = measure_time_in_draft(issue_search_result) + expected = timedelta(days=3) + self.assertEqual(result, expected, "The time in draft should be 3 days.") + + +class TestGetStatsTimeInDraft(unittest.TestCase): + """ + Unit tests for the get_stats_time_in_draft function. + """ + + def test_get_stats_time_in_draft_with_data(self): + """ + Test get_stats_time_in_draft with valid draft times. + """ + issues = [ + MagicMock(time_in_draft=timedelta(days=1)), + MagicMock(time_in_draft=timedelta(days=2)), + MagicMock(time_in_draft=timedelta(days=3)), + ] + + result = get_stats_time_in_draft(issues) + expected = { + "avg": timedelta(days=2), + "med": timedelta(days=2), + "90p": timedelta(days=2, seconds=69120), + } + + self.assertEqual( + result, expected, "The statistics for time in draft are incorrect." + ) + + def test_get_stats_time_in_draft_no_data(self): + """ + Test get_stats_time_in_draft with no draft times. + """ + issues = [ + MagicMock(time_in_draft=None), + MagicMock(time_in_draft=None), + ] + + result = get_stats_time_in_draft(issues) + self.assertIsNone( + result, "The result should be None when there are no draft times." + ) + + def test_get_stats_time_in_draft_empty_list(self): + """ + Test get_stats_time_in_draft with an empty list of issues. + """ + issues = [] + + result = get_stats_time_in_draft(issues) + self.assertIsNone( + result, "The result should be None when the list of issues is empty." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_time_to_answer.py b/test_time_to_answer.py index 700079d1..225fc012 100644 --- a/test_time_to_answer.py +++ b/test_time_to_answer.py @@ -2,7 +2,6 @@ import unittest from datetime import timedelta -from typing import List from classes import IssueWithMetrics from time_to_answer import get_stats_time_to_answer, measure_time_to_answer @@ -20,7 +19,7 @@ class TestGetAverageTimeToAnswer(unittest.TestCase): def test_returns_none_for_empty_list(self): """Tests that the function returns None when given an empty list of issues.""" # Arrange - issues_with_metrics: List[IssueWithMetrics] = [] + issues_with_metrics = [] # Act result = get_stats_time_to_answer(issues_with_metrics) @@ -53,13 +52,19 @@ def test_returns_stats_time_to_answer(self): # Arrange issues_with_metrics = [ - IssueWithMetrics("issue1", "url1", "alice", None, None, timedelta(seconds=10)), - IssueWithMetrics("issue2", "url2", "bob", None, None, timedelta(seconds=20)), - IssueWithMetrics("issue3", "url3", "carol", None, None, timedelta(seconds=30)), + IssueWithMetrics( + "issue1", "url1", "alice", None, None, timedelta(seconds=10) + ), + IssueWithMetrics( + "issue2", "url2", "bob", None, None, timedelta(seconds=20) + ), + IssueWithMetrics( + "issue3", "url3", "carol", None, None, timedelta(seconds=30) + ), ] # Act - result = get_stats_time_to_answer(issues_with_metrics)['avg'] + result = get_stats_time_to_answer(issues_with_metrics)["avg"] # Assert self.assertEqual(result, timedelta(seconds=20)) diff --git a/test_time_to_close.py b/test_time_to_close.py index c4596155..0c09b2cf 100644 --- a/test_time_to_close.py +++ b/test_time_to_close.py @@ -9,11 +9,12 @@ TestGetStatsTimeToClose: A class to test the get_stats_time_to_close function. """ -from datetime import timedelta + import unittest +from datetime import timedelta from unittest.mock import MagicMock -from classes import IssueWithMetrics +from classes import IssueWithMetrics from time_to_close import get_stats_time_to_close, measure_time_to_close @@ -44,7 +45,7 @@ def test_get_stats_time_to_close(self): ] # Call the function and check the result - result = get_stats_time_to_close(issues_with_metrics)['avg'] + result = get_stats_time_to_close(issues_with_metrics)["avg"] expected_result = timedelta(days=3) self.assertEqual(result, expected_result) diff --git a/test_time_to_first_response.py b/test_time_to_first_response.py index a1a124ad..a8e592a0 100644 --- a/test_time_to_first_response.py +++ b/test_time_to_first_response.py @@ -10,6 +10,7 @@ get_stats_time_to_first_response function. """ + import unittest from datetime import datetime, timedelta from unittest.mock import MagicMock @@ -77,14 +78,18 @@ def test_measure_time_to_first_response_with_pull_request_comments(self): # Set up the mock GitHub pull request comments mock_pr_comment1 = MagicMock() - mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T00:00:00Z") # first response + mock_pr_comment1.submitted_at = datetime.fromisoformat( + "2023-01-02T00:00:00Z" + ) # first response mock_pr_comment2 = MagicMock() mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-02T12:00:00Z") mock_pull_request = MagicMock() mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None + ) expected_result = timedelta(days=1) # Check the results @@ -101,7 +106,9 @@ def test_measure_time_to_first_response_issue_comment_faster(self): # Set up the mock GitHub issue comment mock_comment1 = MagicMock() - mock_comment1.created_at = datetime.fromisoformat("2023-01-02T00:00:00Z") # first response + mock_comment1.created_at = datetime.fromisoformat( + "2023-01-02T00:00:00Z" + ) # first response mock_issue1.issue.comments.return_value = [mock_comment1] # Set up the mock GitHub pull request comment @@ -111,7 +118,9 @@ def test_measure_time_to_first_response_issue_comment_faster(self): mock_pull_request.reviews.return_value = [mock_pr_comment1] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None + ) expected_result = timedelta(days=1) # Check the results @@ -133,18 +142,24 @@ def test_measure_time_to_first_response_pull_request_comment_faster(self): # Set up the mock GitHub pull request comment mock_pr_comment1 = MagicMock() - mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T00:00:00Z") # first response + mock_pr_comment1.submitted_at = datetime.fromisoformat( + "2023-01-02T00:00:00Z" + ) # first response mock_pull_request = MagicMock() mock_pull_request.reviews.return_value = [mock_pr_comment1] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None + ) expected_result = timedelta(days=1) # Check the results self.assertEqual(result, expected_result) - def test_measure_time_to_first_response_pull_request_comment_ignore_before_ready(self): + def test_measure_time_to_first_response_pull_request_comment_ignore_before_ready( + self, + ): """Test that measure_time_to_first_response ignores comments from before the pull request was ready for review.""" # Set up the mock GitHub issues mock_issue1 = MagicMock() @@ -163,14 +178,18 @@ def test_measure_time_to_first_response_pull_request_comment_ignore_before_ready mock_pr_comment1 = MagicMock() mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T12:00:00Z") mock_pr_comment2 = MagicMock() - mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") # first response + mock_pr_comment2.submitted_at = datetime.fromisoformat( + "2023-01-04T00:00:00Z" + ) # first response mock_pull_request = MagicMock() mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] ready_for_review_at = datetime.fromisoformat("2023-01-03T00:00:00Z") # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, ready_for_review_at) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, ready_for_review_at + ) expected_result = timedelta(days=1) # Check the results @@ -199,17 +218,51 @@ def test_measure_time_to_first_response_ignore_users(self): mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-03T00:00:00Z") mock_pr_comment2 = MagicMock() mock_pr_comment2.user.login = "not_ignored_user" - mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") # first response + mock_pr_comment2.submitted_at = datetime.fromisoformat( + "2023-01-04T00:00:00Z" + ) # first response mock_pull_request = MagicMock() mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None, ["ignored_user"]) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None, ["ignored_user"] + ) expected_result = timedelta(days=3) # Check the results self.assertEqual(result, expected_result) + def test_measure_time_to_first_response_ignore_pending_review(self): + """Test that measure_time_to_first_response ignores pending reviews""" + + mock_issue1 = MagicMock() + mock_issue1.comments = 2 + mock_issue1.issue.user.login = "issue_owner" + mock_issue1.created_at = "2023-01-01T00:00:00Z" + + # Set up the mock GitHub pull request comments (one ignored, one not ignored) + # Pending Review + mock_pr_comment1 = MagicMock() + mock_pr_comment1.submitted_at = None + # Submitted Comment + mock_pr_comment2 = MagicMock() + mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") + + mock_pull_request = MagicMock() + mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] + + ready_for_review_at = datetime.fromisoformat("2023-01-03T00:00:00Z") + + # Call the function + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, ready_for_review_at + ) + expected_result = timedelta(days=1) + + # Check the results + self.assertEqual(result, expected_result) + def test_measure_time_to_first_response_only_ignored_users(self): """Test that measure_time_to_first_response returns empty for an issue with only ignored users.""" # Set up the mock GitHub issues @@ -239,7 +292,11 @@ def test_measure_time_to_first_response_only_ignored_users(self): # Call the function result = measure_time_to_first_response( - mock_issue1, None, mock_pull_request, None, ["ignored_user", "ignored_user2"] + mock_issue1, + None, + mock_pull_request, + None, + ["ignored_user", "ignored_user2"], ) expected_result = None @@ -274,7 +331,9 @@ def test_measure_time_to_first_response_ignore_issue_owners_comment(self): mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None + ) expected_result = timedelta(days=3) # Check the results @@ -299,12 +358,16 @@ def test_measure_time_to_first_response_ignore_bot(self): mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-03T00:00:00Z") mock_pr_comment2 = MagicMock() mock_pr_comment2.user.type = "User" - mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") # first response + mock_pr_comment2.submitted_at = datetime.fromisoformat( + "2023-01-04T00:00:00Z" + ) # first response mock_pull_request = MagicMock() mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2] # Call the function - result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None) + result = measure_time_to_first_response( + mock_issue1, None, mock_pull_request, None + ) expected_result = timedelta(days=3) # Check the results @@ -325,16 +388,24 @@ def test_get_stats_time_to_first_response(self): # Create mock data issues_with_metrics = [ IssueWithMetrics( - "Issue 1", "https://github.com/user/repo/issues/1", "alice", timedelta(days=1) + "Issue 1", + "https://github.com/user/repo/issues/1", + "alice", + timedelta(days=1), ), IssueWithMetrics( - "Issue 2", "https://github.com/user/repo/issues/2", "bob", timedelta(days=2) + "Issue 2", + "https://github.com/user/repo/issues/2", + "bob", + timedelta(days=2), + ), + IssueWithMetrics( + "Issue 3", "https://github.com/user/repo/issues/3", "carol", None ), - IssueWithMetrics("Issue 3", "https://github.com/user/repo/issues/3", "carol", None), ] # Call the function and check the result - result = get_stats_time_to_first_response(issues_with_metrics)['avg'] + result = get_stats_time_to_first_response(issues_with_metrics)["avg"] expected_result = timedelta(days=1.5) self.assertEqual(result, expected_result) @@ -343,8 +414,12 @@ def test_get_stats_time_to_first_response_with_all_none(self): # Create mock data with all None issues_with_metrics = [ - IssueWithMetrics("Issue 1", "https://github.com/user/repo/issues/1", "alice", None), - IssueWithMetrics("Issue 2", "https://github.com/user/repo/issues/2", "bob", None), + IssueWithMetrics( + "Issue 1", "https://github.com/user/repo/issues/1", "alice", None + ), + IssueWithMetrics( + "Issue 2", "https://github.com/user/repo/issues/2", "bob", None + ), ] # Call the function and check the result diff --git a/test_time_to_merge.py b/test_time_to_merge.py index 08fa4966..a7af2aa4 100644 --- a/test_time_to_merge.py +++ b/test_time_to_merge.py @@ -8,8 +8,9 @@ TestMeasureTimeToMerge: A class to test the measure_time_to_merge function. """ -from datetime import timedelta, datetime + import unittest +from datetime import datetime, timedelta from unittest.mock import MagicMock from time_to_merge import measure_time_to_merge diff --git a/test_time_to_ready_for_review.py b/test_time_to_ready_for_review.py index cd2b6267..ff615a33 100644 --- a/test_time_to_ready_for_review.py +++ b/test_time_to_ready_for_review.py @@ -8,8 +8,9 @@ TestGetTimeToReadyForReview: A class to test the get_time_to_ready_for_review function. """ -from datetime import datetime + import unittest +from datetime import datetime from unittest.mock import MagicMock from time_to_ready_for_review import get_time_to_ready_for_review diff --git a/time_in_draft.py b/time_in_draft.py new file mode 100644 index 00000000..a663cc5a --- /dev/null +++ b/time_in_draft.py @@ -0,0 +1,131 @@ +""" +This module contains a function that measures the time a pull request has been in draft state. +""" + +from datetime import datetime, timedelta +from typing import List, Union + +import github3 +import numpy +import pytz +from classes import IssueWithMetrics + + +def measure_time_in_draft( + issue: github3.issues.Issue, + pull_request: Union[github3.pulls.PullRequest, None] = None, +) -> Union[timedelta, None]: + """If a pull request has had time in the draft state, return the cumulative amount of time it was in draft. + + args: + issue (github3.issues.Issue): A GitHub issue which has been pre-qualified as a pull request. + pull_request (github3.pulls.PullRequest, optional): The pull request object. + + returns: + Union[timedelta, None]: Total time the pull request has spent in draft state. + """ + events = issue.issue.events() + draft_start = None + total_draft_time = timedelta(0) + + # Check if PR was initially created as draft + pr_created_at = None + + try: + if pull_request is None: + pull_request = issue.issue.pull_request() + + pr_created_at = datetime.fromisoformat( + issue.issue.created_at.replace("Z", "+00:00") + ) + + # Look for ready_for_review events to determine if PR was initially draft + ready_for_review_events = [] + converted_to_draft_events = [] + for event in events: + if event.event == "ready_for_review": + ready_for_review_events.append(event) + elif event.event == "converted_to_draft": + converted_to_draft_events.append(event) + + # If there are ready_for_review events, check if PR was initially draft + if ready_for_review_events: + first_ready_event = min(ready_for_review_events, key=lambda x: x.created_at) + prior_draft_events = [ + e + for e in converted_to_draft_events + if e.created_at < first_ready_event.created_at + ] + + if not prior_draft_events: + # PR was initially created as draft, calculate time from creation to first ready_for_review + total_draft_time += first_ready_event.created_at - pr_created_at + + # If there are no ready_for_review events but the PR is currently draft, it might be initially draft and still open + elif not ready_for_review_events and not converted_to_draft_events: + # Check if PR is currently draft and open + if ( + hasattr(pull_request, "draft") + and pull_request.draft + and issue.issue.state == "open" + ): + # PR was initially created as draft and is still draft + draft_start = pr_created_at + + except (AttributeError, ValueError, TypeError): + # If we can't get PR info, fall back to original logic + pass + + for event in events: + if event.event == "converted_to_draft": + draft_start = event.created_at + elif event.event == "ready_for_review" and draft_start: + # Calculate draft time for this interval + total_draft_time += event.created_at - draft_start + draft_start = None + + # If the PR is currently in draft state, calculate the time in draft up to now + if draft_start and issue.issue.state == "open": + total_draft_time += datetime.now(pytz.utc) - draft_start + + return total_draft_time if total_draft_time > timedelta(0) else None + + +def get_stats_time_in_draft( + issues_with_metrics: List[IssueWithMetrics], +) -> Union[dict[str, timedelta], None]: + """ + Calculate stats describing the time in draft for a list of issues. + """ + # Filter out issues with no time in draft + issues_with_time_to_draft = [ + issue for issue in issues_with_metrics if issue.time_in_draft is not None + ] + + # Calculate the total time in draft for all issues + draft_times = [] + if issues_with_time_to_draft: + for issue in issues_with_time_to_draft: + if issue.time_in_draft: + draft_times.append(issue.time_in_draft.total_seconds()) + + # Calculate stats describing time in draft + num_issues_with_time_in_draft = len(issues_with_time_to_draft) + if num_issues_with_time_in_draft > 0: + average_time_in_draft = numpy.round(numpy.average(draft_times)) + med_time_in_draft = numpy.round(numpy.median(draft_times)) + ninety_percentile_time_in_draft = numpy.round( + numpy.percentile(draft_times, 90, axis=0) + ) + else: + return None + + stats = { + "avg": timedelta(seconds=average_time_in_draft), + "med": timedelta(seconds=med_time_in_draft), + "90p": timedelta(seconds=ninety_percentile_time_in_draft), + } + + # Print the average time in draft converting seconds to a readable time format + print(f"Average time in draft: {timedelta(seconds=average_time_in_draft)}") + return stats diff --git a/time_to_answer.py b/time_to_answer.py index 90d1769b..248be58d 100644 --- a/time_to_answer.py +++ b/time_to_answer.py @@ -14,17 +14,17 @@ Measure the time it takes to answer a GitHub discussion. """ + from datetime import datetime, timedelta from typing import List, Union import numpy - from classes import IssueWithMetrics def get_stats_time_to_answer( issues_with_metrics: List[IssueWithMetrics], -) -> Union[timedelta, None]: +) -> Union[dict[str, timedelta], None]: """ Calculate stats describing the time to answer for a list of issues. """ diff --git a/time_to_close.py b/time_to_close.py index 7791ed68..7efecb24 100644 --- a/time_to_close.py +++ b/time_to_close.py @@ -15,12 +15,12 @@ Calculate stats describing the time to close for a list of issues with metrics. """ + from datetime import datetime, timedelta from typing import List, Union import github3 import numpy - from classes import IssueWithMetrics @@ -57,7 +57,7 @@ def measure_time_to_close( def get_stats_time_to_close( issues_with_metrics: List[IssueWithMetrics], -) -> Union[timedelta, None]: +) -> Union[dict[str, timedelta], None]: """Calculate stats describing the time to close for a list of issues. Args: @@ -75,6 +75,7 @@ def get_stats_time_to_close( # Calculate the total time to close for all issues close_times = [] + total_time_to_close = None if issues_with_time_to_close: total_time_to_close = 0 for issue in issues_with_time_to_close: diff --git a/time_to_first_response.py b/time_to_first_response.py index 3193fa69..4de1d2b7 100644 --- a/time_to_first_response.py +++ b/time_to_first_response.py @@ -17,12 +17,12 @@ Calculate stats describing time to first response for a list of issues with metrics. """ + from datetime import datetime, timedelta from typing import List, Union import github3 import numpy - from classes import IssueWithMetrics @@ -31,7 +31,7 @@ def measure_time_to_first_response( discussion: Union[dict, None], pull_request: Union[github3.pulls.PullRequest, None] = None, ready_for_review_at: Union[datetime, None] = None, - ignore_users: List[str] = None, + ignore_users: Union[List[str], None] = None, ) -> Union[timedelta, None]: """Measure the time to first response for a single issue, pull request, or a discussion. @@ -73,17 +73,22 @@ def measure_time_to_first_response( # so we may also get the first review comment time if pull_request: review_comments = pull_request.reviews(number=50) # type: ignore - for review_comment in review_comments: - if ignore_comment( - issue.issue.user, - review_comment.user, - ignore_users, - review_comment.submitted_at, - ready_for_review_at, - ): - continue - first_review_comment_time = review_comment.submitted_at - break + try: + for review_comment in review_comments: + if ignore_comment( + issue.issue.user, + review_comment.user, + ignore_users, + review_comment.submitted_at, + ready_for_review_at, + ): + continue + first_review_comment_time = review_comment.submitted_at + break + except TypeError as e: + print( + f"An error occurred processing review comments. Perhaps the review contains a ghost user. {e}" + ) # Figure out the earliest response timestamp if first_comment_time and first_review_comment_time: @@ -99,7 +104,7 @@ def measure_time_to_first_response( if ready_for_review_at: issue_time = ready_for_review_at else: - issue_time = datetime.fromisoformat(issue.created_at) # type: ignore + issue_time = datetime.fromisoformat(issue.created_at) if discussion and len(discussion["comments"]["nodes"]) > 0: earliest_response = datetime.fromisoformat( @@ -107,9 +112,11 @@ def measure_time_to_first_response( ) issue_time = datetime.fromisoformat(discussion["createdAt"]) - # Calculate the time between the issue and the first comment if earliest_response and issue_time: - return earliest_response - issue_time + time_between_issue_and_first_comment: timedelta | None = ( + earliest_response - issue_time + ) + return time_between_issue_and_first_comment return None @@ -122,21 +129,29 @@ def ignore_comment( ready_for_review_at: Union[datetime, None], ) -> bool: """Check if a comment should be ignored.""" - return ( - # ignore comments by IGNORE_USERS - comment_user.login in ignore_users - # ignore comments by bots - or comment_user.type == "Bot" - # ignore comments by the issue creator - or comment_user.login == issue_user.login - # ignore comments created before the issue was ready for review - or (ready_for_review_at and comment_created_at < ready_for_review_at) + + user_is_ignored: bool = comment_user.login in ignore_users + user_is_a_bot: bool = str(comment_user.type.lower()) == "bot" + user_is_issue_creator: bool = str(comment_user.login) == str(issue_user.login) + issue_was_created_before_ready_for_review: bool = False + is_pending_comment: bool = not isinstance(comment_created_at, datetime) + if ready_for_review_at and not is_pending_comment: + issue_was_created_before_ready_for_review = ( + comment_created_at < ready_for_review_at + ) + result: bool = ( + user_is_ignored + or user_is_a_bot + or user_is_issue_creator + or is_pending_comment + or issue_was_created_before_ready_for_review ) + return result def get_stats_time_to_first_response( issues: List[IssueWithMetrics], -) -> Union[timedelta, None]: +) -> Union[dict[str, timedelta], None]: """Calculate the stats describing time to first response for a list of issues. Args: diff --git a/time_to_merge.py b/time_to_merge.py index dbd4f2c4..b9587319 100644 --- a/time_to_merge.py +++ b/time_to_merge.py @@ -11,6 +11,7 @@ Measure the time it takes to merge a pull request. """ + from datetime import datetime, timedelta from typing import Union diff --git a/time_to_ready_for_review.py b/time_to_ready_for_review.py index 8c744b1f..5c5d7725 100644 --- a/time_to_ready_for_review.py +++ b/time_to_ready_for_review.py @@ -21,7 +21,8 @@ def get_time_to_ready_for_review( - issue: github3.issues.Issue, pull_request: github3.pulls.PullRequest + issue: github3.issues.Issue, + pull_request: github3.pulls.PullRequest, ) -> Union[datetime, None]: """If a pull request was formerly a draft, get the time it was marked as ready for review @@ -37,8 +38,14 @@ def get_time_to_ready_for_review( return None events = issue.issue.events(number=50) - for event in events: - if event.event == "ready_for_review": - return event.created_at + try: + for event in events: + if event.event == "ready_for_review": + return event.created_at + except TypeError as e: + print( + f"An error occurred processing review events. Perhaps issue contains a ghost user. {e}" + ) + return None return None