From 9f876be78717b4536c9e922af1dcc1a05112e5e1 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sun, 7 Apr 2024 14:19:46 -0500 Subject: [PATCH] feat: github app authentication and repo standardization - [x] add ability for users to authenticate with GitHub App Installation - [x] standardize repo - [x] Makefile - [x] Linting - [x] same workflows as other OSPO GitHub Actions - [x] add @jmeridth to CODEOWNERS - [x] update .env-example - [x] update README - [x] standardize all workflows (including examples) permissions - [x] contents: read by default - [x] more details in jobs - [x] add tests - [x] coverage to 60% for now, will get above 80% later - [x] pull request template Signed-off-by: jmeridth --- .env-example | 17 +- .github/CODEOWNERS | 2 +- .github/dependabot.yml | 18 +- .github/linters/.flake8 | 5 + .github/linters/.isort.cfg | 2 + .github/linters/.jscpd.json | 7 + .github/linters/.markdown-lint.yml | 7 + .github/linters/.mypy.ini | 5 + .github/linters/.python-lint | 649 ++++++++++++++++++++++++++ .github/linters/.shellcheckrc | 2 + .github/linters/.textlintrc | 10 + .github/linters/.yaml-lint.yml | 52 +++ .github/pull_request_template.md | 17 + .github/workflows/codeql.yml | 29 +- .github/workflows/docker-image.yml | 17 +- .github/workflows/linter.yml | 45 -- .github/workflows/python-ci.yml | 35 ++ .github/workflows/release-drafter.yml | 10 +- .github/workflows/stale.yaml | 23 + .github/workflows/super-linter.yml | 35 ++ CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING-template.md | 1 + CONTRIBUTING.md | 3 + Dockerfile | 6 +- Makefile | 18 + README.md | 47 ++ auth.py | 49 ++ env.py | 166 +++++++ open_contrib_pr.py | 45 +- requirements-test.txt | 8 + requirements.txt | 2 +- test_auth.py | 58 +++ test_env.py | 160 +++++++ 33 files changed, 1436 insertions(+), 116 deletions(-) create mode 100644 .github/linters/.flake8 create mode 100644 .github/linters/.isort.cfg create mode 100644 .github/linters/.jscpd.json create mode 100644 .github/linters/.markdown-lint.yml create mode 100644 .github/linters/.mypy.ini create mode 100644 .github/linters/.python-lint create mode 100644 .github/linters/.shellcheckrc create mode 100644 .github/linters/.textlintrc create mode 100644 .github/linters/.yaml-lint.yml create mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/linter.yml create mode 100644 .github/workflows/python-ci.yml create mode 100644 .github/workflows/stale.yaml create mode 100644 .github/workflows/super-linter.yml create mode 100644 Makefile create mode 100644 auth.py create mode 100644 env.py create mode 100644 requirements-test.txt create mode 100644 test_auth.py create mode 100644 test_env.py diff --git a/.env-example b/.env-example index 675bbf7..d6a73bb 100644 --- a/.env-example +++ b/.env-example @@ -1,6 +1,11 @@ -GH_ACTOR= ' ' -GH_TOKEN=' ' -PR_TITLE=' ' -PR_BODY=' ' -ORGANIZATION=' ' -REPOS_JSON_LOCATION=' ' \ No newline at end of file +GH_ACTOR="" +GH_TOKEN="" +ORGANIZATION="" +PR_TITLE="" +PR_BODY="" +REPOS_JSON_LOCATION="" + +# GITHUB APP +GH_APP_ID = "" +GH_INSTALLATION_ID = "" +GH_PRIVATE_KEY = "" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 467bd6e..f51f254 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @lindluni @zkoppert +* @jmeridth @lindluni @zkoppert diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a16aac6..a6d93c1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,14 @@ - -# 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 + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "docker" + directory: "/" schedule: interval: "daily" diff --git a/.github/linters/.flake8 b/.github/linters/.flake8 new file mode 100644 index 0000000..8dd6311 --- /dev/null +++ b/.github/linters/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 150 +exclude = venv,.venv,.git,__pycache__ +extend-ignore = C901 +statistics = True diff --git a/.github/linters/.isort.cfg b/.github/linters/.isort.cfg new file mode 100644 index 0000000..c76db01 --- /dev/null +++ b/.github/linters/.isort.cfg @@ -0,0 +1,2 @@ +[isort] +profile = black diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json new file mode 100644 index 0000000..225b930 --- /dev/null +++ b/.github/linters/.jscpd.json @@ -0,0 +1,7 @@ +{ + "threshold": 25, + "ignore": [ + "test*" + ], + "absolute": true +} \ No newline at end of file diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 0000000..af70e5f --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,7 @@ +--- +# line length +MD013: false +# singe h1 +MD025: false +# duplicate headers +MD024: false diff --git a/.github/linters/.mypy.ini b/.github/linters/.mypy.ini new file mode 100644 index 0000000..f0d4703 --- /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 0000000..8f975f1 --- /dev/null +++ b/.github/linters/.python-lint @@ -0,0 +1,649 @@ +[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, + .git, + __pycache__, + venv, + .venv, + +# 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-nested-blocks, + 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/linters/.shellcheckrc b/.github/linters/.shellcheckrc new file mode 100644 index 0000000..8d3c10c --- /dev/null +++ b/.github/linters/.shellcheckrc @@ -0,0 +1,2 @@ +# Don't suggest [ -n "$VAR" ] over [ ! -z "$VAR" ] +disable=SC2129 diff --git a/.github/linters/.textlintrc b/.github/linters/.textlintrc new file mode 100644 index 0000000..58e4ba9 --- /dev/null +++ b/.github/linters/.textlintrc @@ -0,0 +1,10 @@ +{ + "filters": { + "comments": true + }, + "rules": { + "terminology": { + "severity": "warning" + } + } +} diff --git a/.github/linters/.yaml-lint.yml b/.github/linters/.yaml-lint.yml new file mode 100644 index 0000000..98ab893 --- /dev/null +++ b/.github/linters/.yaml-lint.yml @@ -0,0 +1,52 @@ +--- +rules: + braces: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + brackets: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + colons: + level: warning + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: warning + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: disable + comments-indentation: disable + document-end: disable + document-start: + level: warning + present: true + empty-lines: + level: warning + max: 2 + max-start: 0 + max-end: 0 + hyphens: + level: warning + max-spaces-after: 1 + indentation: + level: warning + spaces: consistent + indent-sequences: true + check-multi-line-strings: false + key-duplicates: enable + line-length: + level: warning + max: 1024 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + new-line-at-end-of-file: disable + new-lines: + type: unix + trailing-spaces: disable diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..beb28c0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +# 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 + +### Reviewer + +- [ ] Label as either `bug`, `documentation`, `enhancement`, `infrastructure`, or `breaking` diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3601ef3..9bf5988 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,43 +4,28 @@ on: push: branches: [ main ] pull_request: - # The branches below must be a subset of the branches above branches: [ main ] schedule: - cron: '27 2 * * 1' +permissions: + contents: read + jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write steps: - name: Checkout repository uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 - # 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@v2 - - # ℹī¸ 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@v2 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d4b04d9..baa8efd 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,18 +1,19 @@ +--- 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@v3 - - name: Build the Docker image - run: docker build . --file Dockerfile --tag automatic-contrib-prs:"$(date +%s)" + - uses: actions/checkout@v4 + - name: Build the Docker image + run: docker build . --file Dockerfile --platform linux/amd64 --tag automatic-contributors-pr:"$(date +%s)" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index 147ff14..0000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,45 +0,0 @@ ---- - -# -# Documentation: -# https://help.github.com/en/articles/workflow-syntax-for-github-actions -# - -############################# -# Start the job on all push # -############################# -on: - push: - branches: [main] - pull_request: - -############### -# Set the Job # -############### -jobs: - build: - # Name the Job - name: super-linter - # Set the agent to run on - runs-on: ubuntu-latest - ################## - # Load all steps # - ################## - steps: - ########################## - # Checkout the code base # - ########################## - - name: Checkout Code - uses: actions/checkout@v3 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 - - ################################ - # Run Linter against code base # - ################################ - - name: Lint Code Base - uses: docker://ghcr.io/github/super-linter:latest - env: - DEFAULT_BRANCH: main - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..0cab1e5 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,35 @@ +--- +name: Python package + +on: + push: + branches: main + pull_request: + branches: main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '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 + pip install -r requirements.txt -r requirements-test.txt + - name: Lint + run: | + make lint + - name: Test with pytest + run: | + make test diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5b27fd6..468c66f 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,15 +1,21 @@ +--- name: Release Drafter on: push: - # branches to consider in the event; optional, defaults to all branches: - main +permissions: + contents: read + jobs: update_release_draft: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml new file mode 100644 index 0000000..76f5591 --- /dev/null +++ b/.github/workflows/stale.yaml @@ -0,0 +1,23 @@ +name: 'Close stale issues' +on: + schedule: + - cron: '30 1 * * *' + +permissions: + contents: read + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - uses: actions/stale@v9 + 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.' + days-before-stale: 21 + days-before-close: 14 + days-before-pr-close: -1 + exempt-issue-labels: keep diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml new file mode 100644 index 0000000..49559be --- /dev/null +++ b/.github/workflows/super-linter.yml @@ -0,0 +1,35 @@ +--- +name: Lint Code Base + +on: + pull_request: + branches: main + +permissions: + contents: read + +jobs: + build: + name: Lint Code Base + runs-on: ubuntu-latest + + permissions: + contents: read + packages: read + statuses: write + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-test.txt + - name: Lint Code Base + uses: super-linter/super-linter@v6 + env: + DEFAULT_BRANCH: main + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTIONS_COMMAND_ARGS: -shellcheck= diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0161a26..32b2d68 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -opensource@github.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING-template.md b/CONTRIBUTING-template.md index 48fd790..45ef072 100644 --- a/CONTRIBUTING-template.md +++ b/CONTRIBUTING-template.md @@ -29,6 +29,7 @@ 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44353b1..cec0895 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,7 @@ 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 @@ -89,8 +90,10 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/github - **Explain why this enhancement would be useful** to most automatic-contrib-prs users. ## 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/automatic-contrib-prs/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 diff --git a/Dockerfile b/Dockerfile index 1c07b94..3368644 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ -FROM python:3.8-slim-buster +#checkov:skip=CKV_DOCKER_2 +#checkov:skip=CKV_DOCKER_3 +FROM python:3.12-slim WORKDIR /action/workspace COPY requirements.txt CONTRIBUTING-template.md open_contrib_pr.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 \ + && apt-get -y install --no-install-recommends git-all=1:2.39.2-1.1 \ && rm -rf /var/lib/apt/lists/* CMD ["/action/workspace/open_contrib_pr.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..772e887 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: test +test: + pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=60 --cov-report term-missing + +.PHONY: clean +clean: + rm -rf .pytest_cache .coverage __pycache__ + +.PHONY: lint +lint: + # stop the build if there are Python syntax errors or undefined names + 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 . --config=.github/linters/.flake8 --count --exit-zero --max-complexity=15 --max-line-length=150 + 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 4870d0e..d16f8ca 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # automatic-contrib-prs + [![.github/workflows/linter.yml](https://github.com/github/automatic-contrib-prs/actions/workflows/linter.yml/badge.svg)](https://github.com/github/automatic-contrib-prs/actions/workflows/linter.yml) [![CodeQL](https://github.com/github/automatic-contrib-prs/actions/workflows/codeql.yml/badge.svg)](https://github.com/github/automatic-contrib-prs/actions/workflows/codeql.yml) [![Docker Image CI](https://github.com/github/automatic-contrib-prs/actions/workflows/docker-image.yml/badge.svg)](https://github.com/github/automatic-contrib-prs/actions/workflows/docker-image.yml) @@ -6,12 +7,15 @@ Automatically open a pull request for repositories that have no `CONTRIBUTING.md` file for a targeted set of repositories. ## What this repository does + This code is for a GitHub Action that opens pull requests in the repositories that have a specified repository topic and also don't have a `CONTRIBUTING.md` file. ## Why would someone do this + It is desirable, for example, for all Open Source and InnerSource projects to have a `CONTRIBUTING.md` file that specifies for new contributors what the processes and procedures are for making a new contribution. This has been done in some large GitHub customers organizations. ## How it does this + - It pulls a list of labelled repositories from a `repos.json` which can be generated by the [InnerSource-Crawler GitHub Action](https://github.com/marketplace/actions/innersource-crawler). - It opens a pull request in each of those repositories which adds the `CONTRIBUTING.md` file with some template contents. @@ -22,17 +26,55 @@ It is desirable, for example, for all Open Source and InnerSource projects to ha Note: Your GitHub token will need to have read/write access to all the repositories in the `repos.json` file. 1. Copy the below example workflow to your repository and put it in the `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/auto-contrib-file.yml`) +### Configuration + +Below are the allowed configuration options: + +#### 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. | + +##### 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 | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | +| `PR_TITLE` | False | "Enable Dependabot" | The title of the issue or pull request that will be created if dependabot could be enabled. | +| `PR_BODY` | False | **Pull Request:** "Dependabot could be enabled for this repository. Please enable it by merging this pull request so that we can keep our dependencies up to date and secure." **Issue:** "Please update the repository to include a Dependabot configuration file. This will ensure our dependencies remain updated and secure.Follow the guidelines in [creating Dependabot configuration files](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) to set it up properly.Here's an example of the code:" | The body of the issue or pull request that will be created if dependabot could be enabled. | +| `REPOS_JSON_LOCATION` | False | "Create dependabot.yaml" | The commit message for the pull request that will be created if dependabot could be enabled. | + ### Example workflow + ```yaml name: Find proper repos and open CONTRIBUTING.md prs on: workflow_dispatch: +permissions: + contents: read + jobs: build: name: Open CONTRIBUTING.md in OSS if it doesnt exist runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout code @@ -56,12 +98,15 @@ jobs: ``` ## Scaling for large organizations + - GitHub Actions workflows have time limits currently set at 72 hours per run. If you are operating on more than 1400 repos or so with this action, it will take several runs to complete. ## Contributions + We would :heart: contributions to improve this action. Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for how to get involved. ## Instructions to run locally without Docker + - Clone the repository or open a codespace - Create a personal access token with read only permissions - Copy the `.env-example` file to `.env` @@ -71,6 +116,7 @@ We would :heart: contributions to improve this action. Please see [CONTRIBUTING. - After running locally this will have changed your git config user.name and user.email so those should be reset for this repository ## Docker debug instructions + - Install Docker and make sure docker engine is running - cd to the repository - Edit the Dockerfile to enable interactive docker debug as instructed in the comments of the file @@ -79,6 +125,7 @@ We would :heart: contributions to improve this action. Please see [CONTRIBUTING. - Now you should be at a command prompt inside your docker container and you can begin debugging ## License + [MIT](./LICENSE) ## More OSPO Tools diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..1666aa5 --- /dev/null +++ b/auth.py @@ -0,0 +1,49 @@ +""" +This is the module that contains functions related to authenticating to GitHub with + a personal access token or GitHub App, depending on the environment variables set. +""" + +import github3 + + +def auth_to_github( + gh_app_id: int | None, + gh_app_installation_id: int | None, + gh_app_private_key_bytes: bytes, + gh_enterprise_url: str | None, + token: str | None, +) -> github3.GitHub: + """ + Connect to GitHub.com or GitHub Enterprise, depending on env variables. + + Args: + gh_app_id (int | None): the GitHub App ID + gh_installation_id (int | None): the GitHub App Installation ID + gh_app_private_key (bytes): the GitHub App Private Key + gh_enterprise_url (str): the GitHub Enterprise URL + token (str): the GitHub personal access token + + Returns: + github3.GitHub: the GitHub connection object + """ + + if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + 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 gh_enterprise_url and token: + github_connection = github3.github.GitHubEnterprise( + gh_enterprise_url, 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 diff --git a/env.py b/env.py new file mode 100644 index 0000000..156402f --- /dev/null +++ b/env.py @@ -0,0 +1,166 @@ +""" +Sets up the environment variables for the action. +""" + +import os +from os.path import dirname, join + +from dotenv import load_dotenv + +MAX_TITLE_LENGTH = 70 +MAX_BODY_LENGTH = 65536 + + +def get_int_env_var(env_var_name: str, default: int = -1) -> 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. + """ + default_place_holder = -1 + env_var = os.environ.get(env_var_name, "") + if default == default_place_holder and not env_var.strip(): + return None + try: + return int(env_var) + except ValueError: + return default if default > default_place_holder else None + + +class EnvVars: + # pylint: disable=too-many-instance-attributes + """ + Environment variables + + Attributes: + 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 + gh_actor (str): The GitHub actor to use for authentication + organization (str): The GitHub organization to use for the PR + pr_body (str): The PR body to use for the PR + pr_title (str): The PR title to use for the PR + repos_json_location (str): The location of the repos.json file + """ + + def __init__( + self, + gh_actor: str | None, + gh_app_id: int | None, + gh_app_installation_id: int | None, + gh_app_private_key_bytes: bytes, + gh_enterprise_url: str | None, + gh_token: str | None, + organization: str | None, + pr_body: str | None, + pr_title: str | None, + repos_json_location: str, + ): + self.gh_actor = gh_actor + 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_enterprise_url = gh_enterprise_url + self.gh_token = gh_token + self.organization = organization + self.pr_body = pr_body + self.pr_title = pr_title + self.repos_json_location = repos_json_location + + def __repr__(self): + return ( + f"EnvVars(" + f"{self.gh_actor}," + f"{self.gh_app_id}," + f"{self.gh_app_installation_id}," + f"{self.gh_app_private_key_bytes}," + f"{self.gh_enterprise_url}," + f"{self.gh_token}," + f"{self.organization}," + f"{self.pr_body}," + f"{self.pr_title}," + f"{self.repos_json_location})" + ) + + +def get_env_vars(test: bool = False) -> EnvVars: + """ + Get the environment variables for use in the action. + + Args: + test (bool): Whether or not to load the environment variables from a .env file (default: False) + + Returns: + gh_actor (str): The GitHub actor to use for authentication + 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_enterprise_url (str): The GitHub Enterprise URL to use for authentication + gh_token (str | None): The GitHub token to use for authentication + organization (str): The GitHub organization to use for the PR + pr_body (str): The PR body to use for the PR + pr_title (str): The PR title to use for the PR + repos_json_location (str): The location of the repos.json file + """ + if not test: + # Load from .env file if it exists + dotenv_path = join(dirname(__file__), ".env") + load_dotenv(dotenv_path) + + gh_actor = os.getenv("GH_ACTOR", "nobody") + 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") + + 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_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") + + gh_enterprise_url = os.getenv("GH_ENTERPRISE_URL", default="").strip() + + organization = os.getenv("ORGANIZATION") + if not organization: + raise ValueError("ORGANIZATION environment variable not set") + + pr_title = os.getenv("PR_TITLE", "chore: Add new CONTRIBUTING.md file") + # make sure that title is a string with less than 70 characters + if len(pr_title) > MAX_TITLE_LENGTH: + raise ValueError("PR_TITLE environment variable is too long. Max 70 characters") + + pr_body = os.getenv( + "PR_BODY", + "Add file that specifies the processes and procedures for new contributors to make a new contribution", + ) + # make sure that body is a string with less than 65536 characters + if len(pr_body) > MAX_BODY_LENGTH: + raise ValueError("BODY environment variable is too long. Max 65536 characters") + + repos_json_location = os.getenv("REPOS_JSON_LOCATION", default="repos.json").strip() + + return EnvVars( + gh_actor, + gh_app_id, + gh_app_installation_id, + gh_app_private_key_bytes, + gh_enterprise_url, + gh_token, + organization, + pr_body, + pr_title, + repos_json_location, + ) diff --git a/open_contrib_pr.py b/open_contrib_pr.py index 4306163..09e1ff4 100644 --- a/open_contrib_pr.py +++ b/open_contrib_pr.py @@ -3,30 +3,37 @@ import json import os -from os.path import dirname, join from time import sleep +import auth +import env import github3 -from dotenv import load_dotenv if __name__ == "__main__": - # Load env variables from file - dotenv_path = join(dirname(__file__), ".env") - load_dotenv(dotenv_path) + env_vars = env.get_env_vars() + gh_actor = env_vars.gh_actor + organization = env_vars.organization + pr_body = env_vars.pr_body + pr_title = env_vars.pr_title + repos_json_location = env_vars.repos_json_location + token = env_vars.gh_token + + # Auth to GitHub.com + github_connection = auth.auth_to_github( + env_vars.gh_app_id, + env_vars.gh_app_installation_id, + env_vars.gh_app_private_key_bytes, + env_vars.gh_enterprise_url, + token, + ) - # Auth and identitiy to GitHub.com - gh = github3.login(token=os.getenv("GH_TOKEN")) os.system("git config --global user.name 'GitHub Actions'") os.system("git config --global user.email 'no-reply@github.com'") # Get innersource repos from organization - organization = os.getenv("ORGANIZATION") - gh_actor = os.getenv("GH_ACTOR") - token = os.getenv("GH_TOKEN") - REPOS_JSON_LOCATION = "repos.json" - os.system(f"git clone https://{gh_actor}:{token}@github.com/{REPOS_JSON_LOCATION}") - repos_file = open(str(REPOS_JSON_LOCATION), "r", encoding="utf-8") - innersource_repos = json.loads(repos_file.read()) + os.system(f"git clone https://{gh_actor}:{token}@github.com/{repos_json_location}") + with open(str(repos_json_location), "r", encoding="utf-8") as repos_file: + innersource_repos = json.loads(repos_file.read()) for repo in innersource_repos: print(repo["name"]) @@ -57,14 +64,12 @@ os.system(f"git push -u origin {BRANCH_NAME}") # open a PR from that branch to the default branch default_branch = repo["default_branch"] - PR_TITLE = os.getenv("PR_TITLE") - PR_BODY = os.getenv("PR_BODY") # create the pull request - repository_object = gh.repository(organization, repo_name) + repository_object = github_connection.repository(organization, repo_name) try: repository_object.create_pull( - title=PR_TITLE, - body=PR_BODY, + title=pr_title, + body=pr_body, head=BRANCH_NAME, base=default_branch, ) @@ -76,7 +81,7 @@ print("Pull request failed") except github3.exceptions.ConnectionError: print("Pull request failed") - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught print(e) # Clean up repository dir os.chdir("../") diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..8da89fd --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,8 @@ +black==24.3.0 +flake8==7.0.0 +mypy==1.8.0 +mypy-extensions==1.0.0 +pylint==3.1.0 +pytest==8.1.1 +pytest-cov==5.0.0 +types-requests==2.31.0.20240403 diff --git a/requirements.txt b/requirements.txt index 5162cbd..820c2d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ github3.py==4.0.1 -python-dotenv==1.0.1 \ No newline at end of file +python-dotenv==1.0.1 diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..8ef81e7 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,58 @@ +"""Test cases for the auth module.""" + +import unittest +from unittest.mock import MagicMock, patch + +import auth +import github3 + + +class TestAuthToGitHub(unittest.TestCase): + """ + Test case for the auth module. + """ + + def test_auth_to_github_with_token(self): + """ + Test the auth_to_github function when the token is provided. + """ + + result = auth.auth_to_github("", "", b"", "", "token") + + self.assertIsInstance(result, github3.github.GitHub) + + @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 the token is provided. + """ + mock_login.return_value = MagicMock() + + result = auth.auth_to_github("12345", "678910", b"hello", "", "") + + self.assertIsInstance(result, github3.github.GitHub) + + def test_auth_to_github_without_token(self): + """ + Test the auth_to_github function when the token is not provided. + Expect a ValueError to be raised. + """ + with self.assertRaises(ValueError) as cm: + auth.auth_to_github("", "", b"", "", "") + the_exception = cm.exception + self.assertEqual( + str(the_exception), + "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set", + ) + + def test_auth_to_github_with_github_enterprise_url(self): + """ + Test the auth_to_github function when the GitHub Enterprise URL is provided. + """ + result = auth.auth_to_github("", "", b"", "https://github.example.com", "token") + + self.assertIsInstance(result, github3.github.GitHubEnterprise) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_env.py b/test_env.py new file mode 100644 index 0000000..9502283 --- /dev/null +++ b/test_env.py @@ -0,0 +1,160 @@ +"""Test the get_env_vars function""" + +import os +import unittest +from unittest.mock import patch + +from env import EnvVars, get_env_vars + +BODY = "example CONTRIBUTING file contents" +ORGANIZATION = "Organization01" +REPOS_JSON_LOCATION = "repos.json" +TITLE = "New CONTRIBUTING file" +TOKEN = "Token01" + + +class TestEnv(unittest.TestCase): + """Test the get_env_vars function""" + + def setUp(self): + env_keys = [ + "GH_ACTOR", + "GH_APP_ID", + "GH_APP_INSTALLATION_ID", + "GH_APP_PRIVATE_KEY", + "GH_ENTERPRISE_URL", + "GH_TOKEN", + "ORGANIZATION", + "PR_BODY", + "PR_TITLE", + "REPOS_JSON_LOCATION", + ] + for key in env_keys: + if key in os.environ: + del os.environ[key] + + @patch.dict( + os.environ, + { + "GH_ACTOR": "", + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_ENTERPRISE_URL": "", + "GH_TOKEN": TOKEN, + "ORGANIZATION": ORGANIZATION, + "PR_BODY": BODY, + "PR_TITLE": TITLE, + }, + ) + def test_get_env_vars_with_token(self): + """Test that all environment variables are set correctly using a token""" + expected_result = EnvVars( + "", + None, + None, + b"", + "", + TOKEN, + ORGANIZATION, + BODY, + TITLE, + REPOS_JSON_LOCATION, + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "GH_ACTOR": "", + "GH_APP_ID": "12345", + "GH_APP_INSTALLATION_ID": "678910", + "GH_APP_PRIVATE_KEY": "hello", + "GH_ENTERPRISE_URL": "", + "GH_TOKEN": "", + "ORGANIZATION": ORGANIZATION, + "PR_BODY": BODY, + "PR_TITLE": TITLE, + }, + clear=True, + ) + def test_get_env_vars_with_github_app(self): + """Test that all environment variables are set correctly using github app authentication""" + expected_result = EnvVars( + "", + 12345, + 678910, + b"hello", + "", + "", + ORGANIZATION, + BODY, + TITLE, + REPOS_JSON_LOCATION, + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict( + os.environ, + { + "GH_ACTOR": "testactor", + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_ENTERPRISE_URL": "testghe", + "GH_TOKEN": TOKEN, + "ORGANIZATION": ORGANIZATION, + "PR_BODY": BODY, + "PR_TITLE": TITLE, + "REPOS_JSON_LOCATION": "test/repos.json", + }, + ) + def test_get_env_vars_optional_values(self): + """Test that optional values are set to their default values if not provided""" + expected_result = EnvVars( + "testactor", + None, + None, + b"", + "testghe", + TOKEN, + ORGANIZATION, + BODY, + TITLE, + "test/repos.json", + ) + result = get_env_vars(True) + self.assertEqual(str(result), str(expected_result)) + + @patch.dict(os.environ, {}) + def test_get_env_vars_missing_all_authentication(self): + """Test that an error is raised if required authentication environment variables are not set""" + with self.assertRaises(ValueError) as cm: + get_env_vars() + the_exception = cm.exception + self.assertEqual( + str(the_exception), + "GH_TOKEN environment variable not set", + ) + + @patch.dict( + os.environ, + { + "GH_TOKEN": TOKEN, + }, + ) + def test_get_env_vars_missing_organization(self): + """Test that an error is raised if required organization environment variables is not set""" + with self.assertRaises(ValueError) as cm: + get_env_vars() + the_exception = cm.exception + self.assertEqual( + str(the_exception), + "ORGANIZATION environment variable not set", + ) + + +if __name__ == "__main__": + unittest.main()