From dc80c79338cba3c3709988463b0e7980dbb58cdf Mon Sep 17 00:00:00 2001 From: v0lumehi Date: Sat, 31 Oct 2026 03:58:16 +0100 Subject: [PATCH] Add a daily triage report on open pull requests and issues dm/tools/triage_report.py reads the open PRs and issues over GraphQL and merges each PR's head with main in git's object store (merge-tree). It prints a Markdown report by fixed rules: PRs already in main (a no-op merge, or a commit on main that names the PR), likely duplicates (the same game's title ID, or similar titles and changed files), conflicts with main and the commit since which they conflict, PRs without a linked issue, and issues with no reply and no label. .github/workflows/triage-report.yml runs it daily and on demand and puts the report in the job summary. It checks out main only and never runs a PR's code. It never closes, labels or comments. With the repository variable TRIAGE_REPORT_ISSUE set, a second job replaces that one issue's body with the report. The tests use a recorded API answer and a temporary repository, without network. docs/development.md lists the rules and their limits. Part of #313. --- .github/workflows/triage-report.yml | 68 +++++ dm/tools/tests/test_triage_report.py | 202 ++++++++++++++ dm/tools/tests/triage_report_api.json | 271 +++++++++++++++++++ dm/tools/triage_report.py | 370 ++++++++++++++++++++++++++ docs/development.md | 36 +++ 5 files changed, 947 insertions(+) create mode 100644 .github/workflows/triage-report.yml create mode 100644 dm/tools/tests/test_triage_report.py create mode 100644 dm/tools/tests/triage_report_api.json create mode 100644 dm/tools/triage_report.py diff --git a/.github/workflows/triage-report.yml b/.github/workflows/triage-report.yml new file mode 100644 index 0000000000..b1466a3421 --- /dev/null +++ b/.github/workflows/triage-report.yml @@ -0,0 +1,68 @@ +# A daily triage report on the open pull requests and issues (dm/tools/triage_report.py): which PRs are in main already, which look like duplicates, which conflict +# with main and since when, which link no issue, and which issues have no reply and no label. Fixed rules, listed in docs/development.md (Triage report). +# The report goes to the job summary. It never closes, labels or comments on anything: the maintainer decides. +# No pull request's code is checked out or run. The job checks out main, fetches every open PR's head into the object store, and git merges trees there (merge-tree). +# Optional: with the repository variable TRIAGE_REPORT_ISSUE set to an issue number, the publish job replaces that one issue's body with the report. It never comments. +name: triage-report + +on: + schedule: + - cron: '17 5 * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: triage-report + cancel-in-progress: true + +defaults: + run: + shell: bash + +# Actions are pinned to commit SHAs (the tag in the comment), as in tests.yml. +jobs: + report: + name: Triage report + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + issues: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main # the report is about main, also when started from another branch + fetch-depth: 0 # main's history: "since" and the commits that name a PR + persist-credentials: false + - name: Report on the open pull requests and issues + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 dm/tools/triage_report.py --fetch origin > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: triage-report + path: report.md + retention-days: 7 + + publish: + name: Replace the body of the triage issue + needs: report + if: vars.TRIAGE_REPORT_ISSUE != '' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: triage-report + - name: Edit the issue's body (no comment) + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE: ${{ vars.TRIAGE_REPORT_ISSUE }} + run: gh issue edit "$ISSUE" --body-file report.md diff --git a/dm/tools/tests/test_triage_report.py b/dm/tools/tests/test_triage_report.py new file mode 100644 index 0000000000..967ab8b44d --- /dev/null +++ b/dm/tools/tests/test_triage_report.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""dm/tools/triage_report.py: the rules of the triage report, offline. +triage_report_api.json is the GraphQL answer for a few real open PRs and issues of seedds/nxnative (bodies shortened). A temporary Git repository plays main and the +PR heads, so no network and no token are needed.""" +import contextlib +import copy +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timezone + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import triage_report as tr + +API = Path(__file__).resolve().parent / 'triage_report_api.json' +REPO = 'seedds/nxnative' +NOW = datetime(2026, 10, 31, 12, 0, tzinfo=timezone.utc) +A_C = ''.join('line %d\n' % i for i in range(1, 13)) +VIOLET = 'dm/games/01008F6008C5E000/game.tsv' +ALLSTARS = 'dm/games/010049900F546000/game.tsv' +# the head of each fixture PR, as files it writes on top of the base commit +HEADS = { + 29: {'a.c': A_C.replace('line 1\n', 'pr 29\n')}, + 167: {'b.c': 'pr 167\n'}, + 193: {ALLSTARS: 'menu\n', 'dm/games/010049900F546800/game.tsv': 'update\n'}, + 269: {VIOLET: 'pr 269\n'}, + 408: {'.github/workflows/pr-areas.yml': 'on: pull_request_target\n'}, + 415: {VIOLET: 'pr 415\n'}, + 573: {'dm/games/010003F003A34000/game.tsv': 'pikachu\n'}, + 734: {ALLSTARS: 'mario 64\n'}, + 756: {'x.c': 'fixed\n'}, +} + + +def load(): + return json.loads(API.read_text(encoding='utf-8')) + + +class Server: + """A repository with main and refs/pull//head, as GitHub has them.""" + + def __init__(self, root): + self.root = root + + def git(self, *args, date='2026-10-01T00:00:00Z'): + env = dict(os.environ, GIT_AUTHOR_DATE=date, GIT_COMMITTER_DATE=date) + return subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'init.defaultBranch=main', *args], + cwd=self.root, env=env, check=True, capture_output=True, text=True).stdout.strip() + + def commit(self, files, message, date): + for path, text in files.items(): + (self.root / path).parent.mkdir(parents=True, exist_ok=True) + (self.root / path).write_text(text) + self.git('add', '-A') + self.git('commit', '-qm', message, date=date) + return self.git('rev-parse', 'HEAD') + + +def build(root): + s = Server(root) + s.git('init', '-q') + base = s.commit({'a.c': A_C, 'b.c': 'b\n', 'x.c': 'x\n', 'README.md': 'nxnative\n', 'dm/games/0100AE00096EA000/game.tsv': 'hyrule\n'}, 'Base', '2026-10-01T00:00:00Z') + for n, files in HEADS.items(): + s.git('checkout', '-q', '--detach', base) + s.git('update-ref', 'refs/pull/%d/head' % n, s.commit(files, 'PR %d' % n, '2026-10-15T00:00:00Z')) + s.git('checkout', '-q', 'main') + sha = { + 'touch': s.commit({'a.c': A_C.replace('line 12\n', 'main 12\n')}, 'Touch a.c far from line 1', '2026-10-20T00:00:00Z'), + 'clash': s.commit({'a.c': A_C.replace('line 1\n', 'main 1\n').replace('line 12\n', 'main 12\n')}, 'Rewrite line 1 of a.c', '2026-10-22T00:00:00Z'), + 'violet': s.commit({VIOLET: 'main\n'}, 'Add Pokemon Violet\n\nPR #408 is opened later, so this does not count.', '2026-10-26T08:00:00Z'), + 'landed': s.commit({'b.c': 'reworked\n'}, 'Textures: upload CPU-written textures\n\nThis replaces PR #167.', '2026-10-29T00:00:00Z'), + 'lint': s.commit({'x.c': 'fixed\n'}, 'Fix the lint findings (PR #756)', '2026-10-31T00:00:00Z'), + } + return s, sha + + +class Rules(unittest.TestCase): + def test_linked_issues(self): + pr = {'title': 'Fix the menu', 'closingIssuesReferences': {'nodes': [{'number': 1}]}, 'body': ( + 'Closes: #2. Part of #3. Refs https://github.com/seedds/nxnative/issues/4. Related to #5, see #6 and issue #7.\n' + '\nStacked on #9. Fixes #10, which is an open PR. Fixes https://github.com/other/repo/issues/11.')} + self.assertEqual(tr.linked_issues(pr, REPO, {10}), [1, 2, 3, 4, 5, 6, 7]) + + def test_fixture_links(self): + data = load() + numbers = {p['number'] for p in data['prs']} + links = {p['number']: tr.linked_issues(p, REPO, numbers) for p in data['prs']} + self.assertEqual(links[408], [313]) # "Part of #313" + self.assertEqual(links[573], [224]) # "Related to #224" + self.assertEqual([n for n, ls in links.items() if ls], [408, 573]) + + def test_replied(self): + issues = {i['number']: i for i in load()['issues']} + self.assertFalse(tr.replied(issues[10])) # no comment + self.assertFalse(tr.replied(issues[542])) # only its author + self.assertTrue(tr.replied(issues[313])) + bot = copy.deepcopy(issues[10]) + bot['comments'] = {'totalCount': 1, 'nodes': [{'author': {'__typename': 'Bot', 'login': 'github-actions'}}]} + self.assertFalse(tr.replied(bot)) + bot['comments']['nodes'].append({'author': None}) # a deleted account counts as someone else + bot['comments']['totalCount'] = 2 + self.assertTrue(tr.replied(bot)) + many = copy.deepcopy(issues[542]) + many['comments']['totalCount'] = 31 # more comments than the query reads + self.assertTrue(tr.replied(many)) + + def test_app_id(self): + self.assertEqual(tr.app_id('010049900F546800'), '010049900F546000') # update + self.assertEqual(tr.app_id('010049900F547003'), '010049900F546000') # add-on content + self.assertNotEqual(tr.app_id('010049900F548000'), '010049900F546000') + + def test_cell(self): + self.assertEqual(tr.cell('a | b @someone'), 'a \\| b @\u200bsomeone') + self.assertEqual(len(tr.cell('x' * 100)), 70) + + +class Report(unittest.TestCase): + @classmethod + def setUpClass(cls): + tmp = tempfile.TemporaryDirectory() + cls.addClassCleanup(tmp.cleanup) + root = Path(tmp.name).resolve() + (root / 'server').mkdir() + _, cls.sha = build(root / 'server') + subprocess.run(['git', 'clone', '-q', str(root / 'server'), str(root / 'clone')], check=True, capture_output=True) + cls.clone = str(root / 'clone') + cls.data = load() + tr.fetch_heads(cls.clone, 'origin', [p['number'] for p in cls.data['prs']]) + cls.git = tr.analyse_git(cls.data, cls.clone, 'main') + cls.t = tr.triage(cls.data, cls.git) + + def numbers(self, rows): + return [p['number'] for p in rows] + + def test_states(self): + state = {n: a['state'] for n, a in self.git['prs'].items()} + self.assertEqual(state, {29: 'conflict', 167: 'conflict', 193: 'clean', 269: 'conflict', 408: 'clean', 415: 'conflict', 573: 'clean', 734: 'clean', 756: 'no-op'}) + self.assertEqual(self.git['prs'][29]['conflicts'], ['a.c']) + + def test_in_main(self): + self.assertEqual(self.numbers(self.t['in_main']), [167, 756]) + self.assertEqual(self.git['prs'][167]['named'], [(self.sha['landed'], '2026-10-29T00:00:00Z', 'body')]) + self.assertEqual(self.git['prs'][756]['named'], [(self.sha['lint'], '2026-10-31T00:00:00Z', 'subject')]) + self.assertEqual(self.git['prs'][408]['named'], []) # named by a commit older than the PR + + def test_conflicts_since(self): + self.assertEqual(self.numbers(self.t['conflicts']), [29, 269, 415]) # 167 conflicts too, but a commit on main names it + self.assertEqual(self.git['prs'][29]['since'][0], self.sha['clash']) # not 'touch', which edits a.c too but merges cleanly + self.assertEqual(self.git['prs'][269]['since'][0], self.sha['violet']) + + def test_duplicates(self): + groups = [(g['kind'], g['key'], g['in_main'], self.numbers(g['prs'])) for g in self.t['duplicates']] + self.assertEqual(groups, [('profile', '010049900F546000', False, [193, 734]), ('profile', '01008F6008C5E000', True, [269, 415])]) + + def test_similar_titles_and_files(self): + prs = [{'number': 1, 'title': 'iOS: swipe camera and touch controls', 'createdAt': '2026-10-01T00:00:00Z'}, + {'number': 2, 'title': 'Touch controls editor for iOS', 'createdAt': '2026-10-02T00:00:00Z'}, + {'number': 3, 'title': 'Touch controls editor for iOS', 'createdAt': '2026-10-03T00:00:00Z'}] + analysis = {1: {'files': {'dm/ios/a.m': 'M', 'dm/ios/b.m': 'M'}}, 2: {'files': {'dm/ios/a.m': 'M', 'dm/ios/c.m': 'M'}}, 3: {'files': {'docs/x.md': 'M'}}} + groups = tr.duplicates(prs, analysis, []) + self.assertEqual([(g['kind'], g['key'], [p['number'] for p in g['prs']]) for g in groups], [('files', ['dm/ios/a.m'], [1, 2])]) + + def test_unlinked_and_untriaged(self): + self.assertEqual(self.numbers(self.t['unlinked']), [29, 193, 269, 415, 734]) # 167 and 756 are in main already + self.assertEqual(self.numbers(self.t['untriaged']), [10, 224, 542]) + self.assertEqual(self.t['naming'], {313: [408], 224: [573]}) + self.assertEqual((self.t['no_reply'], self.t['no_label']), (3, 4)) + self.assertEqual(self.t['skipped'], []) + + def test_markdown(self): + md = tr.report(self.data, self.git, self.t, NOW, limit=2) + self.assertIn('| Pull requests already in `main` | 2 |', md) + self.assertIn('| Likely duplicates | 2 groups, 4 pull requests |', md) + self.assertIn('| Pull requests that conflict with `main` | 3 |', md) + self.assertIn('body of [`%s`](https://github.com/seedds/nxnative/commit/%s) (2026-10-29) names it' % (self.sha['landed'][:8], self.sha['landed']), md) + self.assertIn('merges as a no-op, subject of', md) + self.assertIn('2026-10-22 [`%s`]' % self.sha['clash'][:8], md) + self.assertIn('profile `01008F6008C5E000`, already in `main`', md) + self.assertIn('3 more pull requests not shown.', md) + self.assertIn('| [#224](https://github.com/seedds/nxnative/issues/224) |', md) + self.assertNotRegex(md.replace('@\u200b', ''), r'@\w') # nobody is mentioned + + def test_main_load(self): + with tempfile.TemporaryDirectory() as d: + saved = Path(d) / 'api.json' + saved.write_text(json.dumps(self.data)) + out = io.StringIO() + with contextlib.redirect_stdout(out): + tr.main(['--load', str(saved), '--git', self.clone, '--limit', '0', '--repo', 'ignored/by-load']) + md = out.getvalue() + self.assertTrue(md.startswith('# Triage report\n')) + self.assertIn('[#734](https://github.com/seedds/nxnative/pull/734)', md) + self.assertNotIn('not shown', md) + + +if __name__ == '__main__': + unittest.main() diff --git a/dm/tools/tests/triage_report_api.json b/dm/tools/tests/triage_report_api.json new file mode 100644 index 0000000000..d0edd9ef3d --- /dev/null +++ b/dm/tools/tests/triage_report_api.json @@ -0,0 +1,271 @@ +{ + "repo": "seedds/nxnative", + "prs": [ + { + "number": 29, + "title": "Rhythm Heaven Groove: A16 legacy support, touch pad and ambient bars", + "url": "https://github.com/seedds/nxnative/pull/29", + "createdAt": "2026-10-21T04:34:19Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "Roylam23" + }, + "body": "## Summary\n\nThis adds the A16 / older-iPhone support and the complete Rhythm Heaven Groove adaptation:\n\n- **A16 BC fallback**: decodes BC1–BC7 textures on the CPU when the GPU has no BC sampling (`dm/third_party/bcdec`), with an optional offline BC → ASTC cache (`BCA1`, keyed by the guest texture ha", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 167, + "title": "Textures: upload CPU-written linear and unflushed sampled textures", + "url": "https://github.com/seedds/nxnative/pull/167", + "createdAt": "2026-10-25T08:08:32Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "Jiiiin" + }, + "body": "Two CPU-write paths left sampled textures with content 0 in The Great Ace Attorney Chronicles, on the Mac and on the iPad. Both are host-side and general.\n\n## 1. LINEAR textures at a pool offset Metal cannot view\n\n`tex_linear` returns nil when the pool offset or row pitch is not aligned to Metal's m", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 193, + "title": "Super Mario 3D All-Stars: multi-program applications, nn::jit served on iOS, and an opt-in in-process program switch", + "url": "https://github.com/seedds/nxnative/pull/193", + "createdAt": "2026-10-25T16:10:39Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "stan69b" + }, + "body": "## Summary\n\nSuper Mario 3D All-Stars is not one program but four, each its own Program NCA, title ID (base + index), engine and graphics API: a menu (program 0, sead / agl on NVN) that starts Super Mario 64 (1, an N64 emulator on Vulkan with a JIT plugin), Super Mario Sunshine (2, NERD's Hagi with r", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 269, + "title": "Pokémon Violet: profile, Bluetooth LE takeover, ConservativeRasterDilate stub", + "url": "https://github.com/seedds/nxnative/pull/269", + "createdAt": "2026-10-26T07:17:19Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "jthrilly" + }, + "body": "Brings up Pokémon Violet (`01008F6008C5E000`, update 4.0.0, SDK 13.3.13, Game Freak's Trinity engine on NVN, the same engine as Pokémon Legends: Z-A) on the Mac: it boots through the language select, character creation, the whole opening and into play, the player's room and the house's ground floor,", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 408, + "title": "Area labels on pull requests from the paths they change", + "url": "https://github.com/seedds/nxnative/pull/408", + "createdAt": "2026-10-27T11:54:16Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "v0lumehi" + }, + "body": "Part of #313 (\"Triage, so issues and PRs find each other\").\n\n## Problem\n\n98 PRs are open, and none has a label. To find the ones that touch the shader chain, add a game, or change one game's profile, you have to open each.\n\n## Change\n\n- **`dm/tools/pr_areas.py`** maps a PR's changed paths to labels:", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 415, + "title": "Pokémon Violet: profile, Bluetooth stubs, Nintendo controller mapping", + "url": "https://github.com/seedds/nxnative/pull/415", + "createdAt": "2026-10-27T12:14:13Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "peachtaozhutou" + }, + "body": "Adds a game profile for **Pokémon Violet** (update 3.0.1, `v720896`) and the two host changes it needs.\n\n**Profile** (`dm/games/01008F6008C5E000/`, note + table row): SDK 13.3.13, Game Freak's Trinity engine, 35,937 shaders\n(35,897 BNSH in `arc/data.trpfs` decoded with the game's own Oodle, 40 in `m", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 573, + "title": "feat(games): add Let’s Go Pikachu Mac profile and offline BLE startup", + "url": "https://github.com/seedds/nxnative/pull/573", + "createdAt": "2026-10-28T19:06:22Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "dmuiX" + }, + "body": "## What and why\n\nAdd Pokémon: Let's Go, Pikachu! 1.0.2 (`010003F003A34000`, SDK 5.4.151), tested on Mac only.\n\n```text\ndm/games/010003F003A34000/ profile, tables, notes, smoke/benchmark routes, validation record\ndm/host/hle_offline.c offline BLE initialization, idle events, empty discovery\ndm\n\nRelated to #224, not a claim to resolve it", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 734, + "title": "Port Super Mario 3D All-Stars' Super Mario 64 and Super Mario Sunshine to the Mac", + "url": "https://github.com/seedds/nxnative/pull/734", + "createdAt": "2026-10-30T17:17:39Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "joaquinsandoval2307" + }, + "body": "## What and why\n\nSuper Mario 3D All-Stars' Super Mario 64 and Super Mario Sunshine now play on the Mac. Each needed something the host did not have yet:\n\n- **One program of a multi-program application.** The application is 4 programs: launcher 0, Super Mario 64 1, Super Mario Sunshine 2, Super Mario", + "closingIssuesReferences": { + "nodes": [] + } + }, + { + "number": 756, + "title": "Fix shared Python lint failures in Unreal engine-region rewriting", + "url": "https://github.com/seedds/nxnative/pull/756", + "createdAt": "2026-10-30T23:26:42Z", + "isDraft": false, + "baseRefName": "main", + "author": { + "login": "thehobbit85" + }, + "body": "Main still has the two Unreal engine-region lint findings: the operand helper captures the loop instruction, and the observation gate shifts a possibly-optional size-register value. Bind the instruction and assert the detector's size-register invariant, without changing the lint baseline or valid re", + "closingIssuesReferences": { + "nodes": [] + } + } + ], + "issues": [ + { + "number": 10, + "title": "大佬能否支持双人成行双手柄适配?", + "url": "https://github.com/seedds/nxnative/issues/10", + "createdAt": "2026-10-19T03:16:39Z", + "author": { + "login": "gdzhujun" + }, + "labels": { + "nodes": [] + }, + "comments": { + "totalCount": 0, + "nodes": [] + } + }, + { + "number": 224, + "title": "Pokémon: Let's Go, Pikachu! boots and plays on iPhone: patches and findings", + "url": "https://github.com/seedds/nxnative/issues/224", + "createdAt": "2026-10-25T20:51:08Z", + "author": { + "login": "s7ymwkptc4-a11y" + }, + "labels": { + "nodes": [] + }, + "comments": { + "totalCount": 0, + "nodes": [] + } + }, + { + "number": 313, + "title": "Roadmap: keeping every game green as the project grows", + "url": "https://github.com/seedds/nxnative/issues/313", + "createdAt": "2026-10-26T17:20:34Z", + "author": { + "login": "v0lumehi" + }, + "labels": { + "nodes": [] + }, + "comments": { + "totalCount": 3, + "nodes": [ + { + "author": { + "__typename": "User", + "login": "v0lumehi" + } + }, + { + "author": { + "__typename": "User", + "login": "seedds" + } + }, + { + "author": { + "__typename": "User", + "login": "v0lumehi" + } + } + ] + } + }, + { + "number": 542, + "title": "Virtual amiibo (nfp) on the Mac host", + "url": "https://github.com/seedds/nxnative/issues/542", + "createdAt": "2026-10-28T10:09:19Z", + "author": { + "login": "peterkmet" + }, + "labels": { + "nodes": [] + }, + "comments": { + "totalCount": 1, + "nodes": [ + { + "author": { + "__typename": "User", + "login": "peterkmet" + } + } + ] + } + }, + { + "number": 635, + "title": "Patch design: bind every patch as loosely as what it depends on (host, sdk, engine, game, build)", + "url": "https://github.com/seedds/nxnative/issues/635", + "createdAt": "2026-10-29T14:18:22Z", + "author": { + "login": "seedds" + }, + "labels": { + "nodes": [ + { + "name": "enhancement" + } + ] + }, + "comments": { + "totalCount": 3, + "nodes": [ + { + "author": { + "__typename": "User", + "login": "v0lumehi" + } + }, + { + "author": { + "__typename": "User", + "login": "seedds" + } + }, + { + "author": { + "__typename": "User", + "login": "seedds" + } + } + ] + } + } + ] +} diff --git a/dm/tools/triage_report.py b/dm/tools/triage_report.py new file mode 100644 index 0000000000..3ca6215293 --- /dev/null +++ b/dm/tools/triage_report.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""A triage report on the open pull requests and issues of a GitHub repository, as Markdown (.github/workflows/triage-report.yml puts it in the job summary). +Fixed rules over the GitHub API and git, no judgement: it lists, and never closes, labels or comments. docs/development.md (Triage report) has the rules and their limits. + + triage_report.py [--repo OWNER/NAME] [--git DIR] [--main BRANCH] [--fetch REMOTE] [--limit N] [--save FILE | --load FILE] + +The API part (open PRs and issues, a few GraphQL queries) needs a token in GITHUB_TOKEN or GH_TOKEN. The git part needs a clone with main's history and the head of +every open PR as refs/triage/pull/: --fetch REMOTE fetches them from REMOTE's refs/pull//head. Nothing from a pull request is checked out or run: git merges +trees in its object store (merge-tree) and reads commit messages.""" +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.request +from datetime import datetime, timezone + +GRAPHQL = 'https://api.github.com/graphql' +PRS = '''query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 50, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) { pageInfo { hasNextPage endCursor } + nodes { number title url createdAt isDraft baseRefName author { login } body closingIssuesReferences(first: 10) { nodes { number } } } } } }''' +ISSUES = '''query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { + issues(states: OPEN, first: 100, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) { pageInfo { hasNextPage endCursor } + nodes { number title url createdAt author { login } labels(first: 10) { nodes { name } } comments(first: 30) { totalCount nodes { author { __typename login } } } } } } }''' + +PR_REF = 'refs/triage/pull/%d' +NAMED = re.compile(r'\bPRs?\s*#\d+(?:\s*(?:,|and|&)\s*#\d+)*') # "PR #709", "(PR #667, #681)": how a commit on main names the pull request it takes in +LINK_WORDS = r'close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references|relate[sd]\s+to|see|issue' +PROFILE = re.compile(r'dm/games/([0-9A-Fa-f]{16})/game\.tsv$') +TITLE_STOP = {'the', 'and', 'for', 'with', 'from', 'into', 'when', 'not', 'are', 'its', 'that', 'this', 'add', 'adds', 'fix', 'fixes', 'support', 'game'} +SIMILAR_TITLE, SIMILAR_FILES = 0.3, 0.15 # likely duplicates by content: Jaccard index of the title words, and of the changed files, at least these + + +# ---- GitHub API ---- + +def graphql(query, variables, token): + req = urllib.request.Request(GRAPHQL, json.dumps({'query': query, 'variables': variables}).encode(), + {'Authorization': 'bearer ' + token, 'Content-Type': 'application/json', 'User-Agent': 'triage_report.py'}) + with urllib.request.urlopen(req, timeout=120) as r: + out = json.load(r) + if out.get('errors'): + sys.exit('GraphQL: ' + json.dumps(out['errors'])[:500]) + return out['data'] + + +def fetch_api(repo, token): + """Every open PR and issue, oldest first: 4 queries for 200 PRs and 150 issues.""" + owner, name = repo.split('/') + out = {'repo': repo} + for key, field, query in (('prs', 'pullRequests', PRS), ('issues', 'issues', ISSUES)): + out[key], after = [], None + while True: + conn = graphql(query, {'owner': owner, 'name': name, 'after': after}, token)['repository'][field] + out[key] += conn['nodes'] + if not conn['pageInfo']['hasNextPage']: + break + after = conn['pageInfo']['endCursor'] + return out + + +def login(node): + return (node.get('author') or {}).get('login') or 'ghost' + + +def linked_issues(pr, repo, pr_numbers): + """Issues a PR names: those GitHub links (closing keywords, the sidebar) and `#N` or an issue URL after Part of, Refs, See, Issue, Related to and the closing + keywords, in its title or body (HTML comments left out). Numbers of open PRs are left out.""" + text = re.sub(r'', '', (pr.get('title') or '') + '\n' + (pr.get('body') or ''), flags=re.S) + rx = r'\b(?:%s)\b:?\s+(?:#|https://github\.com/%s/issues/)(\d+)\b' % (LINK_WORDS, re.escape(repo)) + found = {int(n) for n in re.findall(rx, text, re.I)} | {n['number'] for n in pr['closingIssuesReferences']['nodes']} + return sorted(found - pr_numbers) + + +def replied(issue): + """A comment from someone other than the issue's author and other than a bot.""" + c = issue['comments'] + if c['totalCount'] > len(c['nodes']): + return True + return any(n['author'] is None or (n['author']['__typename'] != 'Bot' and n['author']['login'] != login(issue)) for n in c['nodes']) + + +# ---- git ---- + +def git(repo, *args, ok=(0,)): + r = subprocess.run(['git', '-C', repo, *args], capture_output=True, text=True) + if r.returncode not in ok: + raise RuntimeError('git %s: %s' % (args[0], r.stderr.strip()[:300])) + return r + + +def fetch_heads(repo, remote, numbers): + for i in range(0, len(numbers), 100): + git(repo, 'fetch', '--no-tags', '--quiet', remote, *['+refs/pull/%d/head:%s' % (n, PR_REF % n) for n in numbers[i:i + 100]]) + + +def merge(repo, base, head): + """(clean, tree, conflicting paths) of merging head into base, in the object store only.""" + r = git(repo, 'merge-tree', '--write-tree', '--name-only', '--no-messages', base, head, ok=(0, 1)) + lines = [x for x in r.stdout.splitlines() if x] + return r.returncode == 0, lines[0], lines[1:] + + +def commit_date(repo, rev): + return git(repo, 'show', '-s', '--format=%cI', rev).stdout.strip() + + +def first_conflict(repo, merge_base, main, head, paths): + """The first commit on main after the merge base that head does not merge cleanly with, by bisection over main's commits that touch the PR's files. It assumes + that a conflict stays once it appears; None when that does not hold.""" + spec = ['--', *paths] if 0 < len(paths) <= 500 else [] + commits = git(repo, 'rev-list', '--first-parent', '--reverse', '%s..%s' % (merge_base, main), *spec).stdout.split() + if not commits: + return None + lo, hi = 0, len(commits) - 1 + while lo < hi: + mid = (lo + hi) // 2 + if merge(repo, commits[mid], head)[0]: + lo = mid + 1 + else: + hi = mid + if merge(repo, commits[lo], head)[0]: + return None + return commits[lo], commit_date(repo, commits[lo]) + + +def analyse(repo, main, main_tree, number): + """What merging PR into main does: state no-op, clean or conflict (with the paths and since when), or missing / unrelated when git cannot tell.""" + head = PR_REF % number + if git(repo, 'rev-parse', '-q', '--verify', head + '^{commit}', ok=(0, 1)).returncode: + return {'state': 'missing', 'files': {}} + r = git(repo, 'merge-base', main, head, ok=(0, 1)) + if r.returncode: + return {'state': 'unrelated', 'files': {}} + mb = r.stdout.strip() + diff = git(repo, 'diff', '--name-status', '--no-renames', '-z', mb, head).stdout.split('\0') + files = {diff[i + 1]: diff[i] for i in range(0, len(diff) - 1, 2)} + clean, tree, conflicts = merge(repo, main, head) + if clean: + return {'state': 'no-op' if tree == main_tree else 'clean', 'files': files} + return {'state': 'conflict', 'files': files, 'conflicts': conflicts, 'since': first_conflict(repo, mb, main, head, list(files))} + + +def named_on_main(repo, main, since): + """PR number → [(commit, date, 'subject' or 'body')], oldest first: commits on main since `since` whose message names the PR as "PR #N".""" + out = {} + log = git(repo, 'log', '--first-parent', '--reverse', '--since=' + since, '--format=%H%x1f%cI%x1f%s%x1f%b%x1e', main).stdout + for rec in log.split('\x1e'): + if rec.strip(): + sha, date, subject, body = rec.strip('\n').split('\x1f', 3) + for where, text in (('subject', subject), ('body', body)): + for n in {int(n) for m in NAMED.finditer(text) for n in re.findall(r'\d+', m.group(0))}: + if not any(c[0] == sha for c in out.get(n, [])): + out.setdefault(n, []).append((sha, date, where)) + return out + + +def main_profiles(repo, main): + return {p.rsplit('/', 1)[-1].upper() for p in git(repo, 'ls-tree', '--name-only', main, 'dm/games/').stdout.split()} + + +def analyse_git(data, repo, main): + """Per PR number: the merge analysis, plus `named` (main's commits that name it, made after it was opened).""" + main_tree = git(repo, 'rev-parse', main + '^{tree}').stdout.strip() + prs = data['prs'] + named = named_on_main(repo, main, min((p['createdAt'] for p in prs), default='1970-01-01T00:00:00Z')) + out = {} + for p in prs: + a = analyse(repo, main, main_tree, p['number']) if p['baseRefName'] == main else {'state': 'other base', 'files': {}} + a['named'] = [c for c in named.get(p['number'], []) if when(c[1]) >= when(p['createdAt'])] + out[p['number']] = a + return {'main': git(repo, 'rev-parse', main).stdout.strip(), 'profiles': sorted(main_profiles(repo, main)), 'prs': out} + + +# ---- rules ---- + +def when(iso): + return datetime.fromisoformat(iso.replace('Z', '+00:00')) + + +def app_id(title_id): + """A title ID's application: an update (+0x800) and add-on content (+0x1000 + n) share the base game's.""" + return '%016X' % (int(title_id, 16) & ~0x1FFF) + + +def words(title): + return {w for w in re.findall(r'[a-z0-9]+', title.lower()) if len(w) > 2 and w not in TITLE_STOP} + + +def jaccard(a, b): + return len(a & b) / len(a | b) if a and b else 0.0 + + +def duplicates(prs, analysis, profiles): + """Groups of PRs: those adding a profile (game.tsv) for the same application, or for one main already has; and PRs whose title words and changed files overlap + by at least SIMILAR_TITLE and SIMILAR_FILES (Jaccard index), joined transitively.""" + main_apps = {app_id(t) for t in profiles if re.fullmatch(r'[0-9A-F]{16}', t)} + by_app = {} + for p in prs: + for path, status in analysis[p['number']]['files'].items(): + m = PROFILE.match(path) + if m and status == 'A' and p not in by_app.setdefault(app_id(m.group(1)), []): + by_app[app_id(m.group(1))].append(p) + groups = [{'kind': 'profile', 'key': t, 'in_main': t in main_apps, 'prs': ps} for t, ps in by_app.items() if len(ps) > 1 or t in main_apps] + parent = {p['number']: p['number'] for p in prs} + + def root(n): + while parent[n] != n: + n = parent[n] + return n + for i, a in enumerate(prs): + fa, wa = set(analysis[a['number']]['files']), words(a['title']) + for b in prs[i + 1:]: + fb = set(analysis[b['number']]['files']) + if jaccard(wa, words(b['title'])) >= SIMILAR_TITLE and jaccard(fa, fb) >= SIMILAR_FILES: + parent[root(b['number'])] = root(a['number']) + sets = {} + for p in prs: + sets.setdefault(root(p['number']), []).append(p) + in_profile_groups = [{q['number'] for q in g['prs']} for g in groups] + for ps in sets.values(): + if len(ps) > 1 and {q['number'] for q in ps} not in in_profile_groups: + common = set.intersection(*(set(analysis[q['number']]['files']) for q in ps)) + groups.append({'kind': 'files', 'key': sorted(common), 'in_main': False, 'prs': ps}) + for g in groups: + g['prs'].sort(key=lambda q: q['createdAt']) + return sorted(groups, key=lambda g: g['prs'][0]['createdAt']) + + +def triage(data, git_data): + """The report's rows, by rule.""" + repo, analysis = data['repo'], git_data['prs'] + prs = sorted(data['prs'], key=lambda p: p['createdAt']) + numbers = {p['number'] for p in prs} + links = {p['number']: linked_issues(p, repo, numbers) for p in prs} + in_main = [p for p in prs if analysis[p['number']]['state'] == 'no-op' or analysis[p['number']]['named']] + done = {p['number'] for p in in_main} + rest = [p for p in prs if p['number'] not in done] + conflicts = sorted((p for p in rest if analysis[p['number']]['state'] == 'conflict'), + key=lambda p: (analysis[p['number']]['since'] or ('', '9999'))[1]) + naming = {} + for p in rest: + for i in links[p['number']]: + naming.setdefault(i, []).append(p['number']) + issues = sorted(data['issues'], key=lambda i: i['createdAt']) + return { + 'in_main': in_main, 'duplicates': duplicates(rest, analysis, git_data['profiles']), 'conflicts': conflicts, + 'unlinked': [p for p in rest if not links[p['number']]], 'links': links, + 'untriaged': [i for i in issues if not replied(i) and not i['labels']['nodes']], 'naming': naming, + 'no_reply': sum(not replied(i) for i in issues), 'no_label': sum(not i['labels']['nodes'] for i in issues), + 'skipped': [p for p in rest if analysis[p['number']]['state'] in ('missing', 'unrelated', 'other base')], + } + + +# ---- Markdown ---- + +def cell(text, n=70): + text = ' '.join((text or '').split()).replace('|', '\\|').replace('@', '@\u200b') # no @mention in an issue body + return text if len(text) <= n else text[:n - 1] + '…' + + +def ref(item): + return '[#%d](%s)' % (item['number'], item['url']) + + +def title(p): + return ('draft: ' if p.get('isDraft') else '') + cell(p['title']) + + +def commit_link(repo, sha): + return '[`%s`](https://github.com/%s/commit/%s)' % (sha[:8], repo, sha) + + +def table(head, rows, limit, what): + out = ['| ' + ' | '.join(head) + ' |', '|' + '---|' * len(head)] + ['| ' + ' | '.join(r) + ' |' for r in rows[:limit or None]] + if limit and len(rows) > limit: + out.append('\n%d more %s not shown.' % (len(rows) - limit, what)) + return out if rows else ['None.'] + + +def report(data, git_data, t, now, limit=25): + repo, analysis = data['repo'], git_data['prs'] + day = lambda iso: iso[:10] + issue_refs = lambda ns: ', '.join('#%d' % n for n in ns) or '-' + drafts = sum(bool(p.get('isDraft')) for p in data['prs']) + in_dups = sum(len(g['prs']) for g in t['duplicates']) + out = [ + '# Triage report', '', + '%s, %d open pull requests (%d drafts) and %d open issues on %s, against `main` at %s. Fixed rules over the API and git, listed in ' + '[docs/development.md](https://github.com/%s/blob/main/docs/development.md#triage-report). The report never closes, labels or comments.' + % (now.strftime('%Y-%m-%d %H:%M UTC'), len(data['prs']), drafts, len(data['issues']), repo, commit_link(repo, git_data['main']), repo), '', + '| | |', '|---|---|', + '| Pull requests already in `main` | %d |' % len(t['in_main']), + '| Likely duplicates | %d groups, %d pull requests |' % (len(t['duplicates']), in_dups), + '| Pull requests that conflict with `main` | %d |' % len(t['conflicts']), + '| Pull requests without a linked issue | %d |' % len(t['unlinked']), + '| Issues with no reply and no label | %d (%d with no reply, %d with no label) |' % (len(t['untriaged']), t['no_reply'], t['no_label']), + '', '## Already in `main`, or named by a commit there', '', + 'Merging the PR into `main` changes nothing, or a commit on `main` made after the PR was opened names it as "PR #N". A commit may name a PR it replaces ' + 'or only takes part of: read it before closing. Oldest first.', ''] + rows = [] + for p in t['in_main']: + a = analysis[p['number']] + why = ['merges as a no-op'] if a['state'] == 'no-op' else [] + why += ['%s of %s (%s) names it' % (w, commit_link(repo, s), day(d)) for s, d, w in a['named'][:2]] + rows.append([ref(p), title(p), login(p), day(p['createdAt']), ', '.join(why), issue_refs(t['links'][p['number']])]) + out += table(['PR', 'Title', 'Author', 'Opened', 'Why', 'Issues'], rows, limit, 'pull requests') + out += ['', '## Likely duplicates', '', + 'Open PRs that add a profile for the same game (by title ID), or for one `main` already has. Also PRs whose title words overlap by at least %d%% and whose ' + 'changed files overlap by at least %d%%. Oldest PR first.' % (SIMILAR_TITLE * 100, SIMILAR_FILES * 100), ''] + rows = [] + for g in t['duplicates']: + if g['kind'] == 'profile': + what = 'profile `%s`%s' % (g['key'], ', already in `main`' if g['in_main'] else '') + else: + what = ', '.join('`%s`' % f for f in g['key'][:2]) + (' +%d' % (len(g['key']) - 2) if len(g['key']) > 2 else '') if g['key'] else 'similar titles and files' + rows.append([what, ', '.join('%s %s (%s, %s)' % (ref(p), cell(p['title'], 50), login(p), day(p['createdAt'])) for p in g['prs'])]) + out += table(['Same', 'Pull requests'], rows, limit, 'groups') + out += ['', '## Conflicts with `main`', '', + '"Since" is the first commit on `main` after the PR\'s merge base that the PR no longer merges cleanly with. A rebase moves it. Longest first.', ''] + rows = [] + for p in t['conflicts']: + a = analysis[p['number']] + since = '%s %s' % (day(a['since'][1]), commit_link(repo, a['since'][0])) if a['since'] else 'unknown' + files = ', '.join('`%s`' % f for f in a['conflicts'][:2]) + (' +%d' % (len(a['conflicts']) - 2) if len(a['conflicts']) > 2 else '') + rows.append([ref(p), title(p), login(p), since, files]) + out += table(['PR', 'Title', 'Author', 'Since', 'Conflicting files'], rows, limit, 'pull requests') + out += ['', '## Pull requests without a linked issue', '', + 'No issue linked on GitHub, and none named as "Fixes #N", "Part of #N", "Refs #N", "Issue #N" or similar in the title or body. Oldest first.', ''] + rows = [[ref(p), title(p), login(p), day(p['createdAt'])] for p in t['unlinked']] + out += table(['PR', 'Title', 'Author', 'Opened'], rows, limit, 'pull requests') + out += ['', '## Issues with no reply and no label', '', + 'No comment from anyone but the author or a bot, and no label. "Named by" lists open PRs that link the issue. Oldest first.', ''] + rows = [[ref(i), cell(i['title']), login(i), day(i['createdAt']), issue_refs(t['naming'].get(i['number'], []))] for i in t['untriaged']] + out += table(['Issue', 'Title', 'Author', 'Opened', 'Named by'], rows, limit, 'issues') + if t['skipped']: + out += ['', 'Not analysed by git (head not fetched, no common history with `main`, or another base branch): %s.' + % ', '.join('%s (%s)' % (ref(p), analysis[p['number']]['state']) for p in t['skipped'])] + return '\n'.join(out) + '\n' + + +def main(argv=None): + ap = argparse.ArgumentParser(description='Triage report on open pull requests and issues (Markdown on stdout)') + ap.add_argument('--repo', default=os.environ.get('GITHUB_REPOSITORY', 'seedds/nxnative'), help='OWNER/NAME (default: $GITHUB_REPOSITORY or seedds/nxnative)') + ap.add_argument('--git', default='.', help='a clone with main and the PR heads (default: the current folder)') + ap.add_argument('--main', default='main', help='the branch PRs are checked against (default: main)') + ap.add_argument('--fetch', metavar='REMOTE', help="fetch every open PR's head from REMOTE into refs/triage/pull/ first") + ap.add_argument('--limit', type=int, default=25, help='rows per table, 0 for all (default: 25)') + ap.add_argument('--save', metavar='FILE', help="write the API's answer here (JSON)") + ap.add_argument('--load', metavar='FILE', help="read the API's answer from FILE instead of asking the API") + a = ap.parse_args(argv) + if a.load: + with open(a.load, encoding='utf-8') as f: + data = json.load(f) + else: + token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN') + if not token: + sys.exit('set GITHUB_TOKEN or GH_TOKEN') + data = fetch_api(a.repo, token) + if a.save: + with open(a.save, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=1) + if a.fetch: + fetch_heads(a.git, a.fetch, [p['number'] for p in data['prs']]) + git_data = analyse_git(data, a.git, a.main) + sys.stdout.write(report(data, git_data, triage(data, git_data), datetime.now(timezone.utc), a.limit)) + + +if __name__ == '__main__': + main() diff --git a/docs/development.md b/docs/development.md index db9de48021..b3513587ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -308,6 +308,42 @@ Raising `LLVM_VERSION` takes a new baseline in the same change (`tidy --rebaseline`; `dm/tools/tests/test_lint_c.py` checks that the two name the same release). +### Triage report + +`.github/workflows/triage-report.yml` runs `dm/tools/triage_report.py` every +day and puts its report in the job summary. The report lists open pull requests +and issues by fixed rules over the GitHub API and git. It never closes, labels +or comments on anything. The maintainer decides what to do with each row. + +- Already in `main`: merging the PR into `main` changes nothing, or a commit + on `main` made after the PR was opened names it as "PR #N". The second rule + also finds a PR that a commit replaces or only takes part of. Read the commit + before closing the PR. A change that landed reworked and unnamed is missed. +- Likely duplicates: open PRs that add `dm/games//game.tsv` for the + same game, or for a game `main` already has. An update or DLC title ID counts + as its base game. Also PRs whose title words overlap by at least 30% and + whose changed files overlap by at least 15%. +- Conflicts with `main`: `git merge-tree` reports a conflict. "Since" is the + first commit on `main` after the PR's merge base that the PR no longer merges + cleanly with, found by bisection over the commits that touch the PR's files. + The date is that commit's date. A rebase moves it. It shows "unknown" when the bisection does not hold. +- Pull requests without a linked issue: GitHub links none, and the title and + body name none after "Fixes", "Closes", "Resolves", "Part of", "Refs", + "Related to", "See" or "Issue". A reference to a closed PR counts as a link. +- Issues with no reply and no label: no comment from anyone but the author or + a bot. "Named by" lists the open PRs that link the issue. + +A PR in the first section shows in no other. The workflow checks out `main` +only. It fetches each open PR's head into the object store and never +checks out or runs it. With the repository variable `TRIAGE_REPORT_ISSUE` set to +an issue number, the workflow also replaces that issue's body with the report. +It edits that one issue and never comments. To run it locally, from a clone +whose `origin` is this repository: + +```sh +GH_TOKEN=$(gh auth token) python3 dm/tools/triage_report.py --fetch origin > report.md # --limit 0 lists every row +``` + ## The packing script in detail `scripts/build_ipa_from_nsp.py` (`-h` lists every option):