feat(merge): auto-resolve all field conflicts deterministically (bd-6l8)
Implement deterministic auto-resolve rules for all merge conflicts: - Title/Description: side with latest updated_at wins - Notes: concatenate both sides with separator - Priority: higher priority wins (lower number) - IssueType: local (left) wins - Status: closed wins (existing) Also fixed bug in isTimeAfter where invalid t1 incorrectly beat valid t2. Removed unused conflict generation code (hasConflict, makeConflict, makeConflictWithBase, issuesEqual, cmp import). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
{"id":"bd-5bj","title":"Registry has cross-process race condition","description":"The global daemon registry (~/.beads/registry.json) can be corrupted when multiple daemons from different workspaces write simultaneously.\n\n**Root cause:**\n- Registry uses an in-process mutex but no file-level locking\n- Register() and Unregister() release the mutex between read and write\n- Multiple daemon processes can interleave their read-modify-write cycles\n\n**Evidence:**\nFound registry.json with double closing bracket: `]]` instead of `]`\n\n**Fix options:**\n1. Use file locking (flock/fcntl) around the entire read-modify-write cycle\n2. Use atomic write pattern (write to temp file, rename)\n3. Both (belt and suspenders)\n\n**Files:**\n- internal/daemon/registry.go:46-64 (readEntries)\n- internal/daemon/registry.go:67-87 (writeEntries)\n- internal/daemon/registry.go:90-108 (Register - the race window)","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-27T13:55:50.426188-08:00","updated_at":"2025-11-27T14:07:06.22622-08:00","closed_at":"2025-11-27T14:07:06.22622-08:00"}
|
{"id":"bd-5bj","title":"Registry has cross-process race condition","description":"The global daemon registry (~/.beads/registry.json) can be corrupted when multiple daemons from different workspaces write simultaneously.\n\n**Root cause:**\n- Registry uses an in-process mutex but no file-level locking\n- Register() and Unregister() release the mutex between read and write\n- Multiple daemon processes can interleave their read-modify-write cycles\n\n**Evidence:**\nFound registry.json with double closing bracket: `]]` instead of `]`\n\n**Fix options:**\n1. Use file locking (flock/fcntl) around the entire read-modify-write cycle\n2. Use atomic write pattern (write to temp file, rename)\n3. Both (belt and suspenders)\n\n**Files:**\n- internal/daemon/registry.go:46-64 (readEntries)\n- internal/daemon/registry.go:67-87 (writeEntries)\n- internal/daemon/registry.go:90-108 (Register - the race window)","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-27T13:55:50.426188-08:00","updated_at":"2025-11-27T14:07:06.22622-08:00","closed_at":"2025-11-27T14:07:06.22622-08:00"}
|
||||||
{"id":"bd-63l","title":"bd hooks install fails in git worktrees","description":"When bd is used in a git worktree, bd hooks install fails with 'mkdir .git: not a directory' because .git is a file (gitdir pointer) not a directory. Beads should detect and follow the .git gitdir pointer to install hooks in the correct location. This blocks normal worktree workflows.\n\n## Symptoms of this bug:\n- Git hooks don't install automatically\n- Auto-sync doesn't run (5-second debounce)\n- Hash mismatch warnings in bd output\n- Daemon fails to start with 'auto_start_failed'\n\n## Workaround:\nUse `git rev-parse --git-dir` to find the actual hooks directory and copy hooks manually:\n```bash\nmkdir -p $(git rev-parse --git-dir)/hooks\ncp -r .beads-hooks/* $(git rev-parse --git-dir)/hooks/\n```","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-29T00:27:59.111163003-07:00","updated_at":"2025-11-29T23:20:04.196608-08:00","closed_at":"2025-11-29T23:20:01.394894-08:00"}
|
{"id":"bd-63l","title":"bd hooks install fails in git worktrees","description":"When bd is used in a git worktree, bd hooks install fails with 'mkdir .git: not a directory' because .git is a file (gitdir pointer) not a directory. Beads should detect and follow the .git gitdir pointer to install hooks in the correct location. This blocks normal worktree workflows.\n\n## Symptoms of this bug:\n- Git hooks don't install automatically\n- Auto-sync doesn't run (5-second debounce)\n- Hash mismatch warnings in bd output\n- Daemon fails to start with 'auto_start_failed'\n\n## Workaround:\nUse `git rev-parse --git-dir` to find the actual hooks directory and copy hooks manually:\n```bash\nmkdir -p $(git rev-parse --git-dir)/hooks\ncp -r .beads-hooks/* $(git rev-parse --git-dir)/hooks/\n```","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-29T00:27:59.111163003-07:00","updated_at":"2025-11-29T23:20:04.196608-08:00","closed_at":"2025-11-29T23:20:01.394894-08:00"}
|
||||||
{"id":"bd-6ic","title":"Second worktree test","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T00:40:28.768125-08:00","updated_at":"2025-11-30T00:40:58.06615-08:00","closed_at":"2025-11-30T00:40:58.06615-08:00"}
|
{"id":"bd-6ic","title":"Second worktree test","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T00:40:28.768125-08:00","updated_at":"2025-11-30T00:40:58.06615-08:00","closed_at":"2025-11-30T00:40:58.06615-08:00"}
|
||||||
{"id":"bd-6l8","title":"Auto-resolve all field conflicts in merge.go","description":"Eliminate manual conflict resolution by adding deterministic auto-resolve rules for all fields:\n\n- Priority: higher priority wins (lower number = more urgent)\n- IssueType: local wins\n- Notes: concatenate both sides with separator\n- Title: side with latest updated_at on the issue wins\n- Description: side with latest updated_at on the issue wins\n\nCurrently true conflicts (both sides changed same field to different values) fail sync. With this change, NO conflicts ever require manual resolution.\n\nLocation: internal/merge/merge.go\nParent issue: bd-3s8","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T19:30:59.244181-08:00","updated_at":"2025-12-02T19:30:59.244181-08:00"}
|
{"id":"bd-6l8","title":"Auto-resolve all field conflicts in merge.go","description":"Eliminate manual conflict resolution by adding deterministic auto-resolve rules for all fields:\n\n- Priority: higher priority wins (lower number = more urgent)\n- IssueType: local wins\n- Notes: concatenate both sides with separator\n- Title: side with latest updated_at on the issue wins\n- Description: side with latest updated_at on the issue wins\n\nCurrently true conflicts (both sides changed same field to different values) fail sync. With this change, NO conflicts ever require manual resolution.\n\nLocation: internal/merge/merge.go\nParent issue: bd-3s8","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-02T19:30:59.244181-08:00","updated_at":"2025-12-02T19:49:51.570143-08:00","closed_at":"2025-12-02T19:49:51.570143-08:00"}
|
||||||
{"id":"bd-736d","title":"Refactor path canonicalization into helper function","description":"The path canonicalization logic (filepath.Abs + EvalSymlinks) is duplicated in 3 places:\n- beads.go:131-137 (BEADS_DIR handling)\n- cmd/bd/main.go:446-451 (--no-db cleanup)\n- cmd/bd/nodb.go:26-31 (--no-db initialization)\n\nRefactoring suggestion:\nExtract to a helper function like:\n func canonicalizePath(path string) string\n\nThis would:\n- Reduce code duplication\n- Make the logic easier to maintain\n- Ensure consistent behavior across all path handling\n\nRelated to bd-e16b implementation.","status":"closed","priority":3,"issue_type":"chore","created_at":"2025-11-02T18:33:47.727443-08:00","updated_at":"2025-11-25T22:27:33.738672-08:00","closed_at":"2025-11-25T22:27:33.738672-08:00"}
|
{"id":"bd-736d","title":"Refactor path canonicalization into helper function","description":"The path canonicalization logic (filepath.Abs + EvalSymlinks) is duplicated in 3 places:\n- beads.go:131-137 (BEADS_DIR handling)\n- cmd/bd/main.go:446-451 (--no-db cleanup)\n- cmd/bd/nodb.go:26-31 (--no-db initialization)\n\nRefactoring suggestion:\nExtract to a helper function like:\n func canonicalizePath(path string) string\n\nThis would:\n- Reduce code duplication\n- Make the logic easier to maintain\n- Ensure consistent behavior across all path handling\n\nRelated to bd-e16b implementation.","status":"closed","priority":3,"issue_type":"chore","created_at":"2025-11-02T18:33:47.727443-08:00","updated_at":"2025-11-25T22:27:33.738672-08:00","closed_at":"2025-11-25T22:27:33.738672-08:00"}
|
||||||
{"id":"bd-73u","title":"Refactor daemon local-only sync functions to reduce duplication","description":"PR #433 added three new local-only sync functions (createLocalSyncFunc, createLocalExportFunc, createLocalAutoImportFunc) that are largely copy-paste of existing ones with git operations removed. Refactor to use a single implementation with a skipGit bool parameter or extract shared logic into helper functions to reduce ~200 lines of duplication.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-01T17:38:02.632264-08:00","updated_at":"2025-12-01T21:08:11.952459-08:00","closed_at":"2025-12-01T21:08:11.952459-08:00","close_reason":"Refactored createSyncFunc and createLocalSyncFunc to use shared performSync implementation. Reduced 54 lines of duplication."}
|
{"id":"bd-73u","title":"Refactor daemon local-only sync functions to reduce duplication","description":"PR #433 added three new local-only sync functions (createLocalSyncFunc, createLocalExportFunc, createLocalAutoImportFunc) that are largely copy-paste of existing ones with git operations removed. Refactor to use a single implementation with a skipGit bool parameter or extract shared logic into helper functions to reduce ~200 lines of duplication.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-01T17:38:02.632264-08:00","updated_at":"2025-12-01T21:08:11.952459-08:00","closed_at":"2025-12-01T21:08:11.952459-08:00","close_reason":"Refactored createSyncFunc and createLocalSyncFunc to use shared performSync implementation. Reduced 54 lines of duplication."}
|
||||||
{"id":"bd-7ch","title":"Auto-push after merge with safety check","description":"Make bd sync a true one-command solution by auto-pushing after successful content merge.\n\nBehavior:\n- After successful content merge, auto-push by default\n- Safety check: detect when \u003e50% issues vanished AND \u003e5 existed before\n- On safety check trigger: warn but still push (do not block happy path)\n\nVanished means issues removed from issues.jsonl entirely, NOT status=closed (closed is legitimate swarm completion).\n\nLocation: internal/syncbranch/worktree.go\nParent issue: bd-3s8","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T19:30:59.540636-08:00","updated_at":"2025-12-02T19:30:59.540636-08:00"}
|
{"id":"bd-7ch","title":"Auto-push after merge with safety check","description":"Make bd sync a true one-command solution by auto-pushing after successful content merge.\n\nBehavior:\n- After successful content merge, auto-push by default\n- Safety check: detect when \u003e50% issues vanished AND \u003e5 existed before\n- On safety check trigger: warn but still push (do not block happy path)\n\nVanished means issues removed from issues.jsonl entirely, NOT status=closed (closed is legitimate swarm completion).\n\nLocation: internal/syncbranch/worktree.go\nParent issue: bd-3s8","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T19:30:59.540636-08:00","updated_at":"2025-12-02T19:30:59.540636-08:00"}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
{"id":"bd-8a5","title":"Refactor: deduplicate FindJSONLInDir and FindJSONLPath","description":"## Background\n\nAfter fixing bd-tqo, we now have two nearly identical functions for finding the JSONL file:\n- `autoimport.FindJSONLInDir(dbDir string)` in internal/autoimport/autoimport.go\n- `beads.FindJSONLPath(dbPath string)` in internal/beads/beads.go\n\nBoth implement the same logic:\n1. Prefer issues.jsonl\n2. Fall back to beads.jsonl for legacy support\n3. Skip deletions.jsonl and merge artifacts\n4. Default to issues.jsonl if nothing found\n\n## Problem\n\nCode duplication means bug fixes need to be applied in multiple places (as we just experienced with bd-tqo).\n\n## Proposed Solution\n\nExtract shared logic to a utility package that both can import. Options:\n1. Create `internal/jsonlpath` package with the core logic\n2. Have `autoimport` import `beads` and call `FindJSONLPath` (but APIs differ slightly)\n3. Move to `internal/utils` if appropriate\n\nNeed to verify no import cycles would be created.\n\n## Affected Files\n- internal/autoimport/autoimport.go\n- internal/beads/beads.go","status":"closed","priority":4,"issue_type":"task","created_at":"2025-11-26T23:45:18.974339-08:00","updated_at":"2025-11-29T22:06:06.330185-08:00","closed_at":"2025-11-28T23:07:08.912247-08:00"}
|
{"id":"bd-8a5","title":"Refactor: deduplicate FindJSONLInDir and FindJSONLPath","description":"## Background\n\nAfter fixing bd-tqo, we now have two nearly identical functions for finding the JSONL file:\n- `autoimport.FindJSONLInDir(dbDir string)` in internal/autoimport/autoimport.go\n- `beads.FindJSONLPath(dbPath string)` in internal/beads/beads.go\n\nBoth implement the same logic:\n1. Prefer issues.jsonl\n2. Fall back to beads.jsonl for legacy support\n3. Skip deletions.jsonl and merge artifacts\n4. Default to issues.jsonl if nothing found\n\n## Problem\n\nCode duplication means bug fixes need to be applied in multiple places (as we just experienced with bd-tqo).\n\n## Proposed Solution\n\nExtract shared logic to a utility package that both can import. Options:\n1. Create `internal/jsonlpath` package with the core logic\n2. Have `autoimport` import `beads` and call `FindJSONLPath` (but APIs differ slightly)\n3. Move to `internal/utils` if appropriate\n\nNeed to verify no import cycles would be created.\n\n## Affected Files\n- internal/autoimport/autoimport.go\n- internal/beads/beads.go","status":"closed","priority":4,"issue_type":"task","created_at":"2025-11-26T23:45:18.974339-08:00","updated_at":"2025-11-29T22:06:06.330185-08:00","closed_at":"2025-11-28T23:07:08.912247-08:00"}
|
||||||
{"id":"bd-8an","title":"bd import auto-detects wrong prefix from directory name instead of issue IDs","description":"When importing issues.jsonl into a fresh database, 'bd import' prints:\n\n ✓ Initialized database with prefix 'beads' (detected from issues)\n\nBut the issues all have prefix 'bd-' (e.g., bd-03r). It appears to be detecting the prefix from the directory name (.beads/) rather than from the actual issue IDs in the JSONL.\n\nThis causes import to fail with:\n validate ID prefix for bd-03r: issue ID 'bd-03r' does not match configured prefix 'beads'\n\nWorkaround: Run 'bd config set issue_prefix bd' before import, or use 'bd init --prefix bd'.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-26T22:28:01.582564-08:00","updated_at":"2025-11-28T22:17:12.607316-08:00","closed_at":"2025-11-27T22:38:48.971617-08:00"}
|
{"id":"bd-8an","title":"bd import auto-detects wrong prefix from directory name instead of issue IDs","description":"When importing issues.jsonl into a fresh database, 'bd import' prints:\n\n ✓ Initialized database with prefix 'beads' (detected from issues)\n\nBut the issues all have prefix 'bd-' (e.g., bd-03r). It appears to be detecting the prefix from the directory name (.beads/) rather than from the actual issue IDs in the JSONL.\n\nThis causes import to fail with:\n validate ID prefix for bd-03r: issue ID 'bd-03r' does not match configured prefix 'beads'\n\nWorkaround: Run 'bd config set issue_prefix bd' before import, or use 'bd init --prefix bd'.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-26T22:28:01.582564-08:00","updated_at":"2025-11-28T22:17:12.607316-08:00","closed_at":"2025-11-27T22:38:48.971617-08:00"}
|
||||||
{"id":"bd-8ib","title":"Update git hooks to be sync.branch aware","description":"## Problem\n\nThe pre-push hook blocks pushes when .beads/issues.jsonl has uncommitted changes. But with sync.branch configured, those changes are intentionally NOT committed to main - they go to the sync branch via worktree.\n\n## Current Behavior\n\n1. User configures sync.branch=beads-sync\n2. bd sync commits changes to beads-sync via worktree \n3. Local .beads/issues.jsonl is updated (needed for import)\n4. git push to main triggers pre-push hook\n5. Hook sees uncommitted .beads changes and blocks push\n6. User must use --no-verify to push\n\n## Expected Behavior\n\nWhen sync.branch is configured, the pre-push hook should:\n1. Check if sync.branch is set (bd config get sync.branch)\n2. If set, skip the .beads uncommitted check OR\n3. Verify changes are committed to the sync branch instead\n\n## Affected Files\n\n- examples/git-hooks/pre-push\n- examples/git-hooks/pre-commit (may also need update)\n\n## Workaround\n\nUse git push --no-verify","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T00:43:40.991951-08:00","updated_at":"2025-11-30T11:12:09.91242-08:00","closed_at":"2025-11-30T11:12:09.91242-08:00"}
|
{"id":"bd-8ib","title":"Update git hooks to be sync.branch aware","description":"## Problem\n\nThe pre-push hook blocks pushes when .beads/issues.jsonl has uncommitted changes. But with sync.branch configured, those changes are intentionally NOT committed to main - they go to the sync branch via worktree.\n\n## Current Behavior\n\n1. User configures sync.branch=beads-sync\n2. bd sync commits changes to beads-sync via worktree \n3. Local .beads/issues.jsonl is updated (needed for import)\n4. git push to main triggers pre-push hook\n5. Hook sees uncommitted .beads changes and blocks push\n6. User must use --no-verify to push\n\n## Expected Behavior\n\nWhen sync.branch is configured, the pre-push hook should:\n1. Check if sync.branch is set (bd config get sync.branch)\n2. If set, skip the .beads uncommitted check OR\n3. Verify changes are committed to the sync branch instead\n\n## Affected Files\n\n- examples/git-hooks/pre-push\n- examples/git-hooks/pre-commit (may also need update)\n\n## Workaround\n\nUse git push --no-verify","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T00:43:40.991951-08:00","updated_at":"2025-11-30T11:12:09.91242-08:00","closed_at":"2025-11-30T11:12:09.91242-08:00"}
|
||||||
|
{"id":"bd-8nz","title":"Merge timestamp tie-breaker should prefer local (left)","description":"In mergeFieldByUpdatedAt, when timestamps are exactly equal, right wins. For consistency with IssueType (where local/left wins), equal timestamps should prefer left. Minor inconsistency.","status":"open","priority":4,"issue_type":"task","created_at":"2025-12-02T20:14:59.898345-08:00","updated_at":"2025-12-02T20:14:59.898345-08:00"}
|
||||||
{"id":"bd-8q0","title":"Add Claude Code web installation docs to README","description":"GH #439 reported installation issues in Claude Code web environment. The go install fallback works, but users need guidance. Add a section to README documenting the workaround: go install + PATH export.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-01T21:02:24.511955-08:00","updated_at":"2025-12-01T21:10:10.587639-08:00","closed_at":"2025-12-01T21:10:10.587639-08:00","close_reason":"Added documentation for go install fallback when npm postinstall fails due to network restrictions in Claude Code web environments."}
|
{"id":"bd-8q0","title":"Add Claude Code web installation docs to README","description":"GH #439 reported installation issues in Claude Code web environment. The go install fallback works, but users need guidance. Add a section to README documenting the workaround: go install + PATH export.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-01T21:02:24.511955-08:00","updated_at":"2025-12-01T21:10:10.587639-08:00","closed_at":"2025-12-01T21:10:10.587639-08:00","close_reason":"Added documentation for go install fallback when npm postinstall fails due to network restrictions in Claude Code web environments."}
|
||||||
{"id":"bd-91x","title":"Fix dependency naming inconsistencies (GH #440)","description":"Parent-child dependency documentation is backwards and UI labels are confusing.\\n\\nProblems:\\n1. DEPENDENCIES.md says 'bd dep add PARENT CHILD' but this is rejected\\n2. bd show displays epic children under 'Blocks' instead of 'Children'\\n3. bd dep tree EPIC shows nothing (need --direction=up)\\n4. Inconsistent with 'bd epic status' which uses 'children'\\n\\nSee: https://github.com/steveyegge/beads/issues/440","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-01T21:01:41.63295-08:00","updated_at":"2025-12-01T21:06:17.866688-08:00","closed_at":"2025-12-01T21:06:17.866688-08:00"}
|
{"id":"bd-91x","title":"Fix dependency naming inconsistencies (GH #440)","description":"Parent-child dependency documentation is backwards and UI labels are confusing.\\n\\nProblems:\\n1. DEPENDENCIES.md says 'bd dep add PARENT CHILD' but this is rejected\\n2. bd show displays epic children under 'Blocks' instead of 'Children'\\n3. bd dep tree EPIC shows nothing (need --direction=up)\\n4. Inconsistent with 'bd epic status' which uses 'children'\\n\\nSee: https://github.com/steveyegge/beads/issues/440","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-01T21:01:41.63295-08:00","updated_at":"2025-12-01T21:06:17.866688-08:00","closed_at":"2025-12-01T21:06:17.866688-08:00"}
|
||||||
{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]}
|
{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]}
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
{"id":"bd-c8x","title":"Don't search parent directories for .beads databases","description":"bd currently walks up the directory tree looking for .beads directories, which can find unrelated databases (e.g., ~/.beads). This causes confusing warnings and potential data pollution.\n\nShould either:\n1. Stop at git root (don't search above it)\n2. Only use explicit BEADS_DB env var or local .beads\n3. At minimum, don't search in home directory","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-27T22:10:41.992686-08:00","updated_at":"2025-11-28T22:17:12.607956-08:00","closed_at":"2025-11-28T22:15:55.878353-08:00"}
|
{"id":"bd-c8x","title":"Don't search parent directories for .beads databases","description":"bd currently walks up the directory tree looking for .beads directories, which can find unrelated databases (e.g., ~/.beads). This causes confusing warnings and potential data pollution.\n\nShould either:\n1. Stop at git root (don't search above it)\n2. Only use explicit BEADS_DB env var or local .beads\n3. At minimum, don't search in home directory","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-27T22:10:41.992686-08:00","updated_at":"2025-11-28T22:17:12.607956-08:00","closed_at":"2025-11-28T22:15:55.878353-08:00"}
|
||||||
{"id":"bd-clg","title":"bd jira sync command","description":"Add a built-in bd command for Jira synchronization.\n\n**Requires**: Both import and export scripts working\n\n**Features**:\n- `bd jira sync --pull` - Import from Jira to beads\n- `bd jira sync --push` - Export from beads to Jira\n- `bd jira sync` - Bidirectional (pull then push, with conflict resolution)\n- `bd jira status` - Show sync status and last sync time\n\n**Conflict resolution**:\n- Timestamp-based: newer update wins\n- Option for --prefer-local or --prefer-jira to override\n- Interactive mode for manual conflict resolution (optional)\n\n**Integration**:\n- Uses jira.* config settings from bd config\n- Stores last sync timestamp in config\n- Logs sync activity for audit\n\n**Stretch goals**:\n- Webhook integration for real-time sync\n- Selective sync by JQL filter","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-11-30T12:56:27.716537-08:00","updated_at":"2025-11-30T15:25:37.896045-08:00","closed_at":"2025-11-30T15:25:37.896045-08:00","dependencies":[{"issue_id":"bd-clg","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:49.796568-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:57:00.075288-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-93d","type":"blocks","created_at":"2025-11-30T12:57:05.206431-08:00","created_by":"stevey"}]}
|
{"id":"bd-clg","title":"bd jira sync command","description":"Add a built-in bd command for Jira synchronization.\n\n**Requires**: Both import and export scripts working\n\n**Features**:\n- `bd jira sync --pull` - Import from Jira to beads\n- `bd jira sync --push` - Export from beads to Jira\n- `bd jira sync` - Bidirectional (pull then push, with conflict resolution)\n- `bd jira status` - Show sync status and last sync time\n\n**Conflict resolution**:\n- Timestamp-based: newer update wins\n- Option for --prefer-local or --prefer-jira to override\n- Interactive mode for manual conflict resolution (optional)\n\n**Integration**:\n- Uses jira.* config settings from bd config\n- Stores last sync timestamp in config\n- Logs sync activity for audit\n\n**Stretch goals**:\n- Webhook integration for real-time sync\n- Selective sync by JQL filter","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-11-30T12:56:27.716537-08:00","updated_at":"2025-11-30T15:25:37.896045-08:00","closed_at":"2025-11-30T15:25:37.896045-08:00","dependencies":[{"issue_id":"bd-clg","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:49.796568-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:57:00.075288-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-93d","type":"blocks","created_at":"2025-11-30T12:57:05.206431-08:00","created_by":"stevey"}]}
|
||||||
{"id":"bd-co0","title":"Imperator does not know its own mail identity","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-30T22:53:49.963803-08:00","updated_at":"2025-12-01T22:01:26.953199-08:00","closed_at":"2025-12-01T22:01:26.953199-08:00"}
|
{"id":"bd-co0","title":"Imperator does not know its own mail identity","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-30T22:53:49.963803-08:00","updated_at":"2025-12-01T22:01:26.953199-08:00","closed_at":"2025-12-01T22:01:26.953199-08:00"}
|
||||||
|
{"id":"bd-d0t","title":"Priority 0 in merge may incorrectly win over set priorities","description":"In mergePriority(), priority 0 (which may mean 'unset' due to Go's zero value) beats any explicitly set priority like P1, P2, etc. Should probably treat 0 as 'no priority set' and not let it win conflicts.","status":"open","priority":3,"issue_type":"bug","created_at":"2025-12-02T20:14:58.906543-08:00","updated_at":"2025-12-02T20:14:58.906543-08:00"}
|
||||||
{"id":"bd-d4i","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-25T17:47:30.747566-08:00","closed_at":"2025-11-25T17:47:30.747566-08:00"}
|
{"id":"bd-d4i","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-25T17:47:30.747566-08:00","closed_at":"2025-11-25T17:47:30.747566-08:00"}
|
||||||
{"id":"bd-dmb","title":"Fresh clone: bd should suggest 'bd init' when no database exists","description":"On a fresh clone of a repo using beads, running `bd stats` or `bd list` gives a cryptic error:\n\n```\nError: failed to open database: post-migration validation failed: migration invariants failed:\n - required_config_present: required config key missing: issue_prefix (database has 2 issues)\n```\n\n**Expected**: A helpful message like:\n```\nNo database found. This appears to be a fresh clone.\nRun 'bd init --prefix \u003cprefix\u003e' to hydrate from the committed JSONL file.\nFound: .beads/beads.jsonl (38 issues)\n```\n\n**Why this matters**: The current UX is confusing for new contributors or fresh clones. The happy path should be obvious.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-11-27T20:21:04.947959-08:00","updated_at":"2025-11-27T22:40:11.654051-08:00","closed_at":"2025-11-27T22:40:11.654051-08:00"}
|
{"id":"bd-dmb","title":"Fresh clone: bd should suggest 'bd init' when no database exists","description":"On a fresh clone of a repo using beads, running `bd stats` or `bd list` gives a cryptic error:\n\n```\nError: failed to open database: post-migration validation failed: migration invariants failed:\n - required_config_present: required config key missing: issue_prefix (database has 2 issues)\n```\n\n**Expected**: A helpful message like:\n```\nNo database found. This appears to be a fresh clone.\nRun 'bd init --prefix \u003cprefix\u003e' to hydrate from the committed JSONL file.\nFound: .beads/beads.jsonl (38 issues)\n```\n\n**Why this matters**: The current UX is confusing for new contributors or fresh clones. The happy path should be obvious.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-11-27T20:21:04.947959-08:00","updated_at":"2025-11-27T22:40:11.654051-08:00","closed_at":"2025-11-27T22:40:11.654051-08:00"}
|
||||||
{"id":"bd-e166","title":"Improve timestamp comparison readability in import","description":"The timestamp comparison logic uses double-negative which can be confusing:\n\nCurrent code:\nif !incoming.UpdatedAt.After(existing.UpdatedAt) {\n // skip update\n}\n\nMore readable:\nif incoming.UpdatedAt.After(existing.UpdatedAt) {\n // perform update\n} else {\n // skip (local is newer)\n}\n\nThis is a minor refactor for code clarity.\n\nRelated: bd-1022\nFiles: internal/importer/importer.go:411, 488","status":"closed","priority":4,"issue_type":"chore","created_at":"2025-11-02T15:32:12.27108-08:00","updated_at":"2025-11-26T22:25:27.124071-08:00","closed_at":"2025-11-26T22:25:27.124071-08:00"}
|
{"id":"bd-e166","title":"Improve timestamp comparison readability in import","description":"The timestamp comparison logic uses double-negative which can be confusing:\n\nCurrent code:\nif !incoming.UpdatedAt.After(existing.UpdatedAt) {\n // skip update\n}\n\nMore readable:\nif incoming.UpdatedAt.After(existing.UpdatedAt) {\n // perform update\n} else {\n // skip (local is newer)\n}\n\nThis is a minor refactor for code clarity.\n\nRelated: bd-1022\nFiles: internal/importer/importer.go:411, 488","status":"closed","priority":4,"issue_type":"chore","created_at":"2025-11-02T15:32:12.27108-08:00","updated_at":"2025-11-26T22:25:27.124071-08:00","closed_at":"2025-11-26T22:25:27.124071-08:00"}
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Issue represents a beads issue with all possible fields
|
// Issue represents a beads issue with all possible fields
|
||||||
@@ -289,12 +287,14 @@ func merge3Way(base, left, right []Issue) ([]Issue, []string) {
|
|||||||
result = append(result, merged)
|
result = append(result, merged)
|
||||||
}
|
}
|
||||||
} else if !inBase && inLeft && inRight {
|
} else if !inBase && inLeft && inRight {
|
||||||
// Added in both - check if identical
|
// Added in both - merge using deterministic rules with empty base
|
||||||
if issuesEqual(leftIssue, rightIssue) {
|
emptyBase := Issue{
|
||||||
result = append(result, leftIssue)
|
ID: leftIssue.ID,
|
||||||
} else {
|
CreatedAt: leftIssue.CreatedAt,
|
||||||
conflicts = append(conflicts, makeConflict(leftIssue.RawLine, rightIssue.RawLine))
|
CreatedBy: leftIssue.CreatedBy,
|
||||||
}
|
}
|
||||||
|
merged, _ := mergeIssue(emptyBase, leftIssue, rightIssue)
|
||||||
|
result = append(result, merged)
|
||||||
} else if inBase && inLeft && !inRight {
|
} else if inBase && inLeft && !inRight {
|
||||||
// Deleted in right, maybe modified in left
|
// Deleted in right, maybe modified in left
|
||||||
// RULE 2: deletion always wins over modification
|
// RULE 2: deletion always wins over modification
|
||||||
@@ -324,31 +324,22 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
|
|||||||
CreatedBy: base.CreatedBy,
|
CreatedBy: base.CreatedBy,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge title
|
// Merge title - on conflict, side with latest updated_at wins
|
||||||
result.Title = mergeField(base.Title, left.Title, right.Title)
|
result.Title = mergeFieldByUpdatedAt(base.Title, left.Title, right.Title, left.UpdatedAt, right.UpdatedAt)
|
||||||
|
|
||||||
// Merge description
|
// Merge description - on conflict, side with latest updated_at wins
|
||||||
result.Description = mergeField(base.Description, left.Description, right.Description)
|
result.Description = mergeFieldByUpdatedAt(base.Description, left.Description, right.Description, left.UpdatedAt, right.UpdatedAt)
|
||||||
|
|
||||||
// Merge notes
|
// Merge notes - on conflict, concatenate both sides
|
||||||
result.Notes = mergeField(base.Notes, left.Notes, right.Notes)
|
result.Notes = mergeNotes(base.Notes, left.Notes, right.Notes)
|
||||||
|
|
||||||
// Merge status - SPECIAL RULE: closed always wins over open
|
// Merge status - SPECIAL RULE: closed always wins over open
|
||||||
result.Status = mergeStatus(base.Status, left.Status, right.Status)
|
result.Status = mergeStatus(base.Status, left.Status, right.Status)
|
||||||
|
|
||||||
// Merge priority (as int)
|
// Merge priority - on conflict, higher priority wins (lower number = more urgent)
|
||||||
if base.Priority == left.Priority && base.Priority != right.Priority {
|
result.Priority = mergePriority(base.Priority, left.Priority, right.Priority)
|
||||||
result.Priority = right.Priority
|
|
||||||
} else if base.Priority == right.Priority && base.Priority != left.Priority {
|
|
||||||
result.Priority = left.Priority
|
|
||||||
} else if left.Priority == right.Priority {
|
|
||||||
result.Priority = left.Priority
|
|
||||||
} else {
|
|
||||||
// Conflict - take left for now
|
|
||||||
result.Priority = left.Priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge issue_type
|
// Merge issue_type - on conflict, local (left) wins
|
||||||
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
|
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
|
||||||
|
|
||||||
// Merge updated_at - take the max
|
// Merge updated_at - take the max
|
||||||
@@ -365,11 +356,7 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
|
|||||||
// Merge dependencies - combine and deduplicate
|
// Merge dependencies - combine and deduplicate
|
||||||
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
|
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
|
||||||
|
|
||||||
// Check if we have a real conflict
|
// All field conflicts are now auto-resolved deterministically
|
||||||
if hasConflict(base, left, right) {
|
|
||||||
return result, makeConflictWithBase(base.RawLine, left.RawLine, right.RawLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, ""
|
return result, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,10 +378,110 @@ func mergeField(base, left, right string) string {
|
|||||||
if base == right && base != left {
|
if base == right && base != left {
|
||||||
return left
|
return left
|
||||||
}
|
}
|
||||||
// Both changed to same value or no change
|
// Both changed to same value or no change - left wins
|
||||||
return left
|
return left
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeFieldByUpdatedAt resolves conflicts by picking the value from the side
|
||||||
|
// with the latest updated_at timestamp
|
||||||
|
func mergeFieldByUpdatedAt(base, left, right, leftUpdatedAt, rightUpdatedAt string) string {
|
||||||
|
// Standard 3-way merge for non-conflict cases
|
||||||
|
if base == left && base != right {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
if base == right && base != left {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
if left == right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
// True conflict: both sides changed to different values
|
||||||
|
// Pick the value from the side with the latest updated_at
|
||||||
|
if isTimeAfter(leftUpdatedAt, rightUpdatedAt) {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeNotes handles notes merging - on conflict, concatenate both sides
|
||||||
|
func mergeNotes(base, left, right string) string {
|
||||||
|
// Standard 3-way merge for non-conflict cases
|
||||||
|
if base == left && base != right {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
if base == right && base != left {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
if left == right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
// True conflict: both sides changed to different values - concatenate
|
||||||
|
if left == "" {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
if right == "" {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return left + "\n\n---\n\n" + right
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergePriority handles priority merging - on conflict, higher priority wins (lower number)
|
||||||
|
func mergePriority(base, left, right int) int {
|
||||||
|
// Standard 3-way merge for non-conflict cases
|
||||||
|
if base == left && base != right {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
if base == right && base != left {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
if left == right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
// True conflict: both sides changed to different values
|
||||||
|
// Higher priority wins (lower number = more urgent)
|
||||||
|
if left < right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTimeAfter returns true if t1 is after t2
|
||||||
|
func isTimeAfter(t1, t2 string) bool {
|
||||||
|
if t1 == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t2 == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
time1, err1 := time.Parse(time.RFC3339Nano, t1)
|
||||||
|
if err1 != nil {
|
||||||
|
time1, err1 = time.Parse(time.RFC3339, t1)
|
||||||
|
}
|
||||||
|
|
||||||
|
time2, err2 := time.Parse(time.RFC3339Nano, t2)
|
||||||
|
if err2 != nil {
|
||||||
|
time2, err2 = time.Parse(time.RFC3339, t2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle parse errors consistently with maxTime:
|
||||||
|
// - Valid timestamp beats invalid
|
||||||
|
// - If both invalid, prefer left (t1) for consistency
|
||||||
|
if err1 != nil && err2 != nil {
|
||||||
|
return true // both invalid, prefer left
|
||||||
|
}
|
||||||
|
if err1 != nil {
|
||||||
|
return false // t1 invalid, t2 valid - t2 wins
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
return true // t1 valid, t2 invalid - t1 wins
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both valid - compare. On exact tie, return false (right wins for now)
|
||||||
|
// TODO: Consider preferring left on tie for consistency with IssueType rule
|
||||||
|
return time1.After(time2)
|
||||||
|
}
|
||||||
|
|
||||||
func maxTime(t1, t2 string) string {
|
func maxTime(t1, t2 string) string {
|
||||||
if t1 == "" && t2 == "" {
|
if t1 == "" && t2 == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -459,62 +546,3 @@ func mergeDependencies(left, right []Dependency) []Dependency {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasConflict(base, left, right Issue) bool {
|
|
||||||
// Check if any field has conflicting changes
|
|
||||||
if base.Title != left.Title && base.Title != right.Title && left.Title != right.Title {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if base.Description != left.Description && base.Description != right.Description && left.Description != right.Description {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if base.Notes != left.Notes && base.Notes != right.Notes && left.Notes != right.Notes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if base.Status != left.Status && base.Status != right.Status && left.Status != right.Status {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if base.Priority != left.Priority && base.Priority != right.Priority && left.Priority != right.Priority {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if base.IssueType != left.IssueType && base.IssueType != right.IssueType && left.IssueType != right.IssueType {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func issuesEqual(a, b Issue) bool {
|
|
||||||
// Use go-cmp for deep equality comparison, ignoring RawLine field
|
|
||||||
return cmp.Equal(a, b, cmp.FilterPath(func(p cmp.Path) bool {
|
|
||||||
return p.String() == "RawLine"
|
|
||||||
}, cmp.Ignore()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeConflict(left, right string) string {
|
|
||||||
conflict := "<<<<<<< left\n"
|
|
||||||
if left != "" {
|
|
||||||
conflict += left + "\n"
|
|
||||||
}
|
|
||||||
conflict += "=======\n"
|
|
||||||
if right != "" {
|
|
||||||
conflict += right + "\n"
|
|
||||||
}
|
|
||||||
conflict += ">>>>>>> right\n"
|
|
||||||
return conflict
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeConflictWithBase(base, left, right string) string {
|
|
||||||
conflict := "<<<<<<< left\n"
|
|
||||||
if left != "" {
|
|
||||||
conflict += left + "\n"
|
|
||||||
}
|
|
||||||
conflict += "||||||| base\n"
|
|
||||||
if base != "" {
|
|
||||||
conflict += base + "\n"
|
|
||||||
}
|
|
||||||
conflict += "=======\n"
|
|
||||||
if right != "" {
|
|
||||||
conflict += right + "\n"
|
|
||||||
}
|
|
||||||
conflict += ">>>>>>> right\n"
|
|
||||||
return conflict
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -298,6 +298,80 @@ func TestMaxTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsTimeAfter tests timestamp comparison including error handling
|
||||||
|
func TestIsTimeAfter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
t1 string
|
||||||
|
t2 string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both empty - prefer left",
|
||||||
|
t1: "",
|
||||||
|
t2: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t1 empty - t2 wins",
|
||||||
|
t1: "",
|
||||||
|
t2: "2024-01-02T00:00:00Z",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t2 empty - t1 wins",
|
||||||
|
t1: "2024-01-01T00:00:00Z",
|
||||||
|
t2: "",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t1 newer",
|
||||||
|
t1: "2024-01-02T00:00:00Z",
|
||||||
|
t2: "2024-01-01T00:00:00Z",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t2 newer",
|
||||||
|
t1: "2024-01-01T00:00:00Z",
|
||||||
|
t2: "2024-01-02T00:00:00Z",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical timestamps - right wins (false)",
|
||||||
|
t1: "2024-01-01T00:00:00Z",
|
||||||
|
t2: "2024-01-01T00:00:00Z",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t1 invalid, t2 valid - t2 wins",
|
||||||
|
t1: "not-a-timestamp",
|
||||||
|
t2: "2024-01-01T00:00:00Z",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t1 valid, t2 invalid - t1 wins",
|
||||||
|
t1: "2024-01-01T00:00:00Z",
|
||||||
|
t2: "not-a-timestamp",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both invalid - prefer left",
|
||||||
|
t1: "invalid1",
|
||||||
|
t2: "invalid2",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isTimeAfter(tt.t1, tt.t2)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isTimeAfter(%q, %q) = %v, want %v", tt.t1, tt.t2, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestMerge3Way_SimpleUpdates tests simple field update scenarios
|
// TestMerge3Way_SimpleUpdates tests simple field update scenarios
|
||||||
func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
||||||
base := []Issue{
|
base := []Issue{
|
||||||
@@ -409,52 +483,54 @@ func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMerge3Way_Conflicts tests conflict detection
|
// TestMerge3Way_AutoResolve tests auto-resolution of conflicts
|
||||||
func TestMerge3Way_Conflicts(t *testing.T) {
|
func TestMerge3Way_AutoResolve(t *testing.T) {
|
||||||
t.Run("conflicting title changes", func(t *testing.T) {
|
t.Run("conflicting title changes - latest updated_at wins", func(t *testing.T) {
|
||||||
base := []Issue{
|
base := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Title: "Original",
|
Title: "Original",
|
||||||
|
UpdatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","title":"Original","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","title":"Original","updated_at":"2024-01-01T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
left := []Issue{
|
left := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Title: "Left version",
|
Title: "Left version",
|
||||||
|
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","title":"Left version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
right := []Issue{
|
right := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Title: "Right version",
|
Title: "Right version",
|
||||||
|
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - this should win
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","title":"Right version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, conflicts := merge3Way(base, left, right)
|
result, conflicts := merge3Way(base, left, right)
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) != 0 {
|
||||||
t.Error("expected conflict for divergent title changes")
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||||
}
|
}
|
||||||
if len(result) != 0 {
|
if len(result) != 1 {
|
||||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||||
}
|
}
|
||||||
if len(conflicts) > 0 {
|
// Right has newer updated_at, so right's title wins
|
||||||
if conflicts[0] == "" {
|
if result[0].Title != "Right version" {
|
||||||
t.Error("conflict marker should not be empty")
|
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("conflicting priority changes", func(t *testing.T) {
|
t.Run("conflicting priority changes - higher priority wins (lower number)", func(t *testing.T) {
|
||||||
base := []Issue{
|
base := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
@@ -467,16 +543,16 @@ func TestMerge3Way_Conflicts(t *testing.T) {
|
|||||||
left := []Issue{
|
left := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Priority: 0,
|
Priority: 3, // Lower priority (higher number)
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","priority":0,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","priority":3,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
right := []Issue{
|
right := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Priority: 1,
|
Priority: 1, // Higher priority (lower number) - this should win
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","priority":1,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","priority":1,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
@@ -484,11 +560,100 @@ func TestMerge3Way_Conflicts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, conflicts := merge3Way(base, left, right)
|
result, conflicts := merge3Way(base, left, right)
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) != 0 {
|
||||||
t.Error("expected conflict for divergent priority changes")
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||||
}
|
}
|
||||||
if len(result) != 0 {
|
if len(result) != 1 {
|
||||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
// Lower priority number wins
|
||||||
|
if result[0].Priority != 1 {
|
||||||
|
t.Errorf("expected priority 1 (higher priority), got %d", result[0].Priority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("conflicting notes - concatenated", func(t *testing.T) {
|
||||||
|
base := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Notes: "Original notes",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","notes":"Original notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
left := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Notes: "Left notes",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","notes":"Left notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
right := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Notes: "Right notes",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","notes":"Right notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, conflicts := merge3Way(base, left, right)
|
||||||
|
if len(conflicts) != 0 {
|
||||||
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||||
|
}
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
// Notes should be concatenated
|
||||||
|
expectedNotes := "Left notes\n\n---\n\nRight notes"
|
||||||
|
if result[0].Notes != expectedNotes {
|
||||||
|
t.Errorf("expected notes %q, got %q", expectedNotes, result[0].Notes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("conflicting issue_type - local (left) wins", func(t *testing.T) {
|
||||||
|
base := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
IssueType: "task",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","issue_type":"task","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
left := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
IssueType: "bug", // Local change - should win
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","issue_type":"bug","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
right := []Issue{
|
||||||
|
{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
IssueType: "feature",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
RawLine: `{"id":"bd-abc123","issue_type":"feature","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, conflicts := merge3Way(base, left, right)
|
||||||
|
if len(conflicts) != 0 {
|
||||||
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||||
|
}
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
// Local (left) wins for issue_type
|
||||||
|
if result[0].IssueType != "bug" {
|
||||||
|
t.Errorf("expected issue_type 'bug' (local wins), got %q", result[0].IssueType)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -679,33 +844,39 @@ func TestMerge3Way_Additions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("added in both with different content - conflict", func(t *testing.T) {
|
t.Run("added in both with different content - auto-resolved", func(t *testing.T) {
|
||||||
base := []Issue{}
|
base := []Issue{}
|
||||||
left := []Issue{
|
left := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Title: "Left version",
|
Title: "Left version",
|
||||||
|
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","title":"Left version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
right := []Issue{
|
right := []Issue{
|
||||||
{
|
{
|
||||||
ID: "bd-abc123",
|
ID: "bd-abc123",
|
||||||
Title: "Right version",
|
Title: "Right version",
|
||||||
|
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - should win
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
CreatedBy: "user1",
|
CreatedBy: "user1",
|
||||||
RawLine: `{"id":"bd-abc123","title":"Right version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, conflicts := merge3Way(base, left, right)
|
result, conflicts := merge3Way(base, left, right)
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) != 0 {
|
||||||
t.Error("expected conflict for different additions")
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||||
}
|
}
|
||||||
if len(result) != 0 {
|
if len(result) != 1 {
|
||||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
// Right has newer updated_at, so right's title wins
|
||||||
|
if result[0].Title != "Right version" {
|
||||||
|
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user