diff --git a/.agents/skills/closing-obsolete-issues/SKILL.md b/.agents/skills/closing-obsolete-issues/SKILL.md index f196d5e28c3..f1683a088ed 100644 --- a/.agents/skills/closing-obsolete-issues/SKILL.md +++ b/.agents/skills/closing-obsolete-issues/SKILL.md @@ -19,7 +19,7 @@ Use this skill to find old, outdated issues in the `flutter/devtools` repository 2. **Investigate Status**: - For each candidate, analyze its description and comments. - - **Pro Tip**: Use the bundled script `scripts/fetch_issue_details.sh ` to get a comprehensive view of the issue and its comments. + - **Pro Tip**: Use the bundled script `dart scripts/fetch_issues.dart ` or `dart scripts/fetch_issues.dart --page ` to get a comprehensive view of issues and their comments. - Compare the issue's request or reported bug with the current state of the codebase. - Refer to `references/rationale_templates.md` for a library of common reasons issues become outdated in DevTools. - **Safety Rule**: Do not assume a bug is fixed or obsolete just because the screen has been updated or the file modified. Verify if the specific bug behavior is still possible. Valid bugs or feature requests should not be closed as stale just because they are old or have no activity. Inactivity alone does not invalidate a feature request or bug report. @@ -43,7 +43,7 @@ Use this skill to find old, outdated issues in the `flutter/devtools` repository - Use available file and content search tools (such as `grep`, `ripgrep`, or environment-specific search tools) to check the current codebase for references to the issue or relevant code. - Look for related PRs that might have fixed the issue but didn't close it automatically. -- **Pro Tip**: Use the bundled script `scripts/search_prs.sh ` to search for PRs in the repository. +- **Pro Tip**: Use the bundled script `dart scripts/search_prs.dart ` to search for PRs in the repository. - For issues reporting specific versions, check the current DevTools version in `packages/devtools_app/pubspec.yaml` to determine if the reported version is very old. If the reported version is 1 or more major versions behind or 12 or more minor versions behind the current version, this issue is a good candidate for being obsolete. diff --git a/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart b/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart new file mode 100644 index 00000000000..c84dfcf65b5 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart @@ -0,0 +1,268 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// ignore_for_file: avoid_print, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +/// A tool to fetch and format comprehensive details for GitHub issues in `flutter/devtools`. +/// +/// Usage: +/// # Fetch specific issues: +/// dart fetch_issues.dart 4152 4072 4071 +/// +/// # Fetch a specific page of open issues (default 25 per page, sort:updated-desc): +/// dart fetch_issues.dart --page 31 +/// +/// # Fetch with custom query or limit: +/// dart fetch_issues.dart --page 31 --query "is:issue state:open sort:updated-desc" +/// dart fetch_issues.dart --query "label:bug is:open sort:created-asc" --limit 20 +void main(List args) async { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + _printUsage(); + return; + } + + String repo = 'flutter/devtools'; + String? query; + int? page; + int perPage = 25; + int? limit; + final issueNumbers = []; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--repo' && i + 1 < args.length) { + repo = args[++i]; + } else if (arg == '--query' && i + 1 < args.length) { + query = args[++i]; + } else if (arg == '--page' && i + 1 < args.length) { + page = int.tryParse(args[++i]); + } else if (arg == '--per-page' && i + 1 < args.length) { + perPage = int.tryParse(args[++i]) ?? 25; + } else if (arg == '--limit' && i + 1 < args.length) { + limit = int.tryParse(args[++i]); + } else if (arg.startsWith('#')) { + final num = int.tryParse(arg.substring(1)); + if (num != null) issueNumbers.add(num); + } else { + final num = int.tryParse(arg); + if (num != null) { + issueNumbers.add(num); + } else { + stderr.writeln('Unrecognized argument: $arg'); + _printUsage(); + exitCode = 1; + return; + } + } + } + + if (issueNumbers.isEmpty) { + if (page != null) { + final defaultQuery = query ?? 'is:issue is:open sort:updated-desc'; + issueNumbers.addAll( + await _fetchIssueNumbersForPage( + repo: repo, + query: defaultQuery, + page: page, + perPage: perPage, + ), + ); + } else if (query != null || limit != null) { + final effectiveQuery = query ?? 'is:issue is:open sort:created-asc'; + final effectiveLimit = limit ?? 25; + issueNumbers.addAll( + await _fetchIssueNumbersForQuery( + repo: repo, + query: effectiveQuery, + limit: effectiveLimit, + ), + ); + } + } + + if (issueNumbers.isEmpty) { + print('No issues found matching criteria.'); + return; + } + + print( + 'Fetching details for ${issueNumbers.length} issues: ${issueNumbers.join(', ')}...\n', + ); + + // Fetch issue details with bounded concurrency (5 concurrent requests). + final results = await _fetchAllIssueDetails(repo, issueNumbers); + + results.forEach(_printFormattedIssue); +} + +void _printUsage() { + print(''' +Usage: + dart fetch_issues.dart [ ...] + dart fetch_issues.dart --page [--per-page ] [--query ] + dart fetch_issues.dart --query "" [--limit ] + +Options: + --repo GitHub repository (default: flutter/devtools) + --page Page number from search results + --per-page Number of results per page (default: 25) + --query Search query string + --limit Total number of issues to fetch + -h, --help Show this help message +'''); +} + +Future> _fetchIssueNumbersForPage({ + required String repo, + required String query, + required int page, + required int perPage, +}) async { + final encodedQuery = 'repo:$repo $query'; + final result = await Process.run('gh', [ + 'api', + 'search/issues?q=${Uri.encodeQueryComponent(encodedQuery)}&per_page=$perPage&page=$page', + '--jq', + '.items[].number', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issues: ${result.stderr}'); + return []; + } + + final lines = (result.stdout as String).trim().split('\n'); + return lines.map((l) => int.tryParse(l.trim())).whereType().toList(); +} + +Future> _fetchIssueNumbersForQuery({ + required String repo, + required String query, + required int limit, +}) async { + final result = await Process.run('gh', [ + 'issue', + 'list', + '--repo', + repo, + '--search', + query, + '--limit', + limit.toString(), + '--json', + 'number', + '--jq', + '.[].number', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issues: ${result.stderr}'); + return []; + } + + final lines = (result.stdout as String).trim().split('\n'); + return lines.map((l) => int.tryParse(l.trim())).whereType().toList(); +} + +Future>> _fetchAllIssueDetails( + String repo, + List numbers, { + int concurrency = 5, +}) async { + final results = ?>[]; + results.length = numbers.length; + + var index = 0; + Future worker() async { + while (true) { + if (index >= numbers.length) return; + final currentIdx = index++; + final num = numbers[currentIdx]; + results[currentIdx] = await _fetchSingleIssueDetails(repo, num); + } + } + + final workers = List.generate(concurrency, (_) => worker()); + await Future.wait(workers); + + return results.whereType>().toList(); +} + +Future?> _fetchSingleIssueDetails( + String repo, + int number, +) async { + final result = await Process.run('gh', [ + 'issue', + 'view', + number.toString(), + '--repo', + repo, + '--json', + 'number,title,author,createdAt,updatedAt,labels,body,comments,state,url', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issue #$number: ${result.stderr}'); + return null; + } + + try { + return jsonDecode(result.stdout as String) as Map; + } catch (e) { + stderr.writeln('Error parsing JSON for issue #$number: $e'); + return null; + } +} + +void _printFormattedIssue(Map data) { + final number = data['number']; + final title = data['title'] ?? ''; + final author = data['author']?['login'] ?? 'unknown'; + final createdAt = data['createdAt'] ?? ''; + final state = data['state'] ?? ''; + final url = + data['url'] ?? 'https://github.com/flutter/devtools/issues/$number'; + final labels = (data['labels'] as List? ?? []) + .map((l) => l['name'] as String) + .toList(); + final body = (data['body'] as String? ?? '').trim(); + final comments = data['comments'] as List? ?? []; + + print('=' * 70); + print('ISSUE #$number: $title'); + print('URL: $url'); + print('Author: $author | Created: $createdAt | State: $state'); + print('Labels: $labels'); + print('\n--- BODY ---'); + if (body.isEmpty) { + print('(No description provided)'); + } else if (body.length > 1000) { + print('${body.substring(0, 1000)}\n... (truncated)'); + } else { + print(body); + } + + print('\n--- COMMENTS (${comments.length}) ---'); + if (comments.isEmpty) { + print('(No comments)'); + } else { + for (final c in comments) { + final cAuthor = c['author']?['login'] ?? 'unknown'; + final cDate = c['createdAt'] ?? ''; + final cBody = (c['body'] as String? ?? '') + .replaceAll('\r\n', '\n') + .trim(); + final preview = cBody.length > 300 + ? '${cBody.substring(0, 300)}...' + : cBody; + final formattedPreview = preview.replaceAll('\n', '\n '); + print(' [$cAuthor at $cDate]:\n $formattedPreview'); + } + } + print(''); +} diff --git a/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart b/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart new file mode 100644 index 00000000000..dca3e5ffa80 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart @@ -0,0 +1,91 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// ignore_for_file: avoid_print, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +/// A script to search for PRs in `flutter/devtools`. +/// +/// Usage: +/// `dart search_prs.dart [--limit ] [--state ]` +void main(List args) async { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + print(''' +Usage: + dart search_prs.dart [--limit ] [--state ] + +Options: + --limit Maximum number of PRs to return (default: 20) + --state Filter by state: open, closed, merged, all (default: all) + --repo GitHub repository (default: flutter/devtools) + -h, --help Show this help message +'''); + return; + } + + String repo = 'flutter/devtools'; + int limit = 20; + String? state; + final queryTerms = []; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--limit' && i + 1 < args.length) { + limit = int.tryParse(args[++i]) ?? 20; + } else if (arg == '--state' && i + 1 < args.length) { + state = args[++i]; + } else if (arg == '--repo' && i + 1 < args.length) { + repo = args[++i]; + } else { + queryTerms.add(arg); + } + } + + var query = queryTerms.join(' '); + if (state != null) { + query += ' state:$state'; + } + + print('--- SEARCHING PRs IN $repo FOR: $query ---\n'); + + final result = await Process.run('gh', [ + 'search', + 'prs', + query, + '--repo', + repo, + '--limit', + limit.toString(), + '--json', + 'number,title,state,url,createdAt,closedAt', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error searching PRs: ${result.stderr}'); + exitCode = 1; + return; + } + + final prs = (jsonDecode(result.stdout as String) as List) + .cast>(); + + if (prs.isEmpty) { + print('No PRs found matching query.'); + return; + } + + for (final pr in prs) { + final number = pr['number']; + final title = pr['title'] ?? ''; + final prState = pr['state'] ?? ''; + final url = pr['url'] ?? ''; + final createdAt = pr['createdAt'] ?? ''; + print('#$number $title ($prState)'); + print('Url: $url'); + print('Created: $createdAt'); + print('-' * 60); + } +}