From b14bbe5d0a49e14ca332fc78d3e2368a0be1fe91 Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 10 Apr 2026 16:04:03 +0800 Subject: [PATCH] feat: react to no-reply decisions for issue comments (#43) --- lib/src/core/issue_assistant_app.dart | 27 ++++++++++++++++++++ lib/src/issue_tracker/client.dart | 30 ++++++++++++++++++++++ test/core/core_test.dart | 13 ++++++++++ test/support/fake_gh.dart | 36 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index e7128df..7116c71 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -390,6 +390,19 @@ class IssueAssistantApp { } if (!selectedResult.shouldReply) { + final noReplyReactionTarget = _latestHumanComment(project, thread); + if (noReplyReactionTarget != null) { + final reacted = await issueTrackerClient.createNoReplyReaction( + provider: project.provider, + trackerProject: project.repo, + commentId: noReplyReactionTarget.id, + ); + _log( + 'issue=${project.key}#${issue.number} ' + 'no_reply_reaction=${reacted ? "posted" : "unsupported"} ' + 'comment_id=${noReplyReactionTarget.id}', + ); + } await database.upsertIssueThread( projectKey: project.key, issueNumber: issue.number, @@ -564,6 +577,20 @@ class IssueAssistantApp { return _looksLikeBot(project, latestAuthor, latestText); } + GitHubIssueComment? _latestHumanComment( + ProjectConfig project, + IssueThread thread, + ) { + final latestComment = thread.latestComment; + if (latestComment == null) { + return null; + } + if (_looksLikeBot(project, latestComment.userLogin, latestComment.body)) { + return null; + } + return latestComment; + } + int _countResponsesSinceLatestHuman( ProjectConfig project, IssueThread thread, diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 9370510..7f12b3f 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -198,6 +198,36 @@ class IssueTrackerClient { ); } + Future createNoReplyReaction({ + required IssueTrackerProvider provider, + required String trackerProject, + required int commentId, + }) async { + switch (provider) { + case IssueTrackerProvider.github: + await _runGhJson([ + 'api', + '-X', + 'POST', + _buildApiPath(parseRepositorySlug(trackerProject), [ + 'issues', + 'comments', + '$commentId', + 'reactions', + ]), + '-H', + 'Accept: application/vnd.github+json', + '-f', + 'content=eyes', + ]); + return true; + case IssueTrackerProvider.gitlab: + case IssueTrackerProvider.gitea: + case IssueTrackerProvider.openproject: + return false; + } + } + Future getAuthenticatedLogin({ required IssueTrackerProvider provider, }) { diff --git a/test/core/core_test.dart b/test/core/core_test.dart index 81dc1d9..ea455a4 100644 --- a/test/core/core_test.dart +++ b/test/core/core_test.dart @@ -247,6 +247,7 @@ projects: // And GitHub issue data containing a comment with an explicit assistant mention. final ghScript = File(p.join(sandbox.path, 'gh')); final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); + final reactionBody = File(p.join(sandbox.path, 'reaction-body.txt')); final issueData = [ { 'number': 43, @@ -274,6 +275,7 @@ projects: issueListResponse: issueData, commentResponses: {43: commentsData}, ghLog: ghLog, + reactionBodyFile: reactionBody, ); // And a responder that explicitly returns no_reply. @@ -327,6 +329,17 @@ projects: .length, 1, ); + expect( + logLines + .where( + (line) => line.contains( + 'repos/owner/sample/issues/comments/430/reactions', + ), + ) + .length, + 1, + ); + expect(await reactionBody.readAsString(), 'eyes'); expect(thread.lastCommentId, isNull); }); diff --git a/test/support/fake_gh.dart b/test/support/fake_gh.dart index c503a68..ff8e8e0 100644 --- a/test/support/fake_gh.dart +++ b/test/support/fake_gh.dart @@ -14,6 +14,7 @@ Future writeFakeGhScript({ Set? postCommentIssueNumbers, Map>? postCommentIssueNumbersByRepo, File? postedBodyFile, + File? reactionBodyFile, String? viewerLogin, bool failViewerLookup = false, }) async { @@ -173,6 +174,41 @@ Future writeFakeGhScript({ } } + for (final repoEntry in normalizedCommentResponsesByRepo.entries) { + final repo = repoEntry.key; + for (final issueEntry in repoEntry.value.entries) { + for (final comment in issueEntry.value) { + final commentId = comment['id']; + if (commentId is! int) { + continue; + } + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/comments/$commentId/reactions" ]]; then', + ) + ..writeln(r''' for arg in "$@"; do''') + ..writeln(r''' :'''); + if (reactionBodyFile != null) { + buffer + ..writeln(r''' if [[ "$arg" == content=* ]]; then''') + ..writeln( + r''' printf '%s' "${arg#content=}" > ''' + '"${bashScriptPath(reactionBodyFile.path)}"', + ) + ..writeln(r''' fi'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'id': 990, 'content': 'eyes'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + } + buffer ..writeln(r'''echo "unexpected gh args: $*" >&2''') ..writeln('exit 1');