diff --git a/.github/workflows/progress-comment.yml b/.github/workflows/progress-comment.yml new file mode 100644 index 000000000..05568e459 --- /dev/null +++ b/.github/workflows/progress-comment.yml @@ -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='' + 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 diff --git a/.github/workflows/progress-report.yml b/.github/workflows/progress-report.yml new file mode 100644 index 000000000..3fbf7361b --- /dev/null +++ b/.github/workflows/progress-report.yml @@ -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 diff --git a/tools/README.md b/tools/README.md index 5a1481df6..b374dc398 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 +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. diff --git a/tools/progress.py b/tools/progress.py index d90d7b6af..2af9611d1 100644 --- a/tools/progress.py +++ b/tools/progress.py @@ -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"
\n{icon} {len(items)} {title}\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
"] + + +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))