mirror of
https://github.com/rothgar/awesome-tuis.git
synced 2026-10-17 13:21:14 -05:00
Auto-close PRs and check alphabetical order
- readme_pr_repo_first_commit.py:
- Close a PR when every added GitHub repo is confirmed younger than
the 6-month minimum. Unknown/unparseable dates are treated as
comment-only and do not trigger close.
- Append an alphabetical-order section to the marker comment when
the PR adds a list item out of order in any `<details><h2>` or
`<h3>` sub-section of README.md. Only flags violations that
involve a line added by the PR.
- Reconstructs the post-PR README from base + diff so the check
works on PRs from forks without needing fork-head access.
- readme_pr_alphabetical.py (new): shared helper for the alphabetical
check.
- workflows/awesome_bot.yaml: removed. awesome_bot was running on
every push and PR, frequently failing on transient network issues
and 404s.
- workflows/link_check.yml (new): weekly lychee check on README.md
(Mon 06:17 UTC + manual dispatch). Tolerant of transient errors,
posts results back to the Actions run summary.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Alphabetical-order check for README.md additions.
|
||||
|
||||
For each section (h2 within <details>) and sub-section (h3 inside an
|
||||
h2 section), verify that list items are sorted alphabetically (case-
|
||||
insensitive). Only flag violations that involve a line added by the PR,
|
||||
not pre-existing misordering.
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
ITEM_RE = re.compile(r"^- \[([^\]]+)\]")
|
||||
H2_OPEN_RE = re.compile(r"<details\b[^>]*>\s*<summary>\s*<h2>([^<]+)</h2>\s*</summary>", re.IGNORECASE)
|
||||
H3_RE = re.compile(r"<h3>([^<]+)</h3>", re.IGNORECASE)
|
||||
DETAILS_CLOSE_RE = re.compile(r"</details>", re.IGNORECASE)
|
||||
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
||||
|
||||
|
||||
def parse_sections(content: str) -> list[dict]:
|
||||
"""Parse README into sections.
|
||||
|
||||
Returns a list of:
|
||||
{"name": str, "items": [(line_no, name)], "subsections": [{name, items}]}
|
||||
|
||||
Sections with sub-sections (e.g. Libraries) yield one entry per
|
||||
sub-section in `subsections` and the top-level `items` is empty.
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
current: dict | None = None
|
||||
current_sub: dict | None = None
|
||||
|
||||
for i, raw in enumerate(content.splitlines(), start=1):
|
||||
line = raw.rstrip("\r")
|
||||
|
||||
if H2_OPEN_RE.search(line):
|
||||
current = {"name": H2_OPEN_RE.search(line).group(1).strip(), "items": [], "subsections": []}
|
||||
current_sub = None
|
||||
sections.append(current)
|
||||
continue
|
||||
|
||||
if H3_RE.search(line):
|
||||
if current is not None:
|
||||
current_sub = {"name": H3_RE.search(line).group(1).strip(), "items": []}
|
||||
current["subsections"].append(current_sub)
|
||||
continue
|
||||
|
||||
if DETAILS_CLOSE_RE.search(line):
|
||||
current = None
|
||||
current_sub = None
|
||||
continue
|
||||
|
||||
m = ITEM_RE.match(line.strip())
|
||||
if not m:
|
||||
continue
|
||||
name = m.group(1).strip()
|
||||
entry = (i, name)
|
||||
if current_sub is not None:
|
||||
current_sub["items"].append(entry)
|
||||
elif current is not None:
|
||||
current["items"].append(entry)
|
||||
return sections
|
||||
|
||||
|
||||
def iter_checkable_groups(sections: list[dict]) -> list[tuple[str, list[tuple[int, str]]]]:
|
||||
"""Return (display_name, items) pairs we should check for ordering."""
|
||||
out: list[tuple[str, list[tuple[int, str]]]] = []
|
||||
for s in sections:
|
||||
if s["subsections"]:
|
||||
for sub in s["subsections"]:
|
||||
out.append((f"{s['name']} > {sub['name']}", sub["items"]))
|
||||
else:
|
||||
out.append((s["name"], s["items"]))
|
||||
return out
|
||||
|
||||
|
||||
def apply_patch(base_lines: list[str], patch_text: str) -> tuple[list[str], set[int]]:
|
||||
"""Reconstruct the new file from base content and a unified diff.
|
||||
|
||||
Returns (new_lines_with_trailing_newlines, set_of_added_line_numbers_1indexed).
|
||||
"""
|
||||
new_lines: list[str] = []
|
||||
added: set[int] = set()
|
||||
|
||||
hunks: list[dict] = []
|
||||
current: dict | None = None
|
||||
for line in patch_text.splitlines(keepends=False):
|
||||
m = HUNK_RE.match(line)
|
||||
if m:
|
||||
if current is not None:
|
||||
hunks.append(current)
|
||||
current = {
|
||||
"old_start": int(m.group(1)),
|
||||
"new_start": int(m.group(2)),
|
||||
"lines": [],
|
||||
}
|
||||
elif current is not None:
|
||||
current["lines"].append(line)
|
||||
if current is not None:
|
||||
hunks.append(current)
|
||||
|
||||
old_pos = 1
|
||||
new_pos = 1
|
||||
for hunk in hunks:
|
||||
while old_pos < hunk["old_start"]:
|
||||
new_lines.append(base_lines[old_pos - 1])
|
||||
old_pos += 1
|
||||
new_pos += 1
|
||||
for hunk_line in hunk["lines"]:
|
||||
if hunk_line.startswith("---") or hunk_line.startswith("+++"):
|
||||
continue
|
||||
if hunk_line.startswith("\\"):
|
||||
# "\ No newline at end of file" marker — skip
|
||||
continue
|
||||
if hunk_line.startswith("-"):
|
||||
old_pos += 1
|
||||
elif hunk_line.startswith("+"):
|
||||
content = hunk_line[1:]
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_lines.append(content)
|
||||
added.add(new_pos)
|
||||
new_pos += 1
|
||||
elif hunk_line.startswith(" "):
|
||||
new_lines.append(base_lines[old_pos - 1])
|
||||
old_pos += 1
|
||||
new_pos += 1
|
||||
while old_pos <= len(base_lines):
|
||||
new_lines.append(base_lines[old_pos - 1])
|
||||
old_pos += 1
|
||||
new_pos += 1
|
||||
return new_lines, added
|
||||
|
||||
|
||||
def collect_added_lines_for_file(changed_file: dict) -> set[int]:
|
||||
"""Parse a single changed-file's patch and return added line numbers (1-indexed)."""
|
||||
patch = (changed_file or {}).get("patch") or ""
|
||||
if not patch:
|
||||
return set()
|
||||
added: set[int] = set()
|
||||
current_new: int | None = None
|
||||
for line in patch.splitlines():
|
||||
m = HUNK_RE.match(line)
|
||||
if m:
|
||||
current_new = int(m.group(2))
|
||||
continue
|
||||
if current_new is None:
|
||||
continue
|
||||
if line.startswith("+++"):
|
||||
continue
|
||||
if line.startswith("+"):
|
||||
added.add(current_new)
|
||||
current_new += 1
|
||||
elif line.startswith("-"):
|
||||
continue
|
||||
elif line.startswith("\\"):
|
||||
continue
|
||||
else:
|
||||
current_new += 1
|
||||
return added
|
||||
|
||||
|
||||
def find_ordering_violations(
|
||||
items: list[tuple[int, str]],
|
||||
added_lines: set[int],
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Check if `items` is alphabetically sorted (case-insensitive).
|
||||
|
||||
Returns (involves_added_line, list_of_descriptions).
|
||||
Only violations where at least one of the two items is on a line
|
||||
added by the PR are reported (and `involves_added_line` is True iff
|
||||
any such violation exists). Pre-existing misordering is ignored.
|
||||
"""
|
||||
names = [n.casefold() for _, n in items]
|
||||
descriptions: list[str] = []
|
||||
involves_added = False
|
||||
for i in range(len(items) - 1):
|
||||
if names[i] > names[i + 1]:
|
||||
line_a, name_a = items[i]
|
||||
line_b, name_b = items[i + 1]
|
||||
pair_involves_added = (line_a in added_lines) or (line_b in added_lines)
|
||||
if pair_involves_added:
|
||||
involves_added = True
|
||||
descriptions.append(
|
||||
f"`{name_a}` (line {line_a}) should come after `{name_b}` (line {line_b})"
|
||||
)
|
||||
return involves_added, descriptions
|
||||
|
||||
|
||||
def check_alphabetical(base_content: str, readme_patch: str) -> str:
|
||||
"""Return a markdown section (empty string if everything is sorted).
|
||||
|
||||
`base_content` is the README on the base branch.
|
||||
`readme_patch` is the unified diff for README.md from the PR.
|
||||
"""
|
||||
base_lines = base_content.splitlines(keepends=True)
|
||||
new_lines, added_lines = apply_patch(base_lines, readme_patch)
|
||||
new_content = "".join(new_lines)
|
||||
|
||||
sections = parse_sections(new_content)
|
||||
flagged: list[tuple[str, list[str]]] = []
|
||||
for group_name, items in iter_checkable_groups(sections):
|
||||
if len(items) < 2:
|
||||
continue
|
||||
involves_added, descriptions = find_ordering_violations(items, added_lines)
|
||||
if involves_added and descriptions:
|
||||
flagged.append((group_name, descriptions))
|
||||
|
||||
if not flagged:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"",
|
||||
"### Alphabetical order",
|
||||
"",
|
||||
"Items below are not in alphabetical order within their section. "
|
||||
"Please sort the affected entries so the section stays alphabetical.",
|
||||
"",
|
||||
]
|
||||
for group_name, descriptions in flagged:
|
||||
lines.append(f"- **{group_name}**")
|
||||
for d in descriptions:
|
||||
lines.append(f" - {d}")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import calendar
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -8,9 +10,25 @@ from datetime import date, datetime, timezone
|
||||
from urllib import error, parse, request
|
||||
|
||||
|
||||
ALPHA_MODULE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "readme_pr_alphabetical.py"
|
||||
)
|
||||
_alpha_spec = importlib.util.spec_from_file_location("readme_pr_alphabetical", ALPHA_MODULE_PATH)
|
||||
_alpha_module = importlib.util.module_from_spec(_alpha_spec)
|
||||
_alpha_spec.loader.exec_module(_alpha_module)
|
||||
check_alphabetical = _alpha_module.check_alphabetical
|
||||
|
||||
|
||||
MARKER = "<!-- readme-pr-repo-first-commit -->"
|
||||
CLOSE_COMMENT_MARKER = "<!-- readme-pr-repo-first-commit:close -->"
|
||||
URL_PATTERN = re.compile(r"https?://[^\s)\]>\"`]+")
|
||||
|
||||
STATUS_PASS = "pass"
|
||||
STATUS_TOO_YOUNG = "too_young"
|
||||
STATUS_UNKNOWN = "unknown"
|
||||
|
||||
ALPHA_MARKER = "<!-- readme-pr-repo-first-commit:alphabetical -->"
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
@@ -135,18 +153,23 @@ def parse_iso_date_utc(iso_str: str) -> date | None:
|
||||
return dt.astimezone(timezone.utc).date()
|
||||
|
||||
|
||||
def go_no_go_for_first_commit(iso_date_str: str) -> tuple[str, str]:
|
||||
def go_no_go_for_first_commit(iso_date_str: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
Returns (display_date, emoji). ✅ if first commit is at least 6 calendar
|
||||
months before today (UTC); ⛔ otherwise or if date missing/unparseable.
|
||||
Returns (display_date, emoji, status).
|
||||
status is one of:
|
||||
- STATUS_PASS: first commit is at least 6 calendar months before today (UTC)
|
||||
- STATUS_TOO_YOUNG: date is parseable but the project is younger than 6 months
|
||||
- STATUS_UNKNOWN: date is missing or could not be parsed (treated as
|
||||
comment-only; does NOT trigger auto-close)
|
||||
"""
|
||||
parsed = parse_iso_date_utc(iso_date_str)
|
||||
if not parsed:
|
||||
return ("—", "⛔")
|
||||
return ("—", "⛔", STATUS_UNKNOWN)
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
cutoff = date_months_before(today_utc, 6)
|
||||
emoji = "✅" if parsed <= cutoff else "⛔"
|
||||
return (parsed.isoformat(), emoji)
|
||||
if parsed <= cutoff:
|
||||
return (parsed.isoformat(), "✅", STATUS_PASS)
|
||||
return (parsed.isoformat(), "⛔", STATUS_TOO_YOUNG)
|
||||
|
||||
|
||||
def get_first_commit_date(client: GithubClient, repo_url: str) -> str:
|
||||
@@ -176,10 +199,16 @@ def markdown_table_row(c1: str, c2: str, c3: str) -> str:
|
||||
return f"| {esc(c1)} | {esc(c2)} | {esc(c3)} |"
|
||||
|
||||
|
||||
def build_comment_table(readme_urls: list[str], client: GithubClient) -> str:
|
||||
"""Rows sorted by Start (date) ascending; rows without a date sort last."""
|
||||
def build_comment_table(readme_urls: list[str], client: GithubClient) -> tuple[str, list[tuple[str, str, str]]]:
|
||||
"""
|
||||
Build the marker comment body and return a parallel list of
|
||||
(repo_url, status, iso_date) for every repo that was evaluated as a
|
||||
real GitHub repo. Non-GitHub URLs are excluded from the status list
|
||||
(they are treated as UNKNOWN and do not affect close decisions).
|
||||
"""
|
||||
first_commit_cache: dict[str, str] = {}
|
||||
rows: list[tuple[date | None, str, str, str]] = []
|
||||
statuses: list[tuple[str, str, str]] = []
|
||||
|
||||
for raw_url in readme_urls:
|
||||
repo_url = normalize_github_repo_url(raw_url)
|
||||
@@ -191,9 +220,10 @@ def build_comment_table(readme_urls: list[str], client: GithubClient) -> str:
|
||||
first_commit_cache[repo_url] = get_first_commit_date(client, repo_url)
|
||||
|
||||
iso = first_commit_cache[repo_url]
|
||||
display_date, emoji = go_no_go_for_first_commit(iso)
|
||||
display_date, emoji, status = go_no_go_for_first_commit(iso)
|
||||
sort_key = parse_iso_date_utc(iso)
|
||||
rows.append((sort_key, display_date, emoji, repo_url))
|
||||
statuses.append((repo_url, status, iso))
|
||||
|
||||
rows.sort(key=lambda r: (r[0] is None, r[0] or date.min))
|
||||
|
||||
@@ -203,7 +233,46 @@ def build_comment_table(readme_urls: list[str], client: GithubClient) -> str:
|
||||
)
|
||||
body_lines = [MARKER, "", header]
|
||||
body_lines.extend(markdown_table_row(c1, c2, c3) for _, c1, c2, c3 in rows)
|
||||
return "\n".join(body_lines)
|
||||
return "\n".join(body_lines), statuses
|
||||
|
||||
|
||||
def build_close_comment(too_young: list[tuple[str, str]]) -> str:
|
||||
"""Compose the comment posted when a PR is auto-closed."""
|
||||
lines = [
|
||||
CLOSE_COMMENT_MARKER,
|
||||
"",
|
||||
"Closing this PR because every repository added by this change is "
|
||||
"younger than the 6-month minimum required by this list.",
|
||||
"",
|
||||
"| Repository | First commit |",
|
||||
"| ---------- | ------------ |",
|
||||
]
|
||||
for repo_url, iso_date in too_young:
|
||||
date_display = iso_date[:10] if iso_date else "—"
|
||||
lines.append(f"| {repo_url} | {date_display} |")
|
||||
lines += [
|
||||
"",
|
||||
"What to do next: please reopen this PR once each of the above "
|
||||
"repositories has been active for at least 6 months (by first "
|
||||
"commit). The status table above will refresh automatically when "
|
||||
"you reopen or push changes.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def should_close_pr(statuses: list[tuple[str, str, str]]) -> tuple[bool, list[tuple[str, str]]]:
|
||||
"""
|
||||
Per repo policy:
|
||||
- Close only if ALL added GitHub repos fail the 6-month requirement.
|
||||
- UNKNOWN (missing/unparseable date) does NOT count as failing and
|
||||
therefore blocks auto-close to avoid false positives.
|
||||
Returns (should_close, list_of_(repo_url, iso_date)_for_too_young_repos).
|
||||
"""
|
||||
if not statuses:
|
||||
return (False, [])
|
||||
if any(status != STATUS_TOO_YOUNG for _, status, _ in statuses):
|
||||
return (False, [])
|
||||
return (True, [(repo_url, iso) for repo_url, _, iso in statuses])
|
||||
|
||||
|
||||
def upsert_comment(client: GithubClient, pr_number: str, body: str) -> None:
|
||||
@@ -231,6 +300,74 @@ def upsert_comment(client: GithubClient, pr_number: str, body: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def upsert_close_comment(client: GithubClient, pr_number: str, body: str) -> None:
|
||||
"""Upsert a close-explanation comment under its own marker."""
|
||||
comments, _ = client.api(f"repos/{client.repository}/issues/{pr_number}/comments?per_page=100")
|
||||
existing_id = ""
|
||||
if isinstance(comments, list):
|
||||
for comment in comments:
|
||||
comment_body = (comment or {}).get("body", "")
|
||||
user_login = ((comment or {}).get("user") or {}).get("login", "")
|
||||
if CLOSE_COMMENT_MARKER in comment_body and user_login == "github-actions[bot]":
|
||||
existing_id = str(comment.get("id", ""))
|
||||
|
||||
if existing_id:
|
||||
client.api(
|
||||
f"repos/{client.repository}/issues/comments/{existing_id}",
|
||||
method="PATCH",
|
||||
payload={"body": body},
|
||||
)
|
||||
return
|
||||
|
||||
client.api(
|
||||
f"repos/{client.repository}/issues/{pr_number}/comments",
|
||||
method="POST",
|
||||
payload={"body": body},
|
||||
)
|
||||
|
||||
|
||||
def get_pr_state(client: GithubClient, pr_number: str) -> str:
|
||||
pr, _ = client.api(f"repos/{client.repository}/pulls/{pr_number}")
|
||||
return (pr or {}).get("state", "")
|
||||
|
||||
|
||||
def close_pull_request(client: GithubClient, pr_number: str) -> None:
|
||||
client.api(
|
||||
f"repos/{client.repository}/pulls/{pr_number}",
|
||||
method="PATCH",
|
||||
payload={"state": "closed"},
|
||||
)
|
||||
|
||||
|
||||
def fetch_base_readme(client: GithubClient, pr_number: str) -> str | None:
|
||||
"""Return the README.md content from the PR's base ref, or None on failure.
|
||||
|
||||
Used by the alphabetical-order check to reconstruct the post-PR
|
||||
README without requiring fork-head access.
|
||||
"""
|
||||
pr, _ = client.api(f"repos/{client.repository}/pulls/{pr_number}")
|
||||
if not isinstance(pr, dict):
|
||||
return None
|
||||
base = pr.get("base", {}) or {}
|
||||
ref = base.get("sha") or ""
|
||||
if not ref:
|
||||
return None
|
||||
path = f"repos/{client.repository}/contents/README.md?ref={ref}"
|
||||
try:
|
||||
data, _ = client.api(path)
|
||||
except SystemExit:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
content_b64 = data.get("content", "")
|
||||
if not content_b64:
|
||||
return None
|
||||
try:
|
||||
return base64.b64decode(content_b64).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
|
||||
pr_number = os.getenv("PR_NUMBER")
|
||||
@@ -245,18 +382,45 @@ def main() -> None:
|
||||
changed_files, _ = client.api(f"repos/{repository}/pulls/{pr_number}/files?per_page=100")
|
||||
|
||||
readme_urls = []
|
||||
readme_patch = ""
|
||||
if isinstance(changed_files, list):
|
||||
readme_urls = find_readme_urls(changed_files)
|
||||
for f in changed_files:
|
||||
if (f or {}).get("filename") == "README.md":
|
||||
readme_patch = (f or {}).get("patch") or ""
|
||||
break
|
||||
|
||||
statuses: list[tuple[str, str, str]] = []
|
||||
if not readme_urls:
|
||||
comment_body = (
|
||||
f"{MARKER}\n"
|
||||
"README.md was changed, but no new or modified URL was found in added lines."
|
||||
)
|
||||
else:
|
||||
comment_body = build_comment_table(readme_urls, client)
|
||||
comment_body, statuses = build_comment_table(readme_urls, client)
|
||||
|
||||
upsert_comment(client, pr_number=pr_number, body=comment_body)
|
||||
alphabetical_section = ""
|
||||
if readme_patch:
|
||||
base_content = fetch_base_readme(client, pr_number)
|
||||
if base_content is not None:
|
||||
alphabetical_section = check_alphabetical(base_content, readme_patch)
|
||||
if alphabetical_section:
|
||||
alphabetical_section = (
|
||||
f"\n{ALPHA_MARKER}\n{alphabetical_section}"
|
||||
)
|
||||
|
||||
upsert_comment(client, pr_number=pr_number, body=comment_body + alphabetical_section)
|
||||
|
||||
should_close, too_young = should_close_pr(statuses)
|
||||
if not should_close:
|
||||
return
|
||||
|
||||
state = get_pr_state(client, pr_number)
|
||||
if state == "closed":
|
||||
return
|
||||
|
||||
upsert_close_comment(client, pr_number, build_close_comment(too_young))
|
||||
close_pull_request(client, pr_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Ruby
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ '*' ]
|
||||
pull_request:
|
||||
branches: [ '*' ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Ruby 2.6
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 2.6
|
||||
- name: Checks
|
||||
run: |
|
||||
gem install awesome_bot
|
||||
awesome_bot --allow-redirect --request-delay 1 README.md
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Link Checker
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: link-check
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
link-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Lychee link checker
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: >-
|
||||
--verbose
|
||||
--no-progress
|
||||
--accept 100,101,200,202,203,204,205,206,300,301,302,303,304,307,308,403,429
|
||||
--retry 3
|
||||
--retry-wait-time 5
|
||||
--timeout 20
|
||||
--max-redirects 5
|
||||
--exclude-loopback
|
||||
--exclude-mail
|
||||
--exclude 'github\.com/.*/(issues|pulls|actions)'
|
||||
README.md
|
||||
output: github
|
||||
fail: true
|
||||
Reference in New Issue
Block a user