Skip to content

Commit e55c4f2

Browse files
authored
Merge pull request #523 from Becheler/ci/dependency-count-bot
infra: add a boostdeps counting bot
2 parents 5a4d169 + 848786f commit e55c4f2

3 files changed

Lines changed: 234 additions & 0 deletions

File tree

.github/scripts/dep_table.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""Print a markdown table of Boost dependency changes between two CI log archives.
3+
4+
Usage: dep_table.py BASELINE.zip AFTER.zip
5+
6+
Each archive is a GitHub Actions run-log zip. The `deps` job of the
7+
dependency-report workflow prints two marker-delimited boostdep reports into
8+
its log:
9+
10+
===DEP-BRIEF-START=== boostdep --brief graph ===DEP-BRIEF-END===
11+
===DEP-PRIMARY-START=== boostdep graph (primary) ===DEP-PRIMARY-END===
12+
13+
From those it derives two metrics and prints their delta vs the baseline:
14+
- the set of transitive Boost modules graph depends on (from --brief:
15+
Primary + Secondary sections = the full transitive closure), and
16+
- the header-inclusion weight of each direct dependency (from the primary
17+
report: how many distinct boost/graph files pull that dependency in).
18+
Weights are the live signal, they drop toward zero as coupling is removed;
19+
the module count is the coarse target that only moves when a dep hits zero.
20+
"""
21+
import re
22+
import sys
23+
import zipfile
24+
25+
BRIEF = ("===DEP-BRIEF-START===", "===DEP-BRIEF-END===")
26+
PRIMARY = ("===DEP-PRIMARY-START===", "===DEP-PRIMARY-END===")
27+
# Module names as boostdep prints them, e.g. "numeric~conversion".
28+
MODULE = re.compile(r"[A-Za-z0-9_.~-]+")
29+
TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") # GitHub log line prefix
30+
ANSI = re.compile(r"\x1b\[[0-9;]*m")
31+
32+
33+
def read_clean_lines(zip_path):
34+
"""Top-level per-job logs, with GitHub timestamp and ANSI prefixes stripped."""
35+
lines = []
36+
with zipfile.ZipFile(zip_path) as z:
37+
for entry in z.namelist():
38+
if "/" in entry or not entry.endswith(".txt"):
39+
continue
40+
for ln in z.read(entry).decode("utf-8", "replace").splitlines():
41+
lines.append(TIMESTAMP.sub("", ANSI.sub("", ln)))
42+
return lines
43+
44+
45+
def block(lines, markers):
46+
"""Lines strictly between the marker lines, matched exactly.
47+
48+
Exact matching is what skips the echoed `echo "<marker>"` command lines
49+
GitHub prepends to a run step, so we capture boostdep's real stdout.
50+
"""
51+
start, end = markers
52+
out, capturing = [], False
53+
for ln in lines:
54+
s = ln.strip()
55+
if s == start:
56+
capturing = True
57+
continue
58+
if s == end and capturing:
59+
break
60+
if capturing:
61+
out.append(ln)
62+
return out
63+
64+
65+
def parse_brief(lines):
66+
"""--brief output -> set of module names (one bare name per line)."""
67+
mods = set()
68+
for ln in lines:
69+
s = ln.strip()
70+
if not s or s.startswith("#") or s.lower().startswith("brief dependency"):
71+
continue
72+
if MODULE.fullmatch(s):
73+
mods.add(s)
74+
return mods
75+
76+
77+
def parse_weights(lines):
78+
"""Primary report -> {dependency: number of distinct graph files that pull it in}."""
79+
weights, cur = {}, None
80+
for ln in lines:
81+
head = re.match(r"^([A-Za-z0-9_.~-]+):\s*$", ln) # "module:" at column 0
82+
if head:
83+
cur = head.group(1)
84+
weights.setdefault(cur, set())
85+
continue
86+
frm = re.match(r"^\s+from\s+(.+?)\s*$", ln)
87+
if frm and cur is not None:
88+
weights[cur].add(frm.group(1))
89+
return {k: len(v) for k, v in weights.items()}
90+
91+
92+
def main(base_zip, pr_zip):
93+
bl, pl = read_clean_lines(base_zip), read_clean_lines(pr_zip)
94+
base_mods = parse_brief(block(bl, BRIEF))
95+
pr_mods = parse_brief(block(pl, BRIEF))
96+
base_w = parse_weights(block(bl, PRIMARY))
97+
pr_w = parse_weights(block(pl, PRIMARY))
98+
99+
# Header-inclusion weights: the live signal. Only rows that changed, most-reduced first.
100+
print("**Header-inclusion weights** (graph files pulling each direct dependency in):")
101+
print()
102+
changed = []
103+
for dep in base_w.keys() | pr_w.keys():
104+
b, p = base_w.get(dep, 0), pr_w.get(dep, 0)
105+
if b != p:
106+
changed.append((p - b, dep, b, p))
107+
if changed:
108+
print("| Dependency | develop | PR | Δ |")
109+
print("|-----|--------:|---:|----:|")
110+
for d, dep, b, p in sorted(changed): # reductions (negative delta) first
111+
print(f"| {dep} | {b} | {p} | {d:+d} |")
112+
else:
113+
print("_No header-inclusion-weight changes._")
114+
print()
115+
116+
# Transitive module set: the coarse target.
117+
added = sorted(pr_mods - base_mods)
118+
removed = sorted(base_mods - pr_mods)
119+
nb, np_ = len(base_mods), len(pr_mods)
120+
diff = np_ - nb
121+
print(f"**Transitive Boost modules:** {nb}{np_} ({f'{diff:+d}' if diff else '0'})")
122+
if added:
123+
print(f"- added: {', '.join(added)}")
124+
if removed:
125+
print(f"- removed: {', '.join(removed)}")
126+
127+
128+
if __name__ == "__main__":
129+
if len(sys.argv) != 3:
130+
sys.exit("usage: dep_table.py BASELINE.zip AFTER.zip")
131+
main(sys.argv[1], sys.argv[2])
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: dependency-count-comment
2+
3+
on:
4+
workflow_run:
5+
workflows: ["dependency-report"]
6+
types: [completed]
7+
8+
permissions:
9+
actions: read
10+
pull-requests: write
11+
contents: read
12+
13+
jobs:
14+
comment:
15+
if: github.event.workflow_run.event == 'pull_request'
16+
runs-on: ubuntu-latest
17+
env:
18+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
REPO: ${{ github.repository }}
20+
PR_RUN: ${{ github.event.workflow_run.id }}
21+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- name: Download this PR run's log archive
26+
run: gh api "repos/$REPO/actions/runs/$PR_RUN/logs" > pr-logs.zip
27+
28+
- name: Download latest successful develop run's log archive
29+
run: |
30+
DEV=$(gh run list --workflow dependency-report --branch develop --status success \
31+
--limit 1 --json databaseId --jq '.[0].databaseId')
32+
if [ -z "$DEV" ]; then echo "no develop baseline run found" >&2; exit 1; fi
33+
echo "DEV_RUN=$DEV" >> "$GITHUB_ENV"
34+
gh api "repos/$REPO/actions/runs/$DEV/logs" > base-logs.zip
35+
36+
- name: Generate table
37+
run: |
38+
{
39+
echo "Boost dependency footprint vs \`develop\` (auto-generated)."
40+
echo "PR run \`$PR_RUN\` vs develop run \`$DEV_RUN\` (\`${HEAD_SHA:0:10}\`)."
41+
echo
42+
python3 .github/scripts/dep_table.py base-logs.zip pr-logs.zip
43+
} > table.md
44+
45+
- name: Resolve PR number
46+
id: pr
47+
run: |
48+
num=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \
49+
--jq "map(select(.head.sha==\"$HEAD_SHA\"))[0].number")
50+
if [ -z "$num" ]; then
51+
echo "no open PR found with head $HEAD_SHA" >&2
52+
exit 1
53+
fi
54+
echo "num=$num" >> "$GITHUB_OUTPUT"
55+
56+
- name: Post or update sticky comment
57+
uses: marocchino/sticky-pull-request-comment@v2
58+
with:
59+
header: dependency-counts
60+
number: ${{ steps.pr.outputs.num }}
61+
path: table.md
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: dependency-report
2+
3+
# Computes graph's Boost dependency footprint with boostdep and prints a
4+
# marker-delimited report into the job log, on every push and PR. The
5+
# dependency-count-comment workflow reads these logs (this PR's run and the
6+
# latest develop run) and posts the diff as a PR comment. Kept separate from
7+
# CI so ci.yml is untouched.
8+
9+
on: [ push, pull_request ]
10+
11+
jobs:
12+
deps:
13+
runs-on: ubuntu-24.04
14+
steps:
15+
- uses: actions/checkout@v4
16+
- name: Set up Boost tree and build boostdep
17+
run: |
18+
git clone -b develop --depth 1 https://github.com/boostorg/boost.git ../boost-root
19+
cd ../boost-root
20+
cp -r $GITHUB_WORKSPACE/* libs/graph
21+
git submodule update --init tools/boostdep
22+
# depinst pulls graph's deps; also ensure boostdep's own dep
23+
# (Boost.Filesystem) is present since boostdep links against it.
24+
python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" graph
25+
python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" filesystem
26+
./bootstrap.sh
27+
./b2 headers
28+
# boostdep #includes <boost/filesystem.hpp> and links the compiled
29+
# library, so build it with b2 (a bare c++ compile can't resolve that).
30+
./b2 tools/boostdep/build
31+
- name: Boostdep dependency report
32+
working-directory: ../boost-root
33+
run: |
34+
BOOSTDEP=$(find "$PWD" -type f -name boostdep -perm -u+x 2>/dev/null | grep -v '/src/' | head -1)
35+
if [ -z "$BOOSTDEP" ]; then echo "boostdep executable not found after build" >&2; exit 1; fi
36+
echo "using boostdep: $BOOSTDEP"
37+
echo "===DEP-BRIEF-START==="
38+
"$BOOSTDEP" --boost-root . --track-sources --no-track-tests --brief graph
39+
echo "===DEP-BRIEF-END==="
40+
echo "===DEP-PRIMARY-START==="
41+
"$BOOSTDEP" --boost-root . --track-sources --no-track-tests graph
42+
echo "===DEP-PRIMARY-END==="

0 commit comments

Comments
 (0)