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):