Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions auto_submit/hook/build.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright 2026 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 'dart:io';

import 'package:native_assets_cli/native_assets_cli.dart';

void main(List<String> args) async {
await build(args, (config, output) async {
// 1. Read the source file (the code freeze config)
final configFile = File('lib/configuration/code_freeze.yaml');
final content = await configFile.readAsString();

// 2. Define the path for the generated code
final outputFile = File('lib/src/generated_config.dart');

// 3. Write the file as a raw string constant
await outputFile.writeAsString("""
// GENERATED CODE - DO NOT MODIFY BY HAND
// Generated by hook/build.dart

const String codeFreezeConfigContent =
r'''$content''';
""");

// 4. Tell Dart that this hook depends on the config file
output.addDependency(configFile.uri);
});
}
7 changes: 7 additions & 0 deletions auto_submit/lib/configuration/code_freeze.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
flutter/flutter:
frozen_labels:
- "f: material design"
- "f: cupertino"
frozen_paths:
- "packages/flutter/lib/src/material/"
- "packages/flutter/lib/src/cupertino/"
90 changes: 90 additions & 0 deletions auto_submit/lib/configuration/code_freeze_configuration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2026 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:github/github.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
import 'package:yaml/yaml.dart';

part 'code_freeze_configuration.g.dart';

/// Configuration for repository-specific code freezes.
@JsonSerializable(explicitToJson: true)
@immutable
final class CodeFreezeConfiguration {
const CodeFreezeConfiguration([this.repoFreezeCriteria = const <String, FreezeCriteria>{}]);

/// A mapping of repository slugs to their freeze criteria.
@JsonKey(name: 'repoFreezeCriteria')
final Map<String, FreezeCriteria> repoFreezeCriteria;

/// Parses the configuration from a YAML string.
factory CodeFreezeConfiguration.fromYaml(String yaml) {
final yamlDoc = loadYaml(yaml) as YamlMap;
final map = <String, dynamic>{
'repoFreezeCriteria': yamlDoc.asMap,
};
return CodeFreezeConfiguration.fromJson(map);
}

/// Creates [CodeFreezeConfiguration] from a [json] object.
factory CodeFreezeConfiguration.fromJson(Map<String, dynamic> json) => _$CodeFreezeConfigurationFromJson(json);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent place for JsonSerializable:

final yamlMap = loadYaml(yamlString) as YamlMap;
return YourJsonSerializable.fromJson(Map<String, dynamic>.from(yamlMap));

/// Converts [CodeFreezeConfiguration] to a [json] object.
Map<String, dynamic> toJson() => _$CodeFreezeConfigurationToJson(this);

/// Returns the freeze criteria for the given [slug].
FreezeCriteria getFreezeCriteria(RepositorySlug slug) {
return repoFreezeCriteria[slug.fullName] ?? const FreezeCriteria();
}
}

/// Criteria used to determine if a PR is affected by a code freeze.
@JsonSerializable()
@immutable
final class FreezeCriteria {
const FreezeCriteria({
this.frozenLabels = const <String>{},
this.frozenPaths = const <String>{},
});

final Set<String> frozenLabels;
final Set<String> frozenPaths;

/// Creates [FreezeCriteria] from a [json] object.
factory FreezeCriteria.fromJson(Map<String, dynamic> json) => _$FreezeCriteriaFromJson(json);

/// Converts [FreezeCriteria] to a [json] object.
Map<String, dynamic> toJson() => _$FreezeCriteriaToJson(this);

bool get isEmpty => frozenLabels.isEmpty && frozenPaths.isEmpty;
}

extension _YamlMapToMap on YamlMap {
Map<String, dynamic> get asMap => <String, dynamic>{
for (final MapEntry(:key, :value) in entries)
if (value is YamlMap)
'$key': value.asMap
else if (value is YamlList)
'$key': value.asList
else if (value is YamlScalar)
'$key': value.value
else
'$key': value,
};
}

extension _YamlListToList on YamlList {
List<dynamic> get asList => <dynamic>[
for (final node in nodes)
if (node is YamlMap)
node.asMap
else if (node is YamlList)
node.asList
else if (node is YamlScalar)
node.value
else
node,
];
}
45 changes: 45 additions & 0 deletions auto_submit/lib/configuration/code_freeze_configuration.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions auto_submit/lib/service/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:cocoon_server/bigquery.dart';
Expand All @@ -16,9 +17,11 @@ import 'package:neat_cache/cache_provider.dart';
import 'package:neat_cache/neat_cache.dart';
import 'package:retry/retry.dart';

import '../configuration/code_freeze_configuration.dart';
import '../configuration/repository_configuration.dart';
import '../configuration/repository_configuration_manager.dart';
import '../foundation/providers.dart';
import '../src/generated_config.dart';
import 'github_service.dart';

class CocoonGitHubRequestException implements Exception {
Expand Down Expand Up @@ -49,9 +52,13 @@ class Config {
this,
cache,
);
codeFreezeConfiguration = CodeFreezeConfiguration.fromYaml(
codeFreezeConfigContent,
);
}

late RepositoryConfigurationManager repositoryConfigurationManager;
late CodeFreezeConfiguration codeFreezeConfiguration;

