asfyaml/feature/github/__init__.py (199 lines of code) (raw):
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""This is the GitHub feature for .asf.yaml."""
from asfyaml.asfyaml import ASFYamlFeature, ASFYamlInstance, DEBUG
import asfyaml.validators
import strictyaml
import os
import sys
import yaml
import string
import github as pygithub
import github.Repository as pygithubrepo
import github.Auth as pygithubAuth
from . import constants
BASE_CACHE_PATH = "/x1/asfyaml" if "pytest" not in sys.modules else "/tmp"
GH_TOKEN_FILE = "/x1/gitbox/tokens/asfyaml.txt" # Path to .asf.yaml github token
_features = []
def directive(func):
_features.append(func)
return func
class JiraSpaceString(strictyaml.Str):
"""YAML validator for Jira spaces, must be uppercase alpha only."""
def validate_scalar(self, chunk):
if not all(char in string.ascii_uppercase + string.digits for char in chunk.contents):
raise strictyaml.YAMLValidationError(
None, "String must be uppercase or digits only, e.g. INFRA or LOG4J2.", chunk
)
return chunk.contents
class ASFGitHubFeature(ASFYamlFeature, name="github"):
""".asf.yaml GitHub feature class."""
schema = strictyaml.Map(
{
# repository description, e.g. "Apache Airflow"
strictyaml.Optional("description"): strictyaml.Str(),
# repository website, e.g. "https://airflow.apache.org/"
strictyaml.Optional("homepage"): strictyaml.Str(),
# labels: a list of labels/tags to describe the repository.
strictyaml.Optional("labels"): strictyaml.Seq(strictyaml.Str()),
# collaborators: non-committers with triage access
strictyaml.Optional("collaborators"): strictyaml.Seq(strictyaml.Str()),
# protected tags
strictyaml.Optional("protected_tags"): asfyaml.validators.EmptyValue() | strictyaml.Seq(strictyaml.Str()),
# custom_subjects
strictyaml.Optional("custom_subjects"): strictyaml.Map(
{strictyaml.Optional(k): strictyaml.Str() for k in constants.VALID_GITHUB_ACTIONS}
),
# Delete branch on merge
strictyaml.Optional("del_branch_on_merge"): strictyaml.Bool(),
# features: enable/disable specific GitHub features. dict of bools.
strictyaml.Optional("features"): strictyaml.Map(
{
strictyaml.Optional("wiki"): strictyaml.Bool(),
strictyaml.Optional("issues"): strictyaml.Bool(),
strictyaml.Optional("projects"): strictyaml.Bool(),
strictyaml.Optional("discussions"): strictyaml.Bool(),
}
),
# enabled_merge_buttons
strictyaml.Optional("enabled_merge_buttons"): strictyaml.Map(
{
strictyaml.Optional("squash"): strictyaml.Bool(),
strictyaml.Optional("squash_commit_message"): strictyaml.Str(),
strictyaml.Optional("merge"): strictyaml.Bool(),
strictyaml.Optional("merge_commit_message"): strictyaml.Str(),
strictyaml.Optional("rebase"): strictyaml.Bool(),
}
),
# Auto-linking for JIRA. Can be a list of Jira projects or a single string value
strictyaml.Optional("autolink_jira"): strictyaml.OrValidator(
JiraSpaceString(),
strictyaml.Seq(JiraSpaceString()),
),
# GitHub Pages: branch (can be default or gh-pages) and path (can be /docs or /)
strictyaml.Optional("ghp_branch"): strictyaml.Str(),
strictyaml.Optional("ghp_path", default="/docs"): strictyaml.Str(),
# Branch protection rules - TODO: add actual schema
strictyaml.Optional("protected_branches"): asfyaml.validators.EmptyValue()
| strictyaml.MapPattern(
strictyaml.Str(),
strictyaml.Map(
{
strictyaml.Optional("required_signatures", default=False): strictyaml.Bool(),
strictyaml.Optional("required_linear_history", default=False): strictyaml.Bool(),
strictyaml.Optional("required_conversation_resolution", default=False): strictyaml.Bool(),
strictyaml.Optional("required_pull_request_reviews"): strictyaml.Map(
{
strictyaml.Optional("dismiss_stale_reviews", default=False): strictyaml.Bool(),
strictyaml.Optional("require_code_owner_reviews", default=False): strictyaml.Bool(),
strictyaml.Optional("require_last_push_approval", default=False): strictyaml.Bool(),
strictyaml.Optional("required_approving_review_count", default=0): strictyaml.Int(),
}
),
strictyaml.Optional("required_status_checks"): strictyaml.Map(
{
strictyaml.Optional("strict", default=False): strictyaml.Bool(),
strictyaml.Optional("contexts"): strictyaml.Seq(strictyaml.Str()),
strictyaml.Optional("checks"): strictyaml.Seq(
strictyaml.Map(
{
strictyaml.Optional("context"): strictyaml.Str(),
strictyaml.Optional("app_id", default=-1): strictyaml.Int(),
}
)
),
}
),
}
),
),
strictyaml.Optional("pull_requests"): strictyaml.Map(
{
strictyaml.Optional("del_branch_on_merge"): strictyaml.Bool(),
strictyaml.Optional("allow_auto_merge"): strictyaml.Bool(),
strictyaml.Optional("allow_update_branch"): strictyaml.Bool(),
}
),
# Delete branch on merge
# TODO: deprecated, use "pull_requests.del_branch_on_merge" instead
strictyaml.Optional("del_branch_on_merge"): strictyaml.Bool(),
# Dependabot
strictyaml.Optional("dependabot_alerts"): strictyaml.Bool(),
strictyaml.Optional("dependabot_updates"): strictyaml.Bool(),
# Deployment environments
strictyaml.Optional("environments"): strictyaml.MapPattern(
strictyaml.Str(),
strictyaml.Map(
{
strictyaml.Optional("required_reviewers"): strictyaml.Seq(
strictyaml.Map(
{
"id": strictyaml.Int() | strictyaml.Str(),
strictyaml.Optional("type", default="User"): strictyaml.Str(),
}
)
),
# not supported yet by PyGithub
# strictyaml.Optional("prevent_self_review"): strictyaml.Bool(),
strictyaml.Optional("wait_timer"): strictyaml.Int(),
strictyaml.Optional("deployment_branch_policy"): strictyaml.Map(
{
strictyaml.Optional("protected_branches", default=False): strictyaml.Bool(),
strictyaml.Optional("policies"): strictyaml.Seq(
strictyaml.Map(
{
"name": strictyaml.Str(),
strictyaml.Optional("type", default="branch"): strictyaml.Str(),
}
)
),
}
),
}
),
),
}
)
def __init__(self, parent: ASFYamlInstance, yaml: strictyaml.YAML, **kwargs):
super().__init__(parent, yaml)
self._gh: pygithub.Github | None = None
self._ghrepo: pygithubrepo.Repository | None = None
@property
def gh(self) -> pygithub.Github:
if self._gh is None:
raise RuntimeError("something went wrong, gh is not set")
else:
return self._gh
@property
def ghrepo(self) -> pygithubrepo.Repository:
if self._ghrepo is None:
raise RuntimeError("something went wrong, ghrepo is not set")
else:
return self._ghrepo
def run(self):
"""GitHub features"""
# Test if we need to process this (only works on the default branch)
if self.instance.branch != self.repository.default_branch:
print(
"[github] Saw GitHub meta-data in .asf.yaml, but not in default branch of repository, not updating..."
)
return
# Check if cached yaml exists, compare if changed
self.previous_yaml = {}
yaml_filepath = f"{BASE_CACHE_PATH}/ghsettings.{self.repository.name}.yml"
if not self.instance.no_cache:
try:
if os.path.exists(yaml_filepath):
self.previous_yaml = yaml.safe_load(open(yaml_filepath).read())
self.previous_yaml.pop("refname", "")
if self.previous_yaml == self.yaml:
if DEBUG:
print("[github] Saw no changes to GitHub settings, skipping this run.")
return
except yaml.YAMLError as _e: # Failed to parse old yaml? bah.
print("[github] Failed to parse previous GitHub settings, please notify users@infra.apache.org")
# Update items
print(f"[github] GitHub meta-data changed for {self.repository.name}, updating...")
gh_token = os.environ.get("GH_TOKEN")
if not self.noop("github"):
# if a GH_TOKEN is set as environment variable, use this, otherwise load it from file
if not gh_token:
gh_token = open(GH_TOKEN_FILE).read().strip()
self._gh = pygithub.Github(auth=pygithubAuth.Token(gh_token))
self._ghrepo = self.gh.get_repo(f"{self.repository.org_id}/{self.repository.name}")
elif gh_token: # If supplied from OS env, load the ghrepo object anyway
self._gh = pygithub.Github(auth=pygithubAuth.Token(gh_token))
self._ghrepo = self.gh.get_repo(f"{self.repository.org_id}/{self.repository.name}")
# For each sub-feature we see (with the @directive decorator on it), run it
for _feat in _features:
_feat(self)
# Save cached version of this YAML for next time.
if os.path.exists(BASE_CACHE_PATH):
with open(yaml_filepath, "w") as f:
f.write(yaml.dump(self.yaml_raw, default_flow_style=False))
else:
print(f"CACHE Path '{BASE_CACHE_PATH}' does not exist, skip caching")
# Import our sub-directives (...after we have declared the feature class, to avoid circular imports)
from . import (
metadata,
autolink,
features,
branch_protection,
pull_requests,
merge_buttons,
pages,
custom_subjects,
collaborators,
housekeeping,
protected_tags,
deployment_environments,
)