From 8469ed00e069cc2b072d9525d53894366ecca34a Mon Sep 17 00:00:00 2001 From: insleker Date: Sun, 5 Apr 2026 00:00:41 +0800 Subject: [PATCH] feat: add prefix, postfix to distinguish human and bot comment --- AGENTS.md | 3 +- README.md | 41 +- examples/README.md | 18 + examples/simple-github.yaml | 15 + lib/code_work_spawner.dart | 1 + lib/src/comment_templates.dart | 9 + lib/src/config.dart | 12 + lib/src/github_client.dart | 17 + lib/src/issue_assistant_app.dart | 54 +- test/app_config_test.dart | 256 +++++++ ...est.dart => issue_assistant_app_test.dart} | 640 +++++++----------- test/test_support.dart | 193 ++++++ test/workspace_manager_test.dart | 52 ++ 13 files changed, 885 insertions(+), 426 deletions(-) create mode 100644 examples/README.md create mode 100644 lib/src/comment_templates.dart create mode 100644 test/app_config_test.dart rename test/{code_work_spawner_test.dart => issue_assistant_app_test.dart} (65%) create mode 100644 test/test_support.dart create mode 100644 test/workspace_manager_test.dart diff --git a/AGENTS.md b/AGENTS.md index b00be4d..6cb4989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ # 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`. diff --git a/README.md b/README.md index 0a8b3a6..157dc64 100644 --- a/README.md +++ b/README.md @@ -25,51 +25,14 @@ It supports multi-repo configuration, responder fallback chains. ## Requirements -- Dart SDK 3.11+ +- Dart SDK - `gh` CLI authenticated for the target repositories - Local checkouts for configured repositories - Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli` ## Config -Create a local `agent-orchestrator.yaml`: - -```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. +Create a local `agent-orchestrator.yaml` follow [`/examples/README.md`](examples/README.md) as a starting point. ## Run diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d404993 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index c7cf754..317ef01 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -10,15 +10,30 @@ defaults: enabled: true pollInterval: 5m 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: # Replace this with a responder available on your machine. - id: codex command: codex args: ["exec", "--json"] timeout: 10m + # - id: opencode + # command: opencode + # timeout: 10m projects: code-work-spawner: repo: existedinnettw/code_work_spawner path: ~/code_work_spawner defaultBranch: main + # sessionPrefix: app diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index 42788c8..c2122e9 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -1,3 +1,4 @@ +export 'src/comment_templates.dart'; export 'src/config.dart'; export 'src/database.dart'; export 'src/github_client.dart'; diff --git a/lib/src/comment_templates.dart b/lib/src/comment_templates.dart new file mode 100644 index 0000000..21e9ec5 --- /dev/null +++ b/lib/src/comment_templates.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}}`._'; diff --git a/lib/src/config.dart b/lib/src/config.dart index a5efd02..37b2a3b 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; +import 'comment_templates.dart'; + class AppConfig { AppConfig({ required this.configPath, @@ -257,6 +259,8 @@ class IssueAssistantConfig { required this.responders, required this.capabilities, required this.commentTag, + required this.commentPrefixTemplate, + required this.commentFooterTemplate, }); final bool enabled; @@ -266,6 +270,8 @@ class IssueAssistantConfig { final List responders; final Set capabilities; final String commentTag; + final String commentPrefixTemplate; + final String commentFooterTemplate; factory IssueAssistantConfig.fromMap(Map map) { final respondersNode = map['responders']; @@ -294,6 +300,12 @@ class IssueAssistantConfig { responders: responders, capabilities: capabilities, commentTag: map['commentTag']?.toString() ?? 'code-work-spawner', + commentPrefixTemplate: + map['commentPrefixTemplate']?.toString() ?? + defaultCommentPrefixTemplate, + commentFooterTemplate: + map['commentFooterTemplate']?.toString() ?? + defaultCommentFooterTemplate, ); } static Duration _parseDuration(String input) { diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 393b50d..259ab39 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -5,6 +5,7 @@ class GitHubClient { GitHubClient({this.ghCommand = 'gh'}); final String ghCommand; + Future? _authenticatedLoginFuture; Future> fetchUpdatedIssues({ required String repo, @@ -74,6 +75,22 @@ class GitHubClient { ); } + Future getAuthenticatedLogin() { + return _authenticatedLoginFuture ??= _loadAuthenticatedLogin(); + } + + Future _loadAuthenticatedLogin() async { + final json = await _runJson(['api', 'user']); + if (json is! Map) { + return null; + } + final login = json['login']?.toString().trim(); + if (login == null || login.isEmpty) { + return null; + } + return login; + } + Future _runJson(List arguments) async { final result = await Process.run(ghCommand, arguments); if (result.exitCode != 0) { diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index 7d088fb..b1b3d53 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -277,9 +277,7 @@ class IssueAssistantApp { stderr: rawStderr, ); } finally { - if (workspacePath != null) { - await workspaceManager.disposeEphemeralWorktree(workspacePath); - } + await workspaceManager.disposeEphemeralWorktree(workspacePath); } } @@ -309,8 +307,10 @@ class IssueAssistantApp { return; } - final commentBody = _decorateComment( - tag: project.issueAssistant.commentTag, + final commentBody = await _decorateComment( + project: project, + issueNumber: issue.number, + trigger: trigger, fingerprint: fingerprint, markdown: selectedResult.markdown, ); @@ -406,12 +406,48 @@ class IssueAssistantApp { return sha256.convert(bytes).toString(); } - String _decorateComment({ - required String tag, + Future _decorateComment({ + required ProjectConfig project, + required int issueNumber, + required String trigger, required String fingerprint, required String markdown, - }) { - return '$markdown\n\n'; + }) async { + 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 = { + '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 = [ + if (prefix.isNotEmpty) prefix, + markdown.trim(), + if (footer.isNotEmpty) footer, + '', + ]; + return sections.where((section) => section.isNotEmpty).join('\n\n'); } void _log(String message) { diff --git a/test/app_config_test.dart b/test/app_config_test.dart new file mode 100644 index 0000000..b7f0680 --- /dev/null +++ b/test/app_config_test.dart @@ -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().having( + (error) => error.message, + 'message', + contains('Unsupported config field: projects.sample.tracker'), + ), + ), + ); + }); + }); +} diff --git a/test/code_work_spawner_test.dart b/test/issue_assistant_app_test.dart similarity index 65% rename from test/code_work_spawner_test.dart rename to test/issue_assistant_app_test.dart index 285fed6..a7e7d33 100644 --- a/test/code_work_spawner_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; 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:test/test.dart'; +import 'test_support.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}}" - 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().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 /// Feature: Issue thread assistant polling /// @@ -245,11 +32,12 @@ projects: // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp('cws-run-'); 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. 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': 12, @@ -274,17 +62,19 @@ projects: 'user': {'login': 'reporter'}, }, ]; - await _writeFakeGhScript( + await writeFakeGhScript( ghScript: ghScript, issueListResponse: issueData, commentResponses: {12: commentsData}, postCommentIssueNumber: 12, ghLog: ghLog, + postedBodyFile: postedBody, + viewerLogin: 'octocat', ); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); - await _writeResponderScript(responderScript, { + await writeResponderScript(responderScript, { 'decision': 'reply', 'mode': 'planning', 'markdown': 'Plan:\n- separate service and persistence', @@ -329,6 +119,16 @@ projects: .length, 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('