Skip to content

Commit 5557b7f

Browse files
authored
Add /api/update-tree-status, which is currently write-only (#4759)
Towards flutter/flutter#74529. Inserts documents into the collection `tree_status_change`, with the following "schema": ```dart static Future<TreeStatusChange> create( FirestoreService firestore, { required DateTime createdOn, required TreeStatus status, required String authoredBy, required RepositorySlug repository, }); ``` To determine if the current tree should be (manually) failing, we'd run: ```dart static Future<TreeStatusChange?> getLatest( FirestoreService firestore, { required RepositorySlug repository, }) async { final docs = await firestore.query( metadata.collectionId, {'$_fieldRepository =': repository.fullName}, limit: 1, orderMap: {_fieldCreateTimestamp: kQueryOrderDescending}, ); return docs.isEmpty ? null : TreeStatusChange.fromDocument(docs.first); } ``` ... however, that is not hooked up yet (we also need to create indexes first, etc).
1 parent 98181ca commit 5557b7f

File tree

6 files changed

+373
-2
lines changed

6 files changed

+373
-2
lines changed

app_dart/lib/server.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'cocoon_service.dart';
99
import 'src/request_handlers/get_engine_artifacts_ready.dart';
1010
import 'src/request_handlers/trigger_workflow.dart';
1111
import 'src/request_handlers/update_discord_status.dart';
12+
import 'src/request_handlers/update_tree_status.dart';
1213
import 'src/service/big_query.dart';
1314
import 'src/service/build_status_service.dart';
1415
import 'src/service/commit_service.dart';
@@ -126,6 +127,11 @@ Server createServer({
126127
authenticationProvider: authProvider,
127128
scheduler: scheduler,
128129
),
130+
'/api/update-tree-status': UpdateTreeStatus(
131+
config: config,
132+
authenticationProvider: authProvider,
133+
firestore: firestore,
134+
),
129135
'/api/scheduler/batch-backfiller': BatchBackfiller(
130136
config: config,
131137
ciYamlFetcher: ciYamlFetcher,
@@ -150,7 +156,6 @@ Server createServer({
150156
bigQuery: bigQuery,
151157
ciYamlFetcher: ciYamlFetcher,
152158
),
153-
154159
'/api/vacuum-github-commits': VacuumGithubCommits(
155160
config: config,
156161
authenticationProvider: authProvider,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:github/github.dart';
6+
import 'package:googleapis/firestore/v1.dart' as g;
7+
8+
import '../../service/firestore.dart';
9+
import 'base.dart';
10+
11+
/// A row for each tree status change.
12+
final class TreeStatusChange extends AppDocument<TreeStatusChange> {
13+
/// Returns the latest [TreeStatusChange].
14+
///
15+
/// If no changes exist, returns `null`.
16+
static Future<TreeStatusChange?> getLatest(
17+
FirestoreService firestore, {
18+
required RepositorySlug repository,
19+
}) async {
20+
final docs = await firestore.query(
21+
metadata.collectionId,
22+
{'$_fieldRepository =': repository.fullName},
23+
limit: 1,
24+
orderMap: {_fieldCreateTimestamp: kQueryOrderDescending},
25+
);
26+
return docs.isEmpty ? null : TreeStatusChange.fromDocument(docs.first);
27+
}
28+
29+
@override
30+
AppDocumentMetadata<TreeStatusChange> get runtimeMetadata => metadata;
31+
32+
/// Description of the document in Firestore.
33+
static final metadata = AppDocumentMetadata<TreeStatusChange>(
34+
collectionId: 'tree_status_change',
35+
fromDocument: TreeStatusChange.fromDocument,
36+
);
37+
38+
/// Creates and inserts a [TreeStatusChange] into [firestore].
39+
static Future<TreeStatusChange> create(
40+
FirestoreService firestore, {
41+
required DateTime createdOn,
42+
required TreeStatus status,
43+
required String authoredBy,
44+
required RepositorySlug repository,
45+
String? reason,
46+
}) async {
47+
final document = TreeStatusChange.fromDocument(
48+
g.Document(
49+
fields: {
50+
_fieldCreateTimestamp: createdOn.toValue(),
51+
_fieldStatus: status.name.toValue(),
52+
_fieldAuthoredBy: authoredBy.toValue(),
53+
_fieldRepository: repository.fullName.toValue(),
54+
if (reason != null) _fieldReason: reason.toValue(),
55+
},
56+
),
57+
);
58+
final result = await firestore.createDocument(
59+
document,
60+
collectionId: metadata.collectionId,
61+
);
62+
return TreeStatusChange.fromDocument(result);
63+
}
64+
65+
/// Create [BuildStatusSnapshot] from a GithubBuildStatus Document.
66+
TreeStatusChange.fromDocument(super.document);
67+
68+
static const _fieldCreateTimestamp = 'createTimestamp';
69+
static const _fieldStatus = 'status';
70+
static const _fieldAuthoredBy = 'author';
71+
static const _fieldRepository = 'repository';
72+
static const _fieldReason = 'reason';
73+
74+
DateTime get createdOn {
75+
return DateTime.parse(fields[_fieldCreateTimestamp]!.timestampValue!);
76+
}
77+
78+
TreeStatus get status {
79+
return TreeStatus.values.byName(fields[_fieldStatus]!.stringValue!);
80+
}
81+
82+
String get authoredBy {
83+
return fields[_fieldAuthoredBy]!.stringValue!;
84+
}
85+
86+
RepositorySlug get repository {
87+
return RepositorySlug.full(fields[_fieldRepository]!.stringValue!);
88+
}
89+
90+
String? get reason {
91+
return fields[_fieldReason]?.stringValue;
92+
}
93+
}
94+
95+
/// Whether the [TreeStatusChange] was a success or failure.
96+
enum TreeStatus { success, failure }
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:github/github.dart';
6+
import 'package:meta/meta.dart';
7+
8+
import '../model/firestore/tree_status_change.dart';
9+
import '../request_handling/api_request_handler.dart';
10+
import '../request_handling/exceptions.dart';
11+
import '../request_handling/request_handler.dart';
12+
import '../request_handling/response.dart';
13+
import '../service/firestore.dart';
14+
15+
/// Manually updates the tree status.
16+
final class UpdateTreeStatus extends ApiRequestHandler {
17+
const UpdateTreeStatus({
18+
required FirestoreService firestore,
19+
required super.config,
20+
required super.authenticationProvider,
21+
@visibleForTesting DateTime Function() now = DateTime.now,
22+
}) : _firestore = firestore,
23+
_now = now;
24+
25+
final FirestoreService _firestore;
26+
final DateTime Function() _now;
27+
28+
static const _paramPassing = 'passing';
29+
static const _paramRepo = 'repo';
30+
static const _paramReason = 'reason';
31+
32+
@override
33+
Future<Response> post(Request request) async {
34+
final body = await request.readBodyAsJson();
35+
checkRequiredParameters(body, [_paramPassing, _paramRepo]);
36+
37+
final passing = body[_paramPassing];
38+
if (passing is! bool) {
39+
throw const BadRequestException(
40+
'Parameter "$_paramPassing" must be a boolean',
41+
);
42+
}
43+
44+
final repository = body[_paramRepo];
45+
if (repository is! String) {
46+
throw const BadRequestException(
47+
'Parameter "$_paramRepo" must be a string',
48+
);
49+
}
50+
51+
final reason = body[_paramReason];
52+
if (reason is! String?) {
53+
throw const BadRequestException(
54+
'Parameter "$_paramReason" must be a string',
55+
);
56+
}
57+
58+
await TreeStatusChange.create(
59+
_firestore,
60+
createdOn: _now(),
61+
status: passing ? TreeStatus.success : TreeStatus.failure,
62+
authoredBy: authContext!.email,
63+
repository: RepositorySlug.full(repository),
64+
reason: reason,
65+
);
66+
return Response.emptyOk;
67+
}
68+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:convert';
6+
7+
import 'package:cocoon_server_test/test_logging.dart';
8+
import 'package:cocoon_service/src/model/firestore/tree_status_change.dart';
9+
import 'package:cocoon_service/src/request_handlers/update_tree_status.dart';
10+
import 'package:cocoon_service/src/request_handling/exceptions.dart';
11+
import 'package:cocoon_service/src/service/config.dart';
12+
import 'package:test/test.dart';
13+
14+
import '../src/fake_config.dart';
15+
import '../src/request_handling/api_request_handler_tester.dart';
16+
import '../src/request_handling/fake_dashboard_authentication.dart';
17+
import '../src/service/fake_firestore_service.dart';
18+
19+
void main() {
20+
useTestLoggerPerTest();
21+
22+
late FakeFirestoreService firestore;
23+
late ApiRequestHandlerTester tester;
24+
late UpdateTreeStatus handler;
25+
26+
final fakeNow = DateTime.now().toUtc();
27+
28+
setUp(() {
29+
firestore = FakeFirestoreService();
30+
tester = ApiRequestHandlerTester();
31+
handler = UpdateTreeStatus(
32+
config: FakeConfig(),
33+
authenticationProvider: FakeDashboardAuthentication(),
34+
firestore: firestore,
35+
now: () => fakeNow,
36+
);
37+
38+
tester.request.body = jsonEncode({
39+
'passing': false,
40+
'repo': 'flutter/flutter',
41+
});
42+
});
43+
44+
test('requires a "passing" status', () async {
45+
tester.request.body = jsonEncode({'repo': 'flutter/flutter'});
46+
await expectLater(
47+
tester.post(handler),
48+
throwsA(isA<BadRequestException>()),
49+
);
50+
51+
expect(firestore, existsInStorage(TreeStatusChange.metadata, isEmpty));
52+
});
53+
54+
test('a "passing" status must be a boolean', () async {
55+
tester.request.body = jsonEncode({
56+
'passing': 'not-a-boolean',
57+
'repo': 'flutter/flutter',
58+
});
59+
await expectLater(
60+
tester.post(handler),
61+
throwsA(isA<BadRequestException>()),
62+
);
63+
64+
expect(firestore, existsInStorage(TreeStatusChange.metadata, isEmpty));
65+
});
66+
67+
test('requires a "repo" field', () async {
68+
tester.request.body = jsonEncode({'passing': false});
69+
await expectLater(
70+
tester.post(handler),
71+
throwsA(isA<BadRequestException>()),
72+
);
73+
74+
expect(firestore, existsInStorage(TreeStatusChange.metadata, isEmpty));
75+
});
76+
77+
test('a "repo" field must be a string', () async {
78+
tester.request.body = jsonEncode({'passing': false, 'repo': 12});
79+
await expectLater(
80+
tester.post(handler),
81+
throwsA(isA<BadRequestException>()),
82+
);
83+
84+
expect(firestore, existsInStorage(TreeStatusChange.metadata, isEmpty));
85+
});
86+
87+
test('a "reason" field must be a string', () async {
88+
tester.request.body = jsonEncode({
89+
'passing': false,
90+
'repo': 'flutter/flutter',
91+
'reason': 123,
92+
});
93+
await expectLater(
94+
tester.post(handler),
95+
throwsA(isA<BadRequestException>()),
96+
);
97+
98+
expect(firestore, existsInStorage(TreeStatusChange.metadata, isEmpty));
99+
});
100+
101+
test('updates Firestore', () async {
102+
await tester.post(handler);
103+
104+
expect(
105+
firestore,
106+
existsInStorage(TreeStatusChange.metadata, [
107+
isTreeStatusChange
108+
.hasCreatedOn(fakeNow)
109+
.hasStatus(TreeStatus.failure)
110+
.hasAuthoredBy('[email protected]')
111+
.hasRepository(Config.flutterSlug)
112+
.hasReason(isNull),
113+
]),
114+
);
115+
});
116+
117+
test('updates Firestore', () async {
118+
await tester.post(handler);
119+
120+
expect(
121+
firestore,
122+
existsInStorage(TreeStatusChange.metadata, [
123+
isTreeStatusChange
124+
.hasCreatedOn(fakeNow)
125+
.hasStatus(TreeStatus.failure)
126+
.hasAuthoredBy('[email protected]')
127+
.hasRepository(Config.flutterSlug)
128+
.hasReason(isNull),
129+
]),
130+
);
131+
});
132+
133+
test('includes an optional reason', () async {
134+
tester.request.body = jsonEncode({
135+
'passing': false,
136+
'repo': 'flutter/flutter',
137+
'reason': 'I said so',
138+
});
139+
await tester.post(handler);
140+
141+
expect(
142+
firestore,
143+
existsInStorage(TreeStatusChange.metadata, [
144+
isTreeStatusChange
145+
.hasCreatedOn(fakeNow)
146+
.hasStatus(TreeStatus.failure)
147+
.hasAuthoredBy('[email protected]')
148+
.hasRepository(Config.flutterSlug)
149+
.hasReason('I said so'),
150+
]),
151+
);
152+
});
153+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2024 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
part of 'firestore_matcher.dart';
6+
7+
final class TreeStatusChangeMatcher extends ModelMatcher<TreeStatusChange> {
8+
const TreeStatusChangeMatcher._(super._delegate) : super._();
9+
10+
@override
11+
AppDocumentMetadata<TreeStatusChange> get metadata {
12+
return TreeStatusChange.metadata;
13+
}
14+
15+
TreeStatusChangeMatcher hasCreatedOn(Object? matcherOr) {
16+
return TreeStatusChangeMatcher._(
17+
_delegate.having((t) => t.createdOn, 'createdOn', matcherOr),
18+
);
19+
}
20+
21+
TreeStatusChangeMatcher hasStatus(Object? matcherOr) {
22+
return TreeStatusChangeMatcher._(
23+
_delegate.having((t) => t.status, 'status', matcherOr),
24+
);
25+
}
26+
27+
TreeStatusChangeMatcher hasAuthoredBy(Object? matcherOr) {
28+
return TreeStatusChangeMatcher._(
29+
_delegate.having((t) => t.authoredBy, 'authoredBy', matcherOr),
30+
);
31+
}
32+
33+
TreeStatusChangeMatcher hasRepository(Object? matcherOr) {
34+
return TreeStatusChangeMatcher._(
35+
_delegate.having((t) => t.repository, 'repository', matcherOr),
36+
);
37+
}
38+
39+
TreeStatusChangeMatcher hasReason(Object? matcherOr) {
40+
return TreeStatusChangeMatcher._(
41+
_delegate.having((t) => t.reason, 'reason', matcherOr),
42+
);
43+
}
44+
}

0 commit comments

Comments
 (0)