| 1 | module main |
| 2 | |
| 3 | import os |
| 4 | import cli |
| 5 | import net.http |
| 6 | import net.urllib |
| 7 | import json |
| 8 | import rand |
| 9 | import term |
| 10 | |
| 11 | const search_endpoint = 'https://api.github.com/search/issues' |
| 12 | const issue_endpoint = 'https://api.github.com/repos/vlang/v/issues' |
| 13 | const confirm_search_query = 'repo:vlang/v is:issue is:open -label:"Status: Confirmed"' |
| 14 | const fix_search_query = 'repo:vlang/v is:issue is:open' |
| 15 | const feature_search_query = 'repo:vlang/v is:issue state:open label:"Feature/Enhancement Request"' |
| 16 | const per_page = 100 |
| 17 | const max_search_results = 1000 |
| 18 | |
| 19 | struct SearchResponse { |
| 20 | total_count int |
| 21 | items []Issue |
| 22 | } |
| 23 | |
| 24 | struct Issue { |
| 25 | number int |
| 26 | title string |
| 27 | html_url string |
| 28 | body string |
| 29 | labels []Label |
| 30 | } |
| 31 | |
| 32 | struct Label { |
| 33 | name string |
| 34 | } |
| 35 | |
| 36 | struct IssueDetails { |
| 37 | number int |
| 38 | title string |
| 39 | html_url string |
| 40 | body string |
| 41 | labels []Label |
| 42 | state string |
| 43 | } |
| 44 | |
| 45 | fn main() { |
| 46 | // the 0th arg is /path/to/vquest, the 1st is `quest`; the args after that are the subcommands |
| 47 | mut args := []string{} |
| 48 | args << os.args[0] |
| 49 | args << os.args#[2..] |
| 50 | mut app := cli.Command{ |
| 51 | name: 'v quest' |
| 52 | description: 'A tool to help make V better for everyone, by spending some time each day, on random tasks/missions like:\n * documenting public APIs\n * issue confirmation reviewing and triage\n * testing' |
| 53 | execute: cli.print_help_for_command |
| 54 | posix_mode: true |
| 55 | defaults: struct { |
| 56 | man: false |
| 57 | } |
| 58 | commands: [ |
| 59 | cli.Command{ |
| 60 | name: 'document' |
| 61 | description: 'Print a random missing doc entry from the V standard library.' |
| 62 | execute: run_document |
| 63 | }, |
| 64 | cli.Command{ |
| 65 | name: 'confirm' |
| 66 | description: 'Open a random vlang/v issue, that is still unconfirmed in your browser.' |
| 67 | flags: issue_flags.clone() |
| 68 | execute: run_confirm |
| 69 | }, |
| 70 | cli.Command{ |
| 71 | name: 'fix' |
| 72 | description: 'Open a random vlang/v issue (but still open) in your browser.' |
| 73 | flags: issue_flags.clone() |
| 74 | execute: run_fix |
| 75 | }, |
| 76 | cli.Command{ |
| 77 | name: 'implement' |
| 78 | description: 'Open a random vlang/v feature request issue in your browser.' |
| 79 | flags: issue_flags.clone() |
| 80 | execute: run_implement |
| 81 | }, |
| 82 | cli.Command{ |
| 83 | name: 'solve' |
| 84 | description: 'Find a random bug reproducible on your OS, print it to stdout and save to bug-<issue_id>.md.' |
| 85 | flags: solve_flags.clone() |
| 86 | execute: run_solve |
| 87 | }, |
| 88 | ] |
| 89 | } |
| 90 | app.setup() |
| 91 | if args.len <= 1 { |
| 92 | if rcmd := rand.element(app.commands) { |
| 93 | rcmd.execute(rcmd)! |
| 94 | return |
| 95 | } |
| 96 | } |
| 97 | app.parse(args) |
| 98 | } |
| 99 | |
| 100 | const issue_flags = [ |
| 101 | cli.Flag{ |
| 102 | description: 'Print the issue URL without opening a browser.' |
| 103 | flag: .bool |
| 104 | name: 'print-only' |
| 105 | abbrev: 'p' |
| 106 | }, |
| 107 | cli.Flag{ |
| 108 | description: 'Start page for issues (default -1: auto). Must be > 0.' |
| 109 | flag: .int |
| 110 | name: 'from' |
| 111 | abbrev: 'f' |
| 112 | default_value: ['-1'] |
| 113 | }, |
| 114 | cli.Flag{ |
| 115 | description: 'End page for issues (default -1: auto). Must be > 0 and >= the from page (see -f).' |
| 116 | flag: .int |
| 117 | name: 'to' |
| 118 | abbrev: 't' |
| 119 | default_value: ['-1'] |
| 120 | }, |
| 121 | ] |
| 122 | |
| 123 | const solve_flags = [ |
| 124 | cli.Flag{ |
| 125 | description: 'Override OS detection (macos, windows, linux).' |
| 126 | flag: .string |
| 127 | name: 'os' |
| 128 | abbrev: 'o' |
| 129 | default_value: [''] |
| 130 | }, |
| 131 | cli.Flag{ |
| 132 | description: 'Start page for issues (default -1: auto). Must be > 0.' |
| 133 | flag: .int |
| 134 | name: 'from' |
| 135 | abbrev: 'f' |
| 136 | default_value: ['-1'] |
| 137 | }, |
| 138 | cli.Flag{ |
| 139 | description: 'End page for issues (default -1: auto). Must be > 0 and >= the from page (see -f).' |
| 140 | flag: .int |
| 141 | name: 'to' |
| 142 | abbrev: 't' |
| 143 | default_value: ['-1'] |
| 144 | }, |
| 145 | cli.Flag{ |
| 146 | description: 'Output file for the bug report (default: bug-<issue_id>.md).' |
| 147 | flag: .string |
| 148 | name: 'output' |
| 149 | abbrev: 'O' |
| 150 | default_value: [''] |
| 151 | }, |
| 152 | ] |
| 153 | |
| 154 | fn run_confirm(cmd cli.Command) ! { |
| 155 | run_issue(cmd, confirm_search_query, 'still unconfirmed', |
| 156 | 'Help us by confirming and triaging this issue:')! |
| 157 | } |
| 158 | |
| 159 | fn run_fix(cmd cli.Command) ! { |
| 160 | run_issue(cmd, fix_search_query, 'open', 'Help us by fixing or confirming this issue:')! |
| 161 | } |
| 162 | |
| 163 | fn run_implement(cmd cli.Command) ! { |
| 164 | run_issue(cmd, feature_search_query, 'feature request', |
| 165 | 'Help us by implementing the issue in a PR, or triage it:')! |
| 166 | } |
| 167 | |
| 168 | fn run_solve(cmd cli.Command) ! { |
| 169 | // Clean up old bug report files |
| 170 | for f in os.glob('bug-*.md') or { []string{} } { |
| 171 | os.rm(f) or {} |
| 172 | } |
| 173 | |
| 174 | user_os := get_target_os(cmd) |
| 175 | os_label := get_os_label(user_os) |
| 176 | output_override := cmd.flags.get_string('output') or { '' } |
| 177 | |
| 178 | // Build query that excludes issues for other OSes |
| 179 | // We look for open bugs that are NOT exclusive to other operating systems |
| 180 | excluded_os_labels := get_excluded_os_labels(user_os) |
| 181 | mut query := 'repo:vlang/v is:issue is:open label:Bug' |
| 182 | for label in excluded_os_labels { |
| 183 | query += ' -label:"${label}"' |
| 184 | } |
| 185 | |
| 186 | total := fetch_total_count(query)! |
| 187 | max_pages := total_to_max_pages(total) |
| 188 | if max_pages == 0 { |
| 189 | return error('no bugs found for ${os_label}') |
| 190 | } |
| 191 | start_page, end_page := resolve_page_range(cmd, max_pages)! |
| 192 | page := start_page + (rand.intn(end_page - start_page + 1) or { 0 }) |
| 193 | eprintln(term.colorize(term.gray, |
| 194 | 'Found: ${total} bugs reproducible on ${os_label}. Fetching from page: ${page} in [${start_page}, ${end_page}] ...')) |
| 195 | |
| 196 | issue := fetch_issue_from_page(query, page)! |
| 197 | details := fetch_issue_details(issue.number)! |
| 198 | |
| 199 | // Format the bug report |
| 200 | report := format_bug_report(details, user_os) |
| 201 | |
| 202 | // Print to stdout |
| 203 | println(report) |
| 204 | |
| 205 | // Determine output filename (default: bug-<issue_id>.md) |
| 206 | output_file := if output_override != '' { output_override } else { 'bug-${details.number}.md' } |
| 207 | |
| 208 | // Write to file |
| 209 | os.write_file(output_file, report) or { |
| 210 | return error('failed to write to ${output_file}: ${err}') |
| 211 | } |
| 212 | eprintln(term.colorize(term.green, '\nBug report saved to: ${output_file}')) |
| 213 | } |
| 214 | |
| 215 | fn get_target_os(cmd cli.Command) string { |
| 216 | override := cmd.flags.get_string('os') or { '' } |
| 217 | if override != '' { |
| 218 | return override.to_lower() |
| 219 | } |
| 220 | return os.user_os() |
| 221 | } |
| 222 | |
| 223 | fn get_os_label(user_os string) string { |
| 224 | return match user_os { |
| 225 | 'macos' { 'macOS' } |
| 226 | 'windows' { 'Windows' } |
| 227 | 'linux' { 'Linux' } |
| 228 | 'freebsd' { 'FreeBSD' } |
| 229 | else { user_os } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | fn get_excluded_os_labels(user_os string) []string { |
| 234 | // Return labels for OSes that should be EXCLUDED |
| 235 | // i.e., if user is on macOS, exclude Windows-only and Linux-only bugs |
| 236 | all_os_labels := ['OS: Windows', 'OS: Linux', 'OS: macOS', 'OS: FreeBSD'] |
| 237 | user_label := match user_os { |
| 238 | 'macos' { 'OS: macOS' } |
| 239 | 'windows' { 'OS: Windows' } |
| 240 | 'linux' { 'OS: Linux' } |
| 241 | 'freebsd' { 'OS: FreeBSD' } |
| 242 | else { '' } |
| 243 | } |
| 244 | |
| 245 | return all_os_labels.filter(it != user_label) |
| 246 | } |
| 247 | |
| 248 | fn fetch_issue_details(issue_number int) !IssueDetails { |
| 249 | url := '${issue_endpoint}/${issue_number}' |
| 250 | body := api_get(url)! |
| 251 | return json.decode(IssueDetails, body)! |
| 252 | } |
| 253 | |
| 254 | fn format_bug_report(issue IssueDetails, user_os string) string { |
| 255 | mut report := '# Bug #${issue.number}: ${issue.title}\n\n' |
| 256 | report += '**URL:** ${issue.html_url}\n\n' |
| 257 | report += '**Target OS:** ${get_os_label(user_os)}\n\n' |
| 258 | |
| 259 | if issue.labels.len > 0 { |
| 260 | labels := issue.labels.map(it.name).join(', ') |
| 261 | report += '**Labels:** ${labels}\n\n' |
| 262 | } |
| 263 | |
| 264 | report += '## Description\n\n' |
| 265 | report += issue.body |
| 266 | report += '\n' |
| 267 | |
| 268 | return report |
| 269 | } |
| 270 | |
| 271 | fn run_issue(cmd cli.Command, issue_query string, issue_label string, help_label string) ! { |
| 272 | print_only := cmd.flags.get_bool('print-only') or { false } |
| 273 | total := fetch_total_count(issue_query)! |
| 274 | max_pages := total_to_max_pages(total) |
| 275 | if max_pages == 0 { |
| 276 | return error('no unconfirmed issues found') |
| 277 | } |
| 278 | start_page, end_page := resolve_page_range(cmd, max_pages)! |
| 279 | page := start_page + (rand.intn(end_page - start_page + 1) or { 0 }) |
| 280 | eprintln(term.colorize(term.gray, |
| 281 | 'Found: ${total} ${issue_label} issues. Fetching issue from page: ${page} in [${start_page}, ${end_page}] ...')) |
| 282 | issue := fetch_issue_from_page(issue_query, page)! |
| 283 | println(term.colorize(term.green, help_label)) |
| 284 | println(issue.html_url) |
| 285 | if print_only { |
| 286 | return |
| 287 | } |
| 288 | open_uri(issue.html_url)! |
| 289 | } |
| 290 | |
| 291 | fn run_document(_cmd cli.Command) ! { |
| 292 | res := |
| 293 | os.execute('${os.quoted_path(@VEXE)} missdoc --exclude vlib/v --exclude /linux_bare/ --exclude /wasm_bare/ @vlib') |
| 294 | if res.exit_code != 0 { |
| 295 | return error('v missdoc failed: ${res.output}') |
| 296 | } |
| 297 | lines := res.output.split_into_lines().filter(it.trim_space() != '') |
| 298 | if lines.len == 0 { |
| 299 | return error('no missing doc entries found') |
| 300 | } |
| 301 | idx := rand.intn(lines.len) or { 0 } |
| 302 | eprintln(term.colorize(term.green, 'Help us document this public API:')) |
| 303 | println(term.colorize(term.bold, lines[idx])) |
| 304 | } |
| 305 | |
| 306 | fn fetch_total_count(query string) !int { |
| 307 | url := build_search_url(query, 1, 1) |
| 308 | body := api_get(url)! |
| 309 | resp := json.decode(SearchResponse, body)! |
| 310 | return resp.total_count |
| 311 | } |
| 312 | |
| 313 | fn resolve_page_range(cmd cli.Command, max_pages int) !(int, int) { |
| 314 | from, from_set := read_page_limit(cmd, 'from', '-f')! |
| 315 | to, to_set := read_page_limit(cmd, 'to', '-t')! |
| 316 | if from_set && to_set && to < from { |
| 317 | return error('invalid page range: -t (${to}) is smaller than -f (${from})') |
| 318 | } |
| 319 | mut start_page := 1 |
| 320 | mut end_page := max_pages |
| 321 | if from_set { |
| 322 | start_page = from |
| 323 | } |
| 324 | if to_set { |
| 325 | end_page = to |
| 326 | } |
| 327 | if start_page < 1 { |
| 328 | start_page = 1 |
| 329 | } |
| 330 | if end_page > max_pages { |
| 331 | end_page = max_pages |
| 332 | } |
| 333 | if end_page < start_page { |
| 334 | return error('no issues found in the requested page range') |
| 335 | } |
| 336 | return start_page, end_page |
| 337 | } |
| 338 | |
| 339 | fn read_page_limit(cmd cli.Command, name string, flag_label string) !(int, bool) { |
| 340 | value := cmd.flags.get_int(name)! |
| 341 | is_set := flag_is_set(cmd, name) |
| 342 | if is_set && value < 0 { |
| 343 | return error('${flag_label} must be >= 0') |
| 344 | } |
| 345 | return value, is_set |
| 346 | } |
| 347 | |
| 348 | fn flag_is_set(cmd cli.Command, name string) bool { |
| 349 | for flag in cmd.flags.get_all_found() { |
| 350 | if flag.name == name { |
| 351 | return true |
| 352 | } |
| 353 | } |
| 354 | return false |
| 355 | } |
| 356 | |
| 357 | fn fetch_issue_from_page(query string, page int) !Issue { |
| 358 | url := build_search_url(query, page, per_page) |
| 359 | body := api_get(url)! |
| 360 | resp := json.decode(SearchResponse, body)! |
| 361 | if resp.items.len == 0 { |
| 362 | return error('no issues returned for page ${page}') |
| 363 | } |
| 364 | idx := rand.intn(resp.items.len) or { 0 } |
| 365 | return resp.items[idx] |
| 366 | } |
| 367 | |
| 368 | fn build_search_url(query string, page int, per_page int) string { |
| 369 | mut values := urllib.new_values() |
| 370 | values.add('q', query) |
| 371 | values.add('per_page', per_page.str()) |
| 372 | values.add('page', page.str()) |
| 373 | return '${search_endpoint}?${values.encode()}' |
| 374 | } |
| 375 | |
| 376 | fn api_get(url string) !string { |
| 377 | resp := http.fetch( |
| 378 | url: url |
| 379 | method: .get |
| 380 | header: http.new_header_from_map({ |
| 381 | http.CommonHeader.accept: 'application/vnd.github+json' |
| 382 | http.CommonHeader.user_agent: 'v quest' |
| 383 | }) |
| 384 | )! |
| 385 | if resp.status_code != 200 { |
| 386 | return error('GitHub API error ${resp.status_code}: ${resp.body}') |
| 387 | } |
| 388 | return resp.body |
| 389 | } |
| 390 | |
| 391 | fn total_to_max_pages(total int) int { |
| 392 | if total <= 0 { |
| 393 | return 0 |
| 394 | } |
| 395 | max_pages := (total + per_page - 1) / per_page |
| 396 | limit := max_search_results / per_page |
| 397 | return if max_pages < limit { max_pages } else { limit } |
| 398 | } |
| 399 | |
| 400 | fn open_uri(uri string) ! { |
| 401 | $if !termux { |
| 402 | os.open_uri(uri)! |
| 403 | } |
| 404 | } |
| 405 | |