-
Notifications
You must be signed in to change notification settings - Fork 0
feat(rfc_tools): add taxonomy parser, github client, and git lister #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'dart:io'; | ||
|
|
||
| import 'github_client.dart' show ProcessRunner; | ||
|
|
||
| /// Signature for querying RFC files on a remote/base git branch. | ||
| typedef GitListFunction = | ||
| Future<Set<String>> Function({String baseBranch, String rfcDir}); | ||
|
|
||
| /// Default implementation querying git via `git ls-tree`. | ||
| Future<Set<String>> defaultGitList({ | ||
| String baseBranch = 'origin/main', | ||
| String rfcDir = 'rfc', | ||
| ProcessRunner processRunner = Process.run, | ||
| }) async { | ||
| try { | ||
| final result = await processRunner('git', [ | ||
| 'ls-tree', | ||
| '-r', | ||
| '--name-only', | ||
| baseBranch, | ||
| '--', | ||
| '$rfcDir/', | ||
| ]); | ||
| if (result.exitCode != 0) { | ||
| stdout.writeln('exit code: ${result.exitCode}'); | ||
| stdout.writeln('git ls-tree stdout:'); | ||
| stdout.writeln(result.stdout); | ||
| stderr.writeln('git ls-tree stderr:'); | ||
| stderr.writeln(result.stderr); | ||
| return const <String>{}; | ||
| } | ||
| final stdoutStr = result.stdout as String; | ||
| return stdoutStr | ||
| .split('\n') | ||
| .map((s) => s.trim()) | ||
| .where((s) => s.isNotEmpty) | ||
| .toSet(); | ||
| } catch (_) { | ||
| return const <String>{}; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'dart:io'; | ||
|
|
||
| /// Signature for running an external process asynchronously. | ||
| typedef ProcessRunner = | ||
| Future<ProcessResult> Function(String executable, List<String> arguments); | ||
|
|
||
| /// Abstract client for interacting with the GitHub API / CLI. | ||
| abstract interface class GitHubClient { | ||
| /// Checks whether a GitHub user exists. | ||
| Future<bool> userExists(String username); | ||
| } | ||
|
|
||
| /// Production implementation using the `gh` CLI. | ||
| class CliGitHubClient implements GitHubClient { | ||
| /// The process runner used to execute external commands. | ||
| final ProcessRunner processRunner; | ||
|
|
||
| const CliGitHubClient({this.processRunner = Process.run}); | ||
|
|
||
| @override | ||
| Future<bool> userExists(String username) async { | ||
| try { | ||
| final result = await processRunner('gh', [ | ||
| 'api', | ||
| 'users/$username', | ||
| '--silent', | ||
| ]); | ||
| if (result.exitCode != 0) { | ||
| stdout.writeln('exit code: ${result.exitCode}'); | ||
| stdout.writeln('gh api stdout:'); | ||
| stdout.writeln(result.stdout); | ||
| stderr.writeln('gh api stderr:'); | ||
| stderr.writeln(result.stderr); | ||
| } | ||
| return result.exitCode == 0; | ||
|
jtmcdole marked this conversation as resolved.
|
||
| } catch (_) { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Test double with in-memory state for hermetic unit testing. | ||
| class FakeGitHubClient implements GitHubClient { | ||
| final Set<String> existingUsers; | ||
|
|
||
| FakeGitHubClient({Set<String>? existingUsers}) | ||
| : existingUsers = existingUsers ?? <String>{}; | ||
|
|
||
| @override | ||
| Future<bool> userExists(String username) async { | ||
| return existingUsers.contains(username); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:file/file.dart'; | ||
|
|
||
| /// Represents the dynamic taxonomy of Flutter subsystems extracted from RFC 000.0001. | ||
| class Taxonomy { | ||
| /// Set of all valid 3-digit category strings (e.g. "000", "010", "110", "210"). | ||
| final Set<String> categories; | ||
|
|
||
| /// Optional mapping from category string to human-readable title. | ||
| final Map<String, String> categoryNames; | ||
|
|
||
| const Taxonomy(this.categories, [this.categoryNames = const {}]); | ||
|
|
||
| /// Checks whether [category] is a recognized subsystem classification. | ||
| bool isValidCategory(String category) => categories.contains(category); | ||
|
|
||
| /// Parses taxonomy categories from markdown content of RFC 000.0001. | ||
| static Taxonomy fromMarkdown(String markdown) { | ||
| final categories = <String>{}; | ||
| final categoryNames = <String, String>{}; | ||
|
|
||
| // Match section headers: ### 000 – General, Process, & Meta | ||
| final sectionHeaderRegex = RegExp( | ||
| r'^###\s+([0-9]{3})\s+[–—-]\s*(.*)$', | ||
| multiLine: true, | ||
| ); | ||
| for (final match in sectionHeaderRegex.allMatches(markdown)) { | ||
| final code = match.group(1)!; | ||
| final name = match.group(2)?.trim() ?? ''; | ||
| categories.add(code); | ||
| categoryNames[code] = name; | ||
| } | ||
|
|
||
| // Match list items: * **110:** Foundation & Low-level | ||
| final listItemRegex = RegExp( | ||
| r'^\*\s+\*\*([0-9]{3}):?\*\*:?\s*(.*)$', | ||
| multiLine: true, | ||
| ); | ||
| for (final match in listItemRegex.allMatches(markdown)) { | ||
| final code = match.group(1)!; | ||
| final name = match.group(2)?.trim() ?? ''; | ||
| categories.add(code); | ||
| if (name.isNotEmpty) { | ||
| categoryNames[code] = name; | ||
| } | ||
| } | ||
|
|
||
| return Taxonomy(categories, categoryNames); | ||
| } | ||
|
|
||
| /// Loads the taxonomy from RFC 000.0001 on the given [fs]. | ||
| static Future<Taxonomy> load(FileSystem fs) async { | ||
| File? file; | ||
|
|
||
| const defaultPath = | ||
| 'rfc/000.0001-flutter-architecture-and-reference-taxonomy.md'; | ||
| file = fs.file(defaultPath); | ||
|
|
||
| if (!await file.exists()) { | ||
| throw StateError( | ||
| 'Could not locate RFC 000.0001 taxonomy document in "rfc". ' | ||
| 'Ensure rfc/000.0001-flutter-architecture-and-reference-taxonomy.md exists.', | ||
| ); | ||
| } | ||
|
|
||
| final content = await file.readAsString(); | ||
| return Taxonomy.fromMarkdown(content); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:rfc_tools/src/git_lister.dart'; | ||
| import 'package:test/test.dart'; | ||
|
|
||
| import 'mock_process_runner.dart'; | ||
|
|
||
| void main() { | ||
| group('defaultGitList', () { | ||
| test('returns parsed file set on zero exit code', () async { | ||
| final runner = MockProcessRunner( | ||
| exitCode: 0, | ||
| stdout: 'rfc/000.0001-taxonomy.md\nrfc/000.0002-process.md\n', | ||
| ); | ||
|
|
||
| final files = await defaultGitList( | ||
| baseBranch: 'origin/main', | ||
| processRunner: runner.run, | ||
| ); | ||
|
|
||
| expect( | ||
| files, | ||
| equals({'rfc/000.0001-taxonomy.md', 'rfc/000.0002-process.md'}), | ||
| ); | ||
| expect(runner.calls, hasLength(1)); | ||
| expect( | ||
| runner.calls.first.arguments, | ||
| equals(['ls-tree', '-r', '--name-only', 'origin/main', '--', 'rfc/']), | ||
| ); | ||
| }); | ||
|
|
||
| test('returns empty set on non-zero exit code', () async { | ||
| final runner = MockProcessRunner( | ||
| exitCode: 128, | ||
| stderr: 'fatal: not a valid object name', | ||
| ); | ||
|
|
||
| final files = await defaultGitList( | ||
| baseBranch: 'origin/main', | ||
| processRunner: runner.run, | ||
| ); | ||
|
|
||
| expect(files, isEmpty); | ||
| }); | ||
|
|
||
| test('returns empty set when process throws', () async { | ||
| final runner = MockProcessRunner( | ||
| exceptionToThrow: Exception('process failed'), | ||
| ); | ||
|
|
||
| final files = await defaultGitList( | ||
| baseBranch: 'origin/main', | ||
| processRunner: runner.run, | ||
| ); | ||
|
|
||
| expect(files, isEmpty); | ||
| }); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'dart:io'; | ||
| import 'package:rfc_tools/src/github_client.dart'; | ||
| import 'package:test/test.dart'; | ||
| import 'mock_process_runner.dart'; | ||
|
|
||
| void main() { | ||
| group('CliGitHubClient', () { | ||
| test('default constructor uses Process.run', () { | ||
| const client = CliGitHubClient(); | ||
| expect(client.processRunner, equals(Process.run)); | ||
| }); | ||
|
|
||
| group('userExists', () { | ||
| test('returns true when exitCode is 0', () async { | ||
| final runner = MockProcessRunner(exitCode: 0); | ||
| final client = CliGitHubClient(processRunner: runner.run); | ||
|
|
||
| final exists = await client.userExists('octocat'); | ||
|
|
||
| expect(exists, isTrue); | ||
| expect(runner.calls, hasLength(1)); | ||
| expect(runner.calls.first.executable, equals('gh')); | ||
| expect( | ||
| runner.calls.first.arguments, | ||
| equals(['api', 'users/octocat', '--silent']), | ||
| ); | ||
| }); | ||
|
|
||
| test('returns false when exitCode is non-zero', () async { | ||
| final runner = MockProcessRunner(exitCode: 1); | ||
| final client = CliGitHubClient(processRunner: runner.run); | ||
|
|
||
| final exists = await client.userExists('unknown-user'); | ||
|
|
||
| expect(exists, isFalse); | ||
| expect(runner.calls, hasLength(1)); | ||
| expect(runner.calls.first.executable, equals('gh')); | ||
| expect( | ||
| runner.calls.first.arguments, | ||
| equals(['api', 'users/unknown-user', '--silent']), | ||
| ); | ||
| }); | ||
|
|
||
| test('returns false when process runner throws', () async { | ||
| final runner = MockProcessRunner( | ||
| exceptionToThrow: const SocketException('network down'), | ||
| ); | ||
| final client = CliGitHubClient(processRunner: runner.run); | ||
|
|
||
| final exists = await client.userExists('octocat'); | ||
|
|
||
| expect(exists, isFalse); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| group('FakeGitHubClient', () { | ||
| test('verifies user existence against existingUsers set', () async { | ||
| final client = FakeGitHubClient(existingUsers: {'user1'}); | ||
|
|
||
| expect(await client.userExists('user1'), isTrue); | ||
| expect(await client.userExists('unknown'), isFalse); | ||
| }); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| // Copyright 2026 The Flutter Authors. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'dart:io'; | ||
|
|
||
| typedef MockProcessHandler = | ||
| Future<ProcessResult> Function(String executable, List<String> arguments); | ||
|
|
||
| /// In-memory mock process runner to record external process invocations and | ||
| /// control process outputs hermetically in tests. | ||
| class MockProcessRunner { | ||
| final List<({String executable, List<String> arguments})> calls = []; | ||
| MockProcessHandler? handler; | ||
| int exitCode; | ||
| dynamic stdout; | ||
| dynamic stderr; | ||
| Object? exceptionToThrow; | ||
|
|
||
| MockProcessRunner({ | ||
| this.exitCode = 0, | ||
| this.stdout = '', | ||
| this.stderr = '', | ||
| this.exceptionToThrow, | ||
| this.handler, | ||
| }); | ||
|
|
||
| Future<ProcessResult> run(String executable, List<String> arguments) async { | ||
| calls.add(( | ||
| executable: executable, | ||
| arguments: List.unmodifiable(arguments), | ||
| )); | ||
| if (exceptionToThrow != null) { | ||
| throw exceptionToThrow!; | ||
| } | ||
| if (handler != null) { | ||
| return await handler!(executable, arguments); | ||
| } | ||
| return ProcessResult(1234, exitCode, stdout, stderr); | ||
| } | ||
|
|
||
| Future<ProcessResult> call(String executable, List<String> arguments) => | ||
| run(executable, arguments); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like stdout and stderr from git are getting dropped here. You might want to print them somewhere if the git command fails.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm adding both stdout and stderr for here and the other commands. That way nothing is lost.