diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2a0a8..2e14f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* Support release commits ([#62](https://github.com/getsentry/sentry-dart-plugin/pull/62)) + ## 1.0.0-beta.3 ### Features diff --git a/README.md b/README.md index 95b6eb1..0a533e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Sentry Dart Plugin [![Sentry Dart Plugin](https://github.com/getsentry/sentry-dart-plugin/actions/workflows/dart_plugin.yml/badge.svg)](https://github.com/getsentry/sentry-dart-plugin/actions/workflows/dart_plugin.yml) -[![pub package](https://img.shields.io/pub/v/sentry_dart_plugin.svg)](https://pub.dev/packages/sentry_dart_plugin) +[![pub package](https://img.shields.io/pub/v/sentry_dart_plugin.svg)](https://pub.dev/packages/sentry_dart_plugin) [![pub points](https://img.shields.io/pub/points/sentry_dart_plugin)](https://pub.dev/packages/sentry_dart_plugin/score) A Dart Build Plugin that uploads debug symbols for Android, iOS/macOS and source maps for Web to Sentry via [sentry-cli](https://docs.sentry.io/product/cli/). @@ -54,9 +54,10 @@ sentry: log_level: error # possible values: trace, debug, info, warn, error release: ... web_build_path: ... + commits: auto ``` -###### Available Configuration Fields: +### Available Configuration Fields | Configuration Name | Description | Default Value And Type | Required | Alternative Environment variable | | - | - | - | - | - | @@ -71,6 +72,7 @@ sentry: | log_level | Configures the log level for sentry-cli | warn (string) | no | SENTRY_LOG_LEVEL | | release | The release version for source maps, it should match the release set by the SDK | default: name@version from pubspec (string) | no | SENTRY_RELEASE | | web_build_path | The web build folder | default: build/web (string) | no | - | +| commits | Release commits integration | default: auto | no | - | ## Troubleshooting diff --git a/integration-test/integration-test-server.py b/integration-test/integration-test-server.py index d59377f..65871e2 100644 --- a/integration-test/integration-test-server.py +++ b/integration-test/integration-test-server.py @@ -11,15 +11,16 @@ apiOrg = 'sentry-sdks' apiProject = 'sentry-dart-plugin' uri = urlparse(sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8000') -version='1.1.0' -appIdentifier='project' +version = '1.1.0' +appIdentifier = 'project' + class Handler(BaseHTTPRequestHandler): body = None def do_GET(self): - self.start_response(HTTPStatus.OK) - + self.start_response() + if self.path == "/STOP": print("HTTP server stopping!") threading.Thread(target=self.server.shutdown).start() @@ -32,17 +33,17 @@ def do_GET(self): '"accept":["debug_files","release_files","pdbs","sources","bcsymbolmaps"]}') elif self.isApi('/api/0/organizations/{}/repos/?cursor='.format(apiOrg)): self.writeJSONFile("assets/repos.json") - elif self.isApi('/api/0/organizations/{}/releases/{}/previous-with-commits/'.format(apiOrg, version)): - self.writeJSONFile("assets/release.json") + elif self.isApi('/api/0/organizations/{}/releases/{}@{}/previous-with-commits/'.format(apiOrg, appIdentifier, version)): + self.writeJSON('{ }') elif self.isApi('/api/0/projects/{}/{}/releases/{}/files/?cursor='.format(apiOrg, apiProject, version)): self.writeJSONFile("assets/artifacts.json") else: - self.end_headers() + self.writeNoApiMatchesError() self.flushLogs() def do_POST(self): - self.start_response(HTTPStatus.OK) + self.start_response() if self.isApi('api/0/projects/{}/{}/files/difs/assemble/'.format(apiOrg, apiProject)): # Request body example: @@ -77,36 +78,37 @@ def do_POST(self): self.writeJSONFile("assets/debug-info-files.json") elif self.isApi('/api/0/projects/{}/{}/files/dsyms/associate/'.format(apiOrg, apiProject)): self.writeJSONFile("assets/associate-dsyms-response.json") + elif self.isApi('/api/0/projects/{}/{}/reprocessing/'.format(apiOrg, apiProject)): + self.writeJSON('{ }') + elif self.isApi('api/0/organizations/{}/chunk-upload/'.format(apiOrg)): + self.writeJSON('{ }') else: - self.end_headers() + self.writeNoApiMatchesError() self.flushLogs() def do_PUT(self): - self.start_response(HTTPStatus.OK) + self.start_response() - if self.isApi('/api/0/organizations/{}/releases/{}/'.format(apiOrg, version)): + if self.isApi('/api/0/organizations/{}/releases/{}@{}/'.format(apiOrg, appIdentifier, version)): self.writeJSONFile("assets/release.json") - if self.isApi('/api/0/projects/{}/{}/releases/{}@{}/'.format(apiOrg, apiProject, appIdentifier, version)): + elif self.isApi('/api/0/projects/{}/{}/releases/{}@{}/'.format(apiOrg, apiProject, appIdentifier, version)): self.writeJSONFile("assets/release.json") else: - self.end_headers() + self.writeNoApiMatchesError() self.flushLogs() - def start_response(self, code): + def start_response(self): self.body = None - self.log_request(code) - self.send_response_only(code) + self.log_request() - def log_request(self, code=None, size=None): - if isinstance(code, HTTPStatus): - code = code.value + def log_request(self, size=None): body = self.body = self.requestBody() if body: body = self.body[0:min(1000, len(body))] - self.log_message('"%s" %s %s%s', - self.requestline, str(code), "({} bytes)".format(size) if size else '', body) + self.log_message('"%s" %s%s', + self.requestline, "({} bytes)".format(size) if size else '', body) # Note: this may only be called once during a single request - can't `.read()` the same stream again. def requestBody(self): @@ -125,15 +127,26 @@ def isApi(self, api: str): return True return False + def writeNoApiMatchesError(self): + err = "Error: no API matched {} '{}'".format(self.command, self.path) + self.log_error(err) + self.writeResponse(HTTPStatus.NOT_IMPLEMENTED, + "text/plain", err) + def writeJSONFile(self, file_name: str): json_file = open(file_name, "r") self.writeJSON(json_file.read()) json_file.close() def writeJSON(self, string: str): - self.send_header("Content-type", "application/json") + self.writeResponse(HTTPStatus.OK, "application/json", string) + + def writeResponse(self, code: HTTPStatus, type: str, body: str): + self.send_response_only(code) + self.send_header("Content-type", type) + self.send_header("Content-Length", len(body)) self.end_headers() - self.wfile.write(str.encode(string)) + self.wfile.write(str.encode(body)) def flushLogs(self): sys.stdout.flush() diff --git a/lib/sentry_dart_plugin.dart b/lib/sentry_dart_plugin.dart index 8177ee6..5f4e141 100644 --- a/lib/sentry_dart_plugin.dart +++ b/lib/sentry_dart_plugin.dart @@ -33,11 +33,21 @@ class SentryDartPlugin { Log.info('uploadNativeSymbols is disabled.'); } + _executeNewRelease(); + if (_configuration.uploadSourceMaps) { _executeCliForSourceMaps(); } else { Log.info('uploadSourceMaps is disabled.'); } + + if (_configuration.commits.toLowerCase() != 'false') { + _executeSetCommits(); + } else { + Log.info('Commit integration is disabled.'); + } + + _executeFinalizeRelease(); } on ExitError catch (e) { return e.code; } @@ -54,14 +64,14 @@ class SentryDartPlugin { params.add('upload-dif'); + _addOrgAndProject(params); + if (_configuration.includeNativeSources) { params.add('--include-sources'); } else { Log.info('includeNativeSources is disabled, not uploading sources.'); } - _addOrgAndProject(params); - params.add(_configuration.buildFilesFolder); _addWait(params); @@ -71,36 +81,52 @@ class SentryDartPlugin { Log.taskCompleted(taskName); } - void _executeCliForSourceMaps() { - const taskName = 'uploading source maps'; - Log.startingTask(taskName); - - List params = []; - + List _releasesCliParams() { + final params = []; _setUrlAndTokenAndLog(params); - params.add('releases'); - _addOrgAndProject(params); + return params; + } + + void _executeNewRelease() { + _executeAndLog('Failed to create a new release', + [..._releasesCliParams(), 'new', _release]); + } - List releaseFinalizeParams = []; - releaseFinalizeParams.addAll(params); + void _executeFinalizeRelease() { + _executeAndLog('Failed to finalize the new release', + [..._releasesCliParams(), 'finalize', _release]); + } + + void _executeSetCommits() { + final params = [ + ..._releasesCliParams(), + 'set-commits', + _release, + ]; + + if (['auto', 'true', ''].contains(_configuration.commits.toLowerCase())) { + params.add('--auto'); + } else { + params.add('--commit'); + params.add(_configuration.commits); + } - // create new release - List releaseNewParams = []; - releaseNewParams.addAll(params); - releaseNewParams.add('new'); + _executeAndLog('Failed to set commits', params); + } - final release = _getRelease(); - releaseNewParams.add(release); + void _executeCliForSourceMaps() { + const taskName = 'uploading source maps'; + Log.startingTask(taskName); - _executeAndLog('Failed to create new release', releaseNewParams); + List params = _releasesCliParams(); // upload source maps (js and map) List releaseJsFilesParams = []; releaseJsFilesParams.addAll(params); - _addExtensionToParams(['map', 'js'], releaseJsFilesParams, release, + _addExtensionToParams(['map', 'js'], releaseJsFilesParams, _release, _configuration.webBuildFilesFolder); _addWait(releaseJsFilesParams); @@ -111,19 +137,13 @@ class SentryDartPlugin { List releaseDartFilesParams = []; releaseDartFilesParams.addAll(params); - _addExtensionToParams(['dart'], releaseDartFilesParams, release, + _addExtensionToParams(['dart'], releaseDartFilesParams, _release, _configuration.buildFilesFolder); _addWait(releaseDartFilesParams); _executeAndLog('Failed to upload source maps', releaseDartFilesParams); - // finalize new release - releaseFinalizeParams.add('finalize'); - releaseFinalizeParams.add(release); - - _executeAndLog('Failed to create new release', releaseFinalizeParams); - Log.taskCompleted(taskName); } @@ -178,9 +198,7 @@ class SentryDartPlugin { } } - String _getRelease() { - return '${_configuration.name}@${_configuration.version}'; - } + String get _release => '${_configuration.name}@${_configuration.version}'; void _addWait(List params) { if (_configuration.waitForProcessing) { diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index 8bba2fc..9a40231 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:file/file.dart'; import 'package:process/process.dart'; -import 'package:sentry_dart_plugin/src/cli/_sources.dart'; import 'package:system_info2/system_info2.dart'; import 'package:yaml/yaml.dart'; @@ -58,6 +57,13 @@ class Configuration { /// the Web Build folder, defaults to build/web late String webBuildFilesFolder; + /// Associate commits with the release. Defaults to `auto` which will discover + /// commits from the current project and compare them with the ones associated + /// to the previous release. See docs for other options: + /// https://docs.sentry.io/product/cli/releases/#sentry-cli-commit-integration + /// Set to `false` to disable this feature completely. + late String commits; + dynamic _getPubspec() { final file = injector.get().file("pubspec.yaml"); if (!file.existsSync()) { @@ -87,6 +93,7 @@ class Configuration { uploadNativeSymbols = config?['upload_native_symbols'] ?? true; uploadSourceMaps = config?['upload_source_maps'] ?? false; includeNativeSources = config?['include_native_sources'] ?? false; + commits = (config?['commits'] ?? 'auto').toString(); // uploading JS and Map files need to have the correct folder structure // otherwise symbolication fails, the default path for the web build folder is build/web @@ -147,7 +154,6 @@ class Configuration { } Future _findAndSetCliPath() async { - final cliSetup = CLISetup(currentCLISources); HostPlatform? platform; if (Platform.isMacOS) { platform = HostPlatform.darwinUniversal; @@ -179,7 +185,7 @@ class Configuration { } try { - cliPath = await cliSetup.download(platform); + cliPath = await injector.get().download(platform); } on Exception catch (e) { Log.error("Failed to download Sentry CLI: $e"); return _setPreInstalledCli(); diff --git a/lib/src/utils/injector.dart b/lib/src/utils/injector.dart index 2050eaa..bb62511 100644 --- a/lib/src/utils/injector.dart +++ b/lib/src/utils/injector.dart @@ -3,6 +3,8 @@ import 'package:file/local.dart'; import 'package:injector/injector.dart'; import 'package:process/process.dart'; +import '../cli/_sources.dart'; +import '../cli/setup.dart'; import '../configuration.dart'; /// Injector singleton instance @@ -13,4 +15,5 @@ void initInjector() { injector.registerSingleton(() => Configuration()); injector.registerSingleton(() => LocalProcessManager()); injector.registerSingleton(() => LocalFileSystem()); + injector.registerSingleton(() => CLISetup(currentCLISources)); } diff --git a/test/plugin_test.dart b/test/plugin_test.dart index fdb88ac..e83ee75 100644 --- a/test/plugin_test.dart +++ b/test/plugin_test.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:process/process.dart'; +import 'package:sentry_dart_plugin/src/cli/host_platform.dart'; +import 'package:sentry_dart_plugin/src/cli/setup.dart'; import 'package:test/test.dart'; import 'package:sentry_dart_plugin/sentry_dart_plugin.dart'; @@ -14,50 +16,128 @@ void main() { late MockProcessManager pm; late FileSystem fs; + const cli = MockCLI.name; + const orgAndProject = '--org o --project p'; + const project = 'project'; + const version = '1.1.0'; + const release = '$project@$version'; + const buildDir = '/subdir'; + setUp(() { // override dependencies for testing pm = MockProcessManager(); injector.registerSingleton(() => pm, override: true); fs = MemoryFileSystem.test(); + fs.currentDirectory = fs.directory(buildDir)..createSync(); injector.registerSingleton(() => fs, override: true); + injector.registerSingleton(() => MockCLI(), override: true); }); - test('fails without args and pubspec', () async { - final exitCode = await plugin.run([]); - expect(exitCode, 1); - expect(pm.commandLog, const [ - 'chmod +x .dart_tool/pub/bin/sentry_dart_plugin/sentry-cli', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli help' - ]); - }); + for (final url in const ['http://127.0.0.1', null]) { + group('url: $url', () { + final commonArgs = + '${url == null ? '' : '--url http://127.0.0.1 '}--auth-token t'; + final commonCommands = [ + if (!Platform.isWindows) 'chmod +x $cli', + '$cli help' + ]; - test('works with pubspec', () async { - fs.file('pubspec.yaml').writeAsStringSync(''' -name: project -version: 1.1.0 + Future> runWith(String config) async { + // properly indent the configuration for the `sentry` section in the yaml + if (url != null) { + config = 'url: $url\n$config'; + } + final configIndented = + config.trim().split('\n').map((l) => ' ${l.trim()}').join('\n'); + + fs.file('pubspec.yaml').writeAsStringSync(''' +name: $project +version: $version sentry: - upload_native_symbols: true - include_native_sources: true - upload_source_maps: true - auth_token: t + auth_token: t # TODO: support not specifying this, let sentry-cli use the value it can find in its configs project: p org: o - url: http://127.0.0.1 - log_level: debug +$configIndented '''); - final exitCode = await plugin.run([]); - expect(exitCode, 0); - expect(pm.commandLog, const [ - 'chmod +x .dart_tool/pub/bin/sentry_dart_plugin/sentry-cli', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli help', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli --url http://127.0.0.1 --auth-token t --log-level debug upload-dif --include-sources --org o --project p /', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli --url http://127.0.0.1 --auth-token t --log-level debug releases --org o --project p new project@1.1.0', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli --url http://127.0.0.1 --auth-token t --log-level debug releases --org o --project p files project@1.1.0 upload-sourcemaps /build/web --ext map --ext js', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli --url http://127.0.0.1 --auth-token t --log-level debug releases --org o --project p files project@1.1.0 upload-sourcemaps / --ext dart', - '.dart_tool/pub/bin/sentry_dart_plugin/sentry-cli --url http://127.0.0.1 --auth-token t --log-level debug releases --org o --project p finalize project@1.1.0' - ]); - }); + + final exitCode = await plugin.run([]); + expect(exitCode, 0); + expect(pm.commandLog.take(commonCommands.length), commonCommands); + return pm.commandLog.skip(commonCommands.length); + } + + test('fails without args and pubspec', () async { + final exitCode = await plugin.run([]); + expect(exitCode, 1); + expect(pm.commandLog, commonCommands); + }); + + test('works with pubspec', () async { + final commandLog = await runWith(''' + upload_native_symbols: true + include_native_sources: true + upload_source_maps: true + log_level: debug + '''); + final args = '$commonArgs --log-level debug'; + expect(commandLog, [ + '$cli $args upload-dif $orgAndProject --include-sources $buildDir', + '$cli $args releases $orgAndProject new $release', + '$cli $args releases $orgAndProject files $release upload-sourcemaps $buildDir/build/web --ext map --ext js', + '$cli $args releases $orgAndProject files $release upload-sourcemaps $buildDir --ext dart', + '$cli $args releases $orgAndProject set-commits $release --auto', + '$cli $args releases $orgAndProject finalize $release' + ]); + }); + + test('defaults', () async { + final commandLog = await runWith(''); + expect(commandLog, [ + '$cli $commonArgs upload-dif $orgAndProject $buildDir', + '$cli $commonArgs releases $orgAndProject new $release', + '$cli $commonArgs releases $orgAndProject set-commits $release --auto', + '$cli $commonArgs releases $orgAndProject finalize $release' + ]); + }); + + group('commits', () { + // https://docs.sentry.io/product/cli/releases/#sentry-cli-commit-integration + for (final value in const [ + null, // test the implicit default + 'true', + 'auto', + 'repo_name@293ea41d67225d27a8c212f901637e771d73c0f7', + 'repo_name@293ea41d67225d27a8c212f901637e771d73c0f7..1e248e5e6c24b79a5c46a2e8be12cef0e41bd58d', + ]) { + test(value, () async { + final commandLog = + await runWith(value == null ? '' : 'commits: $value'); + final expectedArgs = + (value == null || value == 'auto' || value == 'true') + ? '--auto' + : '--commit $value'; + expect(commandLog, [ + '$cli $commonArgs upload-dif $orgAndProject $buildDir', + '$cli $commonArgs releases $orgAndProject new $release', + '$cli $commonArgs releases $orgAndProject set-commits $release $expectedArgs', + '$cli $commonArgs releases $orgAndProject finalize $release' + ]); + }); + } + + // if explicitly disabled + test('false', () async { + final commandLog = await runWith('commits: false'); + expect(commandLog, [ + '$cli $commonArgs upload-dif $orgAndProject $buildDir', + '$cli $commonArgs releases $orgAndProject new $release', + '$cli $commonArgs releases $orgAndProject finalize $release' + ]); + }); + }); + }); + } } class MockProcessManager implements ProcessManager { @@ -102,3 +182,10 @@ class MockProcessManager implements ProcessManager { throw UnimplementedError(); } } + +class MockCLI implements CLISetup { + static const name = 'mock-cli'; + + @override + Future download(HostPlatform platform) => Future.value(name); +}