feat: improve emoji reaction to no-reply comment in issues
This commit is contained in:
parent
da3b15fdd7
commit
0e9bd11d5c
|
|
@ -0,0 +1,3 @@
|
|||
/*
|
||||
!/.gitignore
|
||||
!/dart-gherkin-tests
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -402,10 +402,17 @@ class IssueAssistantApp {
|
|||
|
||||
if (!selectedResult.shouldReply) {
|
||||
final noReplyReactionTarget = _latestHumanComment(project, thread);
|
||||
if (noReplyReactionTarget != null) {
|
||||
if (noReplyReactionTarget == null) {
|
||||
_log(
|
||||
'issue=${project.key}#${issue.number} '
|
||||
'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(
|
||||
|
|
@ -413,6 +420,14 @@ class IssueAssistantApp {
|
|||
'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(
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ class IssueTrackerClient {
|
|||
Future<bool> createNoReplyReaction({
|
||||
required IssueTrackerProvider provider,
|
||||
required String trackerProject,
|
||||
required int issueNumber,
|
||||
required int commentId,
|
||||
}) async {
|
||||
switch (provider) {
|
||||
|
|
@ -224,11 +225,99 @@ class IssueTrackerClient {
|
|||
'content=eyes',
|
||||
]);
|
||||
return true;
|
||||
case IssueTrackerProvider.gitlab:
|
||||
case IssueTrackerProvider.gitea:
|
||||
case IssueTrackerProvider.openproject:
|
||||
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:
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -944,7 +944,7 @@
|
|||
"worktrunk": {
|
||||
"source": "max-sixty/worktrunk",
|
||||
"sourceType": "github",
|
||||
"computedHash": "e95e53e4ef26baddf9efb6b6c4e08276384edba9d7e9269d61a11f2c6871c6d5"
|
||||
"computedHash": "252c02ad30d62a966f6eff331be0b440c058bb346462b8ef0dab276c9ea2abbc"
|
||||
},
|
||||
"write-coding-standards-from-file": {
|
||||
"source": "github/awesome-copilot",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ class FakeGiteaApiServer {
|
|||
final String token;
|
||||
final List<String> requestPaths = <String>[];
|
||||
final List<String> postedBodies = <String>[];
|
||||
final List<String> reactionBodies = <String>[];
|
||||
final List<Map<String, dynamic>> createdWebhooks = <Map<String, dynamic>>[];
|
||||
final List<int> deletedWebhookIds = <int>[];
|
||||
final Map<String, int> _issueListRequestCounts = <String, int>{};
|
||||
|
|
@ -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(<String, Object?>{'id': 990, 'content': 'eyes'}),
|
||||
);
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
response.statusCode = HttpStatus.notFound;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Future<void> writeFakeGlabScript({
|
|||
File? glabLog,
|
||||
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
|
||||
File? postedBodyFile,
|
||||
File? reactionBodyFile,
|
||||
String? viewerLogin,
|
||||
bool failViewerLookup = false,
|
||||
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) {
|
||||
final repo = Uri.encodeComponent(repoEntry.key);
|
||||
for (final issueNumber in repoEntry.value) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue