forked from bkinnightskytw/code_work_spawner
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 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(
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue