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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user