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 ",
+ 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")
}