From cf93e054c3cd1ce22f0043cffbe13d496b452703 Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 10 Apr 2026 13:12:12 +0800 Subject: [PATCH] fix: detect Gitea API error objects before list-casting --- lib/src/issue_tracker/client.dart | 99 ++++++++++++++++++++++++++--- test/issue_tracker_client_test.dart | 96 ++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index a25582f..9370510 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -220,7 +220,7 @@ class IssueTrackerClient { required RepositorySlug repo, required String url, }) async { - final json = await _runTeaJson([ + final result = await _runCommand(teaCommand, [ 'webhooks', 'create', '--type', @@ -234,12 +234,30 @@ class IssueTrackerClient { repo.fullName, url, ]); - if (json is! Map) { - throw const FormatException( - 'tea webhooks create returned an unexpected response.', + final stdoutText = result.stdout.toString().trim(); + if (stdoutText.isEmpty) { + throw FormatException( + 'tea webhooks create returned an empty response for repo ' + '${repo.fullName}.', ); } - return readIntField(json, 'id'); + + try { + final json = jsonDecode(stdoutText); + if (json is Map) { + return readIntField(json, 'id'); + } + } on FormatException { + final webhookId = _extractWebhookIdFromTeaOutput(stdoutText); + if (webhookId != null) { + return webhookId; + } + } + + throw FormatException( + 'tea webhooks create returned an unexpected response for repo ' + '${repo.fullName}. stdout=${_summarizeOutput(stdoutText)}', + ); } Future createGitHubIssueWebhook({ @@ -450,7 +468,19 @@ class IssueTrackerClient { '-r', repo.fullName, ]); - final items = (json as List? ?? const []) + if (json is Map) { + throw FormatException( + 'tea api returned an error while listing Gitea issues for ' + '${repo.fullName}: ${_describeJsonObject(json)}', + ); + } + if (json != null && json is! List) { + throw FormatException( + 'tea api returned ${json.runtimeType} while listing Gitea issues ' + 'for ${repo.fullName}; expected a JSON array.', + ); + } + final items = (json as List? ?? const []) .cast>(); issues.addAll( items @@ -596,6 +626,26 @@ class IssueTrackerClient { } Future _runJson(String command, List arguments) async { + final result = await _runCommand(command, arguments); + final stdoutText = result.stdout.toString().trim(); + if (stdoutText.isEmpty) { + return null; + } + + try { + return jsonDecode(stdoutText); + } on FormatException catch (error) { + throw FormatException( + 'Failed to parse JSON from `$command ${arguments.join(' ')}`: ' + '${error.message}. stdout=${_summarizeOutput(stdoutText)}', + ); + } + } + + Future _runCommand( + String command, + List arguments, + ) async { final result = await runExternalCommand(command, arguments); if (result.exitCode != 0) { throw ProcessException( @@ -605,12 +655,41 @@ class IssueTrackerClient { result.exitCode, ); } + return result; + } - final stdoutText = result.stdout.toString().trim(); - if (stdoutText.isEmpty) { - return null; + int? _extractWebhookIdFromTeaOutput(String stdoutText) { + final match = RegExp( + r'Webhook created successfully \(ID:\s*(\d+)\)', + caseSensitive: false, + ).firstMatch(stdoutText); + return match == null ? null : int.parse(match.group(1)!); + } + + String _describeJsonObject(Map json) { + final message = json['message']?.toString().trim(); + final errors = (json['errors'] as List?) + ?.map((entry) => entry.toString().trim()) + .where((entry) => entry.isNotEmpty) + .toList(growable: false); + final url = json['url']?.toString().trim(); + final parts = [ + if (message != null && message.isNotEmpty) 'message=$message', + if (errors != null && errors.isNotEmpty) 'errors=${errors.join('; ')}', + if (url != null && url.isNotEmpty) 'url=$url', + ]; + if (parts.isEmpty) { + return _summarizeOutput(jsonEncode(json)); } - return jsonDecode(stdoutText); + return parts.join(' '); + } + + String _summarizeOutput(String text, {int maxLength = 240}) { + final singleLine = text.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return '${singleLine.substring(0, maxLength)}...'; } String _buildApiPath( diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart index d42d134..a63d294 100644 --- a/test/issue_tracker_client_test.dart +++ b/test/issue_tracker_client_test.dart @@ -184,4 +184,100 @@ void main() { expect(posted.body, 'posted'); }); }); + + /// ```gherkin + /// Feature: Gitea tracker client integration + /// + /// As a maintainer debugging Gitea tracker failures + /// I want tea-specific API and webhook errors to preserve the original cause + /// So that misconfigured repos and tea output changes are easy to trace + /// ``` + group('IssueTrackerClient Gitea', () { + /// ```gherkin + /// Scenario: Read a webhook id from tea plain-text success output + /// Given a fake tea executable whose webhook create command prints plain text instead of JSON + /// When the tracker client creates a Gitea webhook + /// Then it still extracts and returns the created webhook id + /// ``` + test('Read a webhook id from tea plain-text success output', () async { + // Given a fake tea executable whose webhook create command prints plain text instead of JSON. + final sandbox = await Directory.systemTemp.createTemp('cws-tea-webhook-'); + final teaScript = File(p.join(sandbox.path, 'tea')); + await teaScript.writeAsString('''#!/usr/bin/env bash +set -euo pipefail + +if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then + printf '%s\n' 'Webhook created successfully (ID: 40)' + exit 0 +fi + +echo "unexpected tea args: \$*" >&2 +exit 1 +'''); + await makeScriptExecutable(teaScript); + final client = IssueTrackerClient(teaCommand: teaScript.path); + + // When the tracker client creates a Gitea webhook. + final webhookId = await client.createGiteaIssueWebhook( + repo: RepositorySlug('owner', 'sample'), + url: 'https://gosmee.example.test/sample', + ); + + // Then it still extracts and returns the created webhook id. + expect(webhookId, 40); + }); + + /// ```gherkin + /// Scenario: Surface Gitea API error details when issue listing returns an error object + /// Given a fake tea executable whose issue list request returns a Gitea error object + /// When the tracker client fetches updated Gitea issues + /// Then it throws a format error that includes the repo and API error details + /// ``` + test( + 'Surface Gitea API error details when issue listing returns an error object', + () async { + // Given a fake tea executable whose issue list request returns a Gitea error object. + final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-'); + final teaScript = File(p.join(sandbox.path, 'tea')); + await teaScript.writeAsString('''#!/usr/bin/env bash +set -euo pipefail + +if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then + cat <<'JSON' +{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"} +JSON + exit 0 +fi + +echo "unexpected tea args: \$*" >&2 +exit 1 +'''); + await makeScriptExecutable(teaScript); + final client = IssueTrackerClient(teaCommand: teaScript.path); + + // When the tracker client fetches updated Gitea issues. + final future = client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitea, + trackerProjects: const ['owner/sample'], + since: null, + ); + + // Then it throws a format error that includes the repo and API error details. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('owner/sample'), + contains('GetUserByName'), + contains('user redirect does not exist'), + ), + ), + ), + ); + }, + ); + }); }