package main import ( "bytes" "encoding/base64" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "time" "github.com/google/uuid" "github.com/spf13/cobra" ) const ( reportAPIBase = "https://git.warky.dev/api/v1/repos/wdevs/relspecgo/issues" reportRateLimit = time.Minute reportTokenB64 = "OGQ4ODlhNmY2ZjQ5NjY5OTA5MTJhYTIyZjcyNzExMTNjZTEyZTRhMQ==" reportStateFile = "report_state.json" ) var ( reportBody string reportName string reportEmail string ) var reportCmd = &cobra.Command{ Use: "report", Short: "Report a bug or feature request against RelSpec", Long: "Report a bug or feature request directly to the RelSpec issue tracker.", } var reportBugCmd = &cobra.Command{ Use: "bug ", Short: "Report a bug", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return submitReport("Bug", args[0], reportBody, reportName, reportEmail) }, } var reportFeatureCmd = &cobra.Command{ Use: "feature <title>", Short: "Report a feature request", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return submitReport("Feature", args[0], reportBody, reportName, reportEmail) }, } func init() { for _, c := range []*cobra.Command{reportBugCmd, reportFeatureCmd} { c.Flags().StringVar(&reportBody, "body", "", "Detailed description of the report") c.Flags().StringVar(&reportName, "name", "", "Optional name, if you'd like feedback on this report") c.Flags().StringVar(&reportEmail, "email", "", "Optional email address, if you'd like feedback on this report") } reportCmd.AddCommand(reportBugCmd) reportCmd.AddCommand(reportFeatureCmd) } type reportState struct { LastReport time.Time `json:"last_report"` MachineID string `json:"machine_id,omitempty"` } func reportStateDir() (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", err } dir := filepath.Join(configDir, "relspec") if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } return dir, nil } func loadReportState() (reportState, string, error) { dir, err := reportStateDir() if err != nil { return reportState{}, "", err } path := filepath.Join(dir, reportStateFile) var state reportState data, err := os.ReadFile(path) if err == nil { _ = json.Unmarshal(data, &state) } return state, path, nil } func saveReportState(path string, state reportState) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0o600) } // systemUniqueID returns the OS machine id, falling back to a locally // persisted UUID if the platform-specific id cannot be read. func systemUniqueID(state reportState, statePath string) (string, error) { if id, err := osMachineID(); err == nil && id != "" { return id, nil } if state.MachineID != "" { return state.MachineID, nil } id := uuid.NewString() state.MachineID = id if err := saveReportState(statePath, state); err != nil { return "", err } return id, nil } func osMachineID() (string, error) { switch runtime.GOOS { case "linux": for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { data, err := os.ReadFile(path) if err == nil { return strings.TrimSpace(string(data)), nil } } return "", fmt.Errorf("no machine-id file found") case "darwin": out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() if err != nil { return "", err } re := regexp.MustCompile(`"IOPlatformUUID"\s*=\s*"([^"]+)"`) match := re.FindSubmatch(out) if match == nil { return "", fmt.Errorf("IOPlatformUUID not found") } return string(match[1]), nil case "windows": out, err := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid").Output() if err != nil { return "", err } re := regexp.MustCompile(`MachineGuid\s+REG_SZ\s+(\S+)`) match := re.FindSubmatch(out) if match == nil { return "", fmt.Errorf("MachineGuid not found") } return string(match[1]), nil default: return "", fmt.Errorf("unsupported platform: %s", runtime.GOOS) } } func reportToken() (string, error) { decoded, err := base64.StdEncoding.DecodeString(reportTokenB64) if err != nil { return "", fmt.Errorf("decode report token: %w", err) } return string(decoded), nil } type createIssueRequest struct { Title string `json:"title"` Body string `json:"body"` } func submitReport(kind, title, body, name, email string) error { state, statePath, err := loadReportState() if err != nil { return fmt.Errorf("load report state: %w", err) } if !state.LastReport.IsZero() { if wait := reportRateLimit - time.Since(state.LastReport); wait > 0 { return fmt.Errorf("please wait %s before submitting another report", wait.Round(time.Second)) } } id, err := systemUniqueID(state, statePath) if err != nil { return fmt.Errorf("determine system id: %w", err) } token, err := reportToken() if err != nil { return err } fullTitle := fmt.Sprintf("[%s] %s (id: %s)", kind, title, id) fullBody := body if name != "" || email != "" { var contact []string if name != "" { contact = append(contact, "Name: "+name) } if email != "" { contact = append(contact, "Email: "+email) } fullBody = strings.TrimSpace(fullBody + "\n\n---\n" + strings.Join(contact, "\n")) } payload, err := json.Marshal(createIssueRequest{Title: fullTitle, Body: fullBody}) if err != nil { return fmt.Errorf("build request: %w", err) } req, err := http.NewRequest(http.MethodPost, reportAPIBase, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("submit report: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return fmt.Errorf("submit report: unexpected status %s", resp.Status) } state.LastReport = time.Now() state.MachineID = id if err := saveReportState(statePath, state); err != nil { return fmt.Errorf("save report state: %w", err) } fmt.Printf("Report submitted: %s\n", fullTitle) return nil }