Add auto-detection of issue prefix from git history (#277)

- Check existing JSONL issues before falling back to directory name on initialization
- Implement readFirstIssueFromJSONL() to extract prefix from first issue
- Added tests for readFirstIssueFromJSONL
This commit is contained in:
Yashwanth Reddy
2025-11-10 00:52:12 +05:30
committed by GitHub
parent 77dcf5595c
commit 8f37904c9c
2 changed files with 140 additions and 1 deletions

View File

@@ -1,7 +1,9 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -16,6 +18,8 @@ import (
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
var initCmd = &cobra.Command{
@@ -47,12 +51,24 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite
}
}
// Determine prefix with precedence: flag > config > auto-detect
// Determine prefix with precedence: flag > config > auto-detect from git > auto-detect from directory name
if prefix == "" {
// Try to get from config file
prefix = config.GetString("issue-prefix")
}
// auto-detect prefix from first issue in JSONL file
if prefix == "" {
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {
firstIssue, err := readFirstIssueFromJSONL(jsonlPath)
if firstIssue != nil && err == nil {
prefix = utils.ExtractIssuePrefix(firstIssue.ID)
}
}
}
// auto-detect prefix from directory name
if prefix == "" {
// Auto-detect from directory name
cwd, err := os.Getwd()
@@ -969,3 +985,39 @@ func createConfigYaml(beadsDir string, noDbMode bool) error {
return nil
}
// readFirstIssueFromJSONL reads the first issue from a JSONL file
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// skip empty lines
if line == "" {
continue
}
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err == nil {
return &issue, nil
} else {
// Skip malformed lines with warning
fmt.Fprintf(os.Stderr, "Warning: skipping malformed JSONL line %d: %v\n", lineNum, err)
continue
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading JSONL file: %w", err)
}
return nil, nil
}

View File

@@ -788,3 +788,90 @@ func TestInitMergeDriverAutoConfiguration(t *testing.T) {
}
})
}
// TestReadFirstIssueFromJSONL_ValidFile verifies reading first issue from valid JSONL
func TestReadFirstIssueFromJSONL_ValidFile(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "test.jsonl")
// Create test JSONL file with multiple issues
content := `{"id":"bd-1","title":"First Issue","description":"First test"}
{"id":"bd-2","title":"Second Issue","description":"Second test"}
{"id":"bd-3","title":"Third Issue","description":"Third test"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL failed: %v", err)
}
if issue == nil {
t.Fatal("Expected non-nil issue, got nil")
}
// Verify we got the FIRST issue
if issue.ID != "bd-1" {
t.Errorf("Expected ID 'bd-1', got '%s'", issue.ID)
}
if issue.Title != "First Issue" {
t.Errorf("Expected title 'First Issue', got '%s'", issue.Title)
}
if issue.Description != "First test" {
t.Errorf("Expected description 'First test', got '%s'", issue.Description)
}
}
// TestReadFirstIssueFromJSONL_EmptyLines verifies skipping empty lines
func TestReadFirstIssueFromJSONL_EmptyLines(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "test.jsonl")
// Create JSONL with empty lines before first valid issue
content := `
{"id":"bd-1","title":"First Valid Issue"}
{"id":"bd-2","title":"Second Issue"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL failed: %v", err)
}
if issue == nil {
t.Fatal("Expected non-nil issue, got nil")
}
if issue.ID != "bd-1" {
t.Errorf("Expected ID 'bd-1', got '%s'", issue.ID)
}
if issue.Title != "First Valid Issue" {
t.Errorf("Expected title 'First Valid Issue', got '%s'", issue.Title)
}
}
// TestReadFirstIssueFromJSONL_EmptyFile verifies handling of empty file
func TestReadFirstIssueFromJSONL_EmptyFile(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "empty.jsonl")
// Create empty file
if err := os.WriteFile(jsonlPath, []byte(""), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL should not error on empty file: %v", err)
}
if issue != nil {
t.Errorf("Expected nil issue for empty file, got %+v", issue)
}
}