feat: improve emoji reaction to no-reply comment in issues

This commit is contained in:
insleker 2026-04-11 17:28:16 +08:00
parent da3b15fdd7
commit 0e9bd11d5c
9 changed files with 672 additions and 20 deletions

3
.agents/skills/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/*
!/.gitignore
!/dart-gherkin-tests

4
.gitignore vendored
View File

@ -5,12 +5,12 @@ pubspec.lock
.code_work_spawner.sqlite3 .code_work_spawner.sqlite3
agent-orchestrator.yaml agent-orchestrator.yaml
/.* /.*
# !/.agents !/.agents
!/.git !/.git
!/.github !/.github
!/.vscode !/.vscode
/skills/* /skills/*
!/skills/dart-gherkin-tests # !/skills/dart-gherkin-tests
*.g.dart *.g.dart
**/*.exe **/*.exe
/CWS*.yaml /CWS*.yaml

View File

@ -402,17 +402,32 @@ class IssueAssistantApp {
if (!selectedResult.shouldReply) { if (!selectedResult.shouldReply) {
final noReplyReactionTarget = _latestHumanComment(project, thread); final noReplyReactionTarget = _latestHumanComment(project, thread);
if (noReplyReactionTarget != null) { if (noReplyReactionTarget == null) {
final reacted = await issueTrackerClient.createNoReplyReaction(
provider: project.provider,
trackerProject: project.repo,
commentId: noReplyReactionTarget.id,
);
_log( _log(
'issue=${project.key}#${issue.number} ' 'issue=${project.key}#${issue.number} '
'no_reply_reaction=${reacted ? "posted" : "unsupported"} ' 'no_reply_reaction=skipped reason=no_human_comment',
'comment_id=${noReplyReactionTarget.id}',
); );
} else {
try {
final reacted = await issueTrackerClient.createNoReplyReaction(
provider: project.provider,
trackerProject: project.repo,
issueNumber: issue.number,
commentId: noReplyReactionTarget.id,
);
_log(
'issue=${project.key}#${issue.number} '
'no_reply_reaction=${reacted ? "posted" : "unsupported"} '
'comment_id=${noReplyReactionTarget.id}',
);
} catch (error, stackTrace) {
_logger.warning(
'issue=${project.key}#${issue.number} '
'no_reply_reaction=failed comment_id=${noReplyReactionTarget.id}',
error: error,
stackTrace: stackTrace,
);
}
} }
await database.upsertIssueThread( await database.upsertIssueThread(
projectKey: project.key, projectKey: project.key,
@ -592,14 +607,12 @@ class IssueAssistantApp {
ProjectConfig project, ProjectConfig project,
IssueThread thread, IssueThread thread,
) { ) {
final latestComment = thread.latestComment; for (final comment in thread.comments.reversed) {
if (latestComment == null) { if (!_looksLikeBot(project, comment.userLogin, comment.body)) {
return null; return comment;
}
} }
if (_looksLikeBot(project, latestComment.userLogin, latestComment.body)) { return null;
return null;
}
return latestComment;
} }
int _countResponsesSinceLatestHuman( int _countResponsesSinceLatestHuman(

View File

@ -204,6 +204,7 @@ class IssueTrackerClient {
Future<bool> createNoReplyReaction({ Future<bool> createNoReplyReaction({
required IssueTrackerProvider provider, required IssueTrackerProvider provider,
required String trackerProject, required String trackerProject,
required int issueNumber,
required int commentId, required int commentId,
}) async { }) async {
switch (provider) { switch (provider) {
@ -224,13 +225,101 @@ class IssueTrackerClient {
'content=eyes', 'content=eyes',
]); ]);
return true; return true;
case IssueTrackerProvider.gitlab:
case IssueTrackerProvider.gitea: case IssueTrackerProvider.gitea:
try {
await _runGiteaJson(
method: 'POST',
path: _buildApiPath(parseRepositorySlug(trackerProject), [
'issues',
'comments',
'$commentId',
'reactions',
]),
body: <String, Object?>{'content': 'eyes'},
);
return true;
} on FormatException catch (error) {
final message = error.message.toLowerCase();
if (_isGiteaExistingReactionError(message)) {
return true;
}
if (_isGiteaReactionUnsupported(message)) {
return false;
}
rethrow;
}
case IssueTrackerProvider.gitlab:
try {
await _runGlabJson([
'api',
'-X',
'POST',
_buildGitLabApiPath(trackerProject, [
'issues',
'$issueNumber',
'notes',
'$commentId',
'award_emoji',
]),
'-f',
'name=eyes',
]);
return true;
} on ProcessException catch (error) {
if (_isGitLabReactionUnsupported(
error.message.toLowerCase(),
error.errorCode,
)) {
return false;
}
rethrow;
}
case IssueTrackerProvider.openproject: case IssueTrackerProvider.openproject:
return false; try {
await _runOpenProjectJson(
method: 'PATCH',
path: '/api/v3/activities/$commentId/emoji_reactions',
body: <String, Object?>{'reaction': 'eyes'},
);
return true;
} on HttpException catch (error) {
if (_isOpenProjectEmojiUnsupported(error.message.toLowerCase())) {
return false;
}
rethrow;
}
} }
} }
bool _isGiteaExistingReactionError(String message) {
return message.contains('already') && message.contains('reaction');
}
bool _isGitLabReactionUnsupported(String message, int exitCode) {
if (exitCode == 404 || exitCode == 405) {
return true;
}
return message.contains('404') ||
message.contains('405') ||
message.contains('not found') ||
message.contains('method not allowed') ||
message.contains('award_emoji') && message.contains('unexpected glab');
}
bool _isGiteaReactionUnsupported(String message) {
return message.contains('(404') ||
message.contains('not found') ||
message.contains('(405') ||
message.contains('method not allowed');
}
bool _isOpenProjectEmojiUnsupported(String message) {
return message.contains('(404') ||
message.contains('not found') ||
message.contains('(405') ||
message.contains('method not allowed');
}
Future<String?> getAuthenticatedLogin({ Future<String?> getAuthenticatedLogin({
required IssueTrackerProvider provider, required IssueTrackerProvider provider,
}) { }) {

View File

@ -944,7 +944,7 @@
"worktrunk": { "worktrunk": {
"source": "max-sixty/worktrunk", "source": "max-sixty/worktrunk",
"sourceType": "github", "sourceType": "github",
"computedHash": "e95e53e4ef26baddf9efb6b6c4e08276384edba9d7e9269d61a11f2c6871c6d5" "computedHash": "252c02ad30d62a966f6eff331be0b440c058bb346462b8ef0dab276c9ea2abbc"
}, },
"write-coding-standards-from-file": { "write-coding-standards-from-file": {
"source": "github/awesome-copilot", "source": "github/awesome-copilot",

View File

@ -0,0 +1,507 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import '../test_support.dart';
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
/// ```gherkin
/// Feature: No-reply acknowledgements across providers
///
/// As a maintainer operating multiple tracker providers
/// I want no-reply decisions to leave a lightweight acknowledgement marker
/// So that users can tell the assistant evaluated the update without posting a full reply
/// ```
group('IssueAssistantApp no-reply acknowledgements', () {
/// ```gherkin
/// Scenario: Acknowledge no_reply on Gitea by adding an eyes reaction to the human comment
/// Given a temporary project checkout that can be used as the local repo path
/// And Gitea issue data containing a human comment with an explicit assistant mention
/// And a responder that returns no_reply
/// And an orchestrator-style config that marks the project provider as gitea
/// When the app processes one polling cycle
/// Then it records the evaluation result as no_reply
/// And it patches the triggering Gitea comment with an eyes reaction
/// And it does not post a normal issue comment
/// ```
test(
'Acknowledge no_reply on Gitea by adding an eyes reaction to the human comment',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-gitea-no-reply-ack-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And Gitea issue data containing a human comment with an explicit assistant mention.
final giteaServer = await FakeGiteaApiServer.start(
issueListResponsesByRepo: {
'owner/sample': [
{
'number': 12,
'title': 'Need architecture help',
'body': 'Please review the API layering.',
'state': 'open',
'html_url': 'https://gitea.example.test/owner/sample/issues/12',
'updated_at': '2026-04-04T12:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
'pull_request': null,
},
],
},
commentResponsesByRepo: {
'owner/sample': {
12: [
{
'id': 50,
'body': '@helper this thread is already complete',
'html_url':
'https://gitea.example.test/owner/sample/issues/12#issuecomment-50',
'created_at': '2026-04-04T12:00:00Z',
'updated_at': '2026-04-04T12:00:00Z',
'user': {'login': 'reporter'},
},
],
},
},
);
addTearDown(giteaServer.close);
// And a responder that returns no_reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'thread already resolved',
});
// And an orchestrator-style config that marks the project provider as gitea.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
issueTracker:
eventSource:
pollInterval: 5m
provider: gitea
repo: owner/sample
path: ${repoDir.path}
defaultBranch: main
''');
await File(p.join(sandbox.path, '.env')).writeAsString('''
CWS_GITEA_HOST=${giteaServer.baseUrl}
CWS_GITEA_TOKEN=test-token
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the app processes one polling cycle.
await app.runOnce();
final thread = await app.database.findIssueThread('sample', 12);
await app.close();
// Then it records the evaluation result as no_reply.
expect(thread, isNotNull);
expect(thread!.lastDecision, 'no_reply');
expect(thread.lastCommentId, isNull);
// And it patches the triggering Gitea comment with an eyes reaction.
expect(
giteaServer.requestPaths,
contains('/api/v1/repos/owner/sample/issues/comments/50/reactions'),
);
expect(giteaServer.reactionBodies, hasLength(1));
expect(
(jsonDecode(giteaServer.reactionBodies.single)
as Map<String, dynamic>)['content'],
'eyes',
);
// And it does not post a normal issue comment.
expect(giteaServer.postedBodies, isEmpty);
},
);
/// ```gherkin
/// Scenario: Acknowledge no_reply on GitLab by adding an eyes award emoji to the note
/// Given a temporary project checkout that can be used as the local repo path
/// And GitLab issue data containing a human note with an explicit assistant mention
/// And a responder that returns no_reply
/// And an orchestrator-style config that marks the project provider as gitlab
/// When the app processes one polling cycle
/// Then it records the evaluation result as no_reply
/// And it adds an eyes award emoji to the triggering GitLab note
/// And it does not post a normal issue note
/// ```
test(
'Acknowledge no_reply on GitLab by adding an eyes award emoji to the note',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-gitlab-no-reply-ack-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitLab issue data containing a human note with an explicit assistant mention.
final glabScript = File(p.join(sandbox.path, 'glab'));
final glabLog = File(p.join(sandbox.path, 'glab-log.jsonl'));
final reactionBody = File(p.join(sandbox.path, 'reaction-body.txt'));
await writeFakeGlabScript(
glabScript: glabScript,
issueListResponsesByRepo: {
'group/subgroup/sample': [
{
'iid': 12,
'title': 'Need GitLab support',
'description': 'Please add glab support.',
'state': 'opened',
'web_url':
'https://gitlab.example.test/group/subgroup/sample/issues/12',
'updated_at': '2026-04-04T12:00:00Z',
'author': {'username': 'reporter'},
'labels': const <String>[],
},
],
},
commentResponsesByRepo: {
'group/subgroup/sample': {
12: [
{
'id': 501,
'body': '@helper this thread is already complete',
'created_at': '2026-04-04T12:00:00Z',
'updated_at': '2026-04-04T12:00:00Z',
'author': {'username': 'reporter'},
},
],
},
},
glabLog: glabLog,
reactionBodyFile: reactionBody,
);
// And a responder that returns no_reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'thread already resolved',
});
// And an orchestrator-style config that marks the project provider as gitlab.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
issueTracker:
eventSource:
pollInterval: 5m
provider: gitlab
repo: group/subgroup/sample
path: ${repoDir.path}
defaultBranch: main
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
glabCommand: glabScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the app processes one polling cycle.
await app.runOnce();
final thread = await app.database.findIssueThread('sample', 12);
await app.close();
// Then it records the evaluation result as no_reply.
expect(thread, isNotNull);
expect(thread!.lastDecision, 'no_reply');
expect(thread.lastCommentId, isNull);
// And it adds an eyes award emoji to the triggering GitLab note.
final logLines = await glabLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains(
'projects/group%2Fsubgroup%2Fsample/issues/12/notes/501/award_emoji',
),
)
.length,
1,
);
expect(await reactionBody.readAsString(), 'eyes');
// And it does not post a normal issue note.
expect(
logLines
.where(
(line) =>
line.contains('-X POST') &&
line.contains(
'projects/group%2Fsubgroup%2Fsample/issues/12/notes ',
),
)
.length,
0,
);
},
);
/// ```gherkin
/// Scenario: Acknowledge no_reply on OpenProject by toggling an eyes emoji on the activity
/// Given a temporary project checkout that can be used as the local repo path
/// And an OpenProject API fixture containing a human activity comment with an explicit assistant mention
/// And a responder that returns no_reply
/// And an orchestrator-style config that uses OpenProject as the tracker provider
/// When the app processes one polling cycle
/// Then it records the evaluation result as no_reply
/// And it toggles the eyes emoji reaction for the triggering OpenProject activity
/// And it does not post a normal OpenProject activity comment
/// ```
test(
'Acknowledge no_reply on OpenProject by toggling an eyes emoji on the activity',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-openproject-no-reply-ack-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And an OpenProject API fixture containing a human activity comment with an explicit assistant mention.
final server = await _FakeOpenProjectNoReplyServer.start();
addTearDown(server.close);
// And a responder that returns no_reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'thread already covered',
});
// And an orchestrator-style config that uses OpenProject as the tracker provider.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
issueTracker:
eventSource:
pollInterval: 5m
provider: openproject
scm:
provider: github
repo: Demo project
path: ${repoDir.path}
defaultBranch: main
''');
await File(p.join(sandbox.path, '.env')).writeAsString('''
OP_CLI_HOST=${server.baseUrl}
OP_CLI_TOKEN=test-token
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the app processes one polling cycle.
await app.runOnce();
final thread = await app.database.findIssueThread('sample', 77);
await app.close();
// Then it records the evaluation result as no_reply.
expect(thread, isNotNull);
expect(thread!.lastDecision, 'no_reply');
expect(thread.lastCommentId, isNull);
// And it toggles the eyes emoji reaction for the triggering OpenProject activity.
expect(
server.requestPaths,
contains('/api/v3/activities/501/emoji_reactions'),
);
expect(server.reactionBodies, hasLength(1));
expect(
(jsonDecode(server.reactionBodies.single)
as Map<String, dynamic>)['reaction'],
'eyes',
);
// And it does not post a normal OpenProject activity comment.
expect(server.postedBodies, isEmpty);
},
);
});
}
class _FakeOpenProjectNoReplyServer {
_FakeOpenProjectNoReplyServer._(this._server);
final HttpServer _server;
final List<String> requestPaths = <String>[];
final List<String> postedBodies = <String>[];
final List<String> reactionBodies = <String>[];
String get baseUrl => 'http://${_server.address.host}:${_server.port}';
static Future<_FakeOpenProjectNoReplyServer> start() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fixture = _FakeOpenProjectNoReplyServer._(server);
unawaited(
server.forEach((request) async {
fixture.requestPaths.add(request.uri.toString());
final authHeader = request.headers.value(
HttpHeaders.authorizationHeader,
);
if (authHeader !=
'Basic ${base64Encode(utf8.encode('apikey:test-token'))}') {
request.response.statusCode = HttpStatus.unauthorized;
await request.response.close();
return;
}
final response = request.response
..headers.contentType = ContentType.json;
if (request.method == 'GET' && request.uri.path == '/api/v3/projects') {
response.write(
jsonEncode({
'_embedded': {
'elements': [
{'id': 42, 'name': 'Demo project'},
],
},
}),
);
await response.close();
return;
}
if (request.method == 'GET' &&
request.uri.path == '/api/v3/projects/42/work_packages') {
response.write(
jsonEncode({
'_embedded': {
'elements': [
{
'id': 77,
'subject': 'Need OpenProject support',
'updatedAt': '2026-04-04T12:00:00Z',
'description': {'raw': 'Please add OpenProject support.'},
'_links': {
'self': {
'href': 'https://openproject.example.test/wp/77',
},
'author': {'title': 'reporter'},
},
},
],
},
}),
);
await response.close();
return;
}
if (request.method == 'GET' &&
request.uri.path == '/api/v3/work_packages/77/activities') {
response.write(
jsonEncode({
'_embedded': {
'elements': [
{
'id': 501,
'comment': {'raw': '@helper this is already resolved'},
'createdAt': '2026-04-04T12:00:00Z',
'updatedAt': '2026-04-04T12:00:00Z',
'_links': {
'self': {'href': '/api/v3/activities/501'},
'user': {'title': 'reporter'},
},
},
],
},
}),
);
await response.close();
return;
}
if (request.method == 'PATCH' &&
request.uri.path == '/api/v3/activities/501/emoji_reactions') {
final body = await utf8.decoder.bind(request).join();
fixture.reactionBodies.add(body);
response.write(
jsonEncode({
'_type': 'EmojiReaction',
'id': '501-eyes',
'reaction': 'eyes',
'emoji': 'eyes',
}),
);
await response.close();
return;
}
if (request.method == 'POST' &&
request.uri.path == '/api/v3/work_packages/77/activities') {
final body = await utf8.decoder.bind(request).join();
fixture.postedBodies.add(body);
response.statusCode = HttpStatus.created;
response.write(jsonEncode({'id': 701}));
await response.close();
return;
}
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
}),
);
return fixture;
}
Future<void> close() => _server.close(force: true);
}

View File

@ -24,6 +24,7 @@ class FakeGiteaApiServer {
final String token; final String token;
final List<String> requestPaths = <String>[]; final List<String> requestPaths = <String>[];
final List<String> postedBodies = <String>[]; final List<String> postedBodies = <String>[];
final List<String> reactionBodies = <String>[];
final List<Map<String, dynamic>> createdWebhooks = <Map<String, dynamic>>[]; final List<Map<String, dynamic>> createdWebhooks = <Map<String, dynamic>>[];
final List<int> deletedWebhookIds = <int>[]; final List<int> deletedWebhookIds = <int>[];
final Map<String, int> _issueListRequestCounts = <String, int>{}; final Map<String, int> _issueListRequestCounts = <String, int>{};
@ -149,6 +150,21 @@ class FakeGiteaApiServer {
return; return;
} }
} }
if (segments.length == 9 &&
segments[5] == 'issues' &&
segments[6] == 'comments' &&
segments[8] == 'reactions' &&
request.method == 'POST') {
final body = await utf8.decodeStream(request);
reactionBodies.add(body);
response.statusCode = HttpStatus.created;
response.write(
jsonEncode(<String, Object?>{'id': 990, 'content': 'eyes'}),
);
await response.close();
return;
}
} }
response.statusCode = HttpStatus.notFound; response.statusCode = HttpStatus.notFound;

View File

@ -11,6 +11,7 @@ Future<void> writeFakeGlabScript({
File? glabLog, File? glabLog,
Map<String, Set<int>>? postCommentIssueNumbersByRepo, Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile, File? postedBodyFile,
File? reactionBodyFile,
String? viewerLogin, String? viewerLogin,
bool failViewerLookup = false, bool failViewerLookup = false,
Map<String, int>? webhookIdsByRepo, Map<String, int>? webhookIdsByRepo,
@ -75,6 +76,29 @@ Future<void> writeFakeGlabScript({
} }
} }
for (final repoEntry in commentResponsesByRepo.entries) {
final repo = Uri.encodeComponent(repoEntry.key);
for (final issueEntry in repoEntry.value.entries) {
for (final comment in issueEntry.value) {
final commentId = comment['id'];
if (commentId is int &&
_matches(arguments, 0, 'api') &&
_matches(arguments, 2, 'POST') &&
_matches(
arguments,
3,
'projects/$repo/issues/${issueEntry.key}/notes/$commentId/award_emoji',
)) {
final reaction = _valueArg(arguments, 'name=');
if (reactionBodyFile != null && reaction != null) {
await reactionBodyFile.writeAsString(reaction);
}
return result({'name': reaction ?? 'eyes'});
}
}
}
}
for (final repoEntry in postIssueNumbers.entries) { for (final repoEntry in postIssueNumbers.entries) {
final repo = Uri.encodeComponent(repoEntry.key); final repo = Uri.encodeComponent(repoEntry.key);
for (final issueNumber in repoEntry.value) { for (final issueNumber in repoEntry.value) {