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: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 {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue