feat(cli): add report command for filing bugs/features from the CLI

Adds `relspec report bug|feature <title>` which files an issue directly
against the RelSpec tracker, tagging the title with the system's OS
machine-id (falling back to a persisted UUID) and rate-limiting
submissions to one per minute via a local state file.

Closes #14
This commit is contained in:
Hein
2026-07-21 10:31:20 +02:00
parent 316d9b0e7f
commit 2cecb4c11c
2 changed files with 244 additions and 0 deletions
+243
View File
@@ -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 <title>",
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
}
+1
View File
@@ -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")
}