3131
3232# Define constants for error strings to make checking against them more robust:
3333ERROR_ENABLE_TRAVIS = "Unable to enable Travis build"
34+ ERROR_README_DOWNLOAD_FAILED = "Failed to download README"
35+ ERROR_README_IMAGE_MISSING_ALT = "README image missing alt text"
36+ ERROR_README_DUPLICATE_ALT_TEXT = "README has duplicate alt text"
37+ ERROR_README_MISSING_DISCORD_BADGE = "README missing Discord badge"
38+ ERROR_README_MISSING_RTD_BADGE = "README missing ReadTheDocs badge"
39+ ERROR_README_MISSING_TRAVIS_BADGE = "README missing Travis badge"
3440ERROR_MISMATCHED_READTHEDOCS = "Mismatched readthedocs.yml"
3541ERROR_MISSING_EXAMPLE_FILES = "Missing .py files in examples folder"
3642ERROR_MISSING_EXAMPLE_FOLDER = "Missing examples folder"
3743ERROR_MISSING_LIBRARIANS = "Likely missing CircuitPythonLibrarians team."
3844ERROR_MISSING_LICENSE = "Missing license."
3945ERROR_MISSING_LINT = "Missing lint config"
46+ ERROR_MISSING_CODE_OF_CONDUCT = "Missing CODE_OF_CONDUCT.md"
47+ ERROR_MISSING_README_RST = "Missing README.rst"
4048ERROR_MISSING_READTHEDOCS = "Missing readthedocs.yml"
4149ERROR_MISSING_TRAVIS_CONFIG = "Missing .travis.yml"
4250ERROR_NOT_IN_BUNDLE = "Not in bundle."
4351ERROR_OLD_TRAVIS_CONFIG = "Old travis config"
52+ ERROR_TRAVIS_DOESNT_KNOW_REPO = "Travis doesn't know of repo"
4453ERROR_TRAVIS_ENV = "Unable to read Travis env variables"
4554ERROR_TRAVIS_GITHUB_TOKEN = "Unable to find or create (no auth) GITHUB_TOKEN env variable"
4655ERROR_TRAVIS_TOKEN_CREATE = "Token creation failed"
4756ERROR_UNABLE_PULL_REPO_CONTENTS = "Unable to pull repo contents"
4857ERROR_UNABLE_PULL_REPO_DETAILS = "Unable to pull repo details"
4958ERRRO_UNABLE_PULL_REPO_EXAMPLES = "Unable to retrieve examples folder contents"
5059ERROR_WIKI_DISABLED = "Wiki should be disabled"
60+ ERROR_ONLY_ALLOW_MERGES = "Only allow merges, disallow rebase and squash"
61+ ERROR_RTD_SUBPROJECT_FAILED = "Failed to list CircuitPython subprojects on ReadTheDocs"
62+ ERROR_RTD_SUBPROJECT_MISSING = "ReadTheDocs missing as a subproject on CircuitPython"
63+ ERROR_RTD_ADABOT_MISSING = "ReadTheDocs project missing adabot as owner"
64+ ERROR_RTD_VALID_VERSIONS_FAILED = "Failed to fetch ReadTheDocs valid versions"
65+ ERROR_RTD_FAILED_TO_LOAD_BUILDS = "Unable to load builds webpage"
66+ ERROR_RTD_FAILED_TO_LOAD_BUILD_INFO = "Failed to load build info"
67+ ERROR_RTD_OUTPUT_HAS_WARNINGS = "ReadTheDocs latest build has warnings and/or errors"
68+ ERROR_RTD_AUTODOC_FAILED = "Autodoc failed on ReadTheDocs. (Likely need to automock an import.)"
69+ ERROR_RTD_SPHINX_FAILED = "Sphinx missing files"
70+ ERROR_GITHUB_RELEASE_FAILED = "Failed to fetch latest release from GitHub"
71+ ERROR_RTD_MISSING_LATEST_RELEASE = "ReadTheDocs missing the latest release. (Likely the webhook isn't set up correctly.)"
72+
73+ # These are warnings or errors that sphinx generate that we're ok ignoring.
74+ RTD_IGNORE_NOTICES = ("WARNING: html_static_path entry" , "WARNING: nonlocal image URI found:" )
5175
5276# Constant for bundle repo name.
5377BUNDLE_REPO_NAME = "Adafruit_CircuitPython_Bundle"
5680# full name on Github (like Adafruit_CircuitPython_Bundle).
5781BUNDLE_IGNORE_LIST = [BUNDLE_REPO_NAME ]
5882
83+ # Cache CircuitPython's subprojects on ReadTheDocs so its not fetched every repo check.
84+ rtd_subprojects = None
5985
6086def parse_gitmodules (input_text ):
6187 """Parse a .gitmodules file and return a list of all the git submodules
@@ -234,6 +260,45 @@ def validate_repo_state(repo):
234260 # bundle itself and possibly
235261 # other repos.
236262 errors .append (ERROR_NOT_IN_BUNDLE )
263+ if "allow_squash_merge" not in full_repo or full_repo ["allow_squash_merge" ] or full_repo ["allow_rebase_merge" ]:
264+ errors .append (ERROR_ONLY_ALLOW_MERGES )
265+ return errors
266+
267+ def validate_readme (repo , download_url ):
268+ # We use requests because file contents are hosted by githubusercontent.com, not the API domain.
269+ contents = requests .get (download_url )
270+ if not contents .ok :
271+ return [ERROR_README_DOWNLOAD_FAILED ]
272+
273+ errors = []
274+ badges = {}
275+ current_image = None
276+ for line in contents .text .split ("\n " ):
277+ if line .startswith (".. image" ):
278+ current_image = {}
279+
280+ if line .strip () == "" and current_image is not None :
281+ if "alt" not in current_image :
282+ errors .append (ERROR_README_IMAGE_MISSING_ALT )
283+ elif current_image ["alt" ] in badges :
284+ errors .append (ERROR_README_DUPLICATE_ALT_TEXT )
285+ else :
286+ badges [current_image ["alt" ]] = current_image
287+ current_image = None
288+ elif current_image is not None :
289+ first , second , value = line .split (":" , 2 )
290+ key = first .strip (" ." ) + second .strip ()
291+ current_image [key ] = value .strip ()
292+
293+ if "Discord" not in badges :
294+ errors .append (ERROR_README_MISSING_DISCORD_BADGE )
295+
296+ if "Documentation Status" not in badges :
297+ errors .append (ERROR_README_MISSING_RTD_BADGE )
298+
299+ if "Build Status" not in badges :
300+ errors .append (ERROR_README_MISSING_TRAVIS_BADGE )
301+
237302 return errors
238303
239304def validate_contents (repo ):
@@ -259,6 +324,19 @@ def validate_contents(repo):
259324 if ".pylintrc" not in files :
260325 errors .append (ERROR_MISSING_LINT )
261326
327+ if "CODE_OF_CONDUCT.md" not in files :
328+ errors .append (ERROR_MISSING_CODE_OF_CONDUCT )
329+
330+ if "README.rst" not in files :
331+ errors .append (ERROR_MISSING_README_RST )
332+ else :
333+ readme_info = None
334+ for f in content_list :
335+ if f ["name" ] == "README.rst" :
336+ readme_info = f
337+ break
338+ errors .extend (validate_readme (repo , readme_info ["download_url" ]))
339+
262340 if ".travis.yml" in files :
263341 file_info = content_list [files .index (".travis.yml" )]
264342 if file_info ["size" ] > 1000 :
@@ -304,7 +382,7 @@ def validate_travis(repo):
304382 if not result .ok :
305383 #print(result, result.request.url, result.request.headers)
306384 #print(result.text)
307- return ["Travis error with repo:" , repo [ "full_name" ] ]
385+ return [ERROR_TRAVIS_DOESNT_KNOW_REPO ]
308386 result = result .json ()
309387 if not result ["active" ]:
310388 activate = travis .post (repo_url + "/activate" )
@@ -351,6 +429,91 @@ def validate_travis(repo):
351429 return [ERROR_TRAVIS_GITHUB_TOKEN ]
352430 return []
353431
432+ def validate_readthedocs (repo ):
433+ if not (repo ["owner" ]["login" ] == "adafruit" and
434+ repo ["name" ].startswith ("Adafruit_CircuitPython" )):
435+ return []
436+ if repo ["name" ] in BUNDLE_IGNORE_LIST :
437+ return []
438+ global rtd_subprojects
439+ if not rtd_subprojects :
440+ rtd_response = requests .get ("https://readthedocs.org/api/v2/project/74557/subprojects/" )
441+ if not rtd_response .ok :
442+ return [ERROR_RTD_SUBPROJECT_FAILED ]
443+ rtd_subprojects = {}
444+ for subproject in rtd_response .json ()["subprojects" ]:
445+ rtd_subprojects [sanitize_url (subproject ["repo" ])] = subproject
446+
447+ repo_url = sanitize_url (repo ["clone_url" ])
448+ if repo_url not in rtd_subprojects :
449+ return [ERROR_RTD_SUBPROJECT_MISSING ]
450+
451+ errors = []
452+ subproject = rtd_subprojects [repo_url ]
453+
454+ if 105398 not in subproject ["users" ]:
455+ errors .append (ERROR_RTD_ADABOT_MISSING )
456+
457+ valid_versions = requests .get (
458+ "https://readthedocs.org/api/v2/project/{}/valid_versions/" .format (subproject ["id" ]))
459+ if not valid_versions .ok :
460+ errors .append (ERROR_RTD_VALID_VERSIONS_FAILED )
461+ else :
462+ valid_versions = valid_versions .json ()
463+ latest_release = github .get ("/repos/{}/releases/latest" .format (repo ["full_name" ]))
464+ if not latest_release .ok :
465+ errors .append (ERROR_GITHUB_RELEASE_FAILED )
466+ else :
467+ if latest_release .json ()["tag_name" ] not in valid_versions ["flat" ]:
468+ errors .append (ERROR_RTD_MISSING_LATEST_RELEASE )
469+
470+ # There is no API which gives access to a list of builds for a project so we parse the html
471+ # webpage.
472+ builds_webpage = requests .get (
473+ "https://readthedocs.org/projects/{}/builds/" .format (subproject ["slug" ]))
474+ if not builds_webpage .ok :
475+ errors .append (ERROR_RTD_FAILED_TO_LOAD_BUILDS )
476+ else :
477+ for line in builds_webpage .text .split ("\n " ):
478+ if "<div id=\" build-" in line :
479+ build_id = line .split ("\" " )[1 ][len ("build-" ):]
480+ # We only validate the most recent build. So, break when the first is found.
481+ break
482+ build_info = requests .get ("https://readthedocs.org/api/v2/build/{}/" .format (build_id ))
483+ if not build_info .ok :
484+ errors .append (ERROR_RTD_FAILED_TO_LOAD_BUILD_INFO )
485+ else :
486+ build_info = build_info .json ()
487+ output_ok = True
488+ autodoc_ok = True
489+ sphinx_ok = True
490+ for command in build_info ["commands" ]:
491+ if command ["command" ].endswith ("_build/html" ):
492+ for line in command ["output" ].split ("\n " ):
493+ if "... " in line :
494+ _ , line = line .split ("... " )
495+ if "WARNING" in line or "ERROR" in line :
496+ if not line .startswith (("WARNING" , "ERROR" )):
497+ line = line .split (" " , 1 )[1 ]
498+ if not line .startswith (RTD_IGNORE_NOTICES ):
499+ output_ok = False
500+ print ("error:" , line )
501+ elif line .startswith ("ImportError" ):
502+ print (line )
503+ autodoc_ok = False
504+ elif line .startswith ("sphinx.errors" ) or line .startswith ("SphinxError" ):
505+ print (line )
506+ sphinx_ok = False
507+ break
508+ if not output_ok :
509+ errors .append (ERROR_RTD_OUTPUT_HAS_WARNINGS )
510+ if not autodoc_ok :
511+ errors .append (ERROR_RTD_AUTODOC_FAILED )
512+ if not sphinx_ok :
513+ errors .append (ERROR_RTD_SPHINX_FAILED )
514+
515+ return errors
516+
354517def validate_repo (repo ):
355518 """Run all the current validation functions on the provided repository and
356519 return their results as a list of string errors.
@@ -450,7 +613,7 @@ def print_circuitpython_download_stats():
450613# Functions to run on repositories to validate their state. By convention these
451614# return a list of string errors for the specified repository (a dictionary
452615# of Github API repository object state).
453- validators = [validate_repo_state , validate_travis , validate_contents ]
616+ validators = [validate_repo_state , validate_travis , validate_contents , validate_readthedocs ]
454617# Submodules inside the bundle (result of get_bundle_submodules)
455618bundle_submodules = []
456619
@@ -497,8 +660,6 @@ def print_circuitpython_download_stats():
497660 repos_by_error [error ] = []
498661 repos_by_error [error ].append (repo ["html_url" ])
499662 gather_insights (repo , insights , since )
500- circuitpython_repo = github .get ("/repos/adafruit/circuitpython" ).json ()
501- gather_insights (circuitpython_repo , insights , since )
502663 print ("State of CircuitPython + Libraries" )
503664 print ("* {} pull requests merged" .format (insights ["merged_prs" ]))
504665 authors = insights ["pr_merged_authors" ]
@@ -520,12 +681,13 @@ def print_circuitpython_download_stats():
520681 # print("- [ ] [{0}](https://github.com/{1})".format(repo["name"], repo["full_name"]))
521682 print ("{} out of {} repos need work." .format (need_work , len (repos )))
522683
523- list_repos_for_errors = [ERROR_WIKI_DISABLED , ERROR_MISSING_LIBRARIANS ,
524- ERROR_ENABLE_TRAVIS , ERROR_NOT_IN_BUNDLE ]
684+ list_repos_for_errors = [ERROR_NOT_IN_BUNDLE ]
685+
525686 for error in repos_by_error :
526687 if len (repos_by_error [error ]) == 0 :
527688 continue
528689 print ()
529- print (error , "- {}" .format (len (repos_by_error [error ])))
530- if error in list_repos_for_errors :
690+ error_count = len (repos_by_error [error ])
691+ print ("{} - {}" .format (error , error_count ))
692+ if error_count <= 5 or error in list_repos_for_errors :
531693 print ("\n " .join (repos_by_error [error ]))
0 commit comments