feat: react to no-reply decisions for issue comments (#43)

This commit is contained in:
insleker 2026-04-10 16:04:03 +08:00 committed by existedinnettw
parent 0fb68c886b
commit b14bbe5d0a
4 changed files with 106 additions and 0 deletions

View File

@ -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,

View File

@ -198,6 +198,36 @@ class IssueTrackerClient {
);
}
Future<bool> 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<String?> getAuthenticatedLogin({
required IssueTrackerProvider provider,
}) {

View File

@ -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);
});

View File

@ -14,6 +14,7 @@ Future<void> writeFakeGhScript({
Set<int>? postCommentIssueNumbers,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
File? reactionBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
@ -173,6 +174,41 @@ Future<void> 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');