Merge remote changes and fix isHashID for Base36
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -501,9 +501,23 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
|
|||||||
|
|
||||||
// Capture left snapshot (pre-pull state) for 3-way merge
|
// Capture left snapshot (pre-pull state) for 3-way merge
|
||||||
// This is mandatory for deletion tracking integrity
|
// This is mandatory for deletion tracking integrity
|
||||||
if err := captureLeftSnapshot(jsonlPath); err != nil {
|
// In multi-repo mode, capture snapshots for all JSONL files
|
||||||
log.log("Error: failed to capture snapshot (required for deletion tracking): %v", err)
|
multiRepoPaths := getMultiRepoJSONLPaths()
|
||||||
return
|
if multiRepoPaths != nil {
|
||||||
|
// Multi-repo mode: snapshot each JSONL file
|
||||||
|
for _, path := range multiRepoPaths {
|
||||||
|
if err := captureLeftSnapshot(path); err != nil {
|
||||||
|
log.log("Error: failed to capture snapshot for %s: %v", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.log("Captured %d snapshots (multi-repo mode)", len(multiRepoPaths))
|
||||||
|
} else {
|
||||||
|
// Single-repo mode: snapshot the main JSONL
|
||||||
|
if err := captureLeftSnapshot(jsonlPath); err != nil {
|
||||||
|
log.log("Error: failed to capture snapshot (required for deletion tracking): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoCommit {
|
if autoCommit {
|
||||||
@@ -557,9 +571,23 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform 3-way merge and prune deletions
|
// Perform 3-way merge and prune deletions
|
||||||
if err := applyDeletionsFromMerge(syncCtx, store, jsonlPath); err != nil {
|
// In multi-repo mode, apply deletions for each JSONL file
|
||||||
log.log("Error during 3-way merge: %v", err)
|
multiRepoPathsForMerge := getMultiRepoJSONLPaths()
|
||||||
return
|
if multiRepoPathsForMerge != nil {
|
||||||
|
// Multi-repo mode: merge/prune for each JSONL
|
||||||
|
for _, path := range multiRepoPathsForMerge {
|
||||||
|
if err := applyDeletionsFromMerge(syncCtx, store, path); err != nil {
|
||||||
|
log.log("Error during 3-way merge for %s: %v", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.log("Applied deletions from %d repos", len(multiRepoPathsForMerge))
|
||||||
|
} else {
|
||||||
|
// Single-repo mode
|
||||||
|
if err := applyDeletionsFromMerge(syncCtx, store, jsonlPath); err != nil {
|
||||||
|
log.log("Error during 3-way merge: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := importToJSONLWithStore(syncCtx, store, jsonlPath); err != nil {
|
if err := importToJSONLWithStore(syncCtx, store, jsonlPath); err != nil {
|
||||||
@@ -581,8 +609,18 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update base snapshot after successful import
|
// Update base snapshot after successful import
|
||||||
if err := updateBaseSnapshot(jsonlPath); err != nil {
|
// In multi-repo mode, update snapshots for all JSONL files
|
||||||
log.log("Warning: failed to update base snapshot: %v", err)
|
multiRepoPathsForSnapshot := getMultiRepoJSONLPaths()
|
||||||
|
if multiRepoPathsForSnapshot != nil {
|
||||||
|
for _, path := range multiRepoPathsForSnapshot {
|
||||||
|
if err := updateBaseSnapshot(path); err != nil {
|
||||||
|
log.log("Warning: failed to update base snapshot for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := updateBaseSnapshot(jsonlPath); err != nil {
|
||||||
|
log.log("Warning: failed to update base snapshot: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoPush && autoCommit {
|
if autoPush && autoCommit {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/merge"
|
"github.com/steveyegge/beads/internal/merge"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -99,20 +100,10 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prune accepted deletions from the database
|
// Prune accepted deletions from the database
|
||||||
// Use type assertion to access DeleteIssue method (available in concrete SQLiteStorage)
|
|
||||||
type deleter interface {
|
|
||||||
DeleteIssue(context.Context, string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
d, ok := store.(deleter)
|
|
||||||
if !ok {
|
|
||||||
return false, fmt.Errorf("storage backend does not support DeleteIssue")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all deletion errors - fail the operation if any delete fails
|
// Collect all deletion errors - fail the operation if any delete fails
|
||||||
var deletionErrors []error
|
var deletionErrors []error
|
||||||
for _, id := range acceptedDeletions {
|
for _, id := range acceptedDeletions {
|
||||||
if err := d.DeleteIssue(ctx, id); err != nil {
|
if err := store.DeleteIssue(ctx, id); err != nil {
|
||||||
deletionErrors = append(deletionErrors, fmt.Errorf("issue %s: %w", id, err))
|
deletionErrors = append(deletionErrors, fmt.Errorf("issue %s: %w", id, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,6 +330,33 @@ func initializeSnapshotsIfNeeded(jsonlPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMultiRepoJSONLPaths returns all JSONL file paths for multi-repo mode
|
||||||
|
// Returns nil if not in multi-repo mode
|
||||||
|
func getMultiRepoJSONLPaths() []string {
|
||||||
|
multiRepo := config.GetMultiRepoConfig()
|
||||||
|
if multiRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
// Primary repo JSONL
|
||||||
|
primaryPath := multiRepo.Primary
|
||||||
|
if primaryPath == "" {
|
||||||
|
primaryPath = "."
|
||||||
|
}
|
||||||
|
primaryJSONL := filepath.Join(primaryPath, ".beads", "issues.jsonl")
|
||||||
|
paths = append(paths, primaryJSONL)
|
||||||
|
|
||||||
|
// Additional repos' JSONLs
|
||||||
|
for _, repoPath := range multiRepo.Additional {
|
||||||
|
jsonlPath := filepath.Join(repoPath, ".beads", "issues.jsonl")
|
||||||
|
paths = append(paths, jsonlPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
// applyDeletionsFromMerge applies deletions discovered during 3-way merge
|
// applyDeletionsFromMerge applies deletions discovered during 3-way merge
|
||||||
// This is the main entry point for deletion tracking during sync
|
// This is the main entry point for deletion tracking during sync
|
||||||
func applyDeletionsFromMerge(ctx context.Context, store storage.Storage, jsonlPath string) error {
|
func applyDeletionsFromMerge(ctx context.Context, store storage.Storage, jsonlPath string) error {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
@@ -385,3 +387,285 @@ func TestSnapshotManagement(t *testing.T) {
|
|||||||
t.Errorf("Expected 1 issue in each snapshot, got base=%d left=%d", baseCount, leftCount)
|
t.Errorf("Expected 1 issue in each snapshot, got base=%d left=%d", baseCount, leftCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMultiRepoDeletionTracking tests deletion tracking with multi-repo mode
|
||||||
|
// This is the test for bd-4oob: snapshot files need to be created per-JSONL file
|
||||||
|
func TestMultiRepoDeletionTracking(t *testing.T) {
|
||||||
|
// Setup workspace directories
|
||||||
|
primaryDir := t.TempDir()
|
||||||
|
additionalDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup .beads directories
|
||||||
|
primaryBeadsDir := filepath.Join(primaryDir, ".beads")
|
||||||
|
additionalBeadsDir := filepath.Join(additionalDir, ".beads")
|
||||||
|
if err := os.MkdirAll(primaryBeadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(additionalBeadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create additional .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database in primary dir
|
||||||
|
dbPath := filepath.Join(primaryBeadsDir, "beads.db")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := sqlite.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup multi-repo config
|
||||||
|
config.Set("repos.primary", primaryDir)
|
||||||
|
config.Set("repos.additional", []string{additionalDir})
|
||||||
|
defer func() {
|
||||||
|
config.Set("repos.primary", "")
|
||||||
|
config.Set("repos.additional", nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create issues in different repos
|
||||||
|
primaryIssue := &types.Issue{
|
||||||
|
ID: "bd-primary",
|
||||||
|
Title: "Primary repo issue",
|
||||||
|
Description: "This belongs to primary",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: "task",
|
||||||
|
SourceRepo: ".", // Primary repo
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalIssue := &types.Issue{
|
||||||
|
ID: "bd-additional",
|
||||||
|
Title: "Additional repo issue",
|
||||||
|
Description: "This belongs to additional",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: "task",
|
||||||
|
SourceRepo: additionalDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, primaryIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary issue: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, additionalIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create additional issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to multi-repo (this creates multiple JSONL files)
|
||||||
|
results, err := store.ExportToMultiRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportToMultiRepo failed: %v", err)
|
||||||
|
}
|
||||||
|
if results == nil {
|
||||||
|
t.Fatal("Expected multi-repo results, got nil")
|
||||||
|
}
|
||||||
|
if results["."] != 1 {
|
||||||
|
t.Errorf("Expected 1 issue in primary repo, got %d", results["."])
|
||||||
|
}
|
||||||
|
if results[additionalDir] != 1 {
|
||||||
|
t.Errorf("Expected 1 issue in additional repo, got %d", results[additionalDir])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSONL files exist
|
||||||
|
primaryJSONL := filepath.Join(primaryBeadsDir, "issues.jsonl")
|
||||||
|
additionalJSONL := filepath.Join(additionalBeadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if !fileExists(primaryJSONL) {
|
||||||
|
t.Fatalf("Primary JSONL not created: %s", primaryJSONL)
|
||||||
|
}
|
||||||
|
if !fileExists(additionalJSONL) {
|
||||||
|
t.Fatalf("Additional JSONL not created: %s", additionalJSONL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// THIS IS THE BUG: Initialize snapshots - currently only works for single JSONL
|
||||||
|
// Should create snapshots for BOTH JSONL files
|
||||||
|
if err := initializeSnapshotsIfNeeded(primaryJSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize primary snapshots: %v", err)
|
||||||
|
}
|
||||||
|
if err := initializeSnapshotsIfNeeded(additionalJSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize additional snapshots: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify snapshot files exist for both repos
|
||||||
|
primaryBasePath, primaryLeftPath := getSnapshotPaths(primaryJSONL)
|
||||||
|
additionalBasePath, additionalLeftPath := getSnapshotPaths(additionalJSONL)
|
||||||
|
|
||||||
|
if !fileExists(primaryBasePath) {
|
||||||
|
t.Errorf("Primary base snapshot not created: %s", primaryBasePath)
|
||||||
|
}
|
||||||
|
if !fileExists(additionalBasePath) {
|
||||||
|
t.Errorf("Additional base snapshot not created: %s", additionalBasePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture left snapshot BEFORE simulating git pull
|
||||||
|
// This represents our local state before the pull
|
||||||
|
if err := captureLeftSnapshot(primaryJSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to capture primary left snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := captureLeftSnapshot(additionalJSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to capture additional left snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate remote deletion: replace additional repo JSONL with empty file
|
||||||
|
// This simulates what happens after a git pull where remote deleted the issue
|
||||||
|
emptyFile, err := os.Create(additionalJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create empty JSONL: %v", err)
|
||||||
|
}
|
||||||
|
emptyFile.Close()
|
||||||
|
|
||||||
|
// Verify left snapshots exist
|
||||||
|
if !fileExists(primaryLeftPath) {
|
||||||
|
t.Errorf("Primary left snapshot not created: %s", primaryLeftPath)
|
||||||
|
}
|
||||||
|
if !fileExists(additionalLeftPath) {
|
||||||
|
t.Errorf("Additional left snapshot not created: %s", additionalLeftPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now apply deletion tracking for additional repo
|
||||||
|
// This should detect that bd-additional was deleted remotely and remove it from DB
|
||||||
|
merged, err := merge3WayAndPruneDeletions(ctx, store, additionalJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge3WayAndPruneDeletions failed for additional repo: %v", err)
|
||||||
|
}
|
||||||
|
if !merged {
|
||||||
|
t.Error("Expected merge to be performed (base snapshot exists)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the issue was deleted from the database
|
||||||
|
issue, err := store.GetIssue(ctx, "bd-additional")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error getting issue: %v", err)
|
||||||
|
}
|
||||||
|
if issue != nil {
|
||||||
|
t.Errorf("Expected bd-additional to be deleted from database, but it still exists: %+v", issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify primary issue still exists
|
||||||
|
primaryResult, err := store.GetIssue(ctx, "bd-primary")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Primary issue should still exist: %v", err)
|
||||||
|
}
|
||||||
|
if primaryResult == nil {
|
||||||
|
t.Error("Primary issue should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiRepoSnapshotIsolation verifies that snapshot operations on one repo
|
||||||
|
// don't interfere with another repo's snapshots
|
||||||
|
func TestMultiRepoSnapshotIsolation(t *testing.T) {
|
||||||
|
// Setup two repo directories
|
||||||
|
repo1Dir := t.TempDir()
|
||||||
|
repo2Dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create .beads/issues.jsonl in each
|
||||||
|
repo1JSONL := filepath.Join(repo1Dir, ".beads", "issues.jsonl")
|
||||||
|
repo2JSONL := filepath.Join(repo2Dir, ".beads", "issues.jsonl")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(repo1JSONL), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create repo1 .beads: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(repo2JSONL), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create repo2 .beads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write test issues
|
||||||
|
issue1 := map[string]interface{}{
|
||||||
|
"id": "bd-repo1-issue",
|
||||||
|
"title": "Repo 1 Issue",
|
||||||
|
}
|
||||||
|
issue2 := map[string]interface{}{
|
||||||
|
"id": "bd-repo2-issue",
|
||||||
|
"title": "Repo 2 Issue",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to repo1
|
||||||
|
f1, err := os.Create(repo1JSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create repo1 JSONL: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(f1).Encode(issue1)
|
||||||
|
f1.Close()
|
||||||
|
|
||||||
|
// Write to repo2
|
||||||
|
f2, err := os.Create(repo2JSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create repo2 JSONL: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(f2).Encode(issue2)
|
||||||
|
f2.Close()
|
||||||
|
|
||||||
|
// Initialize snapshots for both
|
||||||
|
if err := initializeSnapshotsIfNeeded(repo1JSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to init repo1 snapshots: %v", err)
|
||||||
|
}
|
||||||
|
if err := initializeSnapshotsIfNeeded(repo2JSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to init repo2 snapshots: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get snapshot paths for both
|
||||||
|
repo1Base, repo1Left := getSnapshotPaths(repo1JSONL)
|
||||||
|
repo2Base, repo2Left := getSnapshotPaths(repo2JSONL)
|
||||||
|
|
||||||
|
// Verify isolation: snapshots should be in different directories
|
||||||
|
if filepath.Dir(repo1Base) == filepath.Dir(repo2Base) {
|
||||||
|
t.Error("Snapshot directories should be different for different repos")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each snapshot contains only its own issue
|
||||||
|
repo1IDs, err := buildIDSet(repo1Base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read repo1 base snapshot: %v", err)
|
||||||
|
}
|
||||||
|
repo2IDs, err := buildIDSet(repo2Base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read repo2 base snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !repo1IDs["bd-repo1-issue"] {
|
||||||
|
t.Error("Repo1 snapshot should contain bd-repo1-issue")
|
||||||
|
}
|
||||||
|
if repo1IDs["bd-repo2-issue"] {
|
||||||
|
t.Error("Repo1 snapshot should NOT contain bd-repo2-issue")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !repo2IDs["bd-repo2-issue"] {
|
||||||
|
t.Error("Repo2 snapshot should contain bd-repo2-issue")
|
||||||
|
}
|
||||||
|
if repo2IDs["bd-repo1-issue"] {
|
||||||
|
t.Error("Repo2 snapshot should NOT contain bd-repo1-issue")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture left snapshots for both
|
||||||
|
if err := captureLeftSnapshot(repo1JSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to capture repo1 left: %v", err)
|
||||||
|
}
|
||||||
|
if err := captureLeftSnapshot(repo2JSONL); err != nil {
|
||||||
|
t.Fatalf("Failed to capture repo2 left: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify left snapshots are isolated
|
||||||
|
if !fileExists(repo1Left) || !fileExists(repo2Left) {
|
||||||
|
t.Error("Both left snapshots should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
repo1LeftIDs, err := buildIDSet(repo1Left)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read repo1 left snapshot: %v", err)
|
||||||
|
}
|
||||||
|
repo2LeftIDs, err := buildIDSet(repo2Left)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read repo2 left snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !repo1LeftIDs["bd-repo1-issue"] || repo1LeftIDs["bd-repo2-issue"] {
|
||||||
|
t.Error("Repo1 left snapshot has wrong issues")
|
||||||
|
}
|
||||||
|
if !repo2LeftIDs["bd-repo2-issue"] || repo2LeftIDs["bd-repo1-issue"] {
|
||||||
|
t.Error("Repo2 left snapshot has wrong issues")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -326,27 +326,35 @@ func replaceIDReferences(text string, mapping map[string]string) string {
|
|||||||
|
|
||||||
// isHashID checks if an ID is hash-based (not sequential)
|
// isHashID checks if an ID is hash-based (not sequential)
|
||||||
func isHashID(id string) bool {
|
func isHashID(id string) bool {
|
||||||
// Hash IDs are Base36-encoded (0-9, a-z) with adaptive length (3-8 chars)
|
// Hash IDs contain hex letters (a-f), sequential IDs are only digits
|
||||||
// Sequential IDs are numeric and typically short (1-4 digits)
|
// May have hierarchical suffix like .1 or .1.2
|
||||||
parts := strings.SplitN(id, "-", 2)
|
parts := strings.SplitN(id, "-", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
suffix := parts[1]
|
suffix := parts[1]
|
||||||
if len(suffix) == 0 {
|
// Strip hierarchical suffix like .1 or .1.2
|
||||||
|
baseSuffix := strings.Split(suffix, ".")[0]
|
||||||
|
|
||||||
|
if len(baseSuffix) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be valid Base36 (0-9, a-z)
|
||||||
|
if !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(baseSuffix) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's 5+ characters long, it's almost certainly a hash ID
|
// If it's 5+ characters long, it's almost certainly a hash ID
|
||||||
// (sequential IDs rarely exceed 9999 = 4 digits)
|
// (sequential IDs rarely exceed 9999 = 4 digits)
|
||||||
if len(suffix) >= 5 {
|
if len(baseSuffix) >= 5 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// For shorter IDs, check if it contains any letter (a-z)
|
// For shorter IDs, check if it contains any letter (a-z)
|
||||||
// Sequential IDs are purely numeric
|
// Sequential IDs are purely numeric
|
||||||
return regexp.MustCompile(`[a-z]`).MatchString(suffix)
|
return regexp.MustCompile(`[a-z]`).MatchString(baseSuffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveMappingFile saves the ID mapping to a JSON file
|
// saveMappingFile saves the ID mapping to a JSON file
|
||||||
|
|||||||
@@ -406,6 +406,29 @@ func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string
|
|||||||
}, actor)
|
}, actor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteIssue permanently deletes an issue and all associated data
|
||||||
|
func (m *MemoryStorage) DeleteIssue(ctx context.Context, id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if issue exists
|
||||||
|
if _, ok := m.issues[id]; !ok {
|
||||||
|
return fmt.Errorf("issue not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the issue
|
||||||
|
delete(m.issues, id)
|
||||||
|
|
||||||
|
// Delete associated data
|
||||||
|
delete(m.dependencies, id)
|
||||||
|
delete(m.labels, id)
|
||||||
|
delete(m.events, id)
|
||||||
|
delete(m.comments, id)
|
||||||
|
delete(m.dirty, id)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SearchIssues finds issues matching query and filters
|
// SearchIssues finds issues matching query and filters
|
||||||
func (m *MemoryStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
func (m *MemoryStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Storage interface {
|
|||||||
GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error)
|
GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error)
|
||||||
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
|
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
|
||||||
CloseIssue(ctx context.Context, id string, reason string, actor string) error
|
CloseIssue(ctx context.Context, id string, reason string, actor string) error
|
||||||
|
DeleteIssue(ctx context.Context, id string) error
|
||||||
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error)
|
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error)
|
||||||
|
|
||||||
// Dependencies
|
// Dependencies
|
||||||
|
|||||||
Reference in New Issue
Block a user