mirror of
https://github.com/boostorg/graph.git
synced 2026-07-21 13:23:42 +00:00
Merge pull request #523 from Becheler/ci/dependency-count-bot
infra: add a boostdeps counting bot
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print a markdown table of Boost dependency changes between two CI log archives.
|
||||
|
||||
Usage: dep_table.py BASELINE.zip AFTER.zip
|
||||
|
||||
Each archive is a GitHub Actions run-log zip. The `deps` job of the
|
||||
dependency-report workflow prints two marker-delimited boostdep reports into
|
||||
its log:
|
||||
|
||||
===DEP-BRIEF-START=== boostdep --brief graph ===DEP-BRIEF-END===
|
||||
===DEP-PRIMARY-START=== boostdep graph (primary) ===DEP-PRIMARY-END===
|
||||
|
||||
From those it derives two metrics and prints their delta vs the baseline:
|
||||
- the set of transitive Boost modules graph depends on (from --brief:
|
||||
Primary + Secondary sections = the full transitive closure), and
|
||||
- the header-inclusion weight of each direct dependency (from the primary
|
||||
report: how many distinct boost/graph files pull that dependency in).
|
||||
Weights are the live signal, they drop toward zero as coupling is removed;
|
||||
the module count is the coarse target that only moves when a dep hits zero.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
BRIEF = ("===DEP-BRIEF-START===", "===DEP-BRIEF-END===")
|
||||
PRIMARY = ("===DEP-PRIMARY-START===", "===DEP-PRIMARY-END===")
|
||||
# Module names as boostdep prints them, e.g. "numeric~conversion".
|
||||
MODULE = re.compile(r"[A-Za-z0-9_.~-]+")
|
||||
TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") # GitHub log line prefix
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def read_clean_lines(zip_path):
|
||||
"""Top-level per-job logs, with GitHub timestamp and ANSI prefixes stripped."""
|
||||
lines = []
|
||||
with zipfile.ZipFile(zip_path) as z:
|
||||
for entry in z.namelist():
|
||||
if "/" in entry or not entry.endswith(".txt"):
|
||||
continue
|
||||
for ln in z.read(entry).decode("utf-8", "replace").splitlines():
|
||||
lines.append(TIMESTAMP.sub("", ANSI.sub("", ln)))
|
||||
return lines
|
||||
|
||||
|
||||
def block(lines, markers):
|
||||
"""Lines strictly between the marker lines, matched exactly.
|
||||
|
||||
Exact matching is what skips the echoed `echo "<marker>"` command lines
|
||||
GitHub prepends to a run step, so we capture boostdep's real stdout.
|
||||
"""
|
||||
start, end = markers
|
||||
out, capturing = [], False
|
||||
for ln in lines:
|
||||
s = ln.strip()
|
||||
if s == start:
|
||||
capturing = True
|
||||
continue
|
||||
if s == end and capturing:
|
||||
break
|
||||
if capturing:
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
|
||||
def parse_brief(lines):
|
||||
"""--brief output -> set of module names (one bare name per line)."""
|
||||
mods = set()
|
||||
for ln in lines:
|
||||
s = ln.strip()
|
||||
if not s or s.startswith("#") or s.lower().startswith("brief dependency"):
|
||||
continue
|
||||
if MODULE.fullmatch(s):
|
||||
mods.add(s)
|
||||
return mods
|
||||
|
||||
|
||||
def parse_weights(lines):
|
||||
"""Primary report -> {dependency: number of distinct graph files that pull it in}."""
|
||||
weights, cur = {}, None
|
||||
for ln in lines:
|
||||
head = re.match(r"^([A-Za-z0-9_.~-]+):\s*$", ln) # "module:" at column 0
|
||||
if head:
|
||||
cur = head.group(1)
|
||||
weights.setdefault(cur, set())
|
||||
continue
|
||||
frm = re.match(r"^\s+from\s+(.+?)\s*$", ln)
|
||||
if frm and cur is not None:
|
||||
weights[cur].add(frm.group(1))
|
||||
return {k: len(v) for k, v in weights.items()}
|
||||
|
||||
|
||||
def main(base_zip, pr_zip):
|
||||
bl, pl = read_clean_lines(base_zip), read_clean_lines(pr_zip)
|
||||
base_mods = parse_brief(block(bl, BRIEF))
|
||||
pr_mods = parse_brief(block(pl, BRIEF))
|
||||
base_w = parse_weights(block(bl, PRIMARY))
|
||||
pr_w = parse_weights(block(pl, PRIMARY))
|
||||
|
||||
# Header-inclusion weights: the live signal. Only rows that changed, most-reduced first.
|
||||
print("**Header-inclusion weights** (graph files pulling each direct dependency in):")
|
||||
print()
|
||||
changed = []
|
||||
for dep in base_w.keys() | pr_w.keys():
|
||||
b, p = base_w.get(dep, 0), pr_w.get(dep, 0)
|
||||
if b != p:
|
||||
changed.append((p - b, dep, b, p))
|
||||
if changed:
|
||||
print("| Dependency | develop | PR | Δ |")
|
||||
print("|-----|--------:|---:|----:|")
|
||||
for d, dep, b, p in sorted(changed): # reductions (negative delta) first
|
||||
print(f"| {dep} | {b} | {p} | {d:+d} |")
|
||||
else:
|
||||
print("_No header-inclusion-weight changes._")
|
||||
print()
|
||||
|
||||
# Transitive module set: the coarse target.
|
||||
added = sorted(pr_mods - base_mods)
|
||||
removed = sorted(base_mods - pr_mods)
|
||||
nb, np_ = len(base_mods), len(pr_mods)
|
||||
diff = np_ - nb
|
||||
print(f"**Transitive Boost modules:** {nb} → {np_} ({f'{diff:+d}' if diff else '0'})")
|
||||
if added:
|
||||
print(f"- added: {', '.join(added)}")
|
||||
if removed:
|
||||
print(f"- removed: {', '.join(removed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: dep_table.py BASELINE.zip AFTER.zip")
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,61 @@
|
||||
name: dependency-count-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["dependency-report"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_RUN: ${{ github.event.workflow_run.id }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download this PR run's log archive
|
||||
run: gh api "repos/$REPO/actions/runs/$PR_RUN/logs" > pr-logs.zip
|
||||
|
||||
- name: Download latest successful develop run's log archive
|
||||
run: |
|
||||
DEV=$(gh run list --workflow dependency-report --branch develop --status success \
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId')
|
||||
if [ -z "$DEV" ]; then echo "no develop baseline run found" >&2; exit 1; fi
|
||||
echo "DEV_RUN=$DEV" >> "$GITHUB_ENV"
|
||||
gh api "repos/$REPO/actions/runs/$DEV/logs" > base-logs.zip
|
||||
|
||||
- name: Generate table
|
||||
run: |
|
||||
{
|
||||
echo "Boost dependency footprint vs \`develop\` (auto-generated)."
|
||||
echo "PR run \`$PR_RUN\` vs develop run \`$DEV_RUN\` (\`${HEAD_SHA:0:10}\`)."
|
||||
echo
|
||||
python3 .github/scripts/dep_table.py base-logs.zip pr-logs.zip
|
||||
} > table.md
|
||||
|
||||
- name: Resolve PR number
|
||||
id: pr
|
||||
run: |
|
||||
num=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \
|
||||
--jq "map(select(.head.sha==\"$HEAD_SHA\"))[0].number")
|
||||
if [ -z "$num" ]; then
|
||||
echo "no open PR found with head $HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "num=$num" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post or update sticky comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: dependency-counts
|
||||
number: ${{ steps.pr.outputs.num }}
|
||||
path: table.md
|
||||
@@ -0,0 +1,42 @@
|
||||
name: dependency-report
|
||||
|
||||
# Computes graph's Boost dependency footprint with boostdep and prints a
|
||||
# marker-delimited report into the job log, on every push and PR. The
|
||||
# dependency-count-comment workflow reads these logs (this PR's run and the
|
||||
# latest develop run) and posts the diff as a PR comment. Kept separate from
|
||||
# CI so ci.yml is untouched.
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
deps:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Boost tree and build boostdep
|
||||
run: |
|
||||
git clone -b develop --depth 1 https://github.com/boostorg/boost.git ../boost-root
|
||||
cd ../boost-root
|
||||
cp -r $GITHUB_WORKSPACE/* libs/graph
|
||||
git submodule update --init tools/boostdep
|
||||
# depinst pulls graph's deps; also ensure boostdep's own dep
|
||||
# (Boost.Filesystem) is present since boostdep links against it.
|
||||
python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" graph
|
||||
python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" filesystem
|
||||
./bootstrap.sh
|
||||
./b2 headers
|
||||
# boostdep #includes <boost/filesystem.hpp> and links the compiled
|
||||
# library, so build it with b2 (a bare c++ compile can't resolve that).
|
||||
./b2 tools/boostdep/build
|
||||
- name: Boostdep dependency report
|
||||
working-directory: ../boost-root
|
||||
run: |
|
||||
BOOSTDEP=$(find "$PWD" -type f -name boostdep -perm -u+x 2>/dev/null | grep -v '/src/' | head -1)
|
||||
if [ -z "$BOOSTDEP" ]; then echo "boostdep executable not found after build" >&2; exit 1; fi
|
||||
echo "using boostdep: $BOOSTDEP"
|
||||
echo "===DEP-BRIEF-START==="
|
||||
"$BOOSTDEP" --boost-root . --track-sources --no-track-tests --brief graph
|
||||
echo "===DEP-BRIEF-END==="
|
||||
echo "===DEP-PRIMARY-START==="
|
||||
"$BOOSTDEP" --boost-root . --track-sources --no-track-tests graph
|
||||
echo "===DEP-PRIMARY-END==="
|
||||
Reference in New Issue
Block a user