diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml new file mode 100644 index 0000000..fcaf9c7 --- /dev/null +++ b/.github/workflows/unittest.yaml @@ -0,0 +1,24 @@ +name: unittest + +on: + push: + pull_request: + +jobs: + dart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + + - name: Install dependencies + run: dart pub get + + - name: Static analysis + run: dart analyze + + - name: Validate Gherkin test format + run: dart run tool/validate_gherkin_test_format.dart + + - name: Run tests + run: dart test diff --git a/.gitignore b/.gitignore index 60b1dc4..7dc9280 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ pubspec.lock .code_work_spawner.sqlite3 agent-orchestrator.yaml +/.* +# !/.agents +!/.git +!/.github +/skills/* +!/skills/dart-gherkin-tests + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b00be4d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Repo Instructions + +- `lefthook install` to set up pre-commit hooks +- 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 ce5fdde..cd33737 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ issue comments, and uses configured CLI responders such as `codex`, - a new comment changes the thread enough that the responder decides a reply is useful -It supports multi-repo configuration, responder fallback chains, Drift-backed -SQLite state, and BDD-style Dart tests. +It supports multi-repo configuration, responder fallback chains. ## Features @@ -93,10 +92,18 @@ dart run bin/code_work_spawner.dart \ ## Testing ```bash -dart test +dart run tool/validate_gherkin_test_format.dart dart analyze +dart test ``` +All Dart tests under `test/` must follow the repo's Gherkin doc-comment format: + +- `group(...)` has a `Feature` doc block above it +- `test(...)` has a `Scenario` doc block above it +- runtime names stay plain, without `Feature:` or `Scenario:` prefixes +- test bodies include `Given` / `When` / `Then` inline comments + ## ref [agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..96c0d8d --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,8 @@ +pre-commit: + commands: + format: + run: dart format --output=none --set-exit-if-changed . + analyze: + run: dart analyze + test: + run: dart test diff --git a/lib/src/gherkin_test_validator.dart b/lib/src/gherkin_test_validator.dart new file mode 100644 index 0000000..1c5bd08 --- /dev/null +++ b/lib/src/gherkin_test_validator.dart @@ -0,0 +1,298 @@ +import 'dart:io'; + +class GherkinValidationIssue { + GherkinValidationIssue({ + required this.path, + required this.line, + required this.message, + }); + + final String path; + final int line; + final String message; + + @override + String toString() => '$path:$line: $message'; +} + +class GherkinTestValidator { + List validateDirectory(Directory testDirectory) { + if (!testDirectory.existsSync()) { + return const []; + } + + final issues = []; + final files = + testDirectory + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('_test.dart')) + .toList() + ..sort((left, right) => left.path.compareTo(right.path)); + + for (final file in files) { + issues.addAll(validateFile(file)); + } + return issues; + } + + List validateFile(File file) { + final lines = file.readAsLinesSync(); + final issues = []; + var inMultilineString = false; + + for (var index = 0; index < lines.length; index++) { + final line = lines[index]; + if (_isInsideMultilineString(line, inMultilineString)) { + inMultilineString = !inMultilineString; + continue; + } + if (inMultilineString) { + continue; + } + if (_isGroupDeclaration(line)) { + issues.addAll(_validateGroup(file.path, lines, index)); + } + if (_isTestDeclaration(line)) { + issues.addAll(_validateTest(file.path, lines, index)); + } + } + + return issues; + } + + List _validateGroup( + String path, + List lines, + int declarationIndex, + ) { + final issues = []; + final docBlock = _precedingDocBlock(lines, declarationIndex); + if (docBlock.isEmpty) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: + 'group(...) must be preceded by a Gherkin Feature doc block.', + ), + ); + } else { + final content = docBlock.join('\n'); + if (!content.contains('```gherkin')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Feature doc block must use ```gherkin fencing.', + ), + ); + } + if (!content.contains('Feature:')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Feature doc block must contain "Feature:".', + ), + ); + } + } + + final runtimeName = _extractFirstStringArgument(lines[declarationIndex]); + if (runtimeName != null && runtimeName.startsWith('Feature:')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Runtime group name must not start with "Feature:".', + ), + ); + } + + return issues; + } + + List _validateTest( + String path, + List lines, + int declarationIndex, + ) { + final issues = []; + final docBlock = _precedingDocBlock(lines, declarationIndex); + if (docBlock.isEmpty) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: + 'test(...) must be preceded by a Gherkin Scenario doc block.', + ), + ); + } else { + final content = docBlock.join('\n'); + if (!content.contains('```gherkin')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Scenario doc block must use ```gherkin fencing.', + ), + ); + } + if (!content.contains('Scenario:')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Scenario doc block must contain "Scenario:".', + ), + ); + } + if (!content.contains('Given ')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Scenario doc block must contain a Given step.', + ), + ); + } + if (!content.contains('When ')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Scenario doc block must contain a When step.', + ), + ); + } + if (!content.contains('Then ')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Scenario doc block must contain a Then step.', + ), + ); + } + } + + final runtimeName = _extractFirstStringArgument(lines[declarationIndex]); + if (runtimeName != null && runtimeName.startsWith('Scenario:')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Runtime test name must not start with "Scenario:".', + ), + ); + } + + final body = _extractTestBody(lines, declarationIndex); + if (!body.contains('// Given')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Test body must contain a "// Given" comment.', + ), + ); + } + if (!body.contains('// When')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Test body must contain a "// When" comment.', + ), + ); + } + if (!body.contains('// Then')) { + issues.add( + GherkinValidationIssue( + path: path, + line: declarationIndex + 1, + message: 'Test body must contain a "// Then" comment.', + ), + ); + } + + return issues; + } + + List _precedingDocBlock(List lines, int declarationIndex) { + final block = []; + var cursor = declarationIndex - 1; + + while (cursor >= 0 && lines[cursor].trim().isEmpty) { + cursor--; + } + + while (cursor >= 0 && lines[cursor].trimLeft().startsWith('///')) { + block.insert(0, lines[cursor]); + cursor--; + } + + return block; + } + + String _extractTestBody(List lines, int declarationIndex) { + final buffer = StringBuffer(); + var braceDepth = 0; + var started = false; + + for (var index = declarationIndex; index < lines.length; index++) { + final line = lines[index]; + for (final rune in line.runes) { + final char = String.fromCharCode(rune); + if (char == '{') { + braceDepth++; + started = true; + } else if (char == '}') { + braceDepth--; + } + } + + if (started) { + buffer.writeln(line); + } + + if (started && braceDepth <= 0) { + break; + } + } + + return buffer.toString(); + } + + String? _extractFirstStringArgument(String line) { + final match = RegExp(r"(?:group|test)\(\s*'([^']*)'").firstMatch(line); + return match?.group(1); + } + + bool _isGroupDeclaration(String line) => + RegExp(r'^\s*group\(').hasMatch(line.trimLeft()); + + bool _isTestDeclaration(String line) => + RegExp(r'^\s*test\(').hasMatch(line.trimLeft()); + + bool _isInsideMultilineString(String line, bool currentlyInside) { + final tripleSingle = "'''".allMatches(line).length; + final tripleDouble = '"""'.allMatches(line).length; + final toggles = tripleSingle + tripleDouble; + if (toggles == 0) { + return false; + } + + if (!currentlyInside && toggles.isOdd) { + return true; + } + + if (currentlyInside && toggles.isOdd) { + return true; + } + + return false; + } +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..d625847 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "skills": { + "create-github-action-workflow-specification": { + "source": "github/awesome-copilot", + "sourceType": "github", + "computedHash": "527e6c77d921d58a9d0059aa95d0d14b441d2166f9c20518263048a56ca8fbbb" + }, + "dart-api-design": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "d60beeedc91e1be7c51d7fea38acb636c0519b8140159402afcf69f31846cfba" + }, + "dart-async-programming": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "ebff987d2ce82a0c0eebab1b7ebc724d29bb925c56273ee55775550d634a3c72" + }, + "dart-code-generation": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "259e371481a822984e666cc5214c97ac1581a0adbbdb4d7cab3997c9b1de2009" + }, + "dart-compilation-deployment": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "862ea7fd5efb9bd9760d9eab58880a1f08bb9b64125f171b778ba6918f79980f" + }, + "dart-concurrency-isolates": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "829a819bb37342813eca318e0008d93db83aa313daa58be1b9a508d5b1674a9f" + }, + "dart-documentation": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "0016010518730eb2aeaabbf5046667deb724040be855a5892db4a9735608ca75" + }, + "dart-effective-style": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "ceaa8cbe82090bf759d763cdc83aa80df79ac2461d0d5f223f63e72f60901ae4" + }, + "dart-idiomatic-usage": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "ac383cc321ce8eeac6412eecc466e68adfcb131fd98c5b74af9d4b33c64381bf" + }, + "dart-language-syntax": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "d1b1f4027530bb736247f62135845c06f69148cd3464fc235f942324499aee54" + }, + "dart-migration-versioning": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "c3fefb316b55d7794a49bc226cee17a2fafc00d75621e23aee1dd3f8c15def3a" + }, + "dart-native-interop-ffi": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "cac814c48f43b964471b41e99622b471203bb2900845bcc03eca02b2a3162ed5" + }, + "dart-package-management": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "a1fbb5422c3d9bf980a310b55f8cb938b969234eda60a27038f65833e5d0e805" + }, + "dart-static-analysis": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "f13d7c751ed2d026da741fe7b2535fb11a8d045c48ee91b48c0bf7b8147a3758" + }, + "dart-testing": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "c6857b34f27d704eadbb770e40512693e18e04bab7345c23818386a6996dea6a" + }, + "dart-web-development": { + "source": "dart-lang/skills", + "sourceType": "github", + "computedHash": "2adc4b77a7d30a2c9c52fc96368df10a8242512c95c8bf2b3fc06abf208dc421" + }, + "gh-cli": { + "source": "github/awesome-copilot", + "sourceType": "github", + "computedHash": "cbe644b0c6760ae2a1eaafa39133920d889331327065d92a131675a665273e56" + } + } +} diff --git a/skills/dart-gherkin-tests/SKILL.md b/skills/dart-gherkin-tests/SKILL.md new file mode 100644 index 0000000..12250c3 --- /dev/null +++ b/skills/dart-gherkin-tests/SKILL.md @@ -0,0 +1,72 @@ +--- +name: dart-gherkin-tests +description: Use when creating or editing Dart tests in this repo. Enforces the repository's required Gherkin-style doc comments for `group(...)` and `test(...)`, plus inline Given/When/Then comments inside test bodies. +--- + +# Dart Gherkin Tests + +Use this skill for any new or modified file under `test/`. + +## Required format + +Every `group(...)` must be preceded by a Gherkin doc block: + +```dart +/// ```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', () { +``` + +Every `test(...)` must be preceded by a Gherkin doc block: + +```dart + /// ```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 { +``` + +Inside the test body, use inline phase comments: + +```dart + // 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. + ... +``` + +## Rules + +- Do add `Feature:` and `Scenario:` only in doc comments. +- Do keep runtime `group(...)` names code-oriented. +- Do keep runtime `test(...)` names as the scenario title only. +- Do use inline `// Given`, `// When`, and `// Then` comments in every test body. +- Do use `// And` when a scenario has multiple assertions or setup steps. +- Do not put `Feature:` in the `group(...)` string. +- Do not put `Scenario:` in the `test(...)` string. +- Do not add tests under `test/` without the required Gherkin doc-comment blocks. + +## Checklist + +Before finishing test work in this repo: + +1. Confirm every `group(...)` has a `Feature` doc block directly above it. +2. Confirm every `test(...)` has a `Scenario` doc block directly above it. +3. Confirm every test body has `Given`, `When`, and `Then` inline comments. +4. Run `dart run tool/validate_gherkin_test_format.dart`. diff --git a/test/code_work_spawner_test.dart b/test/code_work_spawner_test.dart index 1ab687e..a38a8e8 100644 --- a/test/code_work_spawner_test.dart +++ b/test/code_work_spawner_test.dart @@ -9,16 +9,26 @@ 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', () { - test( - 'given AO style config when loading then defaults and project overrides are merged', - () async { - // Given a config file with shared defaults and a project override. - final tempDir = await Directory.systemTemp.createTemp('cws-config-'); - final configFile = File( - p.join(tempDir.path, 'agent-orchestrator.yaml'), - ); - await configFile.writeAsString(''' + /// ```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 @@ -37,107 +47,97 @@ projects: prompt: "project {{project_key}}" '''); - // When the app config is loaded from disk. - final config = await AppConfig.load(configFile.path); + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); - // Then the project inherits defaults and overrides only the prompt. - expect(config.projects['sample'], isNotNull); - expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ - '@helper', - ]); - expect( - config.projects['sample']!.issueAssistant.prompt, - 'project {{project_key}}', - ); - expect( - config.projects['sample']!.issueAssistant.responders.single.id, - 'fallback', - ); - }, - ); + // 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 + /// Feature: Issue thread assistant polling + /// + /// As a maintainer using the issue assistant + /// I want issue thread events to be evaluated through configured responders + /// So that users get help only when the thread actually needs a response + /// ``` group('IssueAssistantApp.runOnce', () { - test( - 'given an explicit mention when polling then the app posts the responder reply once', - () async { - // Given a fake gh client and a fake responder that emits a planning reply. - final sandbox = await Directory.systemTemp.createTemp('cws-run-'); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await _initGitRepo(repoDir); + /// ```gherkin + /// Scenario: Reply once when a user explicitly mentions the assistant + /// Given a temporary project checkout that can be used as the local repo path + /// And GitHub issue data containing a comment with an explicit assistant mention + /// And a responder that emits a planning reply + /// And an orchestrator-style config pointing to the fake repo and responder + /// When the app processes one polling cycle + /// Then it reads issue comments and posts exactly one reply for that issue + /// ``` + test('Reply once when a user explicitly mentions the assistant', () async { + // 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); - final ghScript = File(p.join(sandbox.path, 'gh')); - final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); - final issueData = [ - { - 'number': 12, - 'title': 'Need architecture help', - 'body': 'Please review the API layering.', - 'state': 'open', - 'html_url': 'https://example.test/issues/12', - 'updated_at': '2026-04-04T12:00:00Z', - 'user': {'login': 'reporter'}, - 'labels': [ - {'name': 'help'}, - ], - }, - ]; - final commentsData = [ - { - 'id': 50, - 'body': '@helper can you plan the architecture?', - 'html_url': 'https://example.test/issues/12#issuecomment-50', - 'created_at': '2026-04-04T12:00:00Z', - 'updated_at': '2026-04-04T12:00:00Z', - 'user': {'login': 'reporter'}, - }, - ]; - final replyJson = jsonEncode({ - 'decision': 'reply', - 'mode': 'planning', - 'markdown': 'Plan:\n- separate service and persistence', - 'summary': 'posted planning advice', - }); - await ghScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "\$*" >> "${ghLog.path}" -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then - cat <<'JSON' -${jsonEncode(issueData)} -JSON - exit 0 -fi -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?per_page=100" ]]; then - cat <<'JSON' -${jsonEncode(commentsData)} -JSON - exit 0 -fi -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then - cat <<'JSON' -{"id": 700, "html_url": "https://example.test/comment/700", "body": "posted"} -JSON - exit 0 -fi -echo "unexpected gh args: \$*" >&2 -exit 1 -'''); - await Process.run('chmod', ['+x', ghScript.path]); + // 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 issueData = [ + { + 'number': 12, + 'title': 'Need architecture help', + 'body': 'Please review the API layering.', + 'state': 'open', + 'html_url': 'https://example.test/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': [ + {'name': 'help'}, + ], + }, + ]; + final commentsData = [ + { + 'id': 50, + 'body': '@helper can you plan the architecture?', + 'html_url': 'https://example.test/issues/12#issuecomment-50', + 'created_at': '2026-04-04T12:00:00Z', + 'updated_at': '2026-04-04T12:00:00Z', + 'user': {'login': 'reporter'}, + }, + ]; + await _writeFakeGhScript( + ghScript: ghScript, + issueListResponse: issueData, + commentResponses: {12: commentsData}, + postCommentIssueNumber: 12, + ghLog: ghLog, + ); - final responderScript = File(p.join(sandbox.path, 'responder.sh')); - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -cat >/dev/null -cat <<'JSON' -$replyJson -JSON -'''); - await Process.run('chmod', ['+x', responderScript.path]); + // 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': 'Plan:\n- separate service and persistence', + 'summary': 'posted planning advice', + }); - final configFile = File( - p.join(sandbox.path, 'agent-orchestrator.yaml'), - ); - await configFile.writeAsString(''' + // 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 @@ -153,40 +153,47 @@ projects: path: ${repoDir.path} defaultBranch: main '''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + ); - final config = await AppConfig.load(configFile.path); - final app = await IssueAssistantApp.open( - config: config, - databasePath: p.join(sandbox.path, 'state.sqlite3'), - ghCommand: ghScript.path, - ); + // When the app processes one polling cycle. + await app.runOnce(); + await app.close(); - // When the app processes a single polling cycle. - await app.runOnce(); - await app.close(); - - // Then a GitHub comment is posted for the mentioned issue. - final logLines = await ghLog.readAsLines(); - expect( - logLines - .where( - (line) => - line.contains('repos/owner/sample/issues/12/comments'), - ) - .length, - 2, - ); - }, - ); + // Then it reads issue comments and posts exactly one reply for that issue. + final logLines = await ghLog.readAsLines(); + expect( + logLines + .where( + (line) => line.contains('repos/owner/sample/issues/12/comments'), + ) + .length, + 2, + ); + }); + /// ```gherkin + /// Scenario: Record no reply when the responder decides the discussion is already sufficient + /// Given a temporary project checkout that can be used as the local repo path + /// And GitHub issue data with a new comment that does not require another response + /// And a responder that explicitly returns no_reply + /// And an orchestrator-style config pointing to the fake repo and responder + /// When the app processes one polling cycle + /// Then it records the evaluation result as no_reply + /// And it does not store a posted GitHub comment for that thread version + /// ``` test( - 'given a thread update without a useful delta when polling then the app records no reply', + 'Record no reply when the responder decides the discussion is already sufficient', () async { - // Given a fake gh client and a responder that explicitly returns no_reply. + // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-'); final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); await _initGitRepo(repoDir); + // And GitHub issue data with a new comment that does not require another response. final ghScript = File(p.join(sandbox.path, 'gh')); final issueData = [ { @@ -210,40 +217,21 @@ projects: 'user': {'login': 'reporter'}, }, ]; - final noReplyJson = jsonEncode({ + await _writeFakeGhScript( + ghScript: ghScript, + issueListResponse: issueData, + commentResponses: {42: commentsData}, + ); + + // And a responder that explicitly returns no_reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await _writeResponderScript(responderScript, { 'decision': 'no_reply', 'mode': 'planning', 'summary': 'thread already resolved', }); - await ghScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then - cat <<'JSON' -${jsonEncode(issueData)} -JSON - exit 0 -fi -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/42/comments?per_page=100" ]]; then - cat <<'JSON' -${jsonEncode(commentsData)} -JSON - exit 0 -fi -echo "unexpected gh args: \$*" >&2 -exit 1 -'''); - await Process.run('chmod', ['+x', ghScript.path]); - - final responderScript = File(p.join(sandbox.path, 'responder.sh')); - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -cat >/dev/null -cat <<'JSON' -$noReplyJson -JSON -'''); - await Process.run('chmod', ['+x', responderScript.path]); + // And an orchestrator-style config pointing to the fake repo and responder. final configFile = File( p.join(sandbox.path, 'agent-orchestrator.yaml'), ); @@ -261,27 +249,110 @@ projects: 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 a poll cycle evaluates the unchanged discussion. + // When the app processes one polling cycle. await app.runOnce(); final thread = await app.database.findIssueThread('sample', 42); await app.close(); - // Then the issue is marked as evaluated without storing a comment. + // Then it records the evaluation result as no_reply. expect(thread, isNotNull); expect(thread!.lastDecision, 'no_reply'); + + // And it does not store a posted GitHub comment for that thread version. expect(thread.lastCommentId, isNull); }, ); }); } +Future _writeFakeGhScript({ + required File ghScript, + required List> issueListResponse, + required Map>> 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 _writeResponderScript( + File responderScript, + Map response, +) async { + await responderScript.writeAsString('''#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +cat <<'JSON' +${jsonEncode(response)} +JSON +'''); + await Process.run('chmod', ['+x', responderScript.path]); +} + Future _initGitRepo(Directory directory) async { await Process.run('git', ['init'], workingDirectory: directory.path); await Process.run('git', [ diff --git a/test/gherkin_test_validator_test.dart b/test/gherkin_test_validator_test.dart new file mode 100644 index 0000000..4392a19 --- /dev/null +++ b/test/gherkin_test_validator_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:code_work_spawner/src/gherkin_test_validator.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Gherkin test validator + /// + /// As a maintainer of the repository + /// I want test files to be validated against the required Gherkin comment format + /// So that future tests stay consistent and reviewable + /// ``` + group('GherkinTestValidator', () { + /// ```gherkin + /// Scenario: Accept a correctly formatted test file + /// Given a Dart test file that follows the required Feature and Scenario doc-comment structure + /// When the validator checks that file + /// Then it reports no validation issues + /// ``` + test('Accept a correctly formatted test file', () async { + // Given a Dart test file that follows the required Feature and Scenario doc-comment structure. + final tempDir = await Directory.systemTemp.createTemp('gherkin-good-'); + final testFile = File(p.join(tempDir.path, 'good_test.dart')); + await testFile.writeAsString(_goodTestFile); + final validator = GherkinTestValidator(); + + // When the validator checks that file. + final issues = validator.validateFile(testFile); + + // Then it reports no validation issues. + expect(issues, isEmpty); + }); + + /// ```gherkin + /// Scenario: Reject a test file that does not follow the required format + /// Given a Dart test file that uses plain test declarations without the required Gherkin doc comments + /// When the validator checks that file + /// Then it reports validation issues for the missing structure + /// ``` + test('Reject a test file that does not follow the required format', () async { + // Given a Dart test file that uses plain test declarations without the required Gherkin doc comments. + final tempDir = await Directory.systemTemp.createTemp('gherkin-bad-'); + final testFile = File(p.join(tempDir.path, 'bad_test.dart')); + await testFile.writeAsString(_badTestFile); + final validator = GherkinTestValidator(); + + // When the validator checks that file. + final issues = validator.validateFile(testFile); + + // Then it reports validation issues for the missing structure. + expect(issues, isNotEmpty); + expect( + issues.map((issue) => issue.message), + contains('group(...) must be preceded by a Gherkin Feature doc block.'), + ); + expect( + issues.map((issue) => issue.message), + contains('test(...) must be preceded by a Gherkin Scenario doc block.'), + ); + }); + }); +} + +const String _goodTestFile = r''' +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Example feature + /// + /// As a user + /// I want readable tests + /// So that I can understand their behavior quickly + /// ``` + group('ExampleGroup', () { + /// ```gherkin + /// Scenario: Run a simple assertion + /// Given a valid example + /// When the test is executed + /// Then the assertion succeeds + /// ``` + test('Run a simple assertion', () { + // Given a valid example. + final value = 1; + + // When the test is executed. + final result = value + 1; + + // Then the assertion succeeds. + expect(result, 2); + }); + }); +} +'''; + +const String _badTestFile = r''' +import 'package:test/test.dart'; + +void main() { + group('Feature: Wrong', () { + test('Scenario: Wrong', () { + expect(1 + 1, 2); + }); + }); +} +'''; diff --git a/tool/validate_gherkin_test_format.dart b/tool/validate_gherkin_test_format.dart new file mode 100644 index 0000000..df86592 --- /dev/null +++ b/tool/validate_gherkin_test_format.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:code_work_spawner/src/gherkin_test_validator.dart'; + +void main() { + final validator = GherkinTestValidator(); + final issues = validator.validateDirectory(Directory('test')); + + if (issues.isEmpty) { + stdout.writeln('Gherkin test format validation passed.'); + return; + } + + stderr.writeln('Gherkin test format validation failed:'); + for (final issue in issues) { + stderr.writeln('- $issue'); + } + exitCode = 1; +}