diff --git a/.agents/skills/.gitignore b/.agents/skills/.gitignore new file mode 100644 index 0000000..72e2b53 --- /dev/null +++ b/.agents/skills/.gitignore @@ -0,0 +1,3 @@ +/* +!/.gitignore +!/dart-gherkin-tests diff --git a/skills/dart-gherkin-tests/SKILL.md b/.agents/skills/dart-gherkin-tests/SKILL.md similarity index 100% rename from skills/dart-gherkin-tests/SKILL.md rename to .agents/skills/dart-gherkin-tests/SKILL.md diff --git a/.gitignore b/.gitignore index 2d55045..9b1e487 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,12 @@ pubspec.lock .code_work_spawner.sqlite3 agent-orchestrator.yaml /.* -# !/.agents +!/.agents !/.git !/.github !/.vscode /skills/* -!/skills/dart-gherkin-tests +# !/skills/dart-gherkin-tests *.g.dart **/*.exe /CWS*.yaml diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index 8dd2254..c99b7f3 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -402,17 +402,32 @@ 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, - ); + if (noReplyReactionTarget == null) { _log( 'issue=${project.key}#${issue.number} ' - 'no_reply_reaction=${reacted ? "posted" : "unsupported"} ' - 'comment_id=${noReplyReactionTarget.id}', + 'no_reply_reaction=skipped reason=no_human_comment', ); + } 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( projectKey: project.key, @@ -592,14 +607,12 @@ class IssueAssistantApp { ProjectConfig project, IssueThread thread, ) { - final latestComment = thread.latestComment; - if (latestComment == null) { - return null; + for (final comment in thread.comments.reversed) { + if (!_looksLikeBot(project, comment.userLogin, comment.body)) { + return comment; + } } - if (_looksLikeBot(project, latestComment.userLogin, latestComment.body)) { - return null; - } - return latestComment; + return null; } int _countResponsesSinceLatestHuman( diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 1175dc2..997a964 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -204,6 +204,7 @@ class IssueTrackerClient { Future createNoReplyReaction({ required IssueTrackerProvider provider, required String trackerProject, + required int issueNumber, required int commentId, }) async { switch (provider) { @@ -224,13 +225,101 @@ class IssueTrackerClient { 'content=eyes', ]); return true; - case IssueTrackerProvider.gitlab: case IssueTrackerProvider.gitea: + try { + await _runGiteaJson( + method: 'POST', + path: _buildApiPath(parseRepositorySlug(trackerProject), [ + 'issues', + 'comments', + '$commentId', + 'reactions', + ]), + body: {'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: - return false; + try { + await _runOpenProjectJson( + method: 'PATCH', + path: '/api/v3/activities/$commentId/emoji_reactions', + body: {'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 getAuthenticatedLogin({ required IssueTrackerProvider provider, }) { diff --git a/skills-lock.json b/skills-lock.json index 8777218..e2fd9df 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -944,7 +944,7 @@ "worktrunk": { "source": "max-sixty/worktrunk", "sourceType": "github", - "computedHash": "e95e53e4ef26baddf9efb6b6c4e08276384edba9d7e9269d61a11f2c6871c6d5" + "computedHash": "252c02ad30d62a966f6eff331be0b440c058bb346462b8ef0dab276c9ea2abbc" }, "write-coding-standards-from-file": { "source": "github/awesome-copilot", diff --git a/test/core/no_reply_acknowledgement_test.dart b/test/core/no_reply_acknowledgement_test.dart new file mode 100644 index 0000000..29b93bf --- /dev/null +++ b/test/core/no_reply_acknowledgement_test.dart @@ -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)['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 [], + }, + ], + }, + 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)['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 requestPaths = []; + final List postedBodies = []; + final List reactionBodies = []; + + 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 close() => _server.close(force: true); +} diff --git a/test/support/fake_gitea_api.dart b/test/support/fake_gitea_api.dart index 91ebf59..f23074a 100644 --- a/test/support/fake_gitea_api.dart +++ b/test/support/fake_gitea_api.dart @@ -24,6 +24,7 @@ class FakeGiteaApiServer { final String token; final List requestPaths = []; final List postedBodies = []; + final List reactionBodies = []; final List> createdWebhooks = >[]; final List deletedWebhookIds = []; final Map _issueListRequestCounts = {}; @@ -149,6 +150,21 @@ class FakeGiteaApiServer { 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({'id': 990, 'content': 'eyes'}), + ); + await response.close(); + return; + } } response.statusCode = HttpStatus.notFound; diff --git a/test/support/fake_glab.dart b/test/support/fake_glab.dart index 4ea0a00..a1893c7 100644 --- a/test/support/fake_glab.dart +++ b/test/support/fake_glab.dart @@ -11,6 +11,7 @@ Future writeFakeGlabScript({ File? glabLog, Map>? postCommentIssueNumbersByRepo, File? postedBodyFile, + File? reactionBodyFile, String? viewerLogin, bool failViewerLookup = false, Map? webhookIdsByRepo, @@ -75,6 +76,29 @@ Future 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) { final repo = Uri.encodeComponent(repoEntry.key); for (final issueNumber in repoEntry.value) {