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'), { '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': {'login': 'reporter'}, 'labels': >[ {'name': 'help'}, ], }, ); // When the summary is serialized and read back through fromJson. final restored = GitHubIssueSummary.fromJson( original.toJson().cast(), ); // 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', { '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': {'username': 'reporter'}, 'labels': ['enhancement'], }, ); // When the summary is serialized and read back through fromJson. final restored = GitHubIssueSummary.fromJson( original.toJson().cast(), ); // 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: >>{ 'group/subgroup/sample': >[ { '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': {'username': 'reporter'}, 'labels': ['enhancement'], }, ], }, commentResponsesByRepo: >>>{ 'group/subgroup/sample': >>{ 12: >[ { 'id': 501, 'body': 'Working on it.', 'created_at': '2026-04-04T12:05:00Z', 'updated_at': '2026-04-04T12:06:00Z', 'author': {'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 ['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 ['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'), ), ), ), ); }, ); }); }