Skip to content

Commit 931a805

Browse files
authored
Merge pull request #7 from tannewt/readthedocs
More checks around merging, readme and ReadTheDocs
2 parents 3e2a20f + 350d910 commit 931a805

File tree

1 file changed

+170
-8
lines changed

1 file changed

+170
-8
lines changed

adabot/circuitpython_libraries.py

+170-8
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,47 @@
3131

3232
# Define constants for error strings to make checking against them more robust:
3333
ERROR_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"
3440
ERROR_MISMATCHED_READTHEDOCS = "Mismatched readthedocs.yml"
3541
ERROR_MISSING_EXAMPLE_FILES = "Missing .py files in examples folder"
3642
ERROR_MISSING_EXAMPLE_FOLDER = "Missing examples folder"
3743
ERROR_MISSING_LIBRARIANS = "Likely missing CircuitPythonLibrarians team."
3844
ERROR_MISSING_LICENSE = "Missing license."
3945
ERROR_MISSING_LINT = "Missing lint config"
46+
ERROR_MISSING_CODE_OF_CONDUCT = "Missing CODE_OF_CONDUCT.md"
47+
ERROR_MISSING_README_RST = "Missing README.rst"
4048
ERROR_MISSING_READTHEDOCS = "Missing readthedocs.yml"
4149
ERROR_MISSING_TRAVIS_CONFIG = "Missing .travis.yml"
4250
ERROR_NOT_IN_BUNDLE = "Not in bundle."
4351
ERROR_OLD_TRAVIS_CONFIG = "Old travis config"
52+
ERROR_TRAVIS_DOESNT_KNOW_REPO = "Travis doesn't know of repo"
4453
ERROR_TRAVIS_ENV = "Unable to read Travis env variables"
4554
ERROR_TRAVIS_GITHUB_TOKEN = "Unable to find or create (no auth) GITHUB_TOKEN env variable"
4655
ERROR_TRAVIS_TOKEN_CREATE = "Token creation failed"
4756
ERROR_UNABLE_PULL_REPO_CONTENTS = "Unable to pull repo contents"
4857
ERROR_UNABLE_PULL_REPO_DETAILS = "Unable to pull repo details"
4958
ERRRO_UNABLE_PULL_REPO_EXAMPLES = "Unable to retrieve examples folder contents"
5059
ERROR_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.
5377
BUNDLE_REPO_NAME = "Adafruit_CircuitPython_Bundle"
@@ -56,6 +80,8 @@
5680
# full name on Github (like Adafruit_CircuitPython_Bundle).
5781
BUNDLE_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

6086
def 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

239304
def 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+
354517
def 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)
455618
bundle_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

Comments
 (0)