forked from bkinnightskytw/code_work_spawner
284 lines
12 KiB
Dart
284 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
|
import 'package:github/github.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:test/test.dart';
|
|
|
|
import 'test_support.dart';
|
|
|
|
void main() {
|
|
/// ```gherkin
|
|
/// Feature: Issue tracker payload mapping
|
|
///
|
|
/// As a maintainer extending tracker providers
|
|
/// I want issue summaries to serialize and deserialize consistently
|
|
/// So that stored tracker state can be reused safely across provider-specific API calls
|
|
/// ```
|
|
group('GitHubIssueSummary.fromJson', () {
|
|
/// ```gherkin
|
|
/// Scenario: Preserve repository slugs for repository-backed trackers
|
|
/// Given a GitHub issue summary created from a repository-backed tracker payload
|
|
/// When the summary is serialized and read back through fromJson
|
|
/// Then the repository slug remains available on the deserialized summary
|
|
/// And requiredRepoSlug still returns the original owner and repository
|
|
/// ```
|
|
test('Preserve repository slugs for repository-backed trackers', () {
|
|
// Given a GitHub issue summary created from a repository-backed tracker payload.
|
|
final original = GitHubIssueSummary.fromRepositoryJson(
|
|
RepositorySlug('owner', 'sample'),
|
|
<String, dynamic>{
|
|
'number': 12,
|
|
'title': 'Need architecture help',
|
|
'body': 'Please review the API layering.',
|
|
'state': 'open',
|
|
'html_url': 'https://example.test/issues/12',
|
|
'updated_at': '2026-04-04T12:00:00Z',
|
|
'user': <String, dynamic>{'login': 'reporter'},
|
|
'labels': <Map<String, String>>[
|
|
<String, String>{'name': 'help'},
|
|
],
|
|
},
|
|
);
|
|
|
|
// When the summary is serialized and read back through fromJson.
|
|
final restored = GitHubIssueSummary.fromJson(
|
|
original.toJson().cast<String, dynamic>(),
|
|
);
|
|
|
|
// Then the repository slug remains available on the deserialized summary.
|
|
expect(restored.repoSlug, isNotNull);
|
|
|
|
// And requiredRepoSlug still returns the original owner and repository.
|
|
expect(restored.requiredRepoSlug.owner, 'owner');
|
|
expect(restored.requiredRepoSlug.name, 'sample');
|
|
});
|
|
|
|
/// ```gherkin
|
|
/// Scenario: Preserve GitLab project paths without forcing owner/repo slugs
|
|
/// Given a GitLab issue summary created from a namespaced GitLab project payload
|
|
/// When the summary is serialized and read back through fromJson
|
|
/// Then the original GitLab project path remains available on the deserialized summary
|
|
/// And the deserialized summary does not invent a two-segment repository slug
|
|
/// ```
|
|
test('Preserve GitLab project paths without forcing owner/repo slugs', () {
|
|
// Given a GitLab issue summary created from a namespaced GitLab project payload.
|
|
final original = GitHubIssueSummary.fromGitLabJson(
|
|
'group/subgroup/sample',
|
|
<String, dynamic>{
|
|
'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': <String, dynamic>{'username': 'reporter'},
|
|
'labels': <String>['enhancement'],
|
|
},
|
|
);
|
|
|
|
// When the summary is serialized and read back through fromJson.
|
|
final restored = GitHubIssueSummary.fromJson(
|
|
original.toJson().cast<String, dynamic>(),
|
|
);
|
|
|
|
// Then the original GitLab project path remains available on the deserialized summary.
|
|
expect(restored.trackerProject, 'group/subgroup/sample');
|
|
|
|
// And the deserialized summary does not invent a two-segment repository slug.
|
|
expect(restored.repoSlug, isNull);
|
|
});
|
|
});
|
|
|
|
/// ```gherkin
|
|
/// Feature: GitLab tracker client integration
|
|
///
|
|
/// As a maintainer adding GitLab issue tracking
|
|
/// I want the tracker client to talk to glab and normalize GitLab payloads
|
|
/// So that polling, thread loading, and comment posting work like the existing providers
|
|
/// ```
|
|
group('IssueTrackerClient GitLab', () {
|
|
/// ```gherkin
|
|
/// Scenario: Fetch GitLab issues, comments, and login through glab
|
|
/// Given a fake glab executable with one GitLab issue and one note
|
|
/// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment
|
|
/// Then the GitLab issue payload is normalized into the shared issue summary model
|
|
/// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL
|
|
/// ```
|
|
test('Fetch GitLab issues, comments, and login through glab', () async {
|
|
// Given a fake glab executable with one GitLab issue and one note.
|
|
final sandbox = await Directory.systemTemp.createTemp('cws-glab-');
|
|
final glabScript = File(p.join(sandbox.path, 'glab'));
|
|
final postedBodyFile = File(p.join(sandbox.path, 'posted-body.txt'));
|
|
await writeFakeGlabScript(
|
|
glabScript: glabScript,
|
|
issueListResponsesByRepo: <String, List<Map<String, Object?>>>{
|
|
'group/subgroup/sample': <Map<String, Object?>>[
|
|
<String, Object?>{
|
|
'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': <String, Object?>{'username': 'reporter'},
|
|
'labels': <String>['enhancement'],
|
|
},
|
|
],
|
|
},
|
|
commentResponsesByRepo: <String, Map<int, List<Map<String, Object?>>>>{
|
|
'group/subgroup/sample': <int, List<Map<String, Object?>>>{
|
|
12: <Map<String, Object?>>[
|
|
<String, Object?>{
|
|
'id': 501,
|
|
'body': 'Working on it.',
|
|
'created_at': '2026-04-04T12:05:00Z',
|
|
'updated_at': '2026-04-04T12:06:00Z',
|
|
'author': <String, Object?>{'username': 'maintainer'},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
postedBodyFile: postedBodyFile,
|
|
viewerLogin: 'glab-user',
|
|
);
|
|
final client = IssueTrackerClient(glabCommand: glabScript.path);
|
|
|
|
// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment.
|
|
final issues = await client.fetchUpdatedIssuesForRepos(
|
|
provider: IssueTrackerProvider.gitlab,
|
|
trackerProjects: const <String>['group/subgroup/sample'],
|
|
since: null,
|
|
);
|
|
final thread = await client.fetchThread(
|
|
provider: IssueTrackerProvider.gitlab,
|
|
trackerProject: 'group/subgroup/sample',
|
|
issue: issues.single,
|
|
);
|
|
final login = await client.getAuthenticatedLogin(
|
|
provider: IssueTrackerProvider.gitlab,
|
|
);
|
|
final posted = await client.createIssueComment(
|
|
provider: IssueTrackerProvider.gitlab,
|
|
trackerProject: 'group/subgroup/sample',
|
|
issueNumber: 12,
|
|
body: 'Reply from glab',
|
|
);
|
|
|
|
// Then the GitLab issue payload is normalized into the shared issue summary model.
|
|
expect(client.trackerCliName(IssueTrackerProvider.gitlab), 'glab');
|
|
expect(login, 'glab-user');
|
|
expect(issues.single.trackerProject, 'group/subgroup/sample');
|
|
expect(issues.single.repoSlug, isNull);
|
|
expect(issues.single.number, 12);
|
|
expect(issues.single.state, 'opened');
|
|
expect(issues.single.labels, ['enhancement']);
|
|
|
|
// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL.
|
|
expect(thread.comments.single.url, endsWith('/issues/12#note_501'));
|
|
expect(await postedBodyFile.readAsString(), 'Reply from glab');
|
|
expect(posted.id, 901);
|
|
expect(posted.url, endsWith('/issues/12#note_901'));
|
|
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'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|