feat(tools): report progress changes on pull requests

progress.py stores the names of implemented and pending items, can
measure another source tree with --root and prints a Markdown diff of
two runs with --compare. The Progress report workflow compares every
pull request with its base, and Progress comment posts the result as a
single comment, updated on every push and removed when progress no
longer changes. Commenting runs as a separate workflow_run so pull
requests from forks never run with write permissions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
adriadam10 2026-09-27 18:47:32 +02:00
parent 3c6b0af6f7
commit e73cd7dec3
4 changed files with 149 additions and 6 deletions

42
.github/workflows/progress-comment.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Progress comment
on:
workflow_run:
workflows: [Progress report]
types: [completed]
permissions:
actions: read
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: progress-report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
pr=$(cat pr)
[[ $pr =~ ^[0-9]+$ ]] || exit 1
[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" = "$RUN_SHA" ] || exit 0
marker='<!-- progress-report -->'
id=$(gh api "repos/$GH_REPO/issues/$pr/comments" --paginate \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -n1)
if [ -f report.md ]; then
printf '%s\n%s' "$marker" "$(cat report.md)" > body.md
if [ -n "$id" ]; then
gh api -X PATCH "repos/$GH_REPO/issues/comments/$id" -F body=@body.md > /dev/null
else
gh pr comment "$pr" --body-file body.md
fi
elif [ -n "$id" ]; then
gh api -X DELETE "repos/$GH_REPO/issues/comments/$id"
fi

34
.github/workflows/progress-report.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Progress report
on:
pull_request:
permissions:
contents: read
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
- env:
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
out=$RUNNER_TEMP/out report=$RUNNER_TEMP/report
git worktree add --detach "$RUNNER_TEMP/base" HEAD^1
python3 tools/progress.py --root "$RUNNER_TEMP/base" "$out/base"
python3 tools/progress.py "$out/head"
mkdir "$report"
echo "$PR" > "$report/pr"
body=$(python3 tools/progress.py --compare "$out/base/progress.json" "$out/head/progress.json")
if [ -n "$body" ]; then
printf '### Progress report (%s → %s)\n\n%s\n' "$(git rev-parse --short HEAD^1)" "${HEAD_SHA::7}" "$body" > "$report/report.md"
cat "$report/report.md" >> "$GITHUB_STEP_SUMMARY"
fi
- uses: actions/upload-artifact@v7
with:
name: progress-report
path: ${{ runner.temp }}/report

View File

@ -13,10 +13,20 @@ Requires only Python 3.9+ with the standard library. It writes:
- `progress.svg`: treemaps for system libraries and GPU shader instructions
- `badge-libraries.svg`, `badge-shaders.svg`: percentage badges
- `index.html`: the map with per-library and per-encoding tables
- `progress.json`: raw numbers
- `progress.json`: raw numbers and the names of implemented and pending items
The [Progress workflow](../.github/workflows/progress.yml) runs it on every push to `main` and deploys the output to GitHub Pages, so nothing generated is committed to the repository.
To compare two runs, for example `main` and a pull request:
```sh
python3 tools/progress.py --root <base-checkout> base
python3 tools/progress.py head
python3 tools/progress.py --compare base/progress.json head/progress.json
```
`--root` measures another source tree with the current script. `--compare` prints a Markdown report of implemented, newly declared, reverted and removed items, and prints nothing when progress did not change. The [Progress report workflow](../.github/workflows/progress-report.yml) runs it for every pull request, and [Progress comment](../.github/workflows/progress-comment.yml) posts the report as a single comment on the pull request, updated on every push. It runs as a separate `workflow_run` so pull requests from forks never run with write permissions.
### What is measured
- **System libraries**: functions defined in [core/libs/prx](../core/libs/prx). A function is implemented when it no longer calls `NotImplemented_nid_no_patch`. The total only includes functions already declared in the project, not every function of the PS5 firmware.

View File

@ -24,6 +24,7 @@ OPCODE_ALIASES = {
"VMadMixloF16": "V_FMA_MIXLO_F16",
"VMadMixhiF16": "V_FMA_MIXHI_F16",
}
REPORT_ROWS = 100
PANEL_WIDTH, GAP, MAP_HEIGHT, HEADER = 495, 10, 280, 30
DONE_COLOR, TODO_COLOR, BORDER, TEXT = "#2ea043", "#1f6feb", "#0d1117", "#ffffff"
@ -51,7 +52,8 @@ def scan_library(path):
body = text[match.end() - 1:body_end(text, match.end() - 1)]
(todo if STUB in body else done).add(name)
todo -= done
return {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo)}
return {"name": path.name, "label": path.name.removeprefix("libSce"), "done": len(done), "todo": len(todo),
"done_names": sorted(done), "todo_names": sorted(todo)}
def summarize(groups):
@ -88,8 +90,11 @@ def collect_shaders():
extra.append(opcode)
groups = {}
for name, encoding in isa.items():
group = groups.setdefault(encoding, {"name": encoding, "label": encoding, "done": 0, "todo": 0})
group["done" if name in supported else "todo"] += 1
group = groups.setdefault(encoding, {"name": encoding, "label": encoding, "done": 0, "todo": 0,
"done_names": [], "todo_names": []})
state = "done" if name in supported else "todo"
group[state] += 1
group[f"{state}_names"].append(name)
result = summarize(sorted(groups.values(), key=lambda g: g["name"]))
result["extra"] = extra
return result
@ -226,10 +231,62 @@ def summary(libraries, shaders):
]) + "\n"
def names(data, state):
return {(g["name"], n) for g in data["groups"] for n in g.get(f"{state}_names", [])}
def details(icon, title, column, items):
if not items:
return []
rows = [f"<details>\n<summary>{icon} {len(items)} {title}</summary>\n", f"| {column} | Name |", "| - | - |"]
rows += [f"| {group} | `{name}` |" for group, name in sorted(items)[:REPORT_ROWS]]
if len(items) > REPORT_ROWS:
rows.append(f"| ... | {len(items) - REPORT_ROWS} more |")
return rows + ["\n</details>"]
def compare(title, column, unit, base, head):
base_done, head_done = names(base, "done"), names(head, "done")
base_all, head_all = base_done | names(base, "todo"), head_done | names(head, "todo")
implemented, declared = head_done - base_done, head_all - base_all - head_done
regressed, removed = base_done & (head_all - head_done), base_all - head_all
if not (implemented or declared or regressed or removed):
return []
delta = round(head["percent"] - base["percent"], 2)
icon = "📈" if delta > 0 else "📉" if delta < 0 else "➖"
counts = [f"{n:+} {label}" for n, label in ((len(implemented), "implemented"), (len(declared), "declared"),
(-len(removed), "removed")) if n]
lines = [f'{icon} **{title}**: {head["percent"]}% ({delta:+}%, {", ".join(counts)} {unit})', ""]
lines += details("✅", "implemented", column, implemented)
lines += details("🆕", "declared as stubs", column, declared)
lines += details("⚠️", "went back to stubs", column, regressed)
lines += details("🗑️", "removed", column, removed)
return lines + [""]
def report(base, head):
lines = compare("System libraries", "Library", "functions", base["libraries"], head["libraries"])
lines += compare("GPU shader instructions", "Encoding", "instructions", base["shaders"], head["shaders"])
return "\n".join(lines)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
output = parser.parse_args().output
parser.add_argument("output", type=Path, nargs="?")
parser.add_argument("--root", type=Path, help="source tree to measure instead of the one containing this script")
parser.add_argument("--compare", type=Path, nargs=2, metavar=("BASE", "HEAD"),
help="print a markdown report of the changes between two progress.json files")
args = parser.parse_args()
if args.compare:
base, head = (json.loads(path.read_text()) for path in args.compare)
print(report(base, head), end="")
raise SystemExit
if not args.output:
parser.error("the output directory is required")
if args.root:
PRX = args.root / "core" / "libs" / "prx"
OPCODES = args.root / OPCODES.relative_to(ROOT)
output = args.output
output.mkdir(parents=True, exist_ok=True)
libraries, shaders = collect_libraries(), collect_shaders()
(output / "progress.json").write_text(json.dumps({"libraries": libraries, "shaders": shaders}, indent=2))