From dd56ab45546a02dfedabfeba0db3ef026e213e4d Mon Sep 17 00:00:00 2001 From: insleker Date: Sun, 5 Apr 2026 18:30:20 +0800 Subject: [PATCH] fix(issue-assistant): poll only open issues --- lib/src/github_client.dart | 1 + test/issue_assistant_app_test.dart | 269 ++++++++++++++++------------- test/test_support.dart | 33 +++- 3 files changed, 180 insertions(+), 123 deletions(-) diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 491156d..fcf2113 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -22,6 +22,7 @@ class GitHubClient { final queryParts = [ for (final repo in repoList) 'repo:${repo.fullName}', 'is:issue', + 'state:open', if (since != null) 'updated:>=${since.toUtc().toIso8601String()}', ]; diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index cdcad1e..db48701 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1261,109 +1261,135 @@ projects: }); /// ```gherkin - /// Scenario: Aggregate one search request across multiple enabled projects + /// Scenario: Aggregate one search request across multiple enabled projects while ignoring closed issues /// Given two temporary project checkouts that can be used as local repo paths /// And GitHub issue data containing an explicit assistant mention in each repository + /// And one of the search results is a closed issue /// And a responder that emits a planning reply /// And an orchestrator-style config with both repositories enabled /// When the app processes one polling cycle - /// Then it fetches issue updates through one aggregate GitHub search request + /// Then it fetches issue updates through one aggregate GitHub search request for open issues only /// And it still posts one reply in each repository /// ``` - test('Aggregate one search request across multiple enabled projects', () async { - // Given two temporary project checkouts that can be used as local repo paths. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-aggregate-search-', - ); - final repoOneDir = await Directory( - p.join(sandbox.path, 'repo-one'), - ).create(); - final repoTwoDir = await Directory( - p.join(sandbox.path, 'repo-two'), - ).create(); - await initGitRepo(repoOneDir); - await initGitRepo(repoTwoDir); + test( + 'Aggregate one search request across multiple enabled projects while ignoring closed issues', + () async { + // Given two temporary project checkouts that can be used as local repo paths. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-aggregate-search-', + ); + final repoOneDir = await Directory( + p.join(sandbox.path, 'repo-one'), + ).create(); + final repoTwoDir = await Directory( + p.join(sandbox.path, 'repo-two'), + ).create(); + await initGitRepo(repoOneDir); + await initGitRepo(repoTwoDir); - // And GitHub issue data containing an explicit assistant mention in each repository. - final ghScript = File(p.join(sandbox.path, 'gh')); - final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); - await writeFakeGhScript( - ghScript: ghScript, - issueListResponse: const [], - commentResponses: const {}, - issueListResponsesByRepo: { - 'owner/repo-one': [ - { - 'number': 11, - 'title': 'Repo one mention', - 'body': 'first repo', - 'state': 'open', - 'html_url': 'https://example.test/owner/repo-one/issues/11', - 'updated_at': '2026-04-04T17:00:00Z', - 'user': {'login': 'reporter-one'}, - 'labels': const [], - }, - ], - 'owner/repo-two': [ - { - 'number': 22, - 'title': 'Repo two mention', - 'body': 'second repo', - 'state': 'open', - 'html_url': 'https://example.test/owner/repo-two/issues/22', - 'updated_at': '2026-04-04T17:01:00Z', - 'user': {'login': 'reporter-two'}, - 'labels': const [], - }, - ], - }, - commentResponsesByRepo: { - 'owner/repo-one': { - 11: [ + // And GitHub issue data containing an explicit assistant mention in each repository. + final ghScript = File(p.join(sandbox.path, 'gh')); + final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); + await writeFakeGhScript( + ghScript: ghScript, + issueListResponse: const [], + commentResponses: const {}, + issueListResponsesByRepo: { + 'owner/repo-one': [ { - 'id': 110, - 'body': '@helper please review repo one', - 'html_url': - 'https://example.test/owner/repo-one/issues/11#issuecomment-110', - 'created_at': '2026-04-04T17:00:00Z', + 'number': 11, + 'title': 'Repo one mention', + 'body': 'first repo', + 'state': 'open', + 'html_url': 'https://example.test/owner/repo-one/issues/11', 'updated_at': '2026-04-04T17:00:00Z', 'user': {'login': 'reporter-one'}, + 'labels': const [], }, ], - }, - 'owner/repo-two': { - 22: [ + 'owner/repo-two': [ { - 'id': 220, - 'body': '@helper please review repo two', - 'html_url': - 'https://example.test/owner/repo-two/issues/22#issuecomment-220', - 'created_at': '2026-04-04T17:01:00Z', + 'number': 22, + 'title': 'Repo two mention', + 'body': 'second repo', + 'state': 'open', + 'html_url': 'https://example.test/owner/repo-two/issues/22', 'updated_at': '2026-04-04T17:01:00Z', 'user': {'login': 'reporter-two'}, + 'labels': const [], + }, + { + 'number': 23, + 'title': 'Repo two closed mention', + 'body': 'closed repo issue', + 'state': 'closed', + 'html_url': 'https://example.test/owner/repo-two/issues/23', + 'updated_at': '2026-04-04T17:02:00Z', + 'user': {'login': 'reporter-three'}, + 'labels': const [], }, ], }, - }, - postCommentIssueNumbersByRepo: { - 'owner/repo-one': {11}, - 'owner/repo-two': {22}, - }, - ghLog: ghLog, - ); + commentResponsesByRepo: { + 'owner/repo-one': { + 11: [ + { + 'id': 110, + 'body': '@helper please review repo one', + 'html_url': + 'https://example.test/owner/repo-one/issues/11#issuecomment-110', + 'created_at': '2026-04-04T17:00:00Z', + 'updated_at': '2026-04-04T17:00:00Z', + 'user': {'login': 'reporter-one'}, + }, + ], + }, + 'owner/repo-two': { + 22: [ + { + 'id': 220, + 'body': '@helper please review repo two', + 'html_url': + 'https://example.test/owner/repo-two/issues/22#issuecomment-220', + 'created_at': '2026-04-04T17:01:00Z', + 'updated_at': '2026-04-04T17:01:00Z', + 'user': {'login': 'reporter-two'}, + }, + ], + 23: [ + { + 'id': 230, + 'body': '@helper please review closed repo two', + 'html_url': + 'https://example.test/owner/repo-two/issues/23#issuecomment-230', + 'created_at': '2026-04-04T17:02:00Z', + 'updated_at': '2026-04-04T17:02:00Z', + 'user': {'login': 'reporter-three'}, + }, + ], + }, + }, + postCommentIssueNumbersByRepo: { + 'owner/repo-one': {11}, + 'owner/repo-two': {22}, + }, + ghLog: ghLog, + ); - // And a responder that emits a planning reply. - final responderScript = File(p.join(sandbox.path, 'responder.sh')); - await writeResponderScript(responderScript, { - 'decision': 'reply', - 'mode': 'planning', - 'markdown': 'aggregate reply', - 'summary': 'posted planning advice', - }); + // And a responder that emits a planning reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'aggregate reply', + 'summary': 'posted planning advice', + }); - // And an orchestrator-style config with both repositories enabled. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' + // And an orchestrator-style config with both repositories enabled. + final configFile = File( + p.join(sandbox.path, 'agent-orchestrator.yaml'), + ); + await configFile.writeAsString(''' defaults: issueAssistant: enabled: true @@ -1380,42 +1406,53 @@ projects: repo: owner/repo-two path: ${repoTwoDir.path} '''); - final app = await IssueAssistantApp.open( - config: await AppConfig.load(configFile.path), - databasePath: p.join(sandbox.path, 'state.sqlite3'), - ghCommand: ghScript.path, - ); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + ); - // When the app processes one polling cycle. - await app.runOnce(); - await app.close(); + // When the app processes one polling cycle. + await app.runOnce(); + await app.close(); - // Then it fetches issue updates through one aggregate GitHub search request. - final logLines = await ghLog.readAsLines(); - expect( - logLines.where((line) => line.contains('search/issues')).length, - 1, - ); + // Then it fetches issue updates through one aggregate GitHub search request for open issues only. + final logLines = await ghLog.readAsLines(); + final searchLogs = logLines + .where((line) => line.contains('search/issues')) + .toList(); + expect(searchLogs.length, 1); + expect(searchLogs.single, contains('state:open')); - // And it still posts one reply in each repository. - expect( - logLines - .where( - (line) => - line.contains('repos/owner/repo-one/issues/11/comments'), - ) - .length, - 2, - ); - expect( - logLines - .where( - (line) => - line.contains('repos/owner/repo-two/issues/22/comments'), - ) - .length, - 2, - ); - }); + // And it still posts one reply in each repository. + expect( + logLines + .where( + (line) => + line.contains('repos/owner/repo-one/issues/11/comments'), + ) + .length, + 2, + ); + expect( + logLines + .where( + (line) => + line.contains('repos/owner/repo-two/issues/22/comments'), + ) + .length, + 2, + ); + expect( + logLines + .where( + (line) => + line.contains('repos/owner/repo-two/issues/23/comments'), + ) + .length, + 0, + ); + }, + ); }); } diff --git a/test/test_support.dart b/test/test_support.dart index 8f26d74..5f69f9a 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -50,6 +50,19 @@ Future writeFakeGhScript({ final rightUpdatedAt = right['updated_at'] as String? ?? ''; return leftUpdatedAt.compareTo(rightUpdatedAt); }); + final openSearchItems = searchItems + .where((item) => item['state'] == 'open') + .toList(growable: false); + final allSearchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + final openSearchResponse = jsonEncode({ + 'total_count': openSearchItems.length, + 'incomplete_results': false, + 'items': openSearchItems, + }); final buffer = StringBuffer() ..writeln('#!/usr/bin/env bash') @@ -79,15 +92,21 @@ Future writeFakeGhScript({ buffer ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') + ..writeln(r''' query=""''') + ..writeln(r''' for arg in "$@"; do''') + ..writeln(r''' if [[ "$arg" == q=* ]]; then''') + ..writeln(r''' query="${arg#q=}"''') + ..writeln(r''' fi''') + ..writeln(r''' done''') + ..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''') ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }), - ) + ..writeln(openSearchResponse) ..writeln('JSON') + ..writeln(r''' else''') + ..writeln(" cat <<'JSON'") + ..writeln(allSearchResponse) + ..writeln('JSON') + ..writeln(r''' fi''') ..writeln(' exit 0') ..writeln('fi');