From 5d3b4ff2c1ec2a31c06303294922ea2d8316db82 Mon Sep 17 00:00:00 2001 From: insleker Date: Sun, 5 Apr 2026 00:51:37 +0800 Subject: [PATCH] refactor: git and GitHub integration by add corresponding handle libs --- lib/src/config.dart | 25 +++++++++++++++++++- lib/src/github_client.dart | 40 +++++++++++++++++--------------- lib/src/issue_assistant_app.dart | 6 ++--- lib/src/workspace_manager.dart | 5 +++- pubspec.yaml | 2 ++ test/app_config_test.dart | 37 +++++++++++++++++++++++++++++ test/test_support.dart | 3 ++- 7 files changed, 93 insertions(+), 25 deletions(-) diff --git a/lib/src/config.dart b/lib/src/config.dart index b0a7781..b554dbc 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:github/github.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; @@ -201,6 +202,7 @@ class ProjectConfig { ProjectConfig({ required this.key, required this.repo, + required this.repoSlug, required this.path, required this.defaultBranch, required this.sessionPrefix, @@ -209,6 +211,7 @@ class ProjectConfig { final String key; final String repo; + final RepositorySlug repoSlug; final String path; final String defaultBranch; final String sessionPrefix; @@ -225,9 +228,11 @@ class ProjectConfig { ...defaultAssistantMap, ...assistantMap, }; + final repo = map['repo']?.toString() ?? _missing('projects.$key.repo'); return ProjectConfig( key: key, - repo: map['repo']?.toString() ?? _missing('projects.$key.repo'), + repo: repo, + repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), path: _expandHome( map['path']?.toString() ?? _missing('projects.$key.path'), ), @@ -248,6 +253,24 @@ class ProjectConfig { static String _missing(String field) { throw FormatException('Missing required config field: $field'); } + + static RepositorySlug _parseRepositorySlug( + String value, { + required String field, + }) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw FormatException('Missing required config field: $field'); + } + + final parts = trimmed.split('/'); + if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { + throw FormatException( + 'Invalid repository slug for $field: "$value". Expected owner/repo.', + ); + } + return RepositorySlug(parts[0], parts[1]); + } } class IssueAssistantConfig { diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 259ab39..03f0757 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:github/github.dart'; + class GitHubClient { GitHubClient({this.ghCommand = 'gh'}); @@ -8,7 +10,7 @@ class GitHubClient { Future? _authenticatedLoginFuture; Future> fetchUpdatedIssues({ - required String repo, + required RepositorySlug repo, required DateTime? since, }) async { final query = { @@ -23,7 +25,7 @@ class GitHubClient { 'api', '-X', 'GET', - 'repos/$repo/issues${_encodeQuery(query)}', + _buildApiPath(repo, ['issues'], query), ]); return (json as List) @@ -34,14 +36,18 @@ class GitHubClient { } Future fetchThread({ - required String repo, + required RepositorySlug repo, required GitHubIssueSummary issue, }) async { final comments = await _runJson([ 'api', '-X', 'GET', - 'repos/$repo/issues/${issue.number}/comments?per_page=100', + _buildApiPath( + repo, + ['issues', '${issue.number}', 'comments'], + {'per_page': '100'}, + ), ]); return IssueThread( @@ -54,7 +60,7 @@ class GitHubClient { } Future createIssueComment({ - required String repo, + required RepositorySlug repo, required int issueNumber, required String body, }) async { @@ -62,7 +68,7 @@ class GitHubClient { 'api', '-X', 'POST', - 'repos/$repo/issues/$issueNumber/comments', + _buildApiPath(repo, ['issues', '$issueNumber', 'comments']), '-f', 'body=$body', ]); @@ -109,19 +115,15 @@ class GitHubClient { return jsonDecode(stdoutText); } - String _encodeQuery(Map query) { - if (query.isEmpty) { - return ''; - } - - final encoded = query.entries - .map( - (entry) => - '${Uri.encodeQueryComponent(entry.key)}=' - '${Uri.encodeQueryComponent(entry.value)}', - ) - .join('&'); - return '?$encoded'; + String _buildApiPath( + RepositorySlug repo, + List tailSegments, [ + Map query = const {}, + ]) { + return Uri( + pathSegments: ['repos', repo.owner, repo.name, ...tailSegments], + queryParameters: query.isEmpty ? null : query, + ).toString(); } } diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index f2c88dd..daa32a0 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -191,7 +191,7 @@ class IssueAssistantApp { 'since=${since?.toUtc().toIso8601String() ?? "initial"}', ); final issues = await githubClient.fetchUpdatedIssues( - repo: project.repo, + repo: project.repoSlug, since: since, ); _log('project=${project.key} fetched_issues=${issues.length}'); @@ -273,7 +273,7 @@ class IssueAssistantApp { GitHubIssueSummary issue, ) async { final thread = await githubClient.fetchThread( - repo: project.repo, + repo: project.repoSlug, issue: issue, ); final fingerprint = _fingerprintThread(thread); @@ -465,7 +465,7 @@ class IssueAssistantApp { markdown: selectedResult.markdown, ); final posted = await githubClient.createIssueComment( - repo: project.repo, + repo: project.repoSlug, issueNumber: issue.number, body: commentBody, ); diff --git a/lib/src/workspace_manager.dart b/lib/src/workspace_manager.dart index 5414179..2fb5fef 100644 --- a/lib/src/workspace_manager.dart +++ b/lib/src/workspace_manager.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:git/git.dart'; import 'package:path/path.dart' as p; class WorkspaceManager { @@ -18,11 +19,12 @@ class WorkspaceManager { }; Future createEphemeralWorktree(String projectPath) async { + final repository = await GitDir.fromExisting(projectPath); final parent = await _createWorktreeParent(); final workspacePath = p.join(parent.path, 'repo'); final command = [ '-C', - projectPath, + repository.path, 'worktree', 'add', '--detach', @@ -59,6 +61,7 @@ class WorkspaceManager { } Future disposeEphemeralWorktree(String workspacePath) async { + await GitDir.fromExisting(workspacePath); final result = await _runGit([ '-C', workspacePath, diff --git a/pubspec.yaml b/pubspec.yaml index 6ca045f..628a65e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: dotenv: ^4.2.0 logging: ^1.3.0 meta: ^1.16.0 + github: ^9.25.0 + git: ^2.3.2 dev_dependencies: build_runner: ^2.6.0 diff --git a/test/app_config_test.dart b/test/app_config_test.dart index 07915dc..341b4c3 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -53,6 +53,8 @@ projects: // Then the project keeps inherited defaults that were not overridden. expect(config.projects['sample'], isNotNull); + expect(config.projects['sample']!.repoSlug.owner, 'owner'); + expect(config.projects['sample']!.repoSlug.name, 'sample'); expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ '@helper', ]); @@ -257,5 +259,40 @@ projects: ), ); }); + + /// ```gherkin + /// Scenario: Reject malformed repository slugs + /// Given an orchestrator config whose project repo is not in owner/repo format + /// When loading the app config from disk + /// Then loading fails with a clear repository slug error + /// ``` + test('Reject malformed repository slugs', () async { + // Given an orchestrator config whose project repo is not in owner/repo format. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-invalid-repo-slug-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + repo: owner + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final load = AppConfig.load(configFile.path); + + // Then loading fails with a clear repository slug error. + await expectLater( + load, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Invalid repository slug for projects.sample.repo'), + ), + ), + ); + }); }); } diff --git a/test/test_support.dart b/test/test_support.dart index 5221153..402373f 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:git/git.dart'; import 'package:path/path.dart' as p; Future writeFakeGhScript({ @@ -162,7 +163,7 @@ JSON } Future initGitRepo(Directory directory) async { - await runGit(['init'], workingDirectory: directory.path); + await GitDir.init(directory.path, allowContent: true); await runGit([ 'config', 'user.email',