diff --git a/cmd/relspec/report.go b/cmd/relspec/report.go new file mode 100644 index 0000000..22cbc6d --- /dev/null +++ b/cmd/relspec/report.go @@ -0,0 +1,243 @@ +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 +} diff --git a/cmd/relspec/root.go b/cmd/relspec/root.go index fd92c90..b2847ca 100644 --- a/cmd/relspec/root.go +++ b/cmd/relspec/root.go @@ -70,5 +70,6 @@ func init() { rootCmd.AddCommand(mergeCmd) rootCmd.AddCommand(splitCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(reportCmd) rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas") }