forked from bkinnightskytw/code_work_spawner
feat: add prefix, postfix to distinguish human and bot comment
This commit is contained in:
parent
558db6991c
commit
8469ed00e0
|
|
@ -1,4 +1,5 @@
|
||||||
# Repo Instructions
|
# Repo Instructions
|
||||||
|
|
||||||
- `lefthook install` to set up pre-commit hooks
|
- read `README.md` for general information
|
||||||
|
run `pnpx skills experimental_install`, `lefthook install` before development at first time.
|
||||||
- For any new or modified Dart test under `test/`, use the repo-local skill `dart-gherkin-tests`.
|
- For any new or modified Dart test under `test/`, use the repo-local skill `dart-gherkin-tests`.
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -25,51 +25,14 @@ It supports multi-repo configuration, responder fallback chains.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Dart SDK 3.11+
|
- Dart SDK
|
||||||
- `gh` CLI authenticated for the target repositories
|
- `gh` CLI authenticated for the target repositories
|
||||||
- Local checkouts for configured repositories
|
- Local checkouts for configured repositories
|
||||||
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Create a local `agent-orchestrator.yaml`:
|
Create a local `agent-orchestrator.yaml` follow [`/examples/README.md`](examples/README.md) as a starting point.
|
||||||
|
|
||||||
```yaml
|
|
||||||
defaults:
|
|
||||||
issueAssistant:
|
|
||||||
enabled: true
|
|
||||||
pollInterval: 5m
|
|
||||||
mentionTriggers: ["@helper", "@codex"]
|
|
||||||
commentTag: code-work-spawner
|
|
||||||
prompt: |
|
|
||||||
You are helping on {{repo}}.
|
|
||||||
Trigger: {{trigger}}
|
|
||||||
Thread:
|
|
||||||
{{thread_json}}
|
|
||||||
responders:
|
|
||||||
- id: codex
|
|
||||||
command: codex
|
|
||||||
args: ["exec", "--json"]
|
|
||||||
timeout: 10m
|
|
||||||
- id: opencode
|
|
||||||
command: opencode
|
|
||||||
timeout: 10m
|
|
||||||
|
|
||||||
projects:
|
|
||||||
my-app:
|
|
||||||
repo: owner/my-app
|
|
||||||
path: ~/code/my-app
|
|
||||||
defaultBranch: main
|
|
||||||
sessionPrefix: app
|
|
||||||
```
|
|
||||||
|
|
||||||
Project entries can override `issueAssistant` fields when a repo needs a
|
|
||||||
different prompt, mention triggers, or responder chain.
|
|
||||||
|
|
||||||
For a more minimal Agent Orchestrator compatible subset, start from
|
|
||||||
[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts
|
|
||||||
AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
|
|
||||||
`worktreeDir` for bug-verification worktrees.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Config Examples
|
||||||
|
|
||||||
|
The repo try to [follow Agent Orchestrator style config](https://github.com/ComposioHQ/agent-orchestrator/tree/main/examples).
|
||||||
|
|
||||||
|
Project entries can override `issueAssistant` fields when a repo needs a
|
||||||
|
different prompt, mention triggers, or responder chain.
|
||||||
|
|
||||||
|
Generated comments include both a visible prefix and footer by default so it is
|
||||||
|
clear the reply came from automation even when `gh` is authenticated as a human
|
||||||
|
account. Those markers support template variables such as `{{gh_login}}`,
|
||||||
|
`{{repo}}`, `{{project_key}}`, `{{issue_number}}`, `{{trigger}}`,
|
||||||
|
`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty
|
||||||
|
string to disable that section.
|
||||||
|
|
||||||
|
For a more minimal Agent Orchestrator compatible subset, start from
|
||||||
|
[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts
|
||||||
|
AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
|
||||||
|
`worktreeDir` for bug-verification worktrees.
|
||||||
|
|
@ -10,15 +10,30 @@ defaults:
|
||||||
enabled: true
|
enabled: true
|
||||||
pollInterval: 5m
|
pollInterval: 5m
|
||||||
mentionTriggers: ["@codex", "@helper"]
|
mentionTriggers: ["@codex", "@helper"]
|
||||||
|
commentPrefixTemplate: |
|
||||||
|
> [!NOTE]
|
||||||
|
> Automated reply generated by `code_work_spawner` via `gh` as `@{{gh_login}}`.
|
||||||
|
> Review it as bot output, not as a direct human response.
|
||||||
|
commentFooterTemplate: |
|
||||||
|
_Automation note: generated by `code_work_spawner` via `gh` as `@{{gh_login}}`._
|
||||||
|
# prompt: |
|
||||||
|
# You are helping on {{repo}}.
|
||||||
|
# Trigger: {{trigger}}
|
||||||
|
# Thread:
|
||||||
|
# {{thread_json}}
|
||||||
responders:
|
responders:
|
||||||
# Replace this with a responder available on your machine.
|
# Replace this with a responder available on your machine.
|
||||||
- id: codex
|
- id: codex
|
||||||
command: codex
|
command: codex
|
||||||
args: ["exec", "--json"]
|
args: ["exec", "--json"]
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
|
# - id: opencode
|
||||||
|
# command: opencode
|
||||||
|
# timeout: 10m
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
code-work-spawner:
|
code-work-spawner:
|
||||||
repo: existedinnettw/code_work_spawner
|
repo: existedinnettw/code_work_spawner
|
||||||
path: ~/code_work_spawner
|
path: ~/code_work_spawner
|
||||||
defaultBranch: main
|
defaultBranch: main
|
||||||
|
# sessionPrefix: app
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export 'src/comment_templates.dart';
|
||||||
export 'src/config.dart';
|
export 'src/config.dart';
|
||||||
export 'src/database.dart';
|
export 'src/database.dart';
|
||||||
export 'src/github_client.dart';
|
export 'src/github_client.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
const String defaultCommentPrefixTemplate = '''
|
||||||
|
> [!NOTE]
|
||||||
|
> Automated reply generated by `code_work_spawner` via `gh` as `@{{gh_login}}`.
|
||||||
|
> Review it as bot output, not as a direct human response.
|
||||||
|
''';
|
||||||
|
|
||||||
|
const String defaultCommentFooterTemplate =
|
||||||
|
'_Automation note: generated by `code_work_spawner` via `gh` '
|
||||||
|
'as `@{{gh_login}}`._';
|
||||||
|
|
@ -4,6 +4,8 @@ import 'dart:io';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:yaml/yaml.dart';
|
import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
|
import 'comment_templates.dart';
|
||||||
|
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
AppConfig({
|
AppConfig({
|
||||||
required this.configPath,
|
required this.configPath,
|
||||||
|
|
@ -257,6 +259,8 @@ class IssueAssistantConfig {
|
||||||
required this.responders,
|
required this.responders,
|
||||||
required this.capabilities,
|
required this.capabilities,
|
||||||
required this.commentTag,
|
required this.commentTag,
|
||||||
|
required this.commentPrefixTemplate,
|
||||||
|
required this.commentFooterTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
|
@ -266,6 +270,8 @@ class IssueAssistantConfig {
|
||||||
final List<CliResponderConfig> responders;
|
final List<CliResponderConfig> responders;
|
||||||
final Set<AssistantCapability> capabilities;
|
final Set<AssistantCapability> capabilities;
|
||||||
final String commentTag;
|
final String commentTag;
|
||||||
|
final String commentPrefixTemplate;
|
||||||
|
final String commentFooterTemplate;
|
||||||
|
|
||||||
factory IssueAssistantConfig.fromMap(Map<String, dynamic> map) {
|
factory IssueAssistantConfig.fromMap(Map<String, dynamic> map) {
|
||||||
final respondersNode = map['responders'];
|
final respondersNode = map['responders'];
|
||||||
|
|
@ -294,6 +300,12 @@ class IssueAssistantConfig {
|
||||||
responders: responders,
|
responders: responders,
|
||||||
capabilities: capabilities,
|
capabilities: capabilities,
|
||||||
commentTag: map['commentTag']?.toString() ?? 'code-work-spawner',
|
commentTag: map['commentTag']?.toString() ?? 'code-work-spawner',
|
||||||
|
commentPrefixTemplate:
|
||||||
|
map['commentPrefixTemplate']?.toString() ??
|
||||||
|
defaultCommentPrefixTemplate,
|
||||||
|
commentFooterTemplate:
|
||||||
|
map['commentFooterTemplate']?.toString() ??
|
||||||
|
defaultCommentFooterTemplate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
static Duration _parseDuration(String input) {
|
static Duration _parseDuration(String input) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ class GitHubClient {
|
||||||
GitHubClient({this.ghCommand = 'gh'});
|
GitHubClient({this.ghCommand = 'gh'});
|
||||||
|
|
||||||
final String ghCommand;
|
final String ghCommand;
|
||||||
|
Future<String?>? _authenticatedLoginFuture;
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> fetchUpdatedIssues({
|
Future<List<GitHubIssueSummary>> fetchUpdatedIssues({
|
||||||
required String repo,
|
required String repo,
|
||||||
|
|
@ -74,6 +75,22 @@ class GitHubClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> getAuthenticatedLogin() {
|
||||||
|
return _authenticatedLoginFuture ??= _loadAuthenticatedLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _loadAuthenticatedLogin() async {
|
||||||
|
final json = await _runJson(['api', 'user']);
|
||||||
|
if (json is! Map<String, dynamic>) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final login = json['login']?.toString().trim();
|
||||||
|
if (login == null || login.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Object?> _runJson(List<String> arguments) async {
|
Future<Object?> _runJson(List<String> arguments) async {
|
||||||
final result = await Process.run(ghCommand, arguments);
|
final result = await Process.run(ghCommand, arguments);
|
||||||
if (result.exitCode != 0) {
|
if (result.exitCode != 0) {
|
||||||
|
|
|
||||||
|
|
@ -277,9 +277,7 @@ class IssueAssistantApp {
|
||||||
stderr: rawStderr,
|
stderr: rawStderr,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (workspacePath != null) {
|
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||||
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,8 +307,10 @@ class IssueAssistantApp {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final commentBody = _decorateComment(
|
final commentBody = await _decorateComment(
|
||||||
tag: project.issueAssistant.commentTag,
|
project: project,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
trigger: trigger,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
markdown: selectedResult.markdown,
|
markdown: selectedResult.markdown,
|
||||||
);
|
);
|
||||||
|
|
@ -406,12 +406,48 @@ class IssueAssistantApp {
|
||||||
return sha256.convert(bytes).toString();
|
return sha256.convert(bytes).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _decorateComment({
|
Future<String> _decorateComment({
|
||||||
required String tag,
|
required ProjectConfig project,
|
||||||
|
required int issueNumber,
|
||||||
|
required String trigger,
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String markdown,
|
required String markdown,
|
||||||
}) {
|
}) async {
|
||||||
return '$markdown\n\n<!-- $tag:$fingerprint -->';
|
var ghLogin = 'unknown';
|
||||||
|
try {
|
||||||
|
ghLogin = await githubClient.getAuthenticatedLogin() ?? 'unknown';
|
||||||
|
} catch (error) {
|
||||||
|
_log(
|
||||||
|
'comment_identity_lookup_failed project=${project.key} '
|
||||||
|
'issue=$issueNumber error=$error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final templateValues = <String, String>{
|
||||||
|
'repo': project.repo,
|
||||||
|
'project_key': project.key,
|
||||||
|
'issue_number': issueNumber.toString(),
|
||||||
|
'trigger': trigger,
|
||||||
|
'comment_tag': project.issueAssistant.commentTag,
|
||||||
|
'fingerprint': fingerprint,
|
||||||
|
'gh_login': ghLogin,
|
||||||
|
};
|
||||||
|
final prefix = renderTemplate(
|
||||||
|
project.issueAssistant.commentPrefixTemplate,
|
||||||
|
templateValues,
|
||||||
|
).trim();
|
||||||
|
final footer = renderTemplate(
|
||||||
|
project.issueAssistant.commentFooterTemplate,
|
||||||
|
templateValues,
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
final sections = <String>[
|
||||||
|
if (prefix.isNotEmpty) prefix,
|
||||||
|
markdown.trim(),
|
||||||
|
if (footer.isNotEmpty) footer,
|
||||||
|
'<!-- ${project.issueAssistant.commentTag}:$fingerprint -->',
|
||||||
|
];
|
||||||
|
return sections.where((section) => section.isNotEmpty).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _log(String message) {
|
void _log(String message) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: AppConfig loading
|
||||||
|
///
|
||||||
|
/// As a user of the Code Work Spawner
|
||||||
|
/// I want the app configuration to correctly merge defaults with project-specific overrides
|
||||||
|
/// So that I can specify common settings once and customize only what differs for each project
|
||||||
|
/// ```
|
||||||
|
group('AppConfig.load', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Merge defaults with project overrides
|
||||||
|
/// Given AO style config with shared defaults and one project override
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the project keeps inherited defaults that were not overridden
|
||||||
|
/// And the project-specific prompt replaces the shared prompt
|
||||||
|
/// ```
|
||||||
|
test('Merge defaults with project overrides', () async {
|
||||||
|
// Given AO style config with shared defaults and one project override.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
prompt: "default {{repo}}"
|
||||||
|
commentPrefixTemplate: "> default prefix {{gh_login}}"
|
||||||
|
commentFooterTemplate: "default footer {{issue_number}}"
|
||||||
|
responders:
|
||||||
|
- id: fallback
|
||||||
|
command: responder
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
defaultBranch: main
|
||||||
|
issueAssistant:
|
||||||
|
prompt: "project {{project_key}}"
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the project keeps inherited defaults that were not overridden.
|
||||||
|
expect(config.projects['sample'], isNotNull);
|
||||||
|
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||||
|
'@helper',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.responders.single.id,
|
||||||
|
'fallback',
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the project-specific prompt replaces the shared prompt.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.prompt,
|
||||||
|
'project {{project_key}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Use exported default automation comment templates
|
||||||
|
/// Given an orchestrator config that relies on default issue assistant comment templates
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the assistant uses the exported default prefix and footer templates
|
||||||
|
/// ```
|
||||||
|
test('Use exported default automation comment templates', () async {
|
||||||
|
// Given an orchestrator config that relies on default issue assistant comment templates.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-default-templates-',
|
||||||
|
);
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the assistant uses the exported default prefix and footer templates.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.commentPrefixTemplate,
|
||||||
|
defaultCommentPrefixTemplate,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.commentFooterTemplate,
|
||||||
|
defaultCommentFooterTemplate,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Override visible comment templates per project
|
||||||
|
/// Given AO style config with shared defaults and project-specific comment templates
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the project-specific comment templates replace the shared defaults
|
||||||
|
/// ```
|
||||||
|
test('Override visible comment templates per project', () async {
|
||||||
|
// Given AO style config with shared defaults and project-specific comment templates.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-comment-template-',
|
||||||
|
);
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
commentPrefixTemplate: "> default prefix"
|
||||||
|
commentFooterTemplate: "default footer"
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
commentPrefixTemplate: ""
|
||||||
|
commentFooterTemplate: "_project footer_"
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the project-specific comment templates replace the shared defaults.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.commentPrefixTemplate,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.commentFooterTemplate,
|
||||||
|
'_project footer_',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Normalize legacy codex json flag
|
||||||
|
/// Given an orchestrator config that still uses the legacy codex --json-output flag
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the responder args are normalized to the current codex --json flag
|
||||||
|
/// ```
|
||||||
|
test('Normalize legacy codex json flag', () async {
|
||||||
|
// Given an orchestrator config that still uses the legacy codex --json-output flag.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
responders:
|
||||||
|
- id: codex
|
||||||
|
command: codex
|
||||||
|
args: ["exec", "--json-output"]
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the responder args are normalized to the current codex --json flag.
|
||||||
|
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
|
||||||
|
'exec',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Load AO subset config with shared worktree root
|
||||||
|
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the app exposes the configured data and worktree directories
|
||||||
|
/// And each project inherits the supported shared defaults
|
||||||
|
/// ```
|
||||||
|
test('Load AO subset config with shared worktree root', () async {
|
||||||
|
// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-');
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
dataDir: ~/.agent-orchestrator
|
||||||
|
worktreeDir: ~/.worktrees
|
||||||
|
defaults:
|
||||||
|
defaultBranch: trunk
|
||||||
|
sessionPrefix: ao
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the app exposes the configured data and worktree directories.
|
||||||
|
expect(
|
||||||
|
config.dataDir,
|
||||||
|
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
config.worktreeDir,
|
||||||
|
p.join(Platform.environment['HOME']!, '.worktrees'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And each project inherits the supported shared defaults.
|
||||||
|
expect(config.projects['sample'], isNotNull);
|
||||||
|
expect(config.projects['sample']!.defaultBranch, 'trunk');
|
||||||
|
expect(config.projects['sample']!.sessionPrefix, 'ao');
|
||||||
|
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reject unsupported AO fields
|
||||||
|
/// Given an AO style config that includes an unsupported field
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then loading fails with a clear config error
|
||||||
|
/// ```
|
||||||
|
test('Reject unsupported AO fields', () async {
|
||||||
|
// Given an AO style config that includes an unsupported field.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-');
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
worktreeDir: ~/.worktrees
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
tracker:
|
||||||
|
plugin: github
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final load = AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then loading fails with a clear config error.
|
||||||
|
await expectLater(
|
||||||
|
load,
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
contains('Unsupported config field: projects.sample.tracker'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
|
@ -7,223 +6,11 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
|
||||||
/// ```gherkin
|
|
||||||
/// Feature: AppConfig loading
|
|
||||||
///
|
|
||||||
/// As a user of the Code Work Spawner
|
|
||||||
/// I want the app configuration to correctly merge defaults with project-specific overrides
|
|
||||||
/// So that I can specify common settings once and customize only what differs for each project
|
|
||||||
/// ```
|
|
||||||
group('AppConfig.load', () {
|
|
||||||
/// ```gherkin
|
|
||||||
/// Scenario: Merge defaults with project overrides
|
|
||||||
/// Given AO style config with shared defaults and one project override
|
|
||||||
/// When loading the app config from disk
|
|
||||||
/// Then the project keeps inherited defaults that were not overridden
|
|
||||||
/// And the project-specific prompt replaces the shared prompt
|
|
||||||
/// ```
|
|
||||||
test('Merge defaults with project overrides', () async {
|
|
||||||
// Given AO style config with shared defaults and one project override.
|
|
||||||
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
|
||||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
|
||||||
await configFile.writeAsString('''
|
|
||||||
defaults:
|
|
||||||
issueAssistant:
|
|
||||||
enabled: true
|
|
||||||
pollInterval: 5m
|
|
||||||
mentionTriggers: ["@helper"]
|
|
||||||
prompt: "default {{repo}}"
|
|
||||||
responders:
|
|
||||||
- id: fallback
|
|
||||||
command: responder
|
|
||||||
projects:
|
|
||||||
sample:
|
|
||||||
repo: owner/sample
|
|
||||||
path: ${tempDir.path}
|
|
||||||
defaultBranch: main
|
|
||||||
issueAssistant:
|
|
||||||
prompt: "project {{project_key}}"
|
|
||||||
''');
|
|
||||||
|
|
||||||
// When loading the app config from disk.
|
|
||||||
final config = await AppConfig.load(configFile.path);
|
|
||||||
|
|
||||||
// Then the project keeps inherited defaults that were not overridden.
|
|
||||||
expect(config.projects['sample'], isNotNull);
|
|
||||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
|
||||||
'@helper',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
config.projects['sample']!.issueAssistant.responders.single.id,
|
|
||||||
'fallback',
|
|
||||||
);
|
|
||||||
|
|
||||||
// And the project-specific prompt replaces the shared prompt.
|
|
||||||
expect(
|
|
||||||
config.projects['sample']!.issueAssistant.prompt,
|
|
||||||
'project {{project_key}}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/// ```gherkin
|
|
||||||
/// Scenario: Normalize legacy codex json flag
|
|
||||||
/// Given an orchestrator config that still uses the legacy codex --json-output flag
|
|
||||||
/// When loading the app config from disk
|
|
||||||
/// Then the responder args are normalized to the current codex --json flag
|
|
||||||
/// ```
|
|
||||||
test('Normalize legacy codex json flag', () async {
|
|
||||||
// Given an orchestrator config that still uses the legacy codex --json-output flag.
|
|
||||||
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
|
|
||||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
|
||||||
await configFile.writeAsString('''
|
|
||||||
defaults:
|
|
||||||
issueAssistant:
|
|
||||||
responders:
|
|
||||||
- id: codex
|
|
||||||
command: codex
|
|
||||||
args: ["exec", "--json-output"]
|
|
||||||
projects:
|
|
||||||
sample:
|
|
||||||
repo: owner/sample
|
|
||||||
path: ${tempDir.path}
|
|
||||||
''');
|
|
||||||
|
|
||||||
// When loading the app config from disk.
|
|
||||||
final config = await AppConfig.load(configFile.path);
|
|
||||||
|
|
||||||
// Then the responder args are normalized to the current codex --json flag.
|
|
||||||
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
|
|
||||||
'exec',
|
|
||||||
'--json',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
/// ```gherkin
|
|
||||||
/// Scenario: Load AO subset config with shared worktree root
|
|
||||||
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
|
|
||||||
/// When loading the app config from disk
|
|
||||||
/// Then the app exposes the configured data and worktree directories
|
|
||||||
/// And each project inherits the supported shared defaults
|
|
||||||
/// ```
|
|
||||||
test('Load AO subset config with shared worktree root', () async {
|
|
||||||
// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults.
|
|
||||||
final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-');
|
|
||||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
|
||||||
await configFile.writeAsString('''
|
|
||||||
dataDir: ~/.agent-orchestrator
|
|
||||||
worktreeDir: ~/.worktrees
|
|
||||||
defaults:
|
|
||||||
defaultBranch: trunk
|
|
||||||
sessionPrefix: ao
|
|
||||||
projects:
|
|
||||||
sample:
|
|
||||||
repo: owner/sample
|
|
||||||
path: ${tempDir.path}
|
|
||||||
''');
|
|
||||||
|
|
||||||
// When loading the app config from disk.
|
|
||||||
final config = await AppConfig.load(configFile.path);
|
|
||||||
|
|
||||||
// Then the app exposes the configured data and worktree directories.
|
|
||||||
expect(
|
|
||||||
config.dataDir,
|
|
||||||
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
config.worktreeDir,
|
|
||||||
p.join(Platform.environment['HOME']!, '.worktrees'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// And each project inherits the supported shared defaults.
|
|
||||||
expect(config.projects['sample'], isNotNull);
|
|
||||||
expect(config.projects['sample']!.defaultBranch, 'trunk');
|
|
||||||
expect(config.projects['sample']!.sessionPrefix, 'ao');
|
|
||||||
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
/// ```gherkin
|
|
||||||
/// Scenario: Reject unsupported AO fields
|
|
||||||
/// Given an AO style config that includes an unsupported field
|
|
||||||
/// When loading the app config from disk
|
|
||||||
/// Then loading fails with a clear config error
|
|
||||||
/// ```
|
|
||||||
test('Reject unsupported AO fields', () async {
|
|
||||||
// Given an AO style config that includes an unsupported field.
|
|
||||||
final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-');
|
|
||||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
|
||||||
await configFile.writeAsString('''
|
|
||||||
worktreeDir: ~/.worktrees
|
|
||||||
projects:
|
|
||||||
sample:
|
|
||||||
repo: owner/sample
|
|
||||||
path: ${tempDir.path}
|
|
||||||
tracker:
|
|
||||||
plugin: github
|
|
||||||
''');
|
|
||||||
|
|
||||||
// When loading the app config from disk.
|
|
||||||
final load = AppConfig.load(configFile.path);
|
|
||||||
|
|
||||||
// Then loading fails with a clear config error.
|
|
||||||
await expectLater(
|
|
||||||
load,
|
|
||||||
throwsA(
|
|
||||||
isA<FormatException>().having(
|
|
||||||
(error) => error.message,
|
|
||||||
'message',
|
|
||||||
contains('Unsupported config field: projects.sample.tracker'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/// ```gherkin
|
|
||||||
/// Feature: Worktree management
|
|
||||||
///
|
|
||||||
/// As a maintainer running bug verification jobs
|
|
||||||
/// I want ephemeral git worktrees to respect the configured worktree root
|
|
||||||
/// So that temporary workspaces are created in a predictable location
|
|
||||||
/// ```
|
|
||||||
group('WorkspaceManager.createEphemeralWorktree', () {
|
|
||||||
/// ```gherkin
|
|
||||||
/// Scenario: Create and dispose a worktree under the configured root
|
|
||||||
/// Given a git repository and a dedicated worktree root directory
|
|
||||||
/// When creating and then disposing an ephemeral worktree
|
|
||||||
/// Then the worktree is created beneath the configured root
|
|
||||||
/// And cleanup removes the generated worktree directory without deleting the root
|
|
||||||
/// ```
|
|
||||||
test('Create and dispose a worktree under the configured root', () async {
|
|
||||||
// Given a git repository and a dedicated worktree root directory.
|
|
||||||
final sandbox = await Directory.systemTemp.createTemp(
|
|
||||||
'cws-worktree-root-',
|
|
||||||
);
|
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
|
||||||
final worktreeRoot = await Directory(
|
|
||||||
p.join(sandbox.path, 'worktrees'),
|
|
||||||
).create();
|
|
||||||
await _initGitRepo(repoDir);
|
|
||||||
final manager = WorkspaceManager(worktreeRoot: worktreeRoot.path);
|
|
||||||
|
|
||||||
// When creating and then disposing an ephemeral worktree.
|
|
||||||
final workspacePath = await manager.createEphemeralWorktree(repoDir.path);
|
|
||||||
final workspaceDirectory = Directory(workspacePath);
|
|
||||||
final parentDirectory = Directory(p.dirname(workspacePath));
|
|
||||||
await manager.disposeEphemeralWorktree(workspacePath);
|
|
||||||
|
|
||||||
// Then the worktree is created beneath the configured root.
|
|
||||||
expect(p.isWithin(worktreeRoot.path, workspacePath), isTrue);
|
|
||||||
|
|
||||||
// And cleanup removes the generated worktree directory without deleting the root.
|
|
||||||
expect(await workspaceDirectory.exists(), isFalse);
|
|
||||||
expect(await parentDirectory.exists(), isFalse);
|
|
||||||
expect(await worktreeRoot.exists(), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Feature: Issue thread assistant polling
|
/// Feature: Issue thread assistant polling
|
||||||
///
|
///
|
||||||
|
|
@ -245,11 +32,12 @@ projects:
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-run-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-run-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data containing a comment with an explicit assistant mention.
|
// And GitHub issue data containing a comment with an explicit assistant mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
final issueData = [
|
final issueData = [
|
||||||
{
|
{
|
||||||
'number': 12,
|
'number': 12,
|
||||||
|
|
@ -274,17 +62,19 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {12: commentsData},
|
commentResponses: {12: commentsData},
|
||||||
postCommentIssueNumber: 12,
|
postCommentIssueNumber: 12,
|
||||||
ghLog: ghLog,
|
ghLog: ghLog,
|
||||||
|
postedBodyFile: postedBody,
|
||||||
|
viewerLogin: 'octocat',
|
||||||
);
|
);
|
||||||
|
|
||||||
// And a responder that emits a planning reply.
|
// And a responder that emits a planning reply.
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
await _writeResponderScript(responderScript, {
|
await writeResponderScript(responderScript, {
|
||||||
'decision': 'reply',
|
'decision': 'reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'markdown': 'Plan:\n- separate service and persistence',
|
'markdown': 'Plan:\n- separate service and persistence',
|
||||||
|
|
@ -329,6 +119,16 @@ projects:
|
||||||
.length,
|
.length,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(postedComment, contains('> [!NOTE]'));
|
||||||
|
expect(postedComment, contains('Automated reply generated by'));
|
||||||
|
expect(postedComment, contains('@octocat'));
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- separate service and persistence'),
|
||||||
|
);
|
||||||
|
expect(postedComment, contains('_Automation note:'));
|
||||||
|
expect(postedComment, contains('<!-- code-work-spawner:'));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
|
|
@ -347,7 +147,7 @@ projects:
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data with a new comment that does not require another response.
|
// And GitHub issue data with a new comment that does not require another response.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
|
@ -373,7 +173,7 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {42: commentsData},
|
commentResponses: {42: commentsData},
|
||||||
|
|
@ -381,7 +181,7 @@ projects:
|
||||||
|
|
||||||
// And a responder that explicitly returns no_reply.
|
// And a responder that explicitly returns no_reply.
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
await _writeResponderScript(responderScript, {
|
await writeResponderScript(responderScript, {
|
||||||
'decision': 'no_reply',
|
'decision': 'no_reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'summary': 'thread already resolved',
|
'summary': 'thread already resolved',
|
||||||
|
|
@ -439,7 +239,7 @@ projects:
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-log-output-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-log-output-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data containing an explicit assistant mention.
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
|
@ -465,7 +265,7 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {21: commentsData},
|
commentResponses: {21: commentsData},
|
||||||
|
|
@ -474,7 +274,7 @@ projects:
|
||||||
|
|
||||||
// And a responder that writes stderr diagnostics alongside a no_reply JSON payload.
|
// And a responder that writes stderr diagnostics alongside a no_reply JSON payload.
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder-debug.sh'));
|
final responderScript = File(p.join(sandbox.path, 'responder-debug.sh'));
|
||||||
await _writeResponderScript(responderScript, {
|
await writeResponderScript(responderScript, {
|
||||||
'decision': 'no_reply',
|
'decision': 'no_reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'summary': 'diagnostic skip',
|
'summary': 'diagnostic skip',
|
||||||
|
|
@ -538,7 +338,7 @@ projects:
|
||||||
'cws-mention-worktree-',
|
'cws-mention-worktree-',
|
||||||
);
|
);
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data containing an explicit assistant mention.
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
|
@ -565,7 +365,7 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {24: commentsData},
|
commentResponses: {24: commentsData},
|
||||||
|
|
@ -651,7 +451,7 @@ projects:
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data containing an explicit assistant mention.
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
|
@ -678,7 +478,7 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {18: commentsData},
|
commentResponses: {18: commentsData},
|
||||||
|
|
@ -688,7 +488,7 @@ projects:
|
||||||
|
|
||||||
// And a responder that emits codex style jsonl events instead of a single json object.
|
// And a responder that emits codex style jsonl events instead of a single json object.
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
|
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
|
||||||
await _writeJsonlResponderScript(responderScript, {
|
await writeJsonlResponderScript(responderScript, {
|
||||||
'decision': 'reply',
|
'decision': 'reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'markdown': 'Codex JSONL reply',
|
'markdown': 'Codex JSONL reply',
|
||||||
|
|
@ -733,6 +533,238 @@ projects:
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Cache authenticated gh login across multiple replies
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And GitHub issue data containing two issues that explicitly mention the assistant
|
||||||
|
/// And a responder that replies to both issues
|
||||||
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then it looks up the authenticated gh login only once
|
||||||
|
/// And it still posts one comment per issue
|
||||||
|
/// ```
|
||||||
|
test('Cache authenticated gh login across multiple replies', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-gh-cache-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data containing two issues that explicitly mention the assistant.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 31,
|
||||||
|
'title': 'First mention',
|
||||||
|
'body': 'first',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/31',
|
||||||
|
'updated_at': '2026-04-04T16:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'number': 32,
|
||||||
|
'title': 'Second mention',
|
||||||
|
'body': 'second',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/32',
|
||||||
|
'updated_at': '2026-04-04T16:01:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {
|
||||||
|
31: [
|
||||||
|
{
|
||||||
|
'id': 310,
|
||||||
|
'body': '@helper first',
|
||||||
|
'html_url': 'https://example.test/issues/31#issuecomment-310',
|
||||||
|
'created_at': '2026-04-04T16:00:00Z',
|
||||||
|
'updated_at': '2026-04-04T16:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
32: [
|
||||||
|
{
|
||||||
|
'id': 320,
|
||||||
|
'body': '@helper second',
|
||||||
|
'html_url': 'https://example.test/issues/32#issuecomment-320',
|
||||||
|
'created_at': '2026-04-04T16:01:00Z',
|
||||||
|
'updated_at': '2026-04-04T16:01:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
postCommentIssueNumbers: {31, 32},
|
||||||
|
ghLog: ghLog,
|
||||||
|
viewerLogin: 'octocat',
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that replies to both issues.
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await writeResponderScript(responderScript, {
|
||||||
|
'decision': 'reply',
|
||||||
|
'mode': 'planning',
|
||||||
|
'markdown': 'cached login reply',
|
||||||
|
'summary': 'posted',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the app processes one polling cycle.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then it looks up the authenticated gh login only once.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(logLines.where((line) => line == 'api user').length, 1);
|
||||||
|
|
||||||
|
// And it still posts one comment per issue.
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues/31/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues/32/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Fall back to unknown identity when gh user lookup fails
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And GitHub issue data containing an explicit assistant mention
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And a fake gh command that fails the authenticated user lookup
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then it still posts one comment
|
||||||
|
/// And the visible automation markers use the unknown identity fallback
|
||||||
|
/// ```
|
||||||
|
test('Fall back to unknown identity when gh user lookup fails', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-gh-fallback-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 33,
|
||||||
|
'title': 'Fallback identity',
|
||||||
|
'body': 'identity',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/33',
|
||||||
|
'updated_at': '2026-04-04T16:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 330,
|
||||||
|
'body': '@helper who are you?',
|
||||||
|
'html_url': 'https://example.test/issues/33#issuecomment-330',
|
||||||
|
'created_at': '2026-04-04T16:10:00Z',
|
||||||
|
'updated_at': '2026-04-04T16:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {33: commentsData},
|
||||||
|
postCommentIssueNumber: 33,
|
||||||
|
ghLog: ghLog,
|
||||||
|
postedBodyFile: postedBody,
|
||||||
|
failViewerLookup: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that emits a planning reply.
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await writeResponderScript(responderScript, {
|
||||||
|
'decision': 'reply',
|
||||||
|
'mode': 'planning',
|
||||||
|
'markdown': 'fallback reply',
|
||||||
|
'summary': 'posted',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And a fake gh command that fails the authenticated user lookup.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the app processes one polling cycle.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then it still posts one comment.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues/33/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the visible automation markers use the unknown identity fallback.
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(postedComment, contains('@unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Scenario: Skip issue evaluation when no responders are configured
|
/// Scenario: Skip issue evaluation when no responders are configured
|
||||||
/// Given a temporary project checkout that can be used as the local repo path
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
|
@ -749,7 +781,7 @@ projects:
|
||||||
'cws-no-responders-',
|
'cws-no-responders-',
|
||||||
);
|
);
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data with a comment that would otherwise be evaluated.
|
// And GitHub issue data with a comment that would otherwise be evaluated.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
|
@ -775,7 +807,7 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await _writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {7: commentsData},
|
commentResponses: {7: commentsData},
|
||||||
|
|
@ -814,149 +846,3 @@ projects:
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _writeFakeGhScript({
|
|
||||||
required File ghScript,
|
|
||||||
required List<Map<String, Object?>> issueListResponse,
|
|
||||||
required Map<int, List<Map<String, Object?>>> commentResponses,
|
|
||||||
File? ghLog,
|
|
||||||
int? postCommentIssueNumber,
|
|
||||||
}) async {
|
|
||||||
final buffer = StringBuffer()
|
|
||||||
..writeln('#!/usr/bin/env bash')
|
|
||||||
..writeln('set -euo pipefail');
|
|
||||||
|
|
||||||
if (ghLog != null) {
|
|
||||||
buffer.writeln(
|
|
||||||
r'''printf '%s\n' "$*" >> '''
|
|
||||||
'"${ghLog.path}"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer
|
|
||||||
..writeln(
|
|
||||||
r'''if [[ "$1" == "api" && "$4" == repos/owner/sample/issues\?* ]]; then''',
|
|
||||||
)
|
|
||||||
..writeln(" cat <<'JSON'")
|
|
||||||
..writeln(jsonEncode(issueListResponse))
|
|
||||||
..writeln('JSON')
|
|
||||||
..writeln(' exit 0')
|
|
||||||
..writeln('fi');
|
|
||||||
|
|
||||||
for (final entry in commentResponses.entries) {
|
|
||||||
buffer
|
|
||||||
..writeln(
|
|
||||||
'if [[ "\$1" == "api" && "\$4" == '
|
|
||||||
'"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then',
|
|
||||||
)
|
|
||||||
..writeln(" cat <<'JSON'")
|
|
||||||
..writeln(jsonEncode(entry.value))
|
|
||||||
..writeln('JSON')
|
|
||||||
..writeln(' exit 0')
|
|
||||||
..writeln('fi');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (postCommentIssueNumber != null) {
|
|
||||||
buffer
|
|
||||||
..writeln(
|
|
||||||
'if [[ "\$1" == "api" && "\$4" == '
|
|
||||||
'"repos/owner/sample/issues/$postCommentIssueNumber/comments" ]]; then',
|
|
||||||
)
|
|
||||||
..writeln(" cat <<'JSON'")
|
|
||||||
..writeln(
|
|
||||||
jsonEncode({
|
|
||||||
'id': 700,
|
|
||||||
'html_url': 'https://example.test/comment/700',
|
|
||||||
'body': 'posted',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
..writeln('JSON')
|
|
||||||
..writeln(' exit 0')
|
|
||||||
..writeln('fi');
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer
|
|
||||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
|
||||||
..writeln('exit 1');
|
|
||||||
|
|
||||||
await ghScript.writeAsString(buffer.toString());
|
|
||||||
await Process.run('chmod', ['+x', ghScript.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _writeResponderScript(
|
|
||||||
File responderScript,
|
|
||||||
Map<String, Object?> response, {
|
|
||||||
String stderr = '',
|
|
||||||
int exitCode = 0,
|
|
||||||
}) async {
|
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
cat >/dev/null
|
|
||||||
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"}
|
|
||||||
cat <<'JSON'
|
|
||||||
${jsonEncode(response)}
|
|
||||||
JSON
|
|
||||||
exit $exitCode
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _writeJsonlResponderScript(
|
|
||||||
File responderScript,
|
|
||||||
Map<String, Object?> response,
|
|
||||||
) async {
|
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
cat >/dev/null
|
|
||||||
cat <<'JSON'
|
|
||||||
{"type":"thread.started","thread_id":"test-thread"}
|
|
||||||
{"type":"turn.started"}
|
|
||||||
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
|
|
||||||
{"type":"turn.completed"}
|
|
||||||
JSON
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initGitRepo(Directory directory) async {
|
|
||||||
await _runGit(['init'], workingDirectory: directory.path);
|
|
||||||
await _runGit([
|
|
||||||
'config',
|
|
||||||
'user.email',
|
|
||||||
'test@example.com',
|
|
||||||
], workingDirectory: directory.path);
|
|
||||||
await _runGit([
|
|
||||||
'config',
|
|
||||||
'user.name',
|
|
||||||
'Test User',
|
|
||||||
], workingDirectory: directory.path);
|
|
||||||
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
|
|
||||||
await _runGit(['add', '.'], workingDirectory: directory.path);
|
|
||||||
await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Set<String> _gitContextEnvironmentKeys = {
|
|
||||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
||||||
'GIT_COMMON_DIR',
|
|
||||||
'GIT_DIR',
|
|
||||||
'GIT_IMPLICIT_WORK_TREE',
|
|
||||||
'GIT_INDEX_FILE',
|
|
||||||
'GIT_OBJECT_DIRECTORY',
|
|
||||||
'GIT_PREFIX',
|
|
||||||
'GIT_WORK_TREE',
|
|
||||||
};
|
|
||||||
|
|
||||||
Future<ProcessResult> _runGit(
|
|
||||||
List<String> arguments, {
|
|
||||||
required String workingDirectory,
|
|
||||||
}) {
|
|
||||||
final environment = Map<String, String>.from(Platform.environment)
|
|
||||||
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
|
|
||||||
return Process.run(
|
|
||||||
'git',
|
|
||||||
arguments,
|
|
||||||
workingDirectory: workingDirectory,
|
|
||||||
environment: environment,
|
|
||||||
includeParentEnvironment: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
Future<void> writeFakeGhScript({
|
||||||
|
required File ghScript,
|
||||||
|
required List<Map<String, Object?>> issueListResponse,
|
||||||
|
required Map<int, List<Map<String, Object?>>> commentResponses,
|
||||||
|
File? ghLog,
|
||||||
|
int? postCommentIssueNumber,
|
||||||
|
Set<int>? postCommentIssueNumbers,
|
||||||
|
File? postedBodyFile,
|
||||||
|
String? viewerLogin,
|
||||||
|
bool failViewerLookup = false,
|
||||||
|
}) async {
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('#!/usr/bin/env bash')
|
||||||
|
..writeln('set -euo pipefail');
|
||||||
|
|
||||||
|
if (ghLog != null) {
|
||||||
|
buffer.writeln(
|
||||||
|
r'''printf '%s\n' "$*" >> '''
|
||||||
|
'"${ghLog.path}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||||
|
if (failViewerLookup) {
|
||||||
|
buffer
|
||||||
|
..writeln(r''' echo "viewer lookup failed" >&2''')
|
||||||
|
..writeln(' exit 1')
|
||||||
|
..writeln('fi');
|
||||||
|
} else {
|
||||||
|
buffer
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
r'''if [[ "$1" == "api" && "$4" == repos/owner/sample/issues\?* ]]; then''',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode(issueListResponse))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
for (final entry in commentResponses.entries) {
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode(entry.value))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
|
final commentIssueNumbers = <int>{...?postCommentIssueNumbers};
|
||||||
|
if (postCommentIssueNumber != null) {
|
||||||
|
commentIssueNumbers.add(postCommentIssueNumber);
|
||||||
|
}
|
||||||
|
for (final issueNumber in commentIssueNumbers) {
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/owner/sample/issues/$issueNumber/comments" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(r''' for arg in "$@"; do''')
|
||||||
|
..writeln(r''' :''');
|
||||||
|
if (postedBodyFile != null) {
|
||||||
|
buffer
|
||||||
|
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||||
|
..writeln(
|
||||||
|
r''' printf '%s' "${arg#body=}" > '''
|
||||||
|
'"${postedBodyFile.path}"',
|
||||||
|
)
|
||||||
|
..writeln(r''' fi''');
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
..writeln(r''' done''')
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(
|
||||||
|
jsonEncode({
|
||||||
|
'id': 700,
|
||||||
|
'html_url': 'https://example.test/comment/700',
|
||||||
|
'body': 'posted',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||||
|
..writeln('exit 1');
|
||||||
|
|
||||||
|
await ghScript.writeAsString(buffer.toString());
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeResponderScript(
|
||||||
|
File responderScript,
|
||||||
|
Map<String, Object?> response, {
|
||||||
|
String stderr = '',
|
||||||
|
int exitCode = 0,
|
||||||
|
}) async {
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat >/dev/null
|
||||||
|
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"}
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(response)}
|
||||||
|
JSON
|
||||||
|
exit $exitCode
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeJsonlResponderScript(
|
||||||
|
File responderScript,
|
||||||
|
Map<String, Object?> response,
|
||||||
|
) async {
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat >/dev/null
|
||||||
|
cat <<'JSON'
|
||||||
|
{"type":"thread.started","thread_id":"test-thread"}
|
||||||
|
{"type":"turn.started"}
|
||||||
|
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
|
||||||
|
{"type":"turn.completed"}
|
||||||
|
JSON
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initGitRepo(Directory directory) async {
|
||||||
|
await runGit(['init'], workingDirectory: directory.path);
|
||||||
|
await runGit([
|
||||||
|
'config',
|
||||||
|
'user.email',
|
||||||
|
'test@example.com',
|
||||||
|
], workingDirectory: directory.path);
|
||||||
|
await runGit([
|
||||||
|
'config',
|
||||||
|
'user.name',
|
||||||
|
'Test User',
|
||||||
|
], workingDirectory: directory.path);
|
||||||
|
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
|
||||||
|
await runGit(['add', '.'], workingDirectory: directory.path);
|
||||||
|
await runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Set<String> gitContextEnvironmentKeys = {
|
||||||
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||||
|
'GIT_COMMON_DIR',
|
||||||
|
'GIT_DIR',
|
||||||
|
'GIT_IMPLICIT_WORK_TREE',
|
||||||
|
'GIT_INDEX_FILE',
|
||||||
|
'GIT_OBJECT_DIRECTORY',
|
||||||
|
'GIT_PREFIX',
|
||||||
|
'GIT_WORK_TREE',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> runGit(
|
||||||
|
List<String> arguments, {
|
||||||
|
required String workingDirectory,
|
||||||
|
}) async {
|
||||||
|
final environment = Map<String, String>.from(Platform.environment)
|
||||||
|
..removeWhere((key, _) => gitContextEnvironmentKeys.contains(key));
|
||||||
|
final result = await Process.run(
|
||||||
|
'git',
|
||||||
|
arguments,
|
||||||
|
workingDirectory: workingDirectory,
|
||||||
|
environment: environment,
|
||||||
|
);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw ProcessException(
|
||||||
|
'git',
|
||||||
|
arguments,
|
||||||
|
'${result.stdout}\n${result.stderr}',
|
||||||
|
result.exitCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: Worktree management
|
||||||
|
///
|
||||||
|
/// As a maintainer running bug verification jobs
|
||||||
|
/// I want ephemeral git worktrees to respect the configured worktree root
|
||||||
|
/// So that temporary workspaces are created in a predictable location
|
||||||
|
/// ```
|
||||||
|
group('WorkspaceManager.createEphemeralWorktree', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Create and dispose a worktree under the configured root
|
||||||
|
/// Given a git repository and a dedicated worktree root directory
|
||||||
|
/// When creating and then disposing an ephemeral worktree
|
||||||
|
/// Then the worktree is created beneath the configured root
|
||||||
|
/// And cleanup removes the generated worktree directory without deleting the root
|
||||||
|
/// ```
|
||||||
|
test('Create and dispose a worktree under the configured root', () async {
|
||||||
|
// Given a git repository and a dedicated worktree root directory.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-worktree-root-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
final worktreeRoot = await Directory(
|
||||||
|
p.join(sandbox.path, 'worktrees'),
|
||||||
|
).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
final manager = WorkspaceManager(worktreeRoot: worktreeRoot.path);
|
||||||
|
|
||||||
|
// When creating and then disposing an ephemeral worktree.
|
||||||
|
final workspacePath = await manager.createEphemeralWorktree(repoDir.path);
|
||||||
|
final workspaceDirectory = Directory(workspacePath);
|
||||||
|
final parentDirectory = Directory(p.dirname(workspacePath));
|
||||||
|
await manager.disposeEphemeralWorktree(workspacePath);
|
||||||
|
|
||||||
|
// Then the worktree is created beneath the configured root.
|
||||||
|
expect(p.isWithin(worktreeRoot.path, workspacePath), isTrue);
|
||||||
|
|
||||||
|
// And cleanup removes the generated worktree directory without deleting the root.
|
||||||
|
expect(await workspaceDirectory.exists(), isFalse);
|
||||||
|
expect(await parentDirectory.exists(), isFalse);
|
||||||
|
expect(await worktreeRoot.exists(), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue