fix: detect Gitea API error objects before list-casting
This commit is contained in:
parent
4da82d7d49
commit
cf93e054c3
|
|
@ -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,13 +234,31 @@ class IssueTrackerClient {
|
|||
repo.fullName,
|
||||
url,
|
||||
]);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
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}.',
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
required RepositorySlug repo,
|
||||
|
|
@ -450,7 +468,19 @@ class IssueTrackerClient {
|
|||
'-r',
|
||||
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>>();
|
||||
issues.addAll(
|
||||
items
|
||||
|
|
@ -596,6 +626,26 @@ class IssueTrackerClient {
|
|||
}
|
||||
|
||||
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);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
|
|
@ -605,12 +655,41 @@ class IssueTrackerClient {
|
|||
result.exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
final stdoutText = result.stdout.toString().trim();
|
||||
if (stdoutText.isEmpty) {
|
||||
return null;
|
||||
return result;
|
||||
}
|
||||
return jsonDecode(stdoutText);
|
||||
|
||||
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<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 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(
|
||||
|
|
|
|||
|
|
@ -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 <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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue