├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── analysis_options.yaml ├── pubspec.yaml └── routes └── index.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by the Operating System 4 | .DS_Store 5 | 6 | # Files and directories created by pub 7 | .dart_tool/ 8 | .packages 9 | pubspec.lock 10 | 11 | # Files and directories created by dart_frog 12 | build/ 13 | .dart_frog 14 | 15 | # Test related files 16 | coverage/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["VeryGoodVentures.dart-frog"] 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["dotenv", "jsonwebtoken", "mocktail", "sensei"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # code_sensei_base 2 | 3 | [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] 4 | [![License: MIT][license_badge]][license_link] 5 | [![Powered by Dart Frog](https://img.shields.io/endpoint?url=https://tinyurl.com/dartfrog-badge)](https://dartfrog.vgv.dev) 6 | 7 | An example application built with dart_frog 8 | 9 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 10 | [license_link]: https://opensource.org/licenses/MIT 11 | [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg 12 | [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.5.1.0.yaml 2 | analyzer: 3 | exclude: 4 | - build/** 5 | linter: 6 | rules: 7 | file_names: false 8 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: code_sensei_base 2 | description: An new Dart Frog application 3 | version: 1.0.0+1 4 | publish_to: none 5 | 6 | environment: 7 | sdk: ">=3.0.0 <4.0.0" 8 | 9 | dependencies: 10 | dart_frog: ^1.1.0 11 | dart_jsonwebtoken: ^2.14.0 12 | dotenv: ^4.2.0 13 | github: ^9.24.0 14 | google_generative_ai: ^0.4.1 15 | http: ^1.2.1 16 | 17 | dev_dependencies: 18 | mocktail: ^1.0.0 19 | test: ^1.19.2 20 | very_good_analysis: ^5.1.0 21 | -------------------------------------------------------------------------------- /routes/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:dart_frog/dart_frog.dart'; 5 | import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; 6 | import 'package:dotenv/dotenv.dart'; 7 | import 'package:github/github.dart'; 8 | import 'package:google_generative_ai/google_generative_ai.dart'; 9 | import 'package:http/http.dart' as http; 10 | 11 | Future accessToken( 12 | int installationId, 13 | String base64Pem, 14 | String githubAppId, 15 | ) async { 16 | final pem = utf8.decode(base64Decode(base64Pem)); 17 | File('./github.pem').writeAsStringSync(pem); 18 | 19 | final privateKeyString = File('./github.pem').readAsStringSync(); 20 | final privateKey = RSAPrivateKey(privateKeyString); 21 | 22 | final jwt = JWT( 23 | { 24 | 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, 25 | 'exp': DateTime.now() 26 | .add(const Duration(minutes: 10)) 27 | .millisecondsSinceEpoch ~/ 28 | 1000, 29 | 'iss': int.parse(githubAppId), 30 | }, 31 | ); 32 | 33 | final token = jwt.sign(privateKey, algorithm: JWTAlgorithm.RS256); 34 | 35 | final response = await http.post( 36 | Uri.parse( 37 | 'https://api.github.com/app/installations/$installationId/access_tokens', 38 | ), 39 | headers: { 40 | HttpHeaders.authorizationHeader: 'Bearer $token', 41 | HttpHeaders.acceptHeader: 'application/vnd.github.v3+json', 42 | }, 43 | ); 44 | 45 | final responseBody = jsonDecode(response.body) as Map; 46 | 47 | if (response.statusCode == 201) { 48 | return responseBody['token'].toString(); 49 | } else { 50 | return null; 51 | } 52 | } 53 | 54 | Future onRequest(RequestContext context) async { 55 | final env = DotEnv(includePlatformEnvironment: true)..load(); 56 | 57 | final base64Pem = env['PEM_BASE64']; 58 | 59 | if (base64Pem == null) { 60 | throw Exception('base64Pem is null'); 61 | } 62 | 63 | final githubAppId = env['GITHUB_APP_ID']; 64 | if (githubAppId == null) { 65 | throw Exception('githubAppId is null'); 66 | } 67 | 68 | final geminiApiKey = env['GEMINI_API_KEY']; 69 | 70 | if (geminiApiKey == null) { 71 | throw Exception('geminiApiKey is null'); 72 | } 73 | 74 | final body = await context.request.body(); 75 | final json = jsonDecode(body) as Map; 76 | final installationId = json['installation']['id'] as int; 77 | 78 | final token = await accessToken( 79 | installationId, 80 | base64Pem, 81 | githubAppId, 82 | ); 83 | 84 | if (token == null) { 85 | throw Exception('Failed to obtain GitHub access token'); 86 | } 87 | 88 | final github = GitHub(auth: Authentication.withToken(token)); 89 | 90 | final action = json['action']; 91 | final fullName = json['repository']['full_name'] as String; 92 | final pullRequest = json['pull_request'] as Map?; 93 | 94 | if ((action == 'opened' || action == 'reopened') && pullRequest != null) { 95 | final issueNumber = pullRequest['number'] as int?; 96 | 97 | if (issueNumber == null) { 98 | throw Exception('prNumber is null'); 99 | } 100 | 101 | final diffApiUrl = 102 | 'https://api.github.com/repos/$fullName/pulls/$issueNumber/files'; 103 | final diffResponse = await http.get( 104 | Uri.parse(diffApiUrl), 105 | headers: { 106 | 'Authorization': 'token $token', 107 | 'Accept': 'application/vnd.github.v3+json', 108 | }, 109 | ); 110 | 111 | if (diffResponse.statusCode == 200) { 112 | final files = jsonDecode(diffResponse.body) as List; 113 | 114 | for (final file in files) { 115 | final filename = file['filename']; 116 | final status = file['status']; 117 | final additions = file['additions']; 118 | final deletions = file['deletions']; 119 | final changes = file['changes']; 120 | final patch = file['patch']; 121 | 122 | final diffInfo = { 123 | 'filename': filename, 124 | 'status': status, 125 | 'additions': additions, 126 | 'deletions': deletions, 127 | 'changes': changes, 128 | 'patch': patch, 129 | }; 130 | 131 | final model = GenerativeModel( 132 | model: 'gemini-1.5-flash-latest', 133 | apiKey: geminiApiKey, 134 | ); 135 | 136 | final prompt = '次のコードを日本語でレビューして: $diffInfo'; 137 | final content = [Content.text(prompt)]; 138 | final response = await model.generateContent(content); 139 | 140 | final comments = [ 141 | PullRequestReviewComment( 142 | path: diffInfo['filename'].toString(), 143 | position: 1, 144 | body: response.text, 145 | ), 146 | ]; 147 | 148 | final owner = fullName.split('/')[0]; 149 | final repo = fullName.split('/')[1]; 150 | 151 | final review = CreatePullRequestReview( 152 | owner, 153 | repo, 154 | issueNumber, 155 | 'COMMENT', 156 | comments: comments, 157 | ); 158 | 159 | await github.pullRequests.createReview( 160 | RepositorySlug(owner, repo), 161 | review, 162 | ); 163 | } 164 | } else { 165 | print('Failed to load PR diff: ${diffResponse.statusCode}'); 166 | } 167 | } 168 | 169 | return Response(body: 'Success'); 170 | } 171 | --------------------------------------------------------------------------------