v / cmd / tools / vquest.v
404 lines · 363 sloc · 10.78 KB · 8e35f4d9848f7ad35d857a187dddbfd2eca5e19d
Raw
1module main
2
3import os
4import cli
5import net.http
6import net.urllib
7import json
8import rand
9import term
10
11const search_endpoint = 'https://api.github.com/search/issues'
12const issue_endpoint = 'https://api.github.com/repos/vlang/v/issues'
13const confirm_search_query = 'repo:vlang/v is:issue is:open -label:"Status: Confirmed"'
14const fix_search_query = 'repo:vlang/v is:issue is:open'
15const feature_search_query = 'repo:vlang/v is:issue state:open label:"Feature/Enhancement Request"'
16const per_page = 100
17const max_search_results = 1000
18
19struct SearchResponse {
20 total_count int
21 items []Issue
22}
23
24struct Issue {
25 number int
26 title string
27 html_url string
28 body string
29 labels []Label
30}
31
32struct Label {
33 name string
34}
35
36struct IssueDetails {
37 number int
38 title string
39 html_url string
40 body string
41 labels []Label
42 state string
43}
44
45fn 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
100const 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
123const 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
154fn 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
159fn run_fix(cmd cli.Command) ! {
160 run_issue(cmd, fix_search_query, 'open', 'Help us by fixing or confirming this issue:')!
161}
162
163fn 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
168fn 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
215fn 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
223fn 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
233fn 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
248fn 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
254fn 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
271fn 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
291fn 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
306fn 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
313fn 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
339fn 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
348fn 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
357fn 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
368fn 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
376fn 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
391fn 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
400fn open_uri(uri string) ! {
401 $if !termux {
402 os.open_uri(uri)!
403 }
404}
405