refactor: git and GitHub integration by add corresponding handle libs

This commit is contained in:
insleker 2026-04-05 00:51:37 +08:00
parent eab1a3ffe8
commit 5d3b4ff2c1
7 changed files with 93 additions and 25 deletions

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:github/github.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
@ -201,6 +202,7 @@ class ProjectConfig {
ProjectConfig({ ProjectConfig({
required this.key, required this.key,
required this.repo, required this.repo,
required this.repoSlug,
required this.path, required this.path,
required this.defaultBranch, required this.defaultBranch,
required this.sessionPrefix, required this.sessionPrefix,
@ -209,6 +211,7 @@ class ProjectConfig {
final String key; final String key;
final String repo; final String repo;
final RepositorySlug repoSlug;
final String path; final String path;
final String defaultBranch; final String defaultBranch;
final String sessionPrefix; final String sessionPrefix;
@ -225,9 +228,11 @@ class ProjectConfig {
...defaultAssistantMap, ...defaultAssistantMap,
...assistantMap, ...assistantMap,
}; };
final repo = map['repo']?.toString() ?? _missing('projects.$key.repo');
return ProjectConfig( return ProjectConfig(
key: key, key: key,
repo: map['repo']?.toString() ?? _missing('projects.$key.repo'), repo: repo,
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
path: _expandHome( path: _expandHome(
map['path']?.toString() ?? _missing('projects.$key.path'), map['path']?.toString() ?? _missing('projects.$key.path'),
), ),
@ -248,6 +253,24 @@ class ProjectConfig {
static String _missing(String field) { static String _missing(String field) {
throw FormatException('Missing required config field: $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 { class IssueAssistantConfig {

View File

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:github/github.dart';
class GitHubClient { class GitHubClient {
GitHubClient({this.ghCommand = 'gh'}); GitHubClient({this.ghCommand = 'gh'});
@ -8,7 +10,7 @@ class GitHubClient {
Future<String?>? _authenticatedLoginFuture; Future<String?>? _authenticatedLoginFuture;
Future<List<GitHubIssueSummary>> fetchUpdatedIssues({ Future<List<GitHubIssueSummary>> fetchUpdatedIssues({
required String repo, required RepositorySlug repo,
required DateTime? since, required DateTime? since,
}) async { }) async {
final query = <String, String>{ final query = <String, String>{
@ -23,7 +25,7 @@ class GitHubClient {
'api', 'api',
'-X', '-X',
'GET', 'GET',
'repos/$repo/issues${_encodeQuery(query)}', _buildApiPath(repo, ['issues'], query),
]); ]);
return (json as List<dynamic>) return (json as List<dynamic>)
@ -34,14 +36,18 @@ class GitHubClient {
} }
Future<IssueThread> fetchThread({ Future<IssueThread> fetchThread({
required String repo, required RepositorySlug repo,
required GitHubIssueSummary issue, required GitHubIssueSummary issue,
}) async { }) async {
final comments = await _runJson([ final comments = await _runJson([
'api', 'api',
'-X', '-X',
'GET', 'GET',
'repos/$repo/issues/${issue.number}/comments?per_page=100', _buildApiPath(
repo,
['issues', '${issue.number}', 'comments'],
{'per_page': '100'},
),
]); ]);
return IssueThread( return IssueThread(
@ -54,7 +60,7 @@ class GitHubClient {
} }
Future<PostedComment> createIssueComment({ Future<PostedComment> createIssueComment({
required String repo, required RepositorySlug repo,
required int issueNumber, required int issueNumber,
required String body, required String body,
}) async { }) async {
@ -62,7 +68,7 @@ class GitHubClient {
'api', 'api',
'-X', '-X',
'POST', 'POST',
'repos/$repo/issues/$issueNumber/comments', _buildApiPath(repo, ['issues', '$issueNumber', 'comments']),
'-f', '-f',
'body=$body', 'body=$body',
]); ]);
@ -109,19 +115,15 @@ class GitHubClient {
return jsonDecode(stdoutText); return jsonDecode(stdoutText);
} }
String _encodeQuery(Map<String, String> query) { String _buildApiPath(
if (query.isEmpty) { RepositorySlug repo,
return ''; List<String> tailSegments, [
} Map<String, String> query = const <String, String>{},
]) {
final encoded = query.entries return Uri(
.map( pathSegments: ['repos', repo.owner, repo.name, ...tailSegments],
(entry) => queryParameters: query.isEmpty ? null : query,
'${Uri.encodeQueryComponent(entry.key)}=' ).toString();
'${Uri.encodeQueryComponent(entry.value)}',
)
.join('&');
return '?$encoded';
} }
} }

View File

@ -191,7 +191,7 @@ class IssueAssistantApp {
'since=${since?.toUtc().toIso8601String() ?? "initial"}', 'since=${since?.toUtc().toIso8601String() ?? "initial"}',
); );
final issues = await githubClient.fetchUpdatedIssues( final issues = await githubClient.fetchUpdatedIssues(
repo: project.repo, repo: project.repoSlug,
since: since, since: since,
); );
_log('project=${project.key} fetched_issues=${issues.length}'); _log('project=${project.key} fetched_issues=${issues.length}');
@ -273,7 +273,7 @@ class IssueAssistantApp {
GitHubIssueSummary issue, GitHubIssueSummary issue,
) async { ) async {
final thread = await githubClient.fetchThread( final thread = await githubClient.fetchThread(
repo: project.repo, repo: project.repoSlug,
issue: issue, issue: issue,
); );
final fingerprint = _fingerprintThread(thread); final fingerprint = _fingerprintThread(thread);
@ -465,7 +465,7 @@ class IssueAssistantApp {
markdown: selectedResult.markdown, markdown: selectedResult.markdown,
); );
final posted = await githubClient.createIssueComment( final posted = await githubClient.createIssueComment(
repo: project.repo, repo: project.repoSlug,
issueNumber: issue.number, issueNumber: issue.number,
body: commentBody, body: commentBody,
); );

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:git/git.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class WorkspaceManager { class WorkspaceManager {
@ -18,11 +19,12 @@ class WorkspaceManager {
}; };
Future<String> createEphemeralWorktree(String projectPath) async { Future<String> createEphemeralWorktree(String projectPath) async {
final repository = await GitDir.fromExisting(projectPath);
final parent = await _createWorktreeParent(); final parent = await _createWorktreeParent();
final workspacePath = p.join(parent.path, 'repo'); final workspacePath = p.join(parent.path, 'repo');
final command = [ final command = [
'-C', '-C',
projectPath, repository.path,
'worktree', 'worktree',
'add', 'add',
'--detach', '--detach',
@ -59,6 +61,7 @@ class WorkspaceManager {
} }
Future<void> disposeEphemeralWorktree(String workspacePath) async { Future<void> disposeEphemeralWorktree(String workspacePath) async {
await GitDir.fromExisting(workspacePath);
final result = await _runGit([ final result = await _runGit([
'-C', '-C',
workspacePath, workspacePath,

View File

@ -18,6 +18,8 @@ dependencies:
dotenv: ^4.2.0 dotenv: ^4.2.0
logging: ^1.3.0 logging: ^1.3.0
meta: ^1.16.0 meta: ^1.16.0
github: ^9.25.0
git: ^2.3.2
dev_dependencies: dev_dependencies:
build_runner: ^2.6.0 build_runner: ^2.6.0

View File

@ -53,6 +53,8 @@ projects:
// Then the project keeps inherited defaults that were not overridden. // Then the project keeps inherited defaults that were not overridden.
expect(config.projects['sample'], isNotNull); 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, [ expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@helper', '@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<FormatException>().having(
(error) => error.message,
'message',
contains('Invalid repository slug for projects.sample.repo'),
),
),
);
});
}); });
} }

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:git/git.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
Future<void> writeFakeGhScript({ Future<void> writeFakeGhScript({
@ -162,7 +163,7 @@ JSON
} }
Future<void> initGitRepo(Directory directory) async { Future<void> initGitRepo(Directory directory) async {
await runGit(['init'], workingDirectory: directory.path); await GitDir.init(directory.path, allowContent: true);
await runGit([ await runGit([
'config', 'config',
'user.email', 'user.email',