/// Project/GCP constants
static const String flutter = 'flutter';
Expand Down
12 changes: 12 additions & 0 deletions auto_submit/lib/src/generated_config.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions auto_submit/lib/validations/code_freeze.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 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:github/github.dart' as github;

import '../model/auto_submit_query_result.dart';
import 'validation.dart';

/// Validates that a pull request is not affected by an active code freeze.
class CodeFreeze extends Validation {
CodeFreeze({required super.config});

@override
Future<ValidationResult> validate(
QueryResult result,
github.PullRequest pr,
) async {
final slug = pr.base!.repo!.slug();
final criteria = config.codeFreezeConfiguration.getFreezeCriteria(slug);

if (criteria.isEmpty) {
return ValidationResult(true, Action.IGNORE_FAILURE, '');
}

// Check labels first as it is cheaper.
final prLabels =
pr.labels?.map((label) => label.name).toSet() ?? <String>{};
final matchedLabels = criteria.frozenLabels.intersection(prLabels);
if (matchedLabels.isNotEmpty) {
final message =
'This pull request is blocked due to an active code freeze for the following labels: ${matchedLabels.join(", ")}.';
return ValidationResult(false, Action.REMOVE_LABEL, message);
}

// Check paths if frozen paths are defined.
if (criteria.frozenPaths.isNotEmpty) {
final githubService = await config.createGithubService(slug);
final files = await githubService.getPullRequestFiles(slug, pr);
final matchedPaths = <String>{};

for (final file in files) {
final filename = file.filename;
if (filename == null) continue;
for (final frozenPath in criteria.frozenPaths) {
if (filename.startsWith(frozenPath)) {
matchedPaths.add(frozenPath);
}
}
}

if (matchedPaths.isNotEmpty) {
final message =
'This pull request is blocked due to an active code freeze for the following paths: ${matchedPaths.join(", ")}.';
return ValidationResult(false, Action.REMOVE_LABEL, message);
}
}

return ValidationResult(true, Action.IGNORE_FAILURE, '');
}

@override
String get name => 'CodeFreeze';
}
4 changes: 4 additions & 0 deletions auto_submit/lib/validations/validation_filter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../service/process_method.dart';
import 'approval.dart';
import 'base_commit_date_allowed.dart';
import 'ci_successful.dart';
import 'code_freeze.dart';
import 'empty_checks.dart';
import 'mergeable.dart';
import 'required_check_runs.dart';
Expand Down Expand Up @@ -50,6 +51,7 @@ class PullRequestValidationFilter implements ValidationFilter {

validationsToRun.add(BaseCommitDateAllowed(config: config));
validationsToRun.add(Approval(config: config));
validationsToRun.add(CodeFreeze(config: config));
// If we are running ci then we need to check the checkRuns and make sure
// there are check runs created.
if (repositoryConfiguration.runCi) {
Expand All @@ -72,6 +74,7 @@ class EmergencyValidationFilter implements ValidationFilter {
@override
Set<Validation> getValidations() => {
Approval(config: config),
CodeFreeze(config: config),
Mergeable(config: config),
};
}
Expand All @@ -87,6 +90,7 @@ class RevertRequestValidationFilter implements ValidationFilter {
@override
Set<Validation> getValidations() => {
Approval(config: config),
CodeFreeze(config: config),
RequiredCheckRuns(config: config),
Mergeable(config: config),
};
Expand Down
1 change: 1 addition & 0 deletions auto_submit/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dev_dependencies:
dart_flutter_team_lints: 3.5.2
json_serializable: ^6.9.4
mockito: ^5.4.6
native_assets_cli: ^0.18.0
test: ^1.26.3

builders:
Expand Down
56 changes: 56 additions & 0 deletions auto_submit/test/configuration/code_freeze_configuration_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 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/configuration/code_freeze_configuration.dart';
import 'package:github/github.dart';
import 'package:test/test.dart';

void main() {
group('CodeFreezeConfiguration', () {
test('parses YAML correctly', () {
const yaml = '''
flutter/flutter:
frozen_labels:
- "f: material design"
- "f: cupertino"
frozen_paths:
- "packages/flutter/lib/src/material/"
- "packages/flutter/lib/src/cupertino/"
flutter/packages:
frozen_labels:
- "blocked"
''';
final config = CodeFreezeConfiguration.fromYaml(yaml);

final flutterCriteria = config.getFreezeCriteria(
RepositorySlug('flutter', 'flutter'),
);
expect(
flutterCriteria.frozenLabels,
containsAll(['f: material design', 'f: cupertino']),
);
expect(
flutterCriteria.frozenPaths,
containsAll([
'packages/flutter/lib/src/material/',
'packages/flutter/lib/src/cupertino/',
]),
);

final packagesCriteria = config.getFreezeCriteria(
RepositorySlug('flutter', 'packages'),
);
expect(packagesCriteria.frozenLabels, contains('blocked'));
expect(packagesCriteria.frozenPaths, isEmpty);
});

test('returns empty criteria for unknown slug', () {
final config = CodeFreezeConfiguration.fromYaml('{}');
final criteria = config.getFreezeCriteria(
RepositorySlug('unknown', 'unknown'),
);
expect(criteria.isEmpty, isTrue);
});
});
}
Loading
Loading