diff --git a/README.md b/README.md index 62746c3..c75a94d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,19 @@ | Kilo Code | 19 | 2 | 1 | 0 | 2 | 0 | 0 | **24** | | HDCharts | 116 | 88 | 38 | 2 | 82 | 60 | 58 | **522** | +### πŸ“‹ Issues reported by @hdcodedev + +| Project | 🟑 Open | βœ… Fixed | Total Reported | +| :--- | :---: | :---: | :---: | +| Kilo Code | 6 | 3 | **9** | + # 2026 ## October | Status | Project | Tech Stack | Contribution | | :---: | :--- | :---: | :--- | +| 🟑 | | TypeScript | πŸ“‹ [Issue #15066: [Bug] Text typed in a new chat is lost after opening History](https://github.com/Kilo-Org/kilocode/issues/15066)
πŸ“‹ [Issue #15046: [Bug] kilo run silently drops piped output from a command that takes more than a few seconds](https://github.com/Kilo-Org/kilocode/issues/15046) β†’ [#15050](https://github.com/Kilo-Org/kilocode/pull/15050)
πŸ“‹ [Issue #15044: [Bug] Move to Worktree silently skips new files with non-ASCII names](https://github.com/Kilo-Org/kilocode/issues/15044) β†’ [#15045](https://github.com/Kilo-Org/kilocode/pull/15045)
πŸ“‹ [Issue #15040: [Bug] Review comment sent from the Changes view disappears while the sidebar shows History](https://github.com/Kilo-Org/kilocode/issues/15040) β†’ [#15041](https://github.com/Kilo-Org/kilocode/pull/15041)
πŸ“‹ [Issue #14949: [Bug] Holding Backspace deletes a worktree and its uncommitted files](https://github.com/Kilo-Org/kilocode/issues/14949) β†’ [#14982](https://github.com/Kilo-Org/kilocode/pull/14982)
πŸ“‹ [Issue #14948: [Bug] Move to Worktree sends a sidebar chat into another project's repository](https://github.com/Kilo-Org/kilocode/issues/14948) β†’ [#14983](https://github.com/Kilo-Org/kilocode/pull/14983) | | 🟒 | | TypeScript | πŸ› [#15045: fix(agent-manager): keep new files with non-ASCII names when moving a chat to a worktree](https://github.com/Kilo-Org/kilocode/pull/15045)
πŸ› [#15041: fix(vscode): send review comments from the Changes view while History is open](https://github.com/Kilo-Org/kilocode/pull/15041) | ## September @@ -18,6 +25,7 @@ | Status | Project | Tech Stack | Contribution | | :---: | :--- | :---: | :--- | | 🟣 | | TypeScript | πŸ› [#14590: fix(vscode): keep collapsed pastes when browsing prompt history](https://github.com/Kilo-Org/kilocode/pull/14590)
πŸ› [#14536: fix(vscode): keep chat tabs for older History sessions open](https://github.com/Kilo-Org/kilocode/pull/14536)
πŸ› [#14502: fix(cli): keep sub-agents of a chat in use during session cleanup](https://github.com/Kilo-Org/kilocode/pull/14502)
πŸ› [#14491: fix(vscode): run editor actions when the sidebar shows History](https://github.com/Kilo-Org/kilocode/pull/14491)
πŸ› [#14333: fix(vscode): keep the right backing when a paste chip is deleted](https://github.com/Kilo-Org/kilocode/pull/14333)
πŸ› [#14324: fix(vscode): anchor markdown review comments to the right list item](https://github.com/Kilo-Org/kilocode/pull/14324)
πŸ› [#14319: fix: report git errors when a revert or redo fails ](https://github.com/Kilo-Org/kilocode/pull/14319)
πŸ› [#14236: fix(cli): recover shared board reads from invalid cursors](https://github.com/Kilo-Org/kilocode/pull/14236) | +| βœ… | | TypeScript | πŸ“‹ [Issue #14589: [Bug] Browsing prompt history turns a collapsed paste into literal `[Pasted ~N lines]` text](https://github.com/Kilo-Org/kilocode/issues/14589) β†’ [#14590](https://github.com/Kilo-Org/kilocode/pull/14590)
πŸ“‹ [Issue #14521: [Bug] Chat opened from History's "Load more" closes after reopening History or reloading](https://github.com/Kilo-Org/kilocode/issues/14521) β†’ [#14536](https://github.com/Kilo-Org/kilocode/pull/14536)
πŸ“‹ [Issue #14500: [Bug] Session cleanup deletes sub-agents of a chat that is still in use](https://github.com/Kilo-Org/kilocode/issues/14500) β†’ [#14502](https://github.com/Kilo-Org/kilocode/pull/14502) | ## August @@ -85,4 +93,7 @@ - 🟒 **Open**: The pull request is currently open and active. - 🟣 **Merged**: The pull request has been merged into the codebase. - πŸ”΄ **Closed**: The pull request was closed without being merged. +- 🟑 **Open issue**: The reported issue is still open. +- βœ… **Fixed issue**: The reported issue was closed by a merged pull request. +- ⚫ **Closed issue**: The reported issue was closed without a merged pull request. diff --git a/README_DATA.json b/README_DATA.json index 646ba20..553f925 100644 --- a/README_DATA.json +++ b/README_DATA.json @@ -73,6 +73,33 @@ } ] }, + "issue_stats": { + "columns": [ + { + "status": "OPEN", + "label": "Open", + "emoji": "🟑" + }, + { + "status": "FIXED", + "label": "Fixed", + "emoji": "βœ…" + } + ], + "projects": [ + { + "name": "Kilo Code", + "repo_url": "https://github.com/Kilo-Org/kilocode", + "logo_url": "https://github.com/Kilo-Org.png", + "total_reported": 9, + "statuses": { + "OPEN": 6, + "FIXED": 3 + } + } + ], + "title": "Issues reported by @hdcodedev" + }, "years": [ { "year": 2026, @@ -82,6 +109,54 @@ "month_name": "October", "rows": [ { + "kind": "issue", + "status": "OPEN", + "repo_name": "Kilo-Org/kilocode", + "repo_url": "https://github.com/Kilo-Org/kilocode", + "logo_url": "https://github.com/Kilo-Org.png", + "tech_stack": "TypeScript", + "contributions": [ + { + "number": 15066, + "title": "[Bug] Text typed in a new chat is lost after opening History", + "url": "https://github.com/Kilo-Org/kilocode/issues/15066", + "text": "Issue #15066: [Bug] Text typed in a new chat is lost after opening History" + }, + { + "number": 15046, + "title": "[Bug] kilo run silently drops piped output from a command that takes more than a few seconds", + "url": "https://github.com/Kilo-Org/kilocode/issues/15046", + "text": "Issue #15046: [Bug] kilo run silently drops piped output from a command that takes more than a few seconds" + }, + { + "number": 15044, + "title": "[Bug] Move to Worktree silently skips new files with non-ASCII names", + "url": "https://github.com/Kilo-Org/kilocode/issues/15044", + "text": "Issue #15044: [Bug] Move to Worktree silently skips new files with non-ASCII names" + }, + { + "number": 15040, + "title": "[Bug] Review comment sent from the Changes view disappears while the sidebar shows History", + "url": "https://github.com/Kilo-Org/kilocode/issues/15040", + "text": "Issue #15040: [Bug] Review comment sent from the Changes view disappears while the sidebar shows History" + }, + { + "number": 14949, + "title": "[Bug] Holding Backspace deletes a worktree and its uncommitted files", + "url": "https://github.com/Kilo-Org/kilocode/issues/14949", + "text": "Issue #14949: [Bug] Holding Backspace deletes a worktree and its uncommitted files" + }, + { + "number": 14948, + "title": "[Bug] Move to Worktree sends a sidebar chat into another project's repository", + "url": "https://github.com/Kilo-Org/kilocode/issues/14948", + "text": "Issue #14948: [Bug] Move to Worktree sends a sidebar chat into another project's repository" + } + ], + "contribution_text": "Issue #15066: [Bug] Text typed in a new chat is lost after opening History | Issue #15046: [Bug] kilo run silently drops piped output from a command that takes more than a few seconds | Issue #15044: [Bug] Move to Worktree silently skips new files with non-ASCII names | Issue #15040: [Bug] Review comment sent from the Changes view disappears while the sidebar shows History | Issue #14949: [Bug] Holding Backspace deletes a worktree and its uncommitted files | Issue #14948: [Bug] Move to Worktree sends a sidebar chat into another project's repository" + }, + { + "kind": "pr", "status": "OPEN", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -110,6 +185,7 @@ "month_name": "September", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -166,6 +242,35 @@ } ], "contribution_text": "#14590: fix(vscode): keep collapsed pastes when browsing prompt history | #14536: fix(vscode): keep chat tabs for older History sessions open | #14502: fix(cli): keep sub-agents of a chat in use during session cleanup | #14491: fix(vscode): run editor actions when the sidebar shows History | #14333: fix(vscode): keep the right backing when a paste chip is deleted | #14324: fix(vscode): anchor markdown review comments to the right list item | #14319: fix: report git errors when a revert or redo fails | #14236: fix(cli): recover shared board reads from invalid cursors" + }, + { + "kind": "issue", + "status": "FIXED", + "repo_name": "Kilo-Org/kilocode", + "repo_url": "https://github.com/Kilo-Org/kilocode", + "logo_url": "https://github.com/Kilo-Org.png", + "tech_stack": "TypeScript", + "contributions": [ + { + "number": 14589, + "title": "[Bug] Browsing prompt history turns a collapsed paste into literal `[Pasted ~N lines]` text", + "url": "https://github.com/Kilo-Org/kilocode/issues/14589", + "text": "Issue #14589: [Bug] Browsing prompt history turns a collapsed paste into literal `[Pasted ~N lines]` text" + }, + { + "number": 14521, + "title": "[Bug] Chat opened from History's \"Load more\" closes after reopening History or reloading", + "url": "https://github.com/Kilo-Org/kilocode/issues/14521", + "text": "Issue #14521: [Bug] Chat opened from History's \"Load more\" closes after reopening History or reloading" + }, + { + "number": 14500, + "title": "[Bug] Session cleanup deletes sub-agents of a chat that is still in use", + "url": "https://github.com/Kilo-Org/kilocode/issues/14500", + "text": "Issue #14500: [Bug] Session cleanup deletes sub-agents of a chat that is still in use" + } + ], + "contribution_text": "Issue #14589: [Bug] Browsing prompt history turns a collapsed paste into literal `[Pasted ~N lines]` text | Issue #14521: [Bug] Chat opened from History's \"Load more\" closes after reopening History or reloading | Issue #14500: [Bug] Session cleanup deletes sub-agents of a chat that is still in use" } ] }, @@ -174,6 +279,7 @@ "month_name": "August", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -196,6 +302,7 @@ "month_name": "July", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -236,6 +343,7 @@ "contribution_text": "#12608: chore(jetbrains): centralize test dependency versions | #12607: chore(jetbrains): remove unused compose compiler plugin | #12486: fix(vscode): ignore Enter when no list result is highlighted | #12462: feat(vscode): add searchable session tab switcher | #12370: feat(vscode): support deleting queued chat messages" }, { + "kind": "pr", "status": "MERGED", "repo_name": "home-assistant/android", "repo_url": "https://github.com/home-assistant/android", @@ -264,6 +372,7 @@ "month_name": "June", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "home-assistant/android", "repo_url": "https://github.com/home-assistant/android", @@ -286,6 +395,7 @@ "month_name": "May", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode-legacy", "repo_url": "https://github.com/Kilo-Org/kilocode-legacy", @@ -308,6 +418,7 @@ "month_name": "April", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -336,6 +447,7 @@ "month_name": "March", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -370,6 +482,7 @@ "month_name": "February", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -392,6 +505,7 @@ "month_name": "January", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "ImranR98/Obtainium", "repo_url": "https://github.com/ImranR98/Obtainium", @@ -414,6 +528,7 @@ "contribution_text": "#2742: feat: Detect GitHub repo renames | #2728: fix: ensure apps with unknown release dates sort to the bottom in descending order" }, { + "kind": "pr", "status": "MERGED", "repo_name": "home-assistant/android", "repo_url": "https://github.com/home-assistant/android", @@ -442,6 +557,7 @@ "contribution_text": "#6287: Refactor: reuse modifier and text styles in connectivity checks section | #6247: Add MIUI-specific DiskReadViolation ignore rules for FontSettings and… | #6237: Add connectivity check feature for troubleshooting server connections " }, { + "kind": "pr", "status": "MERGED", "repo_name": "Kilo-Org/kilocode", "repo_url": "https://github.com/Kilo-Org/kilocode", @@ -470,6 +586,7 @@ "contribution_text": "#5167: fix(extension): allow killing detached processes | #5162: fix: preserve images when editing messages with checkpoint | #5150: fix(core): implement deduplication of tool_use blocks to prevent API errors and add tests" }, { + "kind": "pr", "status": "MERGED", "repo_name": "metabrainz/listenbrainz-android", "repo_url": "https://github.com/metabrainz/listenbrainz-android", @@ -497,6 +614,7 @@ "month_name": "December", "rows": [ { + "kind": "pr", "status": "MERGED", "repo_name": "openai/codex", "repo_url": "https://github.com/openai/codex", diff --git a/__test__/test_fetch.py b/__test__/test_fetch.py index 23d055b..72bacf1 100644 --- a/__test__/test_fetch.py +++ b/__test__/test_fetch.py @@ -22,8 +22,25 @@ def pr_node(number, repo, state='MERGED', is_draft=False, private=False, } -def run_fetch(config, nodes): +def issue_node(number, repo, state='OPEN', linked=(), private=False, + created='2026-01-01T00:00:00Z'): + return { + 'number': number, + 'title': f'Issue {number}', + 'url': f'https://github.com/{repo}/issues/{number}', + 'state': state, + 'createdAt': created, + 'repository': {'nameWithOwner': repo, 'isPrivate': private}, + 'closedByPullRequestsReferences': {'nodes': [ + {'number': n, 'url': f'https://github.com/{repo}/pull/{n}', 'state': pr_state} + for n, pr_state in linked + ]}, + } + + +def run_fetch(config, nodes, issues=()): with patch('src.model.fetch_authored_prs', return_value=nodes), \ + patch('src.model.fetch_authored_issues', return_value=list(issues)), \ patch('src.model.get_repo_details', return_value={'description': '', 'tech_stack': 'Python'}): with contextlib.redirect_stdout(io.StringIO()): return model.fetch_from_config(config) @@ -101,6 +118,50 @@ def test_repo_matching_is_case_insensitive(self): self.assertEqual(rows[0]['repository']['nameWithOwner'], 'Kilo-Org/kilocode') +class TestFetchIssues(unittest.TestCase): + def config(self, statuses=()): + return {"repos": [], "statuses": [], "featured_projects": [], "username": None, + "issues": {"repos": ["kilo-org/kilocode"], "statuses": list(statuses)}} + + def fetched(self, config, issues): + data, _ = run_fetch(config, [], issues) + return [(i['number'], i['status']) for i in data[2026][(1, 'January')]] + + def test_status_comes_from_state_and_linked_prs(self): + issues = [ + issue_node(1, 'Kilo-Org/kilocode', 'OPEN', [(9, 'OPEN')]), + issue_node(2, 'Kilo-Org/kilocode', 'CLOSED', [(8, 'CLOSED'), (7, 'MERGED')]), + issue_node(3, 'Kilo-Org/kilocode', 'CLOSED', [(6, 'CLOSED')]), + ] + self.assertEqual(self.fetched(self.config(), issues), + [(1, 'OPEN'), (2, 'FIXED'), (3, 'CLOSED')]) + + def test_statuses_filter_issues(self): + issues = [ + issue_node(1, 'Kilo-Org/kilocode', 'OPEN'), + issue_node(2, 'Kilo-Org/kilocode', 'CLOSED', [(7, 'MERGED')]), + issue_node(3, 'Kilo-Org/kilocode', 'CLOSED'), + ] + self.assertEqual(self.fetched(self.config(['OPEN', 'FIXED']), issues), + [(1, 'OPEN'), (2, 'FIXED')]) + + def test_untracked_and_private_issues_are_dropped(self): + issues = [ + issue_node(1, 'Kilo-Org/kilocode'), + issue_node(2, 'someone/else'), + issue_node(3, 'kilo-org/kilocode', private=True), + ] + self.assertEqual(self.fetched(self.config(), issues), [(1, 'OPEN')]) + + def test_issues_not_fetched_without_repos(self): + config = {"repos": ["o/r"], "statuses": [], "featured_projects": [], "username": None} + with patch('src.model.fetch_authored_prs', return_value=[]), \ + patch('src.model.fetch_authored_issues') as fetch_issues, \ + contextlib.redirect_stdout(io.StringIO()): + model.fetch_from_config(config) + fetch_issues.assert_not_called() + + class TestQueryStates(unittest.TestCase): def test_draft_widens_to_open(self): self.assertEqual(model.query_states({'DRAFT'}), {'OPEN'}) diff --git a/__test__/test_model.py b/__test__/test_model.py index 10d3310..a94096c 100644 --- a/__test__/test_model.py +++ b/__test__/test_model.py @@ -38,7 +38,10 @@ def test_pr_emoji_in_contributions(self): self.assertEqual(repo_b['contributions'][0]['emoji'], config.CONVENTIONAL_EMOJI['feat']) def test_status_legend_present(self): - self.assertEqual(len(self.m['status_legend']), len(config.STATUS_LEGEND)) + self.assertEqual( + len(self.m['status_legend']), + len(config.STATUS_LEGEND) + len(config.ISSUE_STATUS_LEGEND), + ) def test_prs_sorted_newest_first_within_group(self): url_data = [ @@ -150,5 +153,65 @@ def test_group_sums_its_repos_into_one_row(self): self.assertEqual(project['total_merged'], 4) +def mock_issue(number, repo, status, date, linked=()): + """An issue as fetch_from_config leaves it; ``linked`` is (number, state) pairs.""" + issue = mock_pr(f"[Bug] issue {number}", f"https://github.com/{repo}/issues/{number}", + number, 'OPEN' if status == 'OPEN' else 'CLOSED', repo, date) + issue['kind'] = 'issue' + issue['status'] = status + issue['closedByPullRequestsReferences'] = {'nodes': [ + {'number': n, 'url': f"https://github.com/{repo}/pull/{n}", 'state': state} + for n, state in linked + ]} + return issue + + +class TestIssues(unittest.TestCase): + def setUp(self): + self.data, self.featured = grouped_mock([ + mock_pr("fix: a", "u1", 10, "MERGED", "Org/proj", "2026-01-03T00:00:00Z"), + mock_issue(1, "Org/proj", "FIXED", "2026-01-01T00:00:00Z", [(10, 'MERGED')]), + mock_issue(2, "Org/proj", "OPEN", "2026-01-02T00:00:00Z", [(11, 'OPEN'), (12, 'CLOSED')]), + mock_issue(3, "Org/proj", "OPEN", "2025-12-02T00:00:00Z"), + ]) + + def test_issues_get_their_own_rows(self): + m = model.build_readme_model(self.data, self.featured) + rows = m['years'][0]['months'][0]['rows'] + kinds = sorted((r['kind'], r['status'], r['status_icon']) for r in rows) + self.assertEqual(kinds, [ + ('issue', 'FIXED', config.ISSUE_STATUS_ICONS['FIXED']), + ('issue', 'OPEN', config.ISSUE_STATUS_ICONS['OPEN']), + ('pr', 'MERGED', config.STATUS_ICONS['MERGED']), + ]) + + def test_issue_links_open_and_merged_prs_only(self): + m = model.build_readme_model(self.data, self.featured) + row = next(r for r in m['years'][0]['months'][0]['rows'] if r['status'] == 'OPEN') + self.assertEqual( + row['contribution_markdown'], + f"{config.ISSUE_EMOJI} [Issue #2: [Bug] issue 2](https://github.com/Org/proj/issues/2)" + " β†’ [#11](https://github.com/Org/proj/pull/11)", + ) + + def test_issue_stats_count_by_status(self): + m = model.build_readme_model(self.data, self.featured, ['org/proj']) + [project] = m['issue_stats']['projects'] + self.assertEqual(project['statuses'], {'OPEN': 2, 'FIXED': 1}) + self.assertEqual(project['total_reported'], 3) + self.assertEqual([c['status'] for c in m['issue_stats']['columns']], ['OPEN', 'FIXED']) + + def test_issue_stats_title_names_the_reporter(self): + m = model.build_readme_model(self.data, self.featured, ['org/proj'], username='hdcodedev') + self.assertEqual(m['issue_stats']['title'], 'Issues reported by @hdcodedev') + m = model.build_readme_model(self.data, self.featured, ['org/proj']) + self.assertEqual(m['issue_stats']['title'], 'Reported issues') + + def test_issues_stay_out_of_pr_stats(self): + m = model.build_readme_model(self.data, self.featured, ['org/proj']) + [project] = m['stats']['projects'] + self.assertEqual(project['total_merged'], 1) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/oss-contributions.json b/oss-contributions.json index 250b86d..f9a8d49 100644 --- a/oss-contributions.json +++ b/oss-contributions.json @@ -13,6 +13,16 @@ "OPEN" ], "featured_projects": [], + "issues": { + "repos": [ + "Kilo-Org/kilocode", + "kilo-org/kilocode-legacy" + ], + "statuses": [ + "OPEN", + "FIXED" + ] + }, "stats_projects": [ { "name": "Kilo Code", diff --git a/src/cli.py b/src/cli.py index 6d51d78..590b098 100644 --- a/src/cli.py +++ b/src/cli.py @@ -26,7 +26,8 @@ def main(): data, featured_repos = fetch_from_config(config) model = build_readme_model( - data, featured_repos, config['stats_projects'], config['stats_only_repos'] + data, featured_repos, config['stats_projects'], config['stats_only_repos'], + config['username'], ) if _count_contributions(model) == 0 and not model['stats']['projects']: diff --git a/src/config.py b/src/config.py index 7cd9e9f..0a642ba 100644 --- a/src/config.py +++ b/src/config.py @@ -56,6 +56,35 @@ def custom_logo(repo_name): for label, desc in [_STATUS_LEGEND_DESC.get(status, (status.title(), ''))] ] +# Reported issues use their own icons so an issue row never reads as a PR. +# FIXED is not a GitHub state: it is a closed issue with a merged linked PR. +ISSUE_STATUS_ICONS = { + 'OPEN': '🟑', + 'FIXED': 'βœ…', + 'CLOSED': '⚫', +} + +ISSUE_STATUS_LABELS = { + 'OPEN': 'Open', + 'FIXED': 'Fixed', + 'CLOSED': 'Closed', +} + +_ISSUE_LEGEND_DESC = { + 'OPEN': 'The reported issue is still open.', + 'FIXED': 'The reported issue was closed by a merged pull request.', + 'CLOSED': 'The reported issue was closed without a merged pull request.', +} + +ISSUE_STATUS_LEGEND = [ + {'status': status, 'icon': icon, 'label': f"{ISSUE_STATUS_LABELS[status]} issue", + 'description': _ISSUE_LEGEND_DESC[status]} + for status, icon in ISSUE_STATUS_ICONS.items() +] + +# Leads every issue item in the monthly tables. +ISSUE_EMOJI = 'πŸ“‹' + # Conventional-commit prefix -> emoji. CONVENTIONAL_EMOJI = { 'feat': '✨', @@ -124,6 +153,12 @@ def load_config(config_file=None): if isinstance(project, dict) and project.get("stats_only") for repo in project.get("repos", []) ], + # Repos whose issues reported by the user are listed and counted, and + # which issue statuses (OPEN, FIXED, CLOSED) to keep; empty keeps all. + "issues": { + "repos": data.get("issues", {}).get("repos", []), + "statuses": [status.upper() for status in data.get("issues", {}).get("statuses", [])], + }, # None means "whoever the gh token belongs to". "username": data.get("username"), } diff --git a/src/github.py b/src/github.py index d91f3e8..0c047d6 100644 --- a/src/github.py +++ b/src/github.py @@ -36,6 +36,30 @@ """ +# Authored-issue query, scoped to one author the same way as PRs. Linked PRs +# (closed ones included) tell a fixed issue apart from one closed otherwise. +_ISSUE_QUERY_TEMPLATE = """ +query($endCursor: String) { + __AUTHOR__ { + issues(first: __PAGE_SIZE__, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + url + state + createdAt + repository { nameWithOwner isPrivate } + closedByPullRequestsReferences(first: 10, includeClosedPrs: true) { + nodes { number url state } + } + } + } + } +} +""" + + def _gh(args): """Run a ``gh`` command and return stdout, surfacing gh's own error text. @@ -100,6 +124,28 @@ def fetch_authored_prs(username: Optional[str] = None, ] +def build_issue_query(username: Optional[str] = None) -> str: + """Build the authored-issue query for ``username`` (or the token owner).""" + author = f'user(login: "{username}")' if username else "viewer" + return ( + _ISSUE_QUERY_TEMPLATE + .replace("__AUTHOR__", author) + .replace("__PAGE_SIZE__", str(PR_PAGE_SIZE)) + ) + + +def fetch_authored_issues(username: Optional[str] = None) -> List[Dict[str, Any]]: + """Fetch every issue opened by ``username``, newest first.""" + stdout = _gh(["gh", "api", "graphql", "--paginate", + "-f", f"query={build_issue_query(username)}"]) + author_key = "user" if username else "viewer" + return [ + node + for page in parse_paginated_json(stdout) + for node in page["data"][author_key]["issues"]["nodes"] + ] + + def get_repo_details(repo_name: str) -> Dict[str, str]: if repo_name in repo_cache: return repo_cache[repo_name] diff --git a/src/model.py b/src/model.py index 44f6884..50a7280 100644 --- a/src/model.py +++ b/src/model.py @@ -10,13 +10,17 @@ CONVENTIONAL_EMOJI, DEFAULT_PR_EMOJI, DEFAULT_STATUS_ICON, + ISSUE_EMOJI, + ISSUE_STATUS_ICONS, + ISSUE_STATUS_LABELS, + ISSUE_STATUS_LEGEND, KEYWORD_EMOJI, OTHER_CATEGORY, STATUS_ICONS, STATUS_LEGEND, custom_logo, ) -from .github import fetch_authored_prs, get_repo_details +from .github import fetch_authored_issues, fetch_authored_prs, get_repo_details def _conventional_prefix(title): @@ -51,6 +55,14 @@ def query_states(allowed_statuses): return {'OPEN' if status == 'DRAFT' else status for status in allowed_statuses} +def issue_status(issue): + """OPEN, FIXED (closed with a merged linked PR) or CLOSED.""" + if issue['state'] == 'OPEN': + return 'OPEN' + linked = issue['closedByPullRequestsReferences']['nodes'] + return 'FIXED' if any(pr['state'] == 'MERGED' for pr in linked) else 'CLOSED' + + def fetch_from_config(config): """Fetch the configured user's PRs and group them by year -> month. @@ -75,6 +87,16 @@ def fetch_from_config(config): matched_repos = set() private_repos = set() + def add(item, kind, status): + repo_name = item['repository']['nameWithOwner'] + created_at = datetime.strptime(item['createdAt'], "%Y-%m-%dT%H:%M:%SZ") + item['kind'] = kind + item['status'] = status + item['repo_info'] = get_repo_details(repo_name) + # Store parsed datetime for reuse in build_readme_model + item['created_at'] = created_at + contributions_by_date[created_at.year][(created_at.month, created_at.strftime("%B"))].append(item) + for pr in prs: repository = pr['repository'] repo_name = repository['nameWithOwner'] @@ -91,14 +113,27 @@ def fetch_from_config(config): if allowed_statuses is not None and status not in allowed_statuses: continue - created_at = datetime.strptime(pr['createdAt'], "%Y-%m-%dT%H:%M:%SZ") - pr['status'] = status - pr['repo_info'] = get_repo_details(repo_name) - # Store parsed datetime for reuse in build_readme_model - pr['created_at'] = created_at matched_repos.add(repo_name.lower()) - - contributions_by_date[created_at.year][(created_at.month, created_at.strftime("%B"))].append(pr) + add(pr, 'pr', status) + + issue_config = config.get("issues", {}) + issue_repos = {repo.lower() for repo in issue_config.get("repos", [])} + if issue_repos: + allowed_issue_statuses = set(issue_config.get("statuses", [])) or None + issues = fetch_authored_issues(username) + print(f"Fetched {len(issues)} authored issue(s); keeping those in {len(issue_repos)} tracked repo(s).") + for issue in issues: + repository = issue['repository'] + repo_name = repository['nameWithOwner'] + if repo_name.lower() not in issue_repos: + continue + if repository['isPrivate']: + private_repos.add(repo_name.lower()) + continue + status = issue_status(issue) + if allowed_issue_statuses is not None and status not in allowed_issue_statuses: + continue + add(issue, 'issue', status) if private_repos: print(f"Skipped {len(private_repos)} private repo(s).") @@ -117,43 +152,60 @@ def fetch_from_config(config): return contributions_by_date, featured_repos -def build_stats(years, stats_projects): - """Count merged PRs per category for each opted-in project, in config order. +def _project_counts(years, stats_projects, include, bucket): + """Tally contributions per opted-in project, in config order. An entry is either a repo name or ``{"name": ..., "repos": [...]}``, whose repos are summed into one row. Built from the rendered rows, so - private repos never reach the counts. Projects with no merged PRs are - left out. + private repos never reach the counts. Only rows passing ``include`` are + counted, each item under ``bucket(row, item)``. Projects with nothing + counted are left out. Yields ``(project, counts)`` pairs. """ counts = defaultdict(lambda: defaultdict(int)) rows_by_repo = {} for year in years: for month in year['months']: for row in month['rows']: - if row['status'] != 'MERGED': + if not include(row): continue key = row['repo_name'].lower() rows_by_repo[key] = row for item in row['contributions']: - counts[key][item['category']] += 1 + counts[key][bucket(row, item)] += 1 - category_order = [*CATEGORY_LABELS, OTHER_CATEGORY] - projects = [] for entry in stats_projects: if isinstance(entry, str): entry = {'name': None, 'repos': [entry]} keys = [repo.lower() for repo in entry['repos'] if repo.lower() in counts] if not keys: continue - # Link and logo come from the first repo in the entry that has PRs. + # Link and logo come from the first repo in the entry that has rows. row = rows_by_repo[keys[0]] - merged = {c: sum(counts[key][c] for key in keys) for c in category_order} - projects.append({ + summed = defaultdict(int) + for key in keys: + for bucket_key, n in counts[key].items(): + summed[bucket_key] += n + project = { 'name': entry['name'] or row['repo_name'], 'repo_url': row['repo_url'], 'logo_url': row['logo_url'], + } + yield project, summed + + +def build_stats(years, stats_projects): + """Count merged PRs per category for each opted-in project.""" + category_order = [*CATEGORY_LABELS, OTHER_CATEGORY] + projects = [] + for project, merged in _project_counts( + years, stats_projects, + include=lambda row: row['kind'] == 'pr' and row['status'] == 'MERGED', + bucket=lambda row, item: item['category'], + ): + projects.append({ + **project, 'total_merged': sum(merged.values()), - 'categories': {c: n for c, n in merged.items() if n}, + 'categories': {c: merged[c] for c in category_order if merged[c]}, }) used = {c for project in projects for c in project['categories']} @@ -164,7 +216,40 @@ def build_stats(years, stats_projects): return {'columns': columns, 'projects': projects} -def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), stats_only_repos=()): +def build_issue_stats(years, stats_projects): + """Count reported issues per status for each opted-in project.""" + projects = [] + for project, by_status in _project_counts( + years, stats_projects, + include=lambda row: row['kind'] == 'issue', + bucket=lambda row, item: row['status'], + ): + projects.append({ + **project, + 'total_reported': sum(by_status.values()), + 'statuses': {s: by_status[s] for s in ISSUE_STATUS_ICONS if by_status[s]}, + }) + + used = {s for project in projects for s in project['statuses']} + columns = [ + {'status': s, 'label': ISSUE_STATUS_LABELS[s], 'emoji': icon} + for s, icon in ISSUE_STATUS_ICONS.items() if s in used + ] + return {'columns': columns, 'projects': projects} + + +def _issue_markdown(issue, cell_title): + """Issue link plus links to its open or merged PRs.""" + linked = [ + f"[#{pr['number']}]({pr['url']})" + for pr in issue['closedByPullRequestsReferences']['nodes'] + if pr['state'] in ('OPEN', 'MERGED') + ] + markdown = f"{ISSUE_EMOJI} [Issue #{issue['number']}: {cell_title}]({issue['url']})" + return f"{markdown} β†’ {', '.join(linked)}" if linked else markdown + + +def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), stats_only_repos=(), username=None): featured_projects = [] if featured_repos: sorted_featured = sorted(featured_repos.items(), key=lambda item: (item[1], item[0].lower())) @@ -189,31 +274,44 @@ def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), # created_at is already validated and stored in fetch_from_config prs.sort(key=lambda x: ( x['repository']['nameWithOwner'].lower(), + x.get('kind', 'pr'), x.get('status', 'OPEN').upper(), -x['created_at'].timestamp(), )) month_rows = [] grouped = groupby(prs, key=lambda x: ( - x['repository']['nameWithOwner'].lower(), x.get('status', 'OPEN').upper() + x['repository']['nameWithOwner'].lower(), + x.get('kind', 'pr'), + x.get('status', 'OPEN').upper(), )) - for (_repo_key, group_status), repo_prs in grouped: + for (_repo_key, kind, group_status), repo_prs in grouped: repo_prs_list = list(repo_prs) first_pr = repo_prs_list[0] # Group/sort on the lowercased name, but display the canonical one. repo_name = first_pr['repository']['nameWithOwner'] - icon = STATUS_ICONS.get(group_status, DEFAULT_STATUS_ICON) + icons = ISSUE_STATUS_ICONS if kind == 'issue' else STATUS_ICONS + icon = icons.get(group_status, DEFAULT_STATUS_ICON) tech_stack = first_pr.get('repo_info', {}).get('tech_stack', '') owner = repo_name.split('/')[0] logo_url = custom_logo(repo_name) or f"https://github.com/{owner}.png" contributions = [] for pr in repo_prs_list: - emoji = get_pr_emoji(pr['title']) # A '|' in a title would split the markdown table row. cell_title = pr['title'].replace('|', '\\|') + if kind == 'issue': + contributions.append({ + 'emoji': ISSUE_EMOJI, + 'number': pr['number'], + 'title': pr['title'], + 'url': pr['url'], + 'markdown': _issue_markdown(pr, cell_title), + }) + continue + emoji = get_pr_emoji(pr['title']) contributions.append({ 'emoji': emoji, 'category': get_pr_category(pr['title']), @@ -224,6 +322,7 @@ def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), }) month_rows.append({ + 'kind': kind, 'status': group_status, 'status_icon': icon, 'repo_name': repo_name, @@ -250,6 +349,8 @@ def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), years.append({'year': year, 'months': months}) stats = build_stats(years, stats_projects) + issue_stats = build_issue_stats(years, stats_projects) + issue_stats['title'] = f"Issues reported by @{username}" if username else "Reported issues" stats_only = {repo.lower() for repo in stats_only_repos} if stats_only: for year in years: @@ -268,6 +369,7 @@ def build_readme_model(contributions_by_date, featured_repos, stats_projects=(), 'title': 'OSS Contributions', 'featured_projects': featured_projects, 'stats': stats, + 'issue_stats': issue_stats, 'years': years, - 'status_legend': STATUS_LEGEND, + 'status_legend': [*STATUS_LEGEND, *ISSUE_STATUS_LEGEND], } diff --git a/src/render.py b/src/render.py index 147acea..23d388a 100644 --- a/src/render.py +++ b/src/render.py @@ -2,9 +2,19 @@ import json +from .config import ISSUE_EMOJI from .model import build_readme_model +def _project_cell(project): + return ( + f"" + f"\"{project['name']}\"/" + "" + ) + + def generate_markdown(contributions_by_date, featured_repos, output_file="README.md", model=None): """Render the grouped contributions into a Markdown README.""" if model is None: @@ -19,14 +29,19 @@ def generate_markdown(contributions_by_date, featured_repos, output_file="README f.write(f"| {' | '.join(header)} |\n") f.write(f"| :--- |{' :---: |' * (len(header) - 1)}\n") for project in stats['projects']: - repo_display = ( - f"" - f"\"{project['name']}\"/" - "" - ) cells = [str(project['categories'].get(c['category'], 0)) for c in stats['columns']] - f.write(f"| {repo_display} | {' | '.join(cells)} | **{project['total_merged']}** |\n") + f.write(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_merged']}** |\n") + f.write("\n") + + issue_stats = model['issue_stats'] + if issue_stats['projects']: + f.write(f"### {ISSUE_EMOJI} {issue_stats['title']}\n\n") + header = ["Project", *(f"{c['emoji']} {c['label']}" for c in issue_stats['columns']), "Total Reported"] + f.write(f"| {' | '.join(header)} |\n") + f.write(f"| :--- |{' :---: |' * (len(header) - 1)}\n") + for project in issue_stats['projects']: + cells = [str(project['statuses'].get(c['status'], 0)) for c in issue_stats['columns']] + f.write(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_reported']}** |\n") f.write("\n") if model['featured_projects']: @@ -75,6 +90,7 @@ def generate_json_snapshot(contributions_by_date, featured_repos, output_file="R 'title': model['title'], 'featured_projects': model['featured_projects'], 'stats': model['stats'], + 'issue_stats': model['issue_stats'], 'years': [], } @@ -90,15 +106,17 @@ def generate_json_snapshot(contributions_by_date, featured_repos, output_file="R for row in month_data['rows']: plain_contributions = [] + prefix = "Issue " if row['kind'] == 'issue' else "" for item in row['contributions']: plain_contributions.append({ 'number': item['number'], 'title': item['title'], 'url': item['url'], - 'text': f"#{item['number']}: {item['title']}", + 'text': f"{prefix}#{item['number']}: {item['title']}", }) json_month['rows'].append({ + 'kind': row['kind'], 'status': row['status'], 'repo_name': row['repo_name'], 'repo_url': row['repo_url'],