fix: detect Gitea API error objects before list-casting

This commit is contained in:
insleker 2026-04-10 13:12:12 +08:00
parent 4da82d7d49
commit cf93e054c3
2 changed files with 185 additions and 10 deletions

View File

@ -220,7 +220,7 @@ class IssueTrackerClient {
required RepositorySlug repo, required RepositorySlug repo,
required String url, required String url,
}) async { }) async {
final json = await _runTeaJson([ final result = await _runCommand(teaCommand, [
'webhooks', 'webhooks',
'create', 'create',
'--type', '--type',
@ -234,12 +234,30 @@ class IssueTrackerClient {
repo.fullName, repo.fullName,
url, url,
]); ]);
if (json is! Map<String, dynamic>) { final stdoutText = result.stdout.toString().trim();
throw const FormatException( if (stdoutText.isEmpty) {
'tea webhooks create returned an unexpected response.', 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<String, dynamic>) {
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<int> createGitHubIssueWebhook({ Future<int> createGitHubIssueWebhook({
@ -450,7 +468,19 @@ class IssueTrackerClient {
'-r', '-r',
repo.fullName, repo.fullName,
]); ]);
final items = (json as List<dynamic>? ?? const []) if (json is Map<String, dynamic>) {
throw FormatException(
'tea api returned an error while listing Gitea issues for '
'${repo.fullName}: ${_describeJsonObject(json)}',
);
}
if (json != null && json is! List<dynamic>) {
throw FormatException(
'tea api returned ${json.runtimeType} while listing Gitea issues '
'for ${repo.fullName}; expected a JSON array.',
);
}
final items = (json as List<dynamic>? ?? const <dynamic>[])
.cast<Map<String, dynamic>>(); .cast<Map<String, dynamic>>();
issues.addAll( issues.addAll(
items items
@ -596,6 +626,26 @@ class IssueTrackerClient {
} }
Future<Object?> _runJson(String command, List<String> arguments) async { Future<Object?> _runJson(String command, List<String> 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<ProcessResult> _runCommand(
String command,
List<String> arguments,
) async {
final result = await runExternalCommand(command, arguments); final result = await runExternalCommand(command, arguments);
if (result.exitCode != 0) { if (result.exitCode != 0) {
throw ProcessException( throw ProcessException(
@ -605,12 +655,41 @@ class IssueTrackerClient {
result.exitCode, result.exitCode,
); );
} }
return result;
}
final stdoutText = result.stdout.toString().trim(); int? _extractWebhookIdFromTeaOutput(String stdoutText) {
if (stdoutText.isEmpty) { final match = RegExp(
return null; r'Webhook created successfully \(ID:\s*(\d+)\)',
caseSensitive: false,
).firstMatch(stdoutText);
return match == null ? null : int.parse(match.group(1)!);
}
String _describeJsonObject(Map<String, dynamic> json) {
final message = json['message']?.toString().trim();
final errors = (json['errors'] as List<dynamic>?)
?.map((entry) => entry.toString().trim())
.where((entry) => entry.isNotEmpty)
.toList(growable: false);
final url = json['url']?.toString().trim();
final parts = <String>[
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( String _buildApiPath(

View File

@ -184,4 +184,100 @@ void main() {
expect(posted.body, 'posted'); 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 <String>['owner/sample'],
since: null,
);
// Then it throws a format error that includes the repo and API error details.
await expectLater(
future,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
allOf(
contains('owner/sample'),
contains('GetUserByName'),
contains('user redirect does not exist'),
),
),
),
);
},
);
});
} }