forked from bkinnightskytw/code_work_spawner
refactor: git and GitHub integration by add corresponding handle libs
This commit is contained in:
parent
eab1a3ffe8
commit
5d3b4ff2c1
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<String?>? _authenticatedLoginFuture;
|
||||
|
||||
Future<List<GitHubIssueSummary>> fetchUpdatedIssues({
|
||||
required String repo,
|
||||
required RepositorySlug repo,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final query = <String, String>{
|
||||
|
|
@ -23,7 +25,7 @@ class GitHubClient {
|
|||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'repos/$repo/issues${_encodeQuery(query)}',
|
||||
_buildApiPath(repo, ['issues'], query),
|
||||
]);
|
||||
|
||||
return (json as List<dynamic>)
|
||||
|
|
@ -34,14 +36,18 @@ class GitHubClient {
|
|||
}
|
||||
|
||||
Future<IssueThread> 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<PostedComment> 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<String, String> 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<String> tailSegments, [
|
||||
Map<String, String> query = const <String, String>{},
|
||||
]) {
|
||||
return Uri(
|
||||
pathSegments: ['repos', repo.owner, repo.name, ...tailSegments],
|
||||
queryParameters: query.isEmpty ? null : query,
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String> 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<void> disposeEphemeralWorktree(String workspacePath) async {
|
||||
await GitDir.fromExisting(workspacePath);
|
||||
final result = await _runGit([
|
||||
'-C',
|
||||
workspacePath,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('Invalid repository slug for projects.sample.repo'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:git/git.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
Future<void> writeFakeGhScript({
|
||||
|
|
@ -162,7 +163,7 @@ JSON
|
|||
}
|
||||
|
||||
Future<void> initGitRepo(Directory directory) async {
|
||||
await runGit(['init'], workingDirectory: directory.path);
|
||||
await GitDir.init(directory.path, allowContent: true);
|
||||
await runGit([
|
||||
'config',
|
||||
'user.email',
|
||||
|
|
|
|||
Loading…
Reference in New Issue