diff --git a/README.md b/README.md
index 62746c3..c75a94d 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,19 @@
|
| 19 | 2 | 1 | 0 | 2 | 0 | 0 | **24** |
|
| 116 | 88 | 38 | 2 | 82 | 60 | 58 | **522** |
+### π Issues reported by @hdcodedev
+
+| Project | π‘ Open | β
Fixed | Total Reported |
+| :--- | :---: | :---: | :---: |
+|
| 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"
"
+ ""
+ )
+
+
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"
"
- ""
- )
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'],