diff --git a/app_dart/lib/src/model/firestore/commit.dart b/app_dart/lib/src/model/firestore/commit.dart index 0ac2994b7..a8411d0e5 100644 --- a/app_dart/lib/src/model/firestore/commit.dart +++ b/app_dart/lib/src/model/firestore/commit.dart @@ -7,6 +7,16 @@ import 'package:github/github.dart'; import 'package:googleapis/firestore/v1.dart' hide Status; import '../../service/firestore.dart'; +import '../appengine/commit.dart' as datastore; + +const String kCommitCollectionId = 'commits'; +const String kCommitAvatarField = 'avatar'; +const String kCommitBranchField = 'branch'; +const String kCommitCreateTimestampField = 'createTimestamp'; +const String kCommitAuthorField = 'author'; +const String kCommitMessageField = 'message'; +const String kCommitRepositoryPathField = 'repositoryPath'; +const String kCommitShaField = 'sha'; class Commit extends Document { /// Lookup [Commit] from Firestore. @@ -76,3 +86,21 @@ class Commit extends Document { return buf.toString(); } } + +/// Generates commit document based on datastore commit data model. +Commit commitToCommitDocument(datastore.Commit commit) { + return Commit.fromDocument( + commitDocument: Document( + name: '$kDatabase/documents/$kCommitCollectionId/${commit.sha}', + fields: { + kCommitAvatarField: Value(stringValue: commit.authorAvatarUrl), + kCommitBranchField: Value(stringValue: commit.branch), + kCommitCreateTimestampField: Value(integerValue: commit.timestamp.toString()), + kCommitAuthorField: Value(stringValue: commit.author), + kCommitMessageField: Value(stringValue: commit.message), + kCommitRepositoryPathField: Value(stringValue: commit.repository), + kCommitShaField: Value(stringValue: commit.sha), + }, + ), + ); +} diff --git a/app_dart/lib/src/model/firestore/github_gold_status.dart b/app_dart/lib/src/model/firestore/github_gold_status.dart index 7ac9b7bd2..af25d6f60 100644 --- a/app_dart/lib/src/model/firestore/github_gold_status.dart +++ b/app_dart/lib/src/model/firestore/github_gold_status.dart @@ -7,6 +7,15 @@ import 'package:github/github.dart'; import 'package:googleapis/firestore/v1.dart' hide Status; import '../../service/firestore.dart'; +import '../appengine/github_gold_status_update.dart'; + +const String kGithubGoldStatusCollectionId = 'githubGoldStatuses'; +const String kGithubGoldStatusPrNumberField = 'prNumber'; +const String kGithubGoldStatusHeadField = 'head'; +const String kGithubGoldStatusStatusField = 'status'; +const String kGithubGoldStatusDescriptionField = 'description'; +const String kGithubGoldStatusUpdatesField = 'updates'; +const String kGithubGoldStatusRepositoryField = 'repository'; class GithubGoldStatus extends Document { /// Lookup [GithubGoldStatus] from Firestore. @@ -71,3 +80,22 @@ class GithubGoldStatus extends Document { return buf.toString(); } } + +/// Generates GithubGoldStatus document based on datastore GithubGoldStatusUpdate data model. +GithubGoldStatus githubGoldStatusToDocument(GithubGoldStatusUpdate githubGoldStatus) { + // Prefers `_` instead of `/` in Firestore document names. + final String repo = githubGoldStatus.repository!.replaceAll('/', '_'); + return GithubGoldStatus.fromDocument( + githubGoldStatus: Document( + name: '$kDatabase/documents/$kGithubGoldStatusCollectionId/${repo}_${githubGoldStatus.pr}', + fields: { + kGithubGoldStatusDescriptionField: Value(stringValue: githubGoldStatus.description), + kGithubGoldStatusHeadField: Value(stringValue: githubGoldStatus.head), + kGithubGoldStatusPrNumberField: Value(integerValue: githubGoldStatus.pr.toString()), + kGithubGoldStatusRepositoryField: Value(stringValue: githubGoldStatus.repository), + kGithubGoldStatusStatusField: Value(stringValue: githubGoldStatus.status), + kGithubGoldStatusUpdatesField: Value(integerValue: githubGoldStatus.updates.toString()), + }, + ), + ); +} diff --git a/app_dart/lib/src/model/firestore/task.dart b/app_dart/lib/src/model/firestore/task.dart index cfca64b4e..900bd8f23 100644 --- a/app_dart/lib/src/model/firestore/task.dart +++ b/app_dart/lib/src/model/firestore/task.dart @@ -8,8 +8,24 @@ import 'package:googleapis/firestore/v1.dart' hide Status; import '../../request_handling/exceptions.dart'; import '../../service/firestore.dart'; import '../../service/logging.dart'; +import '../appengine/commit.dart'; +import '../appengine/task.dart' as datastore; +import '../ci_yaml/target.dart'; import '../luci/push_message.dart'; +const String kTaskCollectionId = 'tasks'; +const int kTaskDefaultTimestampValue = 0; +const int kTaskInitialAttempt = 1; +const String kTaskBringupField = 'bringup'; +const String kTaskBuildNumberField = 'buildNumber'; +const String kTaskCommitShaField = 'commitSha'; +const String kTaskCreateTimestampField = 'createTimestamp'; +const String kTaskEndTimestampField = 'endTimestamp'; +const String kTaskNameField = 'name'; +const String kTaskStartTimestampField = 'startTimestamp'; +const String kTaskStatusField = 'status'; +const String kTaskTestFlakyField = 'testFlaky'; + class Task extends Document { /// Lookup [Task] from Firestore. /// @@ -245,3 +261,45 @@ class Task extends Document { return buf.toString(); } } + +/// Generates task documents based on targets. +List targetsToTaskDocuments(Commit commit, List targets) { + final Iterable iterableDocuments = targets.map( + (Target target) => Task.fromDocument( + taskDocument: Document( + name: '$kDatabase/documents/$kTaskCollectionId/${commit.sha}_${target.value.name}_$kTaskInitialAttempt', + fields: { + kTaskCreateTimestampField: Value(integerValue: commit.timestamp!.toString()), + kTaskEndTimestampField: Value(integerValue: kTaskDefaultTimestampValue.toString()), + kTaskBringupField: Value(booleanValue: target.value.bringup), + kTaskNameField: Value(stringValue: target.value.name), + kTaskStartTimestampField: Value(integerValue: kTaskDefaultTimestampValue.toString()), + kTaskStatusField: Value(stringValue: Task.statusNew), + kTaskTestFlakyField: Value(booleanValue: false), + kTaskCommitShaField: Value(stringValue: commit.sha), + }, + ), + ), + ); + return iterableDocuments.toList(); +} + +/// Generates task document based on datastore task data model. +Task taskToDocument(datastore.Task task) { + final String commitSha = task.commitKey!.id!.split('/').last; + return Task.fromDocument( + taskDocument: Document( + name: '$kDatabase/documents/$kTaskCollectionId/${commitSha}_${task.name}_${task.attempts}', + fields: { + kTaskCreateTimestampField: Value(integerValue: task.createTimestamp.toString()), + kTaskEndTimestampField: Value(integerValue: task.endTimestamp.toString()), + kTaskBringupField: Value(booleanValue: task.isFlaky), + kTaskNameField: Value(stringValue: task.name), + kTaskStartTimestampField: Value(integerValue: task.startTimestamp.toString()), + kTaskStatusField: Value(stringValue: task.status), + kTaskTestFlakyField: Value(booleanValue: task.isTestFlaky), + kTaskCommitShaField: Value(stringValue: commitSha), + }, + ), + ); +} diff --git a/app_dart/lib/src/request_handlers/dart_internal_subscription.dart b/app_dart/lib/src/request_handlers/dart_internal_subscription.dart index d72992c43..4c12be269 100644 --- a/app_dart/lib/src/request_handlers/dart_internal_subscription.dart +++ b/app_dart/lib/src/request_handlers/dart_internal_subscription.dart @@ -104,7 +104,7 @@ class DartInternalSubscription extends SubscriptionHandler { await datastore.insert([taskToInsert]); try { final FirestoreService firestoreService = await config.createFirestoreService(); - final firestore.Task taskDocument = taskToTaskDocument(taskToInsert); + final firestore.Task taskDocument = firestore.taskToDocument(taskToInsert); final List writes = documentsToWrites([taskDocument]); await firestoreService.batchWriteDocuments(BatchWriteRequest(writes: writes), kDatabase); } catch (error) { diff --git a/app_dart/lib/src/request_handlers/reset_prod_task.dart b/app_dart/lib/src/request_handlers/reset_prod_task.dart index eb89e74ec..08fa80223 100644 --- a/app_dart/lib/src/request_handlers/reset_prod_task.dart +++ b/app_dart/lib/src/request_handlers/reset_prod_task.dart @@ -146,7 +146,8 @@ class ResetProdTask extends ApiRequestHandler { firestore.Task? taskDocument; final int currentAttempt = task.attempts!; - final String documentName = '$kDatabase/documents/$kTaskCollectionId/${sha}_${taskName}_$currentAttempt'; + final String documentName = + '$kDatabase/documents/${firestore.kTaskCollectionId}/${sha}_${taskName}_$currentAttempt'; try { taskDocument = await firestore.Task.fromFirestore(firestoreService: firestoreService, documentName: documentName); } catch (error) { diff --git a/app_dart/lib/src/request_handlers/scheduler/batch_backfiller.dart b/app_dart/lib/src/request_handlers/scheduler/batch_backfiller.dart index 1c93c6ab8..f4ece8658 100644 --- a/app_dart/lib/src/request_handlers/scheduler/batch_backfiller.dart +++ b/app_dart/lib/src/request_handlers/scheduler/batch_backfiller.dart @@ -120,7 +120,7 @@ class BatchBackfiller extends RequestHandler { if (tasks.isEmpty) { return; } - final List taskDocuments = tasks.map((e) => taskToTaskDocument(e)).toList(); + final List taskDocuments = tasks.map((e) => firestore.taskToDocument(e)).toList(); final List writes = documentsToWrites(taskDocuments, exists: true); final FirestoreService firestoreService = await config.createFirestoreService(); await firestoreService.writeViaTransaction(writes); diff --git a/app_dart/lib/src/request_handlers/scheduler/vacuum_stale_tasks.dart b/app_dart/lib/src/request_handlers/scheduler/vacuum_stale_tasks.dart index b27a40d41..5bfc6802d 100644 --- a/app_dart/lib/src/request_handlers/scheduler/vacuum_stale_tasks.dart +++ b/app_dart/lib/src/request_handlers/scheduler/vacuum_stale_tasks.dart @@ -74,7 +74,7 @@ class VacuumStaleTasks extends RequestHandler { if (tasks.isEmpty) { return; } - final List taskDocuments = tasks.map((e) => taskToTaskDocument(e)).toList(); + final List taskDocuments = tasks.map((e) => firestore.taskToDocument(e)).toList(); final List writes = documentsToWrites(taskDocuments, exists: true); final FirestoreService firestoreService = await config.createFirestoreService(); await firestoreService.writeViaTransaction(writes); diff --git a/app_dart/lib/src/service/commit_service.dart b/app_dart/lib/src/service/commit_service.dart index 1dcad50d8..88c230254 100644 --- a/app_dart/lib/src/service/commit_service.dart +++ b/app_dart/lib/src/service/commit_service.dart @@ -94,7 +94,7 @@ class CommitService { log.info('Commit does not exist in datastore, inserting into datastore'); await datastore.insert([commit]); try { - final firestore.Commit commitDocument = commitToCommitDocument(commit); + final firestore.Commit commitDocument = firestore.commitToCommitDocument(commit); final List writes = documentsToWrites([commitDocument], exists: false); await firestoreService.batchWriteDocuments(BatchWriteRequest(writes: writes), kDatabase); } catch (error) { diff --git a/app_dart/lib/src/service/firestore.dart b/app_dart/lib/src/service/firestore.dart index efd0ac23b..c1514f41e 100644 --- a/app_dart/lib/src/service/firestore.dart +++ b/app_dart/lib/src/service/firestore.dart @@ -8,13 +8,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:googleapis/firestore/v1.dart'; import 'package:http/http.dart'; -import '../model/appengine/commit.dart'; -import '../model/appengine/github_gold_status_update.dart'; -import '../model/appengine/task.dart'; -import '../model/firestore/commit.dart' as firestore_comit; -import '../model/firestore/github_gold_status.dart'; import '../model/firestore/task.dart' as firestore; -import '../model/ci_yaml/target.dart'; import 'access_client_provider.dart'; import 'config.dart'; @@ -22,36 +16,6 @@ const String kDatabase = 'projects/${Config.flutterGcpProjectId}/databases/${Con const String kDocumentParent = '$kDatabase/documents'; const String kFieldFilterOpEqual = 'EQUAL'; -const String kTaskCollectionId = 'tasks'; -const int kTaskDefaultTimestampValue = 0; -const int kTaskInitialAttempt = 1; -const String kTaskBringupField = 'bringup'; -const String kTaskBuildNumberField = 'buildNumber'; -const String kTaskCommitShaField = 'commitSha'; -const String kTaskCreateTimestampField = 'createTimestamp'; -const String kTaskEndTimestampField = 'endTimestamp'; -const String kTaskNameField = 'name'; -const String kTaskStartTimestampField = 'startTimestamp'; -const String kTaskStatusField = 'status'; -const String kTaskTestFlakyField = 'testFlaky'; - -const String kCommitCollectionId = 'commits'; -const String kCommitAvatarField = 'avatar'; -const String kCommitBranchField = 'branch'; -const String kCommitCreateTimestampField = 'createTimestamp'; -const String kCommitAuthorField = 'author'; -const String kCommitMessageField = 'message'; -const String kCommitRepositoryPathField = 'repositoryPath'; -const String kCommitShaField = 'sha'; - -const String kGithubGoldStatusCollectionId = 'githubGoldStatuses'; -const String kGithubGoldStatusPrNumberField = 'prNumber'; -const String kGithubGoldStatusHeadField = 'head'; -const String kGithubGoldStatusStatusField = 'status'; -const String kGithubGoldStatusDescriptionField = 'description'; -const String kGithubGoldStatusUpdatesField = 'updates'; -const String kGithubGoldStatusRepositoryField = 'repository'; - class FirestoreService { const FirestoreService(this.accessClientProvider); @@ -105,10 +69,12 @@ class FirestoreService { Future> queryCommitTasks(String commitSha) async { final ProjectsDatabasesDocumentsResource databasesDocumentsResource = await documentResource(); - final List from = [CollectionSelector(collectionId: kTaskCollectionId)]; + final List from = [ + CollectionSelector(collectionId: firestore.kTaskCollectionId), + ]; final Filter filter = Filter( fieldFilter: FieldFilter( - field: FieldReference(fieldPath: kTaskCommitShaField), + field: FieldReference(fieldPath: firestore.kTaskCommitShaField), op: kFieldFilterOpEqual, value: Value(stringValue: commitSha), ), @@ -122,83 +88,6 @@ class FirestoreService { } } -/// Generates task documents based on targets. -List targetsToTaskDocuments(Commit commit, List targets) { - final Iterable iterableDocuments = targets.map( - (Target target) => firestore.Task.fromDocument( - taskDocument: Document( - name: '$kDatabase/documents/$kTaskCollectionId/${commit.sha}_${target.value.name}_$kTaskInitialAttempt', - fields: { - kTaskCreateTimestampField: Value(integerValue: commit.timestamp!.toString()), - kTaskEndTimestampField: Value(integerValue: kTaskDefaultTimestampValue.toString()), - kTaskBringupField: Value(booleanValue: target.value.bringup), - kTaskNameField: Value(stringValue: target.value.name), - kTaskStartTimestampField: Value(integerValue: kTaskDefaultTimestampValue.toString()), - kTaskStatusField: Value(stringValue: Task.statusNew), - kTaskTestFlakyField: Value(booleanValue: false), - kTaskCommitShaField: Value(stringValue: commit.sha), - }, - ), - ), - ); - return iterableDocuments.toList(); -} - -/// Generates commit document based on datastore commit data model. -firestore_comit.Commit commitToCommitDocument(Commit commit) { - return firestore_comit.Commit.fromDocument( - commitDocument: Document( - name: '$kDatabase/documents/$kCommitCollectionId/${commit.sha}', - fields: { - kCommitAvatarField: Value(stringValue: commit.authorAvatarUrl), - kCommitBranchField: Value(stringValue: commit.branch), - kCommitCreateTimestampField: Value(integerValue: commit.timestamp.toString()), - kCommitAuthorField: Value(stringValue: commit.author), - kCommitMessageField: Value(stringValue: commit.message), - kCommitRepositoryPathField: Value(stringValue: commit.repository), - kCommitShaField: Value(stringValue: commit.sha), - }, - ), - ); -} - -/// Generates task document based on datastore task data model. -firestore.Task taskToTaskDocument(Task task) { - final String commitSha = task.commitKey!.id!.split('/').last; - return firestore.Task.fromDocument( - taskDocument: Document( - name: '$kDatabase/documents/$kTaskCollectionId/${commitSha}_${task.name}_${task.attempts}', - fields: { - kTaskCreateTimestampField: Value(integerValue: task.createTimestamp.toString()), - kTaskEndTimestampField: Value(integerValue: task.endTimestamp.toString()), - kTaskBringupField: Value(booleanValue: task.isFlaky), - kTaskNameField: Value(stringValue: task.name), - kTaskStartTimestampField: Value(integerValue: task.startTimestamp.toString()), - kTaskStatusField: Value(stringValue: task.status), - kTaskTestFlakyField: Value(booleanValue: task.isTestFlaky), - kTaskCommitShaField: Value(stringValue: commitSha), - }, - ), - ); -} - -/// Generates GithubGoldStatus document based on datastore GithubGoldStatusUpdate data model. -GithubGoldStatus githubGoldStatusToDocument(GithubGoldStatusUpdate githubGoldStatus) { - return GithubGoldStatus.fromDocument( - githubGoldStatus: Document( - name: '$kDatabase/documents/$kGithubGoldStatusCollectionId/${githubGoldStatus.head}_${githubGoldStatus.pr}', - fields: { - kGithubGoldStatusDescriptionField: Value(stringValue: githubGoldStatus.description), - kGithubGoldStatusHeadField: Value(stringValue: githubGoldStatus.head), - kGithubGoldStatusPrNumberField: Value(integerValue: githubGoldStatus.pr.toString()), - kGithubGoldStatusRepositoryField: Value(stringValue: githubGoldStatus.repository), - kGithubGoldStatusStatusField: Value(stringValue: githubGoldStatus.status), - kGithubGoldStatusUpdatesField: Value(integerValue: githubGoldStatus.updates.toString()), - }, - ), - ); -} - /// Creates a list of [Write] based on documents. /// /// Null `exists` means either update when a document exists or insert when a document doesn't. diff --git a/app_dart/lib/src/service/scheduler.dart b/app_dart/lib/src/service/scheduler.dart index 6b0ffefd0..343fa2b0c 100644 --- a/app_dart/lib/src/service/scheduler.dart +++ b/app_dart/lib/src/service/scheduler.dart @@ -167,8 +167,8 @@ class Scheduler { await _batchScheduleBuilds(commit, toBeScheduled); await _uploadToBigQuery(commit); - final firestore_commmit.Commit commitDocument = commitToCommitDocument(commit); - final List taskDocuments = targetsToTaskDocuments(commit, initialTargets); + final firestore_commmit.Commit commitDocument = firestore_commmit.commitToCommitDocument(commit); + final List taskDocuments = firestore.targetsToTaskDocuments(commit, initialTargets); final List writes = documentsToWrites([...taskDocuments, commitDocument], exists: false); final FirestoreService firestoreService = await config.createFirestoreService(); // TODO(keyonghan): remove try catch logic after validated to work. diff --git a/app_dart/test/model/firestore/commit_test.dart b/app_dart/test/model/firestore/commit_test.dart index 9181f5016..2734b54f2 100644 --- a/app_dart/test/model/firestore/commit_test.dart +++ b/app_dart/test/model/firestore/commit_test.dart @@ -2,7 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:cocoon_service/src/model/appengine/commit.dart' as datastore; import 'package:cocoon_service/src/model/firestore/commit.dart'; +import 'package:cocoon_service/src/service/firestore.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -36,4 +38,17 @@ void main() { expect(resultedCommit.fields, commit.fields); }); }); + + test('creates commit document correctly from commit data model', () async { + final datastore.Commit commit = generateCommit(1); + final Commit commitDocument = commitToCommitDocument(commit); + expect(commitDocument.name, '$kDatabase/documents/$kCommitCollectionId/${commit.sha}'); + expect(commitDocument.fields![kCommitAvatarField]!.stringValue, commit.authorAvatarUrl); + expect(commitDocument.fields![kCommitBranchField]!.stringValue, commit.branch); + expect(commitDocument.fields![kCommitCreateTimestampField]!.integerValue, commit.timestamp.toString()); + expect(commitDocument.fields![kCommitAuthorField]!.stringValue, commit.author); + expect(commitDocument.fields![kCommitMessageField]!.stringValue, commit.message); + expect(commitDocument.fields![kCommitRepositoryPathField]!.stringValue, commit.repository); + expect(commitDocument.fields![kCommitShaField]!.stringValue, commit.sha); + }); } diff --git a/app_dart/test/model/firestore/github_gold_status_test.dart b/app_dart/test/model/firestore/github_gold_status_test.dart index 7350cc96d..db078ada9 100644 --- a/app_dart/test/model/firestore/github_gold_status_test.dart +++ b/app_dart/test/model/firestore/github_gold_status_test.dart @@ -2,7 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:cocoon_service/src/model/appengine/github_gold_status_update.dart'; import 'package:cocoon_service/src/model/firestore/github_gold_status.dart'; +import 'package:cocoon_service/src/service/firestore.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -36,4 +38,29 @@ void main() { expect(resultedGithubGoldStatus.fields, githubGoldStatus.fields); }); }); + + test('creates github gold status document correctly from data model', () async { + final GithubGoldStatusUpdate githubGoldStatusUpdate = GithubGoldStatusUpdate( + head: 'sha', + pr: 1, + status: GithubGoldStatusUpdate.statusCompleted, + updates: 2, + description: '', + repository: 'flutter/flutter', + ); + final GithubGoldStatus commitDocument = githubGoldStatusToDocument(githubGoldStatusUpdate); + expect( + commitDocument.name, + '$kDatabase/documents/$kGithubGoldStatusCollectionId/${githubGoldStatusUpdate.repository!.replaceAll('/', '_')}_${githubGoldStatusUpdate.pr}', + ); + expect(commitDocument.fields![kGithubGoldStatusHeadField]!.stringValue, githubGoldStatusUpdate.head); + expect(commitDocument.fields![kGithubGoldStatusPrNumberField]!.integerValue, githubGoldStatusUpdate.pr.toString()); + expect(commitDocument.fields![kGithubGoldStatusStatusField]!.stringValue, githubGoldStatusUpdate.status); + expect( + commitDocument.fields![kGithubGoldStatusUpdatesField]!.integerValue, + githubGoldStatusUpdate.updates.toString(), + ); + expect(commitDocument.fields![kGithubGoldStatusDescriptionField]!.stringValue, githubGoldStatusUpdate.description); + expect(commitDocument.fields![kGithubGoldStatusRepositoryField]!.stringValue, githubGoldStatusUpdate.repository); + }); } diff --git a/app_dart/test/model/firestore/task_test.dart b/app_dart/test/model/firestore/task_test.dart index 997630880..ab748e3bf 100644 --- a/app_dart/test/model/firestore/task_test.dart +++ b/app_dart/test/model/firestore/task_test.dart @@ -2,8 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:cocoon_service/src/model/appengine/commit.dart'; +import 'package:cocoon_service/src/model/appengine/task.dart' as datastore; +import 'package:cocoon_service/src/model/ci_yaml/target.dart'; import 'package:cocoon_service/src/model/firestore/task.dart'; import 'package:cocoon_service/src/model/luci/push_message.dart' as pm; +import 'package:cocoon_service/src/service/firestore.dart'; import 'package:googleapis/firestore/v1.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -18,6 +22,43 @@ void main() { expect(() => task.setStatus('unknown'), throwsArgumentError); }); + test('creates task document correctly from task data model', () async { + final datastore.Task task = generateTask(1); + final String commitSha = task.commitKey!.id!.split('/').last; + final Task taskDocument = taskToDocument(task); + expect(taskDocument.name, '$kDatabase/documents/$kTaskCollectionId/${commitSha}_${task.name}_${task.attempts}'); + expect(taskDocument.createTimestamp, task.createTimestamp); + expect(taskDocument.endTimestamp, task.endTimestamp); + expect(taskDocument.bringup, task.isFlaky); + expect(taskDocument.taskName, task.name); + expect(taskDocument.startTimestamp, task.startTimestamp); + expect(taskDocument.status, task.status); + expect(taskDocument.testFlaky, task.isTestFlaky); + expect(taskDocument.commitSha, commitSha); + }); + + test('creates task documents correctly from targets', () async { + final Commit commit = generateCommit(1); + final List targets = [ + generateTarget(1, platform: 'Mac'), + generateTarget(2, platform: 'Linux'), + ]; + final List taskDocuments = targetsToTaskDocuments(commit, targets); + expect(taskDocuments.length, 2); + expect( + taskDocuments[0].name, + '$kDatabase/documents/$kTaskCollectionId/${commit.sha}_${targets[0].value.name}_$kTaskInitialAttempt', + ); + expect(taskDocuments[0].fields![kTaskCreateTimestampField]!.integerValue, commit.timestamp.toString()); + expect(taskDocuments[0].fields![kTaskEndTimestampField]!.integerValue, '0'); + expect(taskDocuments[0].fields![kTaskBringupField]!.booleanValue, false); + expect(taskDocuments[0].fields![kTaskNameField]!.stringValue, targets[0].value.name); + expect(taskDocuments[0].fields![kTaskStartTimestampField]!.integerValue, '0'); + expect(taskDocuments[0].fields![kTaskStatusField]!.stringValue, Task.statusNew); + expect(taskDocuments[0].fields![kTaskTestFlakyField]!.booleanValue, false); + expect(taskDocuments[0].fields![kTaskCommitShaField]!.stringValue, commit.sha); + }); + group('updateFromBuild', () { test('updates if buildNumber is null', () { final DateTime created = DateTime.utc(2022, 1, 11, 1, 1); diff --git a/app_dart/test/request_handlers/reset_prod_task_test.dart b/app_dart/test/request_handlers/reset_prod_task_test.dart index 3ab6c5059..7917313a0 100644 --- a/app_dart/test/request_handlers/reset_prod_task_test.dart +++ b/app_dart/test/request_handlers/reset_prod_task_test.dart @@ -100,7 +100,10 @@ void main() { final List captured = verify(mockFirestoreService.getDocument(captureAny)).captured; expect(captured.length, 1); final String documentName = captured[0] as String; - expect(documentName, '$kDatabase/documents/$kTaskCollectionId/${commit.sha}_${task.name}_${task.attempts}'); + expect( + documentName, + '$kDatabase/documents/${firestore.kTaskCollectionId}/${commit.sha}_${task.name}_${task.attempts}', + ); }); test('Re-schedule existing task', () async { diff --git a/app_dart/test/service/commit_service_test.dart b/app_dart/test/service/commit_service_test.dart index d1d2a3a24..c7a965c5f 100644 --- a/app_dart/test/service/commit_service_test.dart +++ b/app_dart/test/service/commit_service_test.dart @@ -4,6 +4,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/appengine/commit.dart'; +import 'package:cocoon_service/src/model/firestore/commit.dart' as firestore; import 'package:cocoon_service/src/service/commit_service.dart'; import 'package:github/github.dart'; import 'package:googleapis/firestore/v1.dart' hide Status; @@ -99,7 +100,7 @@ void main() { final BatchWriteRequest batchWriteRequest = captured[0] as BatchWriteRequest; expect(batchWriteRequest.writes!.length, 1); final Document insertedCommitDocument = batchWriteRequest.writes![0].update!; - expect(insertedCommitDocument.name, '$kDatabase/documents/$kCommitCollectionId/$sha'); + expect(insertedCommitDocument.name, '$kDatabase/documents/${firestore.kCommitCollectionId}/$sha'); }); test('does not add commit to db if it exists in the datastore', () async { diff --git a/app_dart/test/service/firestore_test.dart b/app_dart/test/service/firestore_test.dart index 30f3bd6ad..049a0bec3 100644 --- a/app_dart/test/service/firestore_test.dart +++ b/app_dart/test/service/firestore_test.dart @@ -2,96 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:cocoon_service/src/model/appengine/commit.dart'; -import 'package:cocoon_service/src/model/appengine/github_gold_status_update.dart'; -import 'package:cocoon_service/src/model/appengine/task.dart'; -import 'package:cocoon_service/src/model/firestore/commit.dart' as firestore_commmit; -import 'package:cocoon_service/src/model/firestore/github_gold_status.dart'; -import 'package:cocoon_service/src/model/firestore/task.dart' as firestore; -import 'package:cocoon_service/src/model/ci_yaml/target.dart'; import 'package:cocoon_service/src/service/firestore.dart'; import 'package:googleapis/firestore/v1.dart'; import 'package:test/test.dart'; -import '../src/utilities/entity_generators.dart'; - void main() { - test('creates task documents correctly from targets', () async { - final Commit commit = generateCommit(1); - final List targets = [ - generateTarget(1, platform: 'Mac'), - generateTarget(2, platform: 'Linux'), - ]; - final List taskDocuments = targetsToTaskDocuments(commit, targets); - expect(taskDocuments.length, 2); - expect( - taskDocuments[0].name, - '$kDatabase/documents/$kTaskCollectionId/${commit.sha}_${targets[0].value.name}_$kTaskInitialAttempt', - ); - expect(taskDocuments[0].fields![kTaskCreateTimestampField]!.integerValue, commit.timestamp.toString()); - expect(taskDocuments[0].fields![kTaskEndTimestampField]!.integerValue, '0'); - expect(taskDocuments[0].fields![kTaskBringupField]!.booleanValue, false); - expect(taskDocuments[0].fields![kTaskNameField]!.stringValue, targets[0].value.name); - expect(taskDocuments[0].fields![kTaskStartTimestampField]!.integerValue, '0'); - expect(taskDocuments[0].fields![kTaskStatusField]!.stringValue, Task.statusNew); - expect(taskDocuments[0].fields![kTaskTestFlakyField]!.booleanValue, false); - expect(taskDocuments[0].fields![kTaskCommitShaField]!.stringValue, commit.sha); - }); - - test('creates commit document correctly from commit data model', () async { - final Commit commit = generateCommit(1); - final firestore_commmit.Commit commitDocument = commitToCommitDocument(commit); - expect(commitDocument.name, '$kDatabase/documents/$kCommitCollectionId/${commit.sha}'); - expect(commitDocument.fields![kCommitAvatarField]!.stringValue, commit.authorAvatarUrl); - expect(commitDocument.fields![kCommitBranchField]!.stringValue, commit.branch); - expect(commitDocument.fields![kCommitCreateTimestampField]!.integerValue, commit.timestamp.toString()); - expect(commitDocument.fields![kCommitAuthorField]!.stringValue, commit.author); - expect(commitDocument.fields![kCommitMessageField]!.stringValue, commit.message); - expect(commitDocument.fields![kCommitRepositoryPathField]!.stringValue, commit.repository); - expect(commitDocument.fields![kCommitShaField]!.stringValue, commit.sha); - }); - - test('creates github gold status document correctly from data model', () async { - final GithubGoldStatusUpdate githubGoldStatusUpdate = GithubGoldStatusUpdate( - head: 'sha', - pr: 1, - status: GithubGoldStatusUpdate.statusCompleted, - updates: 2, - description: '', - repository: '', - ); - final GithubGoldStatus commitDocument = githubGoldStatusToDocument(githubGoldStatusUpdate); - expect( - commitDocument.name, - '$kDatabase/documents/$kGithubGoldStatusCollectionId/${githubGoldStatusUpdate.head}_${githubGoldStatusUpdate.pr}', - ); - expect(commitDocument.fields![kGithubGoldStatusHeadField]!.stringValue, githubGoldStatusUpdate.head); - expect(commitDocument.fields![kGithubGoldStatusPrNumberField]!.integerValue, githubGoldStatusUpdate.pr.toString()); - expect(commitDocument.fields![kGithubGoldStatusStatusField]!.stringValue, githubGoldStatusUpdate.status); - expect( - commitDocument.fields![kGithubGoldStatusUpdatesField]!.integerValue, - githubGoldStatusUpdate.updates.toString(), - ); - expect(commitDocument.fields![kGithubGoldStatusDescriptionField]!.stringValue, ''); - expect(commitDocument.fields![kGithubGoldStatusRepositoryField]!.stringValue, ''); - }); - - test('creates task document correctly from task data model', () async { - final Task task = generateTask(1); - final String commitSha = task.commitKey!.id!.split('/').last; - final firestore.Task taskDocument = taskToTaskDocument(task); - expect(taskDocument.name, '$kDatabase/documents/$kTaskCollectionId/${commitSha}_${task.name}_${task.attempts}'); - expect(taskDocument.createTimestamp, task.createTimestamp); - expect(taskDocument.endTimestamp, task.endTimestamp); - expect(taskDocument.bringup, task.isFlaky); - expect(taskDocument.taskName, task.name); - expect(taskDocument.startTimestamp, task.startTimestamp); - expect(taskDocument.status, task.status); - expect(taskDocument.testFlaky, task.isTestFlaky); - expect(taskDocument.commitSha, commitSha); - }); - test('creates writes correctly from documents', () async { final List documents = [ Document(name: 'd1', fields: {'key1': Value(stringValue: 'value1')}), diff --git a/app_dart/test/src/utilities/entity_generators.dart b/app_dart/test/src/utilities/entity_generators.dart index f8e7df165..232133930 100644 --- a/app_dart/test/src/utilities/entity_generators.dart +++ b/app_dart/test/src/utilities/entity_generators.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:cocoon_service/ci_yaml.dart'; -import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/appengine/commit.dart'; import 'package:cocoon_service/src/model/appengine/task.dart'; import 'package:cocoon_service/src/model/firestore/commit.dart' as firestore_commit; @@ -15,7 +14,6 @@ import 'package:cocoon_service/src/model/gerrit/commit.dart'; import 'package:cocoon_service/src/model/luci/buildbucket.dart'; import 'package:cocoon_service/src/model/luci/push_message.dart' as push_message; import 'package:cocoon_service/src/model/proto/protos.dart' as pb; -import 'package:cocoon_service/src/service/firestore.dart'; import 'package:gcloud/db.dart'; import 'package:googleapis/firestore/v1.dart' hide Status; import 'package:github/github.dart' as github; @@ -119,17 +117,17 @@ firestore.Task generateFirestoreTask( final firestore.Task task = firestore.Task() ..name = '${sha}_${taskName}_$attempts' ..fields = { - kTaskCreateTimestampField: Value(integerValue: (created?.millisecondsSinceEpoch ?? 0).toString()), - kTaskStartTimestampField: Value(integerValue: (started?.millisecondsSinceEpoch ?? 0).toString()), - kTaskEndTimestampField: Value(integerValue: (ended?.millisecondsSinceEpoch ?? 0).toString()), - kTaskBringupField: Value(booleanValue: bringup), - kTaskTestFlakyField: Value(booleanValue: testFlaky), - kTaskStatusField: Value(stringValue: status), - kTaskNameField: Value(stringValue: taskName), - kTaskCommitShaField: Value(stringValue: sha), + firestore.kTaskCreateTimestampField: Value(integerValue: (created?.millisecondsSinceEpoch ?? 0).toString()), + firestore.kTaskStartTimestampField: Value(integerValue: (started?.millisecondsSinceEpoch ?? 0).toString()), + firestore.kTaskEndTimestampField: Value(integerValue: (ended?.millisecondsSinceEpoch ?? 0).toString()), + firestore.kTaskBringupField: Value(booleanValue: bringup), + firestore.kTaskTestFlakyField: Value(booleanValue: testFlaky), + firestore.kTaskStatusField: Value(stringValue: status), + firestore.kTaskNameField: Value(stringValue: taskName), + firestore.kTaskCommitShaField: Value(stringValue: sha), }; if (buildNumber != null) { - task.fields![kTaskBuildNumberField] = Value(integerValue: buildNumber.toString()); + task.fields![firestore.kTaskBuildNumberField] = Value(integerValue: buildNumber.toString()); } return task; } @@ -145,10 +143,10 @@ firestore_commit.Commit generateFirestoreCommit( final firestore_commit.Commit commit = firestore_commit.Commit() ..name = sha ?? '$i' ..fields = { - kCommitCreateTimestampField: Value(integerValue: (createTimestamp ?? i).toString()), - kCommitRepositoryPathField: Value(stringValue: '$owner/$repo'), - kCommitBranchField: Value(stringValue: branch), - kCommitShaField: Value(stringValue: sha ?? '$i'), + firestore_commit.kCommitCreateTimestampField: Value(integerValue: (createTimestamp ?? i).toString()), + firestore_commit.kCommitRepositoryPathField: Value(stringValue: '$owner/$repo'), + firestore_commit.kCommitBranchField: Value(stringValue: branch), + firestore_commit.kCommitShaField: Value(stringValue: sha ?? '$i'), }; return commit; } diff --git a/auto_submit/lib/action/git_cli_revert_method.dart b/auto_submit/lib/action/git_cli_revert_method.dart index 78a5a1a4b..5c24c37fc 100644 --- a/auto_submit/lib/action/git_cli_revert_method.dart +++ b/auto_submit/lib/action/git_cli_revert_method.dart @@ -14,9 +14,8 @@ import 'package:auto_submit/requests/exceptions.dart'; import 'package:auto_submit/service/config.dart'; import 'package:auto_submit/service/github_service.dart'; import 'package:auto_submit/service/log.dart'; -import 'package:auto_submit/service/revert_issue_body_formatter.dart'; +import 'package:auto_submit/revert/revert_issue_body_formatter.dart'; import 'package:github/github.dart' as github; -import 'package:github/github.dart'; import 'package:retry/retry.dart'; class GitCliRevertMethod implements RevertMethod { @@ -57,7 +56,7 @@ class GitCliRevertMethod implements RevertMethod { const RetryOptions retryOptions = RetryOptions(delayFactor: Duration(seconds: 1), maxDelay: Duration(seconds: 1), maxAttempts: 4); - Branch? branch; + github.Branch? branch; // Attempt a few times to get the branch name. This may not be needed. // Let the exception bubble up from here. await retryOptions.retry( diff --git a/auto_submit/lib/model/discord_message.dart b/auto_submit/lib/model/discord_message.dart new file mode 100644 index 000000000..8401a6a91 --- /dev/null +++ b/auto_submit/lib/model/discord_message.dart @@ -0,0 +1,21 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:json_annotation/json_annotation.dart'; + +part 'discord_message.g.dart'; + +@JsonSerializable() +class Message { + Message({this.content, this.username, this.avatarUrl}); + + String? content; + String? username; + // avatar_url + @JsonKey(name: 'avatar_url', includeIfNull: false) + String? avatarUrl; + + factory Message.fromJson(Map input) => _$MessageFromJson(input); + Map toJson() => _$MessageToJson(this); +} diff --git a/auto_submit/lib/model/discord_message.g.dart b/auto_submit/lib/model/discord_message.g.dart new file mode 100644 index 000000000..ef12addca --- /dev/null +++ b/auto_submit/lib/model/discord_message.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'discord_message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Message _$MessageFromJson(Map json) => Message( + content: json['content'] as String?, + username: json['username'] as String?, + avatarUrl: json['avatar_url'] as String?, + ); + +Map _$MessageToJson(Message instance) { + final val = { + 'content': instance.content, + 'username': instance.username, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('avatar_url', instance.avatarUrl); + return val; +} diff --git a/auto_submit/lib/revert/revert_discord_message.dart b/auto_submit/lib/revert/revert_discord_message.dart new file mode 100644 index 000000000..9772c625f --- /dev/null +++ b/auto_submit/lib/revert/revert_discord_message.dart @@ -0,0 +1,33 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:auto_submit/model/discord_message.dart'; + +class RevertDiscordMessage extends Message { + static const String _username = 'Revert bot'; + static const int discordMessageLength = 2000; + static const int elipsesOffset = 3; + + RevertDiscordMessage({super.content, super.username, super.avatarUrl}); + + static RevertDiscordMessage generateMessage( + String originalPrUrl, + String originalPrDisplayText, + String revertPrUrl, + String revertPrDisplayText, + String initiatingAuthor, + String reasonForRevert, + ) { + final String content = ''' +Pull Request [$originalPrDisplayText](<$originalPrUrl>) has been reverted by $initiatingAuthor. +Please see the revert PR here: [$revertPrDisplayText](<$revertPrUrl>). +Reason for reverting: $reasonForRevert'''; + + final String truncatedContent = content.length <= discordMessageLength + ? content + : '${content.substring(0, discordMessageLength - elipsesOffset)}...'; + + return RevertDiscordMessage(content: truncatedContent, username: _username); + } +} diff --git a/auto_submit/lib/revert/revert_info_collection.dart b/auto_submit/lib/revert/revert_info_collection.dart new file mode 100644 index 000000000..cede959fe --- /dev/null +++ b/auto_submit/lib/revert/revert_info_collection.dart @@ -0,0 +1,111 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +class RevertInfoCollection { + RevertInfoCollection(); + + // tags that appear in the revert issue body. + // Fields in the revert information in the body appears as + // + // key: value + // + final String startOriginalPrLinkTag = ''; + final String endOriginalPrLinkTag = ''; + + final String startInitiatingAuthorTag = ''; + final String endInitiatingAuthorTag = ''; + + final String startRevertReasonTag = ''; + final String endRevertReasonTag = ''; + + final String startOriginalPrAuthorTag = ''; + final String endOriginalPrAuthorTag = ''; + + final String startReviewersTag = ''; + final String endReviewersTag = ''; + + final String startRevertBodyTag = ''; + final String endRevertBodyTag = ''; + + String? extractOriginalPrLink(String text) { + return _extract( + startOriginalPrLinkTag, + endOriginalPrLinkTag, + text, + ); + } + + String? extractInitiatingAuthor(String text) { + return _extract( + startInitiatingAuthorTag, + endInitiatingAuthorTag, + text, + ); + } + + String? extractRevertReason(String text) { + return _extract( + startRevertReasonTag, + endRevertReasonTag, + text, + ); + } + + String? extractOriginalPrAuthor(String text) { + return _extract( + startOriginalPrAuthorTag, + endOriginalPrAuthorTag, + text, + ); + } + + String? extractReviewers(String text) { + return _extract( + startReviewersTag, + endReviewersTag, + text, + ); + } + + String? extractRevertBody(String text) { + return _extract( + startRevertBodyTag, + endRevertBodyTag, + text, + ); + } + + String? extractWithTags( + String text, + String startTag, + String endTag, + ) { + return _extract( + startTag, + endTag, + text, + ); + } + + String? _extract(String startTag, String endTag, String text) { + String? match; + String pattern = '$startTag([\\S\\s]*)$endTag'; + pattern = pattern.replaceAll('<', '\\<'); + pattern = pattern.replaceAll('>', '\\>'); + pattern = pattern.replaceAll('-', '\\-'); + pattern = pattern.replaceAll('!', '\\!'); + final RegExp regExp = RegExp( + pattern, + multiLine: true, + ); + if (regExp.hasMatch(text)) { + final matches = regExp.allMatches(text); + final Match m = matches.first; + match = m.group(1); + } + final String foundMatch = match!.trim(); + final List split = foundMatch.split(':'); + return split.elementAt(1).trim(); + } +} diff --git a/auto_submit/lib/service/revert_issue_body_formatter.dart b/auto_submit/lib/revert/revert_issue_body_formatter.dart similarity index 100% rename from auto_submit/lib/service/revert_issue_body_formatter.dart rename to auto_submit/lib/revert/revert_issue_body_formatter.dart diff --git a/auto_submit/lib/service/config.dart b/auto_submit/lib/service/config.dart index 07d34f9f2..ef088c342 100644 --- a/auto_submit/lib/service/config.dart +++ b/auto_submit/lib/service/config.dart @@ -44,6 +44,7 @@ class Config { static const String kGithubAppId = 'AUTO_SUBMIT_GITHUB_APP_ID'; static const String kWebHookKey = 'AUTO_SUBMIT_WEBHOOK_TOKEN'; static const String kFlutterGitHubBotKey = 'AUTO_SUBMIT_FLUTTER_GITHUB_TOKEN'; + static const String kTreeStatusDiscordUrl = 'TREE_STATUS_DISCORD_WEBHOOK_URL'; /// Labels autosubmit looks for on pull requests static const String kAutosubmitLabel = 'autosubmit'; @@ -280,6 +281,13 @@ class Config { return String.fromCharCodes(cacheValue!); } + Future getTreeStatusDiscordUrl() async { + final Uint8List? cacheValue = await cache[kTreeStatusDiscordUrl].get( + () => _getValueFromSecretManager(kTreeStatusDiscordUrl), + ) as Uint8List?; + return String.fromCharCodes(cacheValue!); + } + Future _getValueFromSecretManager(String key) async { final String value = await secretManager.get(key); return Uint8List.fromList(value.codeUnits); diff --git a/auto_submit/lib/service/discord_notification.dart b/auto_submit/lib/service/discord_notification.dart new file mode 100644 index 000000000..83ef67523 --- /dev/null +++ b/auto_submit/lib/service/discord_notification.dart @@ -0,0 +1,35 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:http/http.dart' as http; + +import '../foundation/providers.dart'; +import 'log.dart'; + +class DiscordNotification { + DiscordNotification({required this.targetUri, Map? headers}) { + this.headers = headers ??= defaultHeaders; + } + + Uri? targetUri; + Map? headers; + Map defaultHeaders = { + 'content-type': 'application/json', + }; + + final HttpProvider httpProvider = Providers.freshHttpClient; + + notifyDiscordChannelWebhook(String jsonMessageString) async { + final http.Client client = httpProvider(); + + final http.Response response = await client.post( + targetUri!, + headers: defaultHeaders, + body: jsonMessageString, + ); + + log.info('discord webhook status: ${response.statusCode}'); + log.info('discord webhook response body: ${response.body}'); + } +} diff --git a/auto_submit/lib/service/revert_request_validation_service.dart b/auto_submit/lib/service/revert_request_validation_service.dart index 273f01f54..bd3b364a1 100644 --- a/auto_submit/lib/service/revert_request_validation_service.dart +++ b/auto_submit/lib/service/revert_request_validation_service.dart @@ -2,13 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + +import 'package:auto_submit/model/discord_message.dart'; import 'package:auto_submit/action/git_cli_revert_method.dart'; import 'package:auto_submit/configuration/repository_configuration.dart'; import 'package:auto_submit/model/auto_submit_query_result.dart'; import 'package:auto_submit/model/pull_request_data_types.dart'; import 'package:auto_submit/request_handling/pubsub.dart'; import 'package:auto_submit/requests/github_pull_request_event.dart'; +import 'package:auto_submit/revert/revert_discord_message.dart'; +import 'package:auto_submit/revert/revert_info_collection.dart'; import 'package:auto_submit/service/approver_service.dart'; +import 'package:auto_submit/service/discord_notification.dart'; import 'package:auto_submit/service/validation_service.dart'; import 'package:auto_submit/service/config.dart'; import 'package:auto_submit/service/github_service.dart'; @@ -24,8 +30,11 @@ import 'process_method.dart'; enum RevertProcessMethod { revert, revertOf, none } class RevertRequestValidationService extends ValidationService { - RevertRequestValidationService(Config config, {RetryOptions? retryOptions, RevertMethod? revertMethod}) - : revertMethod = revertMethod ?? GitCliRevertMethod(), + RevertRequestValidationService( + Config config, { + RetryOptions? retryOptions, + RevertMethod? revertMethod, + }) : revertMethod = revertMethod ?? GitCliRevertMethod(), super(config, retryOptions: retryOptions) { /// Validates a PR marked with the reverts label. approverService = ApproverService(config); @@ -36,6 +45,7 @@ class RevertRequestValidationService extends ValidationService { RevertMethod? revertMethod; @visibleForTesting ValidationFilter? validationFilter; + DiscordNotification? discordNotification; /// TODO run the actual request from here and remove the shouldProcess call. /// Processes a pub/sub message associated with PullRequest event. @@ -289,6 +299,11 @@ class RevertRequestValidationService extends ValidationService { await githubService.createComment(slug, prNumber, message); log.info(message); } else { + // Need to add the discord notification here. + final DiscordNotification discordNotification = await discordNotificationClient; + final Message discordMessage = craftDiscordRevertMessage(messagePullRequest); + discordNotification.notifyDiscordChannelWebhook(jsonEncode(discordMessage.toJson())); + log.info('Revert merged successfully, deleting branch ${messagePullRequest.head!.ref!}'); await githubService.deleteBranch(slug, messagePullRequest.head!.ref!); log.info('Pull Request ${slug.fullName}/$prNumber was merged successfully!'); @@ -303,4 +318,41 @@ class RevertRequestValidationService extends ValidationService { log.info('Ack the processed message : $ackId.'); await pubsub.acknowledge(config.pubsubRevertRequestSubscription, ackId); } + + Future get discordNotificationClient async { + discordNotification ??= DiscordNotification( + targetUri: Uri( + host: 'discord.com', + path: await config.getTreeStatusDiscordUrl(), + scheme: 'https', + ), + ); + return discordNotification!; + } + + RevertDiscordMessage craftDiscordRevertMessage(github.PullRequest messagePullRequest) { + const String githubPrefix = 'https://github.com'; + final RevertInfoCollection revertInfoCollection = RevertInfoCollection(); + final String prBody = messagePullRequest.body!; + // Reverts ${slug.fullName}#$prToRevertNumber' + final String? githubFormattedPrLink = revertInfoCollection.extractOriginalPrLink(prBody); + final List prLinkSplit = githubFormattedPrLink!.split('#'); + final int originalPrNumber = int.parse(prLinkSplit.elementAt(1)); + final github.RepositorySlug slug = messagePullRequest.base!.repo!.slug(); + final int revertPrNumber = messagePullRequest.number!; + final String githubFormattedRevertPrLink = '${slug.fullName}#$revertPrNumber'; + // https://github.com/flutter/flutter/pull + final String constructedOriginalPrUrl = '$githubPrefix/${slug.fullName}/pull/$originalPrNumber'; + final String constructedRevertPrUrl = '$githubPrefix/${slug.fullName}/pull/$revertPrNumber'; + final String? initiatingAuthor = revertInfoCollection.extractInitiatingAuthor(prBody); + final String? revertReason = revertInfoCollection.extractRevertReason(prBody); + return RevertDiscordMessage.generateMessage( + constructedOriginalPrUrl, + githubFormattedPrLink, + constructedRevertPrUrl, + githubFormattedRevertPrLink, + initiatingAuthor!, + revertReason!, + ); + } } diff --git a/auto_submit/test/revert/revert_discord_message_test.dart b/auto_submit/test/revert/revert_discord_message_test.dart new file mode 100644 index 000000000..55a846a1a --- /dev/null +++ b/auto_submit/test/revert/revert_discord_message_test.dart @@ -0,0 +1,135 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:auto_submit/revert/revert_discord_message.dart'; +import 'package:test/test.dart'; + +void main() { + void checkExpectedOutput( + String originalPrUrl, + String originalPrDisplayText, + String revertPrUrl, + String revertPrDisplayText, + String initiatingAuthor, + String reasonForRevert, + String realOutput, + ) { + final String expectedFormattedOutput = ''' +Pull Request [$originalPrDisplayText](<$originalPrUrl>) has been reverted by $initiatingAuthor. +Please see the revert PR here: [$revertPrDisplayText](<$revertPrUrl>). +Reason for reverting: $reasonForRevert'''; + expect(expectedFormattedOutput, equals(realOutput)); + } + + test('generateMessage truncates content when necessary', () { + const String originalPrUrl = 'https://example.com/pr/1'; + const String originalPrDisplayText = 'flutter/coconut#1234'; + const String revertPrUrl = 'https://example.com/pr/2'; + const String revertPrDisplayText = 'flutter/coconut#1235'; + const String initiatingAuthor = 'John Doe'; + const String reasonForRevert = '''Test failed very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + very long reason that will exceed the character limit + '''; + + final RevertDiscordMessage message = RevertDiscordMessage.generateMessage( + originalPrUrl, + originalPrDisplayText, + revertPrUrl, + revertPrDisplayText, + initiatingAuthor, + reasonForRevert, + ); + + expect(message.content!.contains('...'), isTrue); + }); + + test('generateMessage does not truncate short content', () { + const String originalPrUrl = 'https://example.com/pr/1'; + const String originalPrDisplayText = 'flutter/coconut#1234'; + const String revertPrUrl = 'https://example.com/pr/2'; + const String revertPrDisplayText = 'flutter/coconut#1235'; + const String initiatingAuthor = 'John Doe'; + const String reasonForRevert = 'Test failed'; + + final RevertDiscordMessage message = RevertDiscordMessage.generateMessage( + originalPrUrl, + originalPrDisplayText, + revertPrUrl, + revertPrDisplayText, + initiatingAuthor, + reasonForRevert, + ); + + checkExpectedOutput( + originalPrUrl, + originalPrDisplayText, + revertPrUrl, + revertPrDisplayText, + initiatingAuthor, + reasonForRevert, + message.content!, + ); + }); + + test('RevertDiscordMessage generates a RevertDiscordMessage', () { + const String originalPrUrl = 'https://example.com/pr/1'; + const String originalPrDisplayText = 'flutter/coconut#1234'; + const String revertPrUrl = 'https://example.com/pr/2'; + const String revertPrDisplayText = 'flutter/coconut#1235'; + const String initiatingAuthor = 'John Doe'; + const String reasonForRevert = 'Test failed'; + + final RevertDiscordMessage message = RevertDiscordMessage.generateMessage( + originalPrUrl, + originalPrDisplayText, + revertPrUrl, + revertPrDisplayText, + initiatingAuthor, + reasonForRevert, + ); + + checkExpectedOutput( + originalPrUrl, + originalPrDisplayText, + revertPrUrl, + revertPrDisplayText, + initiatingAuthor, + reasonForRevert, + message.content!, + ); + }); +} diff --git a/auto_submit/test/revert/revert_info_collection_test.dart b/auto_submit/test/revert/revert_info_collection_test.dart new file mode 100644 index 000000000..7e593fe64 --- /dev/null +++ b/auto_submit/test/revert/revert_info_collection_test.dart @@ -0,0 +1,46 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:auto_submit/revert/revert_info_collection.dart'; +import 'package:test/test.dart'; +import 'revert_support_data.dart'; + +void main() { + RevertInfoCollection? revertInfoCollection; + + setUp(() { + revertInfoCollection = RevertInfoCollection(); + }); + + test('extract reverts link', () { + const String expected = 'flutter/cocoon#3460'; + expect(revertInfoCollection!.extractOriginalPrLink(sampleRevertBody), expected); + }); + + test('extract initiating author', () { + const String expected = 'yusuf-goog'; + expect(revertInfoCollection!.extractInitiatingAuthor(sampleRevertBody), expected); + }); + + test('extract revert reason', () { + const String expected = 'comment was added by mistake.'; + expect(revertInfoCollection!.extractRevertReason(sampleRevertBody), expected); + }); + + test('extract original pr author', () { + const String expected = 'ricardoamador'; + expect(revertInfoCollection!.extractOriginalPrAuthor(sampleRevertBody), expected); + }); + + test('extract original pr reviewers', () { + const String expected = '{keyonghan}'; + expect(revertInfoCollection!.extractReviewers(sampleRevertBody), expected); + }); + + test('extract the original revert info', () { + const String expected = 'A long winded description about this change is revolutionary.'; + final String? description = revertInfoCollection!.extractRevertBody(sampleRevertBody); + expect(description!.contains(expected), isTrue); + }); +} diff --git a/auto_submit/test/service/revert_issue_body_formatter_test.dart b/auto_submit/test/revert/revert_issue_body_formatter_test.dart similarity index 95% rename from auto_submit/test/service/revert_issue_body_formatter_test.dart rename to auto_submit/test/revert/revert_issue_body_formatter_test.dart index c358ad41a..0dfb9e45f 100644 --- a/auto_submit/test/service/revert_issue_body_formatter_test.dart +++ b/auto_submit/test/revert/revert_issue_body_formatter_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:auto_submit/service/revert_issue_body_formatter.dart'; +import 'package:auto_submit/revert/revert_issue_body_formatter.dart'; import 'package:github/github.dart'; import 'package:test/test.dart'; diff --git a/auto_submit/test/revert/revert_support_data.dart b/auto_submit/test/revert/revert_support_data.dart new file mode 100644 index 000000000..d1ec18a16 --- /dev/null +++ b/auto_submit/test/revert/revert_support_data.dart @@ -0,0 +1,31 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +String sampleRevertBody = ''' + +Reverts: flutter/cocoon#3460 + + +Initiated by: yusuf-goog + + +Reason for reverting: comment was added by mistake. + + +Original PR Author: ricardoamador + + + +Reviewed By: {keyonghan} + + + +Original Description: A long winded description about this change is revolutionary. + + +*Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.* + +*List which issues are fixed by this PR. You must list at least one issue.* + +'''; diff --git a/auto_submit/test/service/revert_request_validation_service_test.dart b/auto_submit/test/service/revert_request_validation_service_test.dart index 3f2700c7e..76e0c4ce0 100644 --- a/auto_submit/test/service/revert_request_validation_service_test.dart +++ b/auto_submit/test/service/revert_request_validation_service_test.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:auto_submit/configuration/repository_configuration.dart'; import 'package:auto_submit/model/auto_submit_query_result.dart' as auto hide PullRequest; import 'package:auto_submit/requests/github_pull_request_event.dart'; +import 'package:auto_submit/model/discord_message.dart'; import 'package:auto_submit/service/revert_request_validation_service.dart'; import 'package:auto_submit/service/validation_service.dart'; import 'package:auto_submit/validations/validation.dart'; @@ -19,11 +20,13 @@ import 'package:test/test.dart'; import '../configuration/repository_configuration_data.dart'; import '../requests/github_webhook_test_data.dart'; +import '../revert/revert_support_data.dart'; import '../src/action/fake_revert_method.dart'; import '../src/request_handling/fake_pubsub.dart'; import '../src/service/fake_approver_service.dart'; import '../src/service/fake_bigquery_service.dart'; import '../src/service/fake_config.dart'; +import '../src/service/fake_discord_notification.dart'; import '../src/service/fake_graphql_client.dart'; import '../src/service/fake_github_service.dart'; import '../src/validations/fake_approval.dart'; @@ -44,6 +47,7 @@ void main() { late MockJobsResource jobsResource; late FakeBigqueryService bigqueryService; late FakeRevertMethod revertMethod; + late FakeDiscordNotification discordNotification; setUp(() { githubGraphQLClient = FakeGraphQLClient(); @@ -60,6 +64,8 @@ void main() { bigqueryService = FakeBigqueryService(jobsResource); config.bigqueryService = bigqueryService; config.repositoryConfigurationMock = RepositoryConfiguration.fromYaml(sampleConfigNoOverride); + discordNotification = FakeDiscordNotification(targetUri: Uri(host: 'localhost')); + validationService.discordNotification = discordNotification; when(jobsResource.query(captureAny, any)).thenAnswer((Invocation invocation) { return Future.value( @@ -480,7 +486,8 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + // body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -549,7 +556,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -616,7 +623,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -686,7 +693,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -762,7 +769,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -843,7 +850,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -934,7 +941,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -1027,7 +1034,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -1129,7 +1136,7 @@ void main() { prNumber: 0, repoName: slug.name, labelName: 'revert of', - body: 'Reverts flutter/flutter#1234', + body: sampleRevertBody.replaceAll('\n', ''), ); final GithubPullRequestEvent githubPullRequestEvent = GithubPullRequestEvent( @@ -1175,6 +1182,29 @@ void main() { }); }); + group('Craft discord message', () { + test('Craft discord message', () async { + const String expected = ''' +Pull Request [flutter/cocoon#3460]() has been reverted by yusuf-goog. +Please see the revert PR here: [flutter/cocoon#3461](). +Reason for reverting: comment was added by mistake.'''; + + const String expectedReason = 'Reason for reverting: comment was added by mistake.'; + final PullRequest pullRequest = generatePullRequest( + prNumber: 3461, + repoName: slug.name, + labelName: 'revert of', + body: sampleRevertBody.replaceAll('\n', ''), + ); + + final Message message = validationService.craftDiscordRevertMessage(pullRequest); + + expect(message.username, 'Revert bot'); + expect(message.content!.contains(expected), isTrue); + expect(message.content!.contains(expectedReason), isTrue); + }); + }); + group('processMerge:', () { test('Correct PR titles when merging to use Reland', () async { final PullRequest pullRequest = generatePullRequest( diff --git a/auto_submit/test/src/service/fake_config.dart b/auto_submit/test/src/service/fake_config.dart index d0454a9dd..8741489b0 100644 --- a/auto_submit/test/src/service/fake_config.dart +++ b/auto_submit/test/src/service/fake_config.dart @@ -88,6 +88,9 @@ class FakeConfig extends Config { return 'not_a_real_token'; } + @override + Future getTreeStatusDiscordUrl() async => 'discord.com'; + @override Future createBigQueryService() async => bigqueryService!; diff --git a/auto_submit/test/src/service/fake_discord_notification.dart b/auto_submit/test/src/service/fake_discord_notification.dart new file mode 100644 index 000000000..1ad32231c --- /dev/null +++ b/auto_submit/test/src/service/fake_discord_notification.dart @@ -0,0 +1,14 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:auto_submit/service/discord_notification.dart'; + +class FakeDiscordNotification extends DiscordNotification { + FakeDiscordNotification({required super.targetUri}); + + @override + notifyDiscordChannelWebhook(String jsonMessageString) { + // do nothing + } +} diff --git a/auto_submit/test/utilities/mocks.mocks.dart b/auto_submit/test/utilities/mocks.mocks.dart index cfa5a26cd..71e28d006 100644 --- a/auto_submit/test/utilities/mocks.mocks.dart +++ b/auto_submit/test/utilities/mocks.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in auto_submit/test/utilities/mocks.dart. // Do not manually edit this file. @@ -20,6 +20,8 @@ import 'package:mockito/src/dummies.dart' as _i10; // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -855,12 +857,18 @@ class MockGitHub extends _i1.Mock implements _i5.GitHub { @override String get endpoint => (super.noSuchMethod( Invocation.getter(#endpoint), - returnValue: '', + returnValue: _i10.dummyValue( + this, + Invocation.getter(#endpoint), + ), ) as String); @override String get version => (super.noSuchMethod( Invocation.getter(#version), - returnValue: '', + returnValue: _i10.dummyValue( + this, + Invocation.getter(#version), + ), ) as String); @override _i2.Client get client => (super.noSuchMethod( @@ -2163,7 +2171,16 @@ class MockRepositoriesService extends _i1.Mock implements _i5.RepositoriesServic sha, ], ), - returnValue: _i6.Future.value(''), + returnValue: _i6.Future.value(_i10.dummyValue( + this, + Invocation.method( + #getCommitDiff, + [ + slug, + sha, + ], + ), + )), ) as _i6.Future); @override _i6.Future<_i5.GitHubComparison> compareCommits( @@ -3144,7 +3161,10 @@ class MockResponse extends _i1.Mock implements _i2.Response { @override String get body => (super.noSuchMethod( Invocation.getter(#body), - returnValue: '', + returnValue: _i10.dummyValue( + this, + Invocation.getter(#body), + ), ) as String); @override int get statusCode => (super.noSuchMethod( diff --git a/dashboard/lib/widgets/commit_author_avatar.dart b/dashboard/lib/widgets/commit_author_avatar.dart index 0091fb247..08bab74a7 100644 --- a/dashboard/lib/widgets/commit_author_avatar.dart +++ b/dashboard/lib/widgets/commit_author_avatar.dart @@ -29,7 +29,7 @@ class CommitAuthorAvatar extends StatelessWidget { final ThemeData theme = Theme.of(context); final double hue = (360.0 * authorHash / (1 << 15)) % 360.0; - final double themeValue = HSVColor.fromColor(theme.colorScheme.background).value; + final double themeValue = HSVColor.fromColor(theme.colorScheme.surface).value; Color authorColor = HSVColor.fromAHSV(1.0, hue, 0.4, themeValue).toColor(); if (theme.brightness == Brightness.dark) { authorColor = HSLColor.fromColor(authorColor).withLightness(.65).toColor(); diff --git a/dashboard/test/widgets/goldens/commit_box_test.idle.png b/dashboard/test/widgets/goldens/commit_box_test.idle.png index ede67a961..45d7248a8 100644 Binary files a/dashboard/test/widgets/goldens/commit_box_test.idle.png and b/dashboard/test/widgets/goldens/commit_box_test.idle.png differ diff --git a/dashboard/test/widgets/goldens/commit_box_test.open.png b/dashboard/test/widgets/goldens/commit_box_test.open.png index cb4e720e8..8c8fe8ffc 100644 Binary files a/dashboard/test/widgets/goldens/commit_box_test.open.png and b/dashboard/test/widgets/goldens/commit_box_test.open.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png index 171433ccb..00d03d91e 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png index 9eb3ef1fa..2602947dd 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png index c9873d9cd..8c89ae881 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png index 81e623dbd..32abae3bf 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png index f51f434dc..bc9262f29 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png index 171433ccb..00d03d91e 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png index efeaa8a96..a5a6e7a2b 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png index 9eb3ef1fa..2602947dd 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png index 074a979fe..77fc4178e 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png index 15a6cec45..25e57a03d 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png index 51c93f564..a6696473a 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png index 5318313ed..0d23f1299 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png index b8bb5f176..d49bbaaa6 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png index 718a2da67..5212a7303 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png differ diff --git a/dev/tree-status-bot/function-source.zip b/dev/tree-status-bot/function-source.zip new file mode 100644 index 000000000..807e856b1 Binary files /dev/null and b/dev/tree-status-bot/function-source.zip differ