Merge upstream/main into subtle-ux-improvements
Resolves conflicts and converts new defer/undefer commands from fatih/color to the lipgloss semantic color system. Key changes: - Added StatusDeferred case in graph.go with ui.RenderAccent - Converted status.go to use ui package for colorized output - Converted defer.go/undefer.go to use ui package - Merged GroupID and Aliases for status command - Updated pre-commit hook version to 0.31.0 - Ran go mod tidy to remove fatih/color dependency
This commit is contained in:
@@ -276,6 +276,7 @@
|
||||
{"id":"bd-u0g9","title":"GH#405: Prefix parsing with hyphens treats first segment as prefix","description":"Prefix me-py-toolkit gets parsed as just me- when detecting mismatches. Fix prefix parsing to handle multi-hyphen prefixes. See GitHub issue #405.","status":"tombstone","priority":2,"issue_type":"bug","created_at":"2025-12-16T01:03:18.354066-08:00","updated_at":"2025-12-17T16:11:17.070763-08:00","deleted_at":"2025-12-17T16:11:17.070763-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"bug"}
|
||||
{"id":"bd-umbf","title":"Design contributor namespace isolation for beads pollution prevention","description":"## Problem\n\nWhen contributors work on beads-the-project using beads-the-tool, their personal work-tracking issues leak into PRs. The .beads/issues.jsonl is intentionally tracked (it's the project's issue database), but contributors' local issues pollute the diff.\n\nThis is a recursion problem unique to self-hosting projects.\n\n## Possible Solutions to Explore\n\n1. **Contributor namespaces** - Each contributor gets a private prefix (e.g., `bd-steve-xxxx`) that's gitignored or filtered\n2. **Separate database** - Contributors use BEADS_DIR pointing elsewhere for personal tracking\n3. **Issue ownership/visibility flags** - Mark issues as \"local-only\" vs \"project\"\n4. **Prefix-based filtering** - Configure which prefixes are committed vs ignored\n\n## Design Considerations\n\n- Should be zero-friction for contributors (no manual setup)\n- Must not break existing workflows\n- Needs to work with sync/collaboration features\n- Consider: what if a \"personal\" issue graduates to \"project\" issue?\n\n## Expansion Needed\n\nThis is a placeholder. Needs detailed design exploration before implementation.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-13T18:00:29.638743-08:00","updated_at":"2025-12-13T18:00:41.345673-08:00"}
|
||||
{"id":"bd-uqfn","title":"Work on beads-wkt: Output control parameters for MCP tool...","description":"Work on beads-wkt: Output control parameters for MCP tools (GH#622). Add brief, fields, max_description_length params to ready/list/show. When done, submit MR (not PR) to integration branch for Refinery.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T22:57:10.675535-08:00","updated_at":"2025-12-20T00:26:12.606293-08:00","closed_at":"2025-12-19T23:28:25.362931-08:00"}
|
||||
{"id":"bd-usro","title":"Rename 'template instantiate' to 'mol bond'","description":"Rename the template instantiation command to match molecule metaphor.\n\nCurrent: bd template instantiate \u003cid\u003e --var key=value\nTarget: bd mol bond \u003cid\u003e --var key=value\n\nChanges needed:\n- Add 'mol' command group (or extend existing)\n- Add 'bond' subcommand that wraps template instantiate logic\n- Keep 'template instantiate' as deprecated alias for backward compat\n- Update help text and docs to use molecule terminology\n\nThe 'bond' verb captures:\n1. Chemistry metaphor (molecules bond to form structures)\n2. Dependency linking (child issues bonded in a DAG)\n3. Short and active\n\nSee also: molecule execution model in Gas Town","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-20T16:56:37.582795-08:00","updated_at":"2025-12-20T16:56:37.582795-08:00"}
|
||||
{"id":"bd-uutv","title":"Work on beads-rs0: Namepool configuration for themed pole...","description":"Work on beads-rs0: Namepool configuration for themed polecat names. See bd show beads-rs0 for full details.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:49:48.129778-08:00","updated_at":"2025-12-19T21:59:25.565894-08:00","closed_at":"2025-12-19T21:59:25.565894-08:00"}
|
||||
{"id":"bd-vgi5","title":"Push version bump to GitHub","description":"git push origin main - triggers CI but no release yet.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-18T22:43:05.363604-08:00","updated_at":"2025-12-18T22:46:57.50777-08:00","closed_at":"2025-12-18T22:46:57.50777-08:00","dependencies":[{"issue_id":"bd-vgi5","depends_on_id":"bd-qqc","type":"parent-child","created_at":"2025-12-18T22:43:16.87736-08:00","created_by":"daemon"},{"issue_id":"bd-vgi5","depends_on_id":"bd-3ggb","type":"blocks","created_at":"2025-12-18T22:43:21.078208-08:00","created_by":"daemon"}]}
|
||||
{"id":"bd-vpan","title":"Re: Thread Test 2","description":"Got your message. Testing reply feature.","status":"tombstone","priority":2,"issue_type":"message","created_at":"2025-12-16T18:21:29.144352-08:00","updated_at":"2025-12-17T16:11:17.070763-08:00","dependencies":[{"issue_id":"bd-vpan","depends_on_id":"bd-x36g","type":"replies-to","created_at":"2025-12-18T13:45:31.137191-08:00","created_by":"migration"}],"deleted_at":"2025-12-17T16:11:17.070763-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"message"}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"name": "beads",
|
||||
"source": "./",
|
||||
"description": "AI-supervised issue tracker for coding workflows",
|
||||
"version": "0.30.7"
|
||||
"version": "0.31.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beads",
|
||||
"description": "AI-supervised issue tracker for coding workflows. Manage tasks, discover work, and maintain context with simple CLI commands.",
|
||||
"version": "0.30.7",
|
||||
"version": "0.31.0",
|
||||
"author": {
|
||||
"name": "Steve Yegge",
|
||||
"url": "https://github.com/steveyegge"
|
||||
|
||||
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.31.0] - 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- **`bd defer` / `bd undefer` commands** (bd-4jr) - New "deferred" status for icebox issues
|
||||
- Issues that are deliberately postponed, not blocked by dependencies
|
||||
- Deferred issues excluded from `bd ready` and shown with ❄️ snowflake styling
|
||||
- Full support in MCP server, graph views, and statistics
|
||||
|
||||
- **Agent audit trail** (GH#649) - Append-only logging for AI agent interactions
|
||||
- New `.beads/interactions.jsonl` audit file
|
||||
- `bd audit record` - Log LLM calls, tool calls, or pipe JSON via stdin
|
||||
- `bd audit label <id>` - Append quality labels (good/bad) for dataset curation
|
||||
- `bd compact --audit` - Optionally log compaction prompts/responses
|
||||
- Audit entries are immutable; labels create new referencing entries
|
||||
|
||||
- **Directory-aware label scoping** (GH#541) - Auto-filter issues by directory in monorepos
|
||||
- Configure `directory.labels` to map paths to label filters
|
||||
- `bd ready` and `bd list` auto-apply when in matching directories
|
||||
- Example: `packages/maverick: maverick` shows only maverick-labeled issues
|
||||
|
||||
- **Molecules catalog** (gt-0ei3) - Separate storage for template molecules
|
||||
- Templates now live in `molecules.jsonl`, distinct from work items
|
||||
- Hierarchical loading: built-in → town → user → project
|
||||
- Molecules use `mol-*` ID namespace with `is_template: true`
|
||||
|
||||
- **Windows winget manifest** (GH#524) - Prepare beads for Windows Package Manager
|
||||
- Added manifest files for winget submission
|
||||
- Once merged to microsoft/winget-pkgs: `winget install SteveYegge.beads`
|
||||
|
||||
- **Git commit configuration** (GH#600) - Control beads auto-commit behavior
|
||||
- `git.author` - Override commit author (useful for bots)
|
||||
- `git.no-gpg-sign` - Disable GPG signing (fixes Touch ID prompts)
|
||||
|
||||
- **Require description config** (GH#596) - Enforce descriptions on issue creation
|
||||
- Set `create.require-description: true` to error on missing descriptions
|
||||
- Also supports `BD_CREATE_REQUIRE_DESCRIPTION` env var
|
||||
|
||||
### Changed
|
||||
|
||||
- **`bd stats` merged into `bd status`** (GH#644) - Consolidated status commands
|
||||
- `stats` now an alias for `status`
|
||||
- Colorized output with emoji header
|
||||
- Shows all statistics (tombstones, pinned, epics, lead time)
|
||||
- Added `--no-activity` flag to skip git activity parsing
|
||||
|
||||
- **Thin hook shims** (GH#615) - Hooks now delegate to `bd hooks run`
|
||||
- Eliminates hook version drift after upgrades
|
||||
- No more manual `bd hooks install --force` needed
|
||||
- Shims use `# bd-shim v1` marker (format version, not bd version)
|
||||
|
||||
- **MCP context tool consolidation** - Merged 3 tools into 1
|
||||
- Combined `set_context`, `where_am_i`, `init` into single `context` tool
|
||||
- Actions: `set` (default with workspace_root), `show` (default), `init`
|
||||
|
||||
- **Doctor improvements** (GH#656) - Enhanced diagnostics and testing
|
||||
- Visual improvements with grouped output
|
||||
- Comprehensive test coverage added
|
||||
- Fixed gosec warnings
|
||||
- Contributed by @rsnodgrass
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`relates-to` cycle detection** (GH#661) - Exclude relates-to from cycle detection
|
||||
- Relates-to links are bidirectional by design, not cycles
|
||||
|
||||
- **Doctor `.local_version` check** (GH#662) - Check correct version file
|
||||
- Now checks `.local_version` instead of deprecated `LastBdVersion`
|
||||
|
||||
- **Doctor Claude plugin link** (GH#623) - Updated broken documentation link
|
||||
|
||||
- **Read-only gitignore in stealth mode** (GH#663) - Print manual instructions instead of failing
|
||||
- When global gitignore is read-only (e.g., symlink to immutable location), shows what to add manually
|
||||
- Contributed by @qmx
|
||||
|
||||
## [0.30.7] - 2025-12-19
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -77,6 +77,7 @@ const (
|
||||
StatusOpen = types.StatusOpen
|
||||
StatusInProgress = types.StatusInProgress
|
||||
StatusBlocked = types.StatusBlocked
|
||||
StatusDeferred = types.StatusDeferred
|
||||
StatusClosed = types.StatusClosed
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ Custom Status States:
|
||||
bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs"
|
||||
|
||||
This enables issues to use statuses like 'awaiting_review' in addition to
|
||||
the built-in statuses (open, in_progress, blocked, closed).
|
||||
the built-in statuses (open, in_progress, blocked, deferred, closed).
|
||||
|
||||
Examples:
|
||||
bd config set jira.url "https://company.atlassian.net"
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@ Examples:
|
||||
|
||||
func init() {
|
||||
// Filter flags (same as list command)
|
||||
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
|
||||
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
|
||||
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var deferCmd = &cobra.Command{
|
||||
Use: "defer [id...]",
|
||||
Short: "Defer one or more issues for later",
|
||||
Long: `Defer issues to put them on ice for later.
|
||||
|
||||
Deferred issues are deliberately set aside - not blocked by anything specific,
|
||||
just postponed for future consideration. Unlike blocked issues, there's no
|
||||
dependency keeping them from being worked. Unlike closed issues, they will
|
||||
be revisited.
|
||||
|
||||
Deferred issues don't show in 'bd ready' but remain visible in 'bd list'.
|
||||
|
||||
Examples:
|
||||
bd defer bd-abc # Defer a single issue
|
||||
bd defer bd-abc bd-def # Defer multiple issues`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("defer")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs first
|
||||
var resolvedIDs []string
|
||||
if daemonClient != nil {
|
||||
for _, id := range args {
|
||||
resolveArgs := &rpc.ResolveIDArgs{ID: id}
|
||||
resp, err := daemonClient.ResolveID(resolveArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var resolvedID string
|
||||
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resolvedIDs = append(resolvedIDs, resolvedID)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
deferredIssues := []*types.Issue{}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
for _, id := range resolvedIDs {
|
||||
status := string(types.StatusDeferred)
|
||||
updateArgs := &rpc.UpdateArgs{
|
||||
ID: id,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
resp, err := daemonClient.Update(updateArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
deferredIssues = append(deferredIssues, &issue)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), id)
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput && len(deferredIssues) > 0 {
|
||||
outputJSON(deferredIssues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to direct storage access
|
||||
if store == nil {
|
||||
fmt.Fprintln(os.Stderr, "Error: database not initialized")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, id := range args {
|
||||
fullID, err := utils.ResolvePartialID(ctx, store, id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"status": string(types.StatusDeferred),
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", fullID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
issue, _ := store.GetIssue(ctx, fullID)
|
||||
if issue != nil {
|
||||
deferredIssues = append(deferredIssues, issue)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), fullID)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule auto-flush if any issues were deferred
|
||||
if len(args) > 0 {
|
||||
markDirtyAndScheduleFlush()
|
||||
}
|
||||
|
||||
if jsonOutput && len(deferredIssues) > 0 {
|
||||
outputJSON(deferredIssues)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(deferCmd)
|
||||
}
|
||||
+3
-1
@@ -490,6 +490,8 @@ func getStatusEmoji(status types.Status) string {
|
||||
return "◧" // U+25E7 Square Left Half Black
|
||||
case types.StatusBlocked:
|
||||
return "⚠" // U+26A0 Warning Sign
|
||||
case types.StatusDeferred:
|
||||
return "❄" // U+2744 Snowflake (on ice)
|
||||
case types.StatusClosed:
|
||||
return "☑" // U+2611 Ballot Box with Check
|
||||
default:
|
||||
@@ -736,7 +738,7 @@ func init() {
|
||||
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
|
||||
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)")
|
||||
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
|
||||
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)")
|
||||
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)")
|
||||
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
|
||||
|
||||
+48
-38
@@ -5,11 +5,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
// CheckCLIVersion checks if the CLI version is up to date.
|
||||
@@ -53,62 +52,73 @@ func CheckCLIVersion(cliVersion string) DoctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// CheckMetadataVersionTracking checks if metadata.json has proper version tracking.
|
||||
// localVersionFile is the gitignored file that stores the last bd version used locally.
|
||||
// Must match the constant in version_tracking.go.
|
||||
const localVersionFile = ".local_version"
|
||||
|
||||
// CheckMetadataVersionTracking checks if version tracking is properly configured.
|
||||
// Version tracking uses .local_version file (gitignored) to track the last bd version used.
|
||||
//
|
||||
// GH#662: This was updated to check .local_version instead of metadata.json:LastBdVersion,
|
||||
// which is now deprecated.
|
||||
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
localVersionPath := filepath.Join(beadsDir, localVersionFile)
|
||||
|
||||
// Load metadata.json
|
||||
cfg, err := configfile.Load(beadsDir)
|
||||
// Read .local_version file
|
||||
// #nosec G304 - path is constructed from controlled beadsDir + constant
|
||||
data, err := os.ReadFile(localVersionPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// File doesn't exist yet - will be created on next bd command
|
||||
return DoctorCheck{
|
||||
Name: "Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: "Version tracking not initialized",
|
||||
Detail: "The .local_version file will be created on next bd command",
|
||||
Fix: "Run any bd command (e.g., 'bd ready') to initialize version tracking",
|
||||
}
|
||||
}
|
||||
// Other error reading file
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusError,
|
||||
Message: "Unable to read metadata.json",
|
||||
Message: "Unable to read .local_version file",
|
||||
Detail: err.Error(),
|
||||
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
|
||||
Fix: "Check file permissions on .beads/.local_version",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if metadata.json exists
|
||||
if cfg == nil {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: "metadata.json not found",
|
||||
Fix: "Run any bd command to create metadata.json with version tracking",
|
||||
}
|
||||
}
|
||||
lastVersion := strings.TrimSpace(string(data))
|
||||
|
||||
// Check if LastBdVersion field is present
|
||||
if cfg.LastBdVersion == "" {
|
||||
// Check if file is empty
|
||||
if lastVersion == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: "LastBdVersion field is empty (first run)",
|
||||
Message: ".local_version file is empty",
|
||||
Detail: "Version tracking will be initialized on next command",
|
||||
Fix: "Run any bd command to initialize version tracking",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that LastBdVersion is a valid semver-like string
|
||||
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
|
||||
if !IsValidSemver(cfg.LastBdVersion) {
|
||||
// Validate that version is a valid semver-like string
|
||||
if !IsValidSemver(lastVersion) {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
|
||||
Message: fmt.Sprintf("Invalid version format in .local_version: %q", lastVersion),
|
||||
Detail: "Expected semver format like '0.24.2'",
|
||||
Fix: "Run any bd command to reset version tracking to current version",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LastBdVersion is very old (> 10 versions behind)
|
||||
// Calculate version distance
|
||||
versionDiff := CompareVersions(currentVersion, cfg.LastBdVersion)
|
||||
// Check if version is very old (> 10 versions behind)
|
||||
versionDiff := CompareVersions(currentVersion, lastVersion)
|
||||
if versionDiff > 0 {
|
||||
// Current version is newer - check how far behind
|
||||
currentParts := ParseVersionParts(currentVersion)
|
||||
lastParts := ParseVersionParts(cfg.LastBdVersion)
|
||||
lastParts := ParseVersionParts(lastVersion)
|
||||
|
||||
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
|
||||
majorDiff := currentParts[0] - lastParts[0]
|
||||
@@ -116,27 +126,27 @@ func CheckMetadataVersionTracking(path string, currentVersion string) DoctorChec
|
||||
|
||||
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, currentVersion),
|
||||
Message: fmt.Sprintf("Last recorded version is very old: %s (current: %s)", lastVersion, currentVersion),
|
||||
Detail: "You may have missed important upgrade notifications",
|
||||
Fix: "Run 'bd upgrade review' to see recent changes",
|
||||
}
|
||||
}
|
||||
|
||||
// Version is behind but not too old
|
||||
// Version is behind but not too old - this is normal after upgrade
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, currentVersion),
|
||||
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", lastVersion, currentVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// Version is current or ahead (shouldn't happen, but handle it)
|
||||
// Version is current or ahead
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Name: "Version Tracking",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
|
||||
Message: fmt.Sprintf("Version tracking active (version: %s)", lastVersion),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-50
@@ -877,89 +877,57 @@ func TestGetClaudePluginVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
// GH#662: Tests updated to use .local_version file instead of metadata.json:LastBdVersion
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMetadata func(beadsDir string) error
|
||||
setupVersion func(beadsDir string) error
|
||||
expectedStatus string
|
||||
expectWarning bool
|
||||
}{
|
||||
{
|
||||
name: "valid current version",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
cfg := map[string]string{
|
||||
"database": "beads.db",
|
||||
"last_bd_version": Version,
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
setupVersion: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(Version+"\n"), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "slightly outdated version",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
cfg := map[string]string{
|
||||
"database": "beads.db",
|
||||
"last_bd_version": "0.24.0",
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
setupVersion: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.24.0\n"), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "very old version",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
cfg := map[string]string{
|
||||
"database": "beads.db",
|
||||
"last_bd_version": "0.14.0",
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
setupVersion: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.14.0\n"), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "empty version field",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
cfg := map[string]string{
|
||||
"database": "beads.db",
|
||||
"last_bd_version": "",
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
name: "empty version file",
|
||||
setupVersion: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(""), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "invalid version format",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
cfg := map[string]string{
|
||||
"database": "beads.db",
|
||||
"last_bd_version": "invalid-version",
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
setupVersion: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("invalid-version\n"), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "corrupted metadata.json",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte("{invalid json}"), 0644)
|
||||
},
|
||||
expectedStatus: doctor.StatusError,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "missing metadata.json",
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
// Don't create metadata.json
|
||||
name: "missing .local_version file",
|
||||
setupVersion: func(beadsDir string) error {
|
||||
// Don't create .local_version
|
||||
return nil
|
||||
},
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
@@ -975,8 +943,8 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Setup metadata.json
|
||||
if err := tc.setupMetadata(beadsDir); err != nil {
|
||||
// Setup .local_version file
|
||||
if err := tc.setupVersion(beadsDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,9 @@ func renderNodeBox(node *GraphNode, width int) string {
|
||||
case types.StatusBlocked:
|
||||
statusIcon = "●"
|
||||
titleStr = ui.RenderFail(padRight(title, width-4))
|
||||
case types.StatusDeferred:
|
||||
statusIcon = "❄"
|
||||
titleStr = ui.RenderAccent(padRight(title, width-4))
|
||||
case types.StatusClosed:
|
||||
statusIcon = "✓"
|
||||
titleStr = ui.RenderPass(padRight(title, width-4))
|
||||
@@ -461,6 +464,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
|
||||
case types.StatusBlocked:
|
||||
statusIcon = "●"
|
||||
titleStr = ui.RenderFail(padRight(title, width-4))
|
||||
case types.StatusDeferred:
|
||||
statusIcon = "❄"
|
||||
titleStr = ui.RenderAccent(padRight(title, width-4))
|
||||
case types.StatusClosed:
|
||||
statusIcon = "✓"
|
||||
titleStr = ui.RenderPass(padRight(title, width-4))
|
||||
|
||||
+6
-6
@@ -409,7 +409,7 @@ func uninstallHooks() error {
|
||||
// runPreCommitHook flushes pending changes to JSONL before commit.
|
||||
// Returns 0 on success (or if not applicable), non-zero on error.
|
||||
//
|
||||
//nolint:unparam // Always returns 0 by design - warns but doesn't block commits
|
||||
//nolint:unparam // Always returns 0 by design - warnings don't block commits
|
||||
func runPreCommitHook() int {
|
||||
// Check if we're in a bd workspace
|
||||
if _, err := os.Stat(".beads"); os.IsNotExist(err) {
|
||||
@@ -433,7 +433,7 @@ func runPreCommitHook() int {
|
||||
// Stage all tracked JSONL files
|
||||
for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
// #nosec G204 -- f is a fixed string from the hardcoded slice above
|
||||
// #nosec G204 - f is from hardcoded list above, not user input
|
||||
gitAdd := exec.Command("git", "add", f)
|
||||
_ = gitAdd.Run() // Ignore errors - file may not exist
|
||||
}
|
||||
@@ -445,7 +445,7 @@ func runPreCommitHook() int {
|
||||
// runPostMergeHook imports JSONL after pull/merge.
|
||||
// Returns 0 on success (or if not applicable), non-zero on error.
|
||||
//
|
||||
//nolint:unparam // Always returns 0 by design - warns but doesn't block merges
|
||||
//nolint:unparam // Always returns 0 by design - warnings don't block merges
|
||||
func runPostMergeHook() int {
|
||||
// Skip during rebase
|
||||
if isRebaseInProgress() {
|
||||
@@ -510,7 +510,7 @@ func runPrePushHook() int {
|
||||
files = append(files, f)
|
||||
} else {
|
||||
// Check if tracked by git
|
||||
// #nosec G204 -- f is a fixed string from the hardcoded slice above
|
||||
// #nosec G204 - f is from hardcoded list above, not user input
|
||||
checkCmd := exec.Command("git", "ls-files", "--error-unmatch", f)
|
||||
if checkCmd.Run() == nil {
|
||||
files = append(files, f)
|
||||
@@ -524,7 +524,7 @@ func runPrePushHook() int {
|
||||
|
||||
// Check for uncommitted changes using git status
|
||||
args := append([]string{"status", "--porcelain", "--"}, files...)
|
||||
// #nosec G204 -- args contains only fixed strings from hardcoded slice
|
||||
// #nosec G204 - args built from hardcoded list and git subcommands
|
||||
statusCmd := exec.Command("git", args...)
|
||||
output, _ := statusCmd.Output()
|
||||
if len(output) > 0 {
|
||||
@@ -548,7 +548,7 @@ func runPrePushHook() int {
|
||||
// args: [previous-HEAD, new-HEAD, flag] where flag=1 for branch checkout
|
||||
// Returns 0 on success (or if not applicable), non-zero on error.
|
||||
//
|
||||
//nolint:unparam // Always returns 0 by design - warns but doesn't block checkouts
|
||||
//nolint:unparam // Always returns 0 by design - warnings don't block checkouts
|
||||
func runPostCheckoutHook(args []string) int {
|
||||
// Only run on branch checkouts (flag=1)
|
||||
if len(args) >= 3 && args[2] != "1" {
|
||||
|
||||
@@ -288,6 +288,24 @@ type VersionChange struct {
|
||||
|
||||
// versionChanges contains agent-actionable changes for recent versions
|
||||
var versionChanges = []VersionChange{
|
||||
{
|
||||
Version: "0.31.0",
|
||||
Date: "2025-12-20",
|
||||
Changes: []string{
|
||||
"NEW: bd defer/bd undefer commands - Deferred status for icebox issues (bd-4jr)",
|
||||
"NEW: Agent audit trail - .beads/interactions.jsonl with bd audit record/label (GH#649)",
|
||||
"NEW: Directory-aware label scoping for monorepos (GH#541) - Auto-filter by directory.labels config",
|
||||
"NEW: Molecules catalog - Templates in separate molecules.jsonl with hierarchical loading",
|
||||
"NEW: Git commit config - git.author and git.no-gpg-sign options (GH#600)",
|
||||
"NEW: create.require-description config option (GH#596)",
|
||||
"CHANGED: bd stats merged into bd status (GH#644) - stats is now alias, colorized output",
|
||||
"CHANGED: Thin hook shims (GH#615) - Hooks delegate to bd hooks run, no more version drift",
|
||||
"CHANGED: MCP context tool consolidation - set_context/where_am_i/init merged into single context tool",
|
||||
"FIX: relates-to excluded from cycle detection (GH#661)",
|
||||
"FIX: Doctor checks .local_version instead of deprecated LastBdVersion (GH#662)",
|
||||
"FIX: Read-only gitignore in stealth mode prints manual instructions (GH#663)",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.30.7",
|
||||
Date: "2025-12-19",
|
||||
|
||||
+13
-1
@@ -1489,7 +1489,19 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro
|
||||
// Write the updated ignore file
|
||||
// #nosec G306 - config file needs 0644
|
||||
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write global gitignore: %w", err)
|
||||
fmt.Printf("\nUnable to write to %s (file is read-only)\n\n", ignorePath)
|
||||
fmt.Printf("To enable stealth mode, add these lines to your global gitignore:\n\n")
|
||||
if !hasBeads || !hasClaude {
|
||||
fmt.Printf("# Beads stealth mode: %s\n", projectPath)
|
||||
}
|
||||
if !hasBeads {
|
||||
fmt.Printf("%s\n", beadsPattern)
|
||||
}
|
||||
if !hasClaude {
|
||||
fmt.Printf("%s\n", claudePattern)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
if verbose {
|
||||
|
||||
@@ -1047,3 +1047,92 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
|
||||
t.Error("File should contain bd onboard prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupGlobalGitIgnore_ReadOnly verifies graceful handling when the
|
||||
// gitignore file cannot be written (prints manual instructions instead of failing).
|
||||
func TestSetupGlobalGitIgnore_ReadOnly(t *testing.T) {
|
||||
t.Run("read-only file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "git")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ignorePath := filepath.Join(configDir, "ignore")
|
||||
if err := os.WriteFile(ignorePath, []byte("# existing\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(ignorePath, 0444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(ignorePath, 0644)
|
||||
|
||||
output := captureStdout(t, func() error {
|
||||
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "Unable to write") {
|
||||
t.Error("expected instructions for manual addition")
|
||||
}
|
||||
if !strings.Contains(output, "/test/project/.beads/") {
|
||||
t.Error("expected .beads pattern in output")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink to read-only file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Target file in a separate location
|
||||
targetDir := filepath.Join(tmpDir, "target")
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetFile := filepath.Join(targetDir, "ignore")
|
||||
if err := os.WriteFile(targetFile, []byte("# existing\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(targetFile, 0444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(targetFile, 0644)
|
||||
|
||||
// Symlink from expected location
|
||||
configDir := filepath.Join(tmpDir, ".config", "git")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(targetFile, filepath.Join(configDir, "ignore")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() error {
|
||||
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "Unable to write") {
|
||||
t.Error("expected instructions for manual addition")
|
||||
}
|
||||
if !strings.Contains(output, "/test/project/.beads/") {
|
||||
t.Error("expected .beads pattern in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func() error) string {
|
||||
t.Helper()
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := fn()
|
||||
|
||||
w.Close()
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
+1
-1
@@ -621,7 +621,7 @@ var listCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
|
||||
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
registerPriorityFlag(listCmd, "")
|
||||
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")
|
||||
|
||||
+1
-85
@@ -251,90 +251,7 @@ var blockedCmd = &cobra.Command{
|
||||
}
|
||||
},
|
||||
}
|
||||
var statsCmd = &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show statistics",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Stats()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var stats types.Statistics
|
||||
if err := json.Unmarshal(resp.Data, &stats); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if jsonOutput {
|
||||
outputJSON(stats)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
|
||||
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
|
||||
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
|
||||
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
|
||||
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
|
||||
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
|
||||
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
|
||||
if stats.TombstoneIssues > 0 {
|
||||
fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues)
|
||||
}
|
||||
if stats.PinnedIssues > 0 {
|
||||
fmt.Printf("Pinned: %d\n", stats.PinnedIssues)
|
||||
}
|
||||
if stats.AverageLeadTime > 0 {
|
||||
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
|
||||
}
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
// Direct mode
|
||||
ctx := rootCtx
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// If no issues found, check if git has issues and auto-import
|
||||
if stats.TotalIssues == 0 {
|
||||
if checkAndAutoImport(ctx, store) {
|
||||
// Re-run the stats after import
|
||||
stats, err = store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonOutput {
|
||||
outputJSON(stats)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
|
||||
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
|
||||
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
|
||||
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
|
||||
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
|
||||
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
|
||||
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
|
||||
if stats.TombstoneIssues > 0 {
|
||||
fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues)
|
||||
}
|
||||
if stats.PinnedIssues > 0 {
|
||||
fmt.Printf("Pinned: %d\n", stats.PinnedIssues)
|
||||
}
|
||||
if stats.EpicsEligibleForClosure > 0 {
|
||||
fmt.Printf("Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
|
||||
}
|
||||
if stats.AverageLeadTime > 0 {
|
||||
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
|
||||
}
|
||||
fmt.Println()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
|
||||
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
|
||||
@@ -346,5 +263,4 @@ func init() {
|
||||
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
|
||||
rootCmd.AddCommand(readyCmd)
|
||||
rootCmd.AddCommand(blockedCmd)
|
||||
rootCmd.AddCommand(statsCmd)
|
||||
}
|
||||
|
||||
+1
-1
@@ -371,7 +371,7 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) {
|
||||
|
||||
func init() {
|
||||
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
|
||||
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
|
||||
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
|
||||
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
|
||||
|
||||
+3
-3
@@ -26,8 +26,8 @@ This helps identify:
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
// Use global jsonOutput set by PersistentPreRun
|
||||
// Validate status if provided
|
||||
if status != "" && status != "open" && status != "in_progress" && status != "blocked" {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status)
|
||||
if status != "" && status != "open" && status != "in_progress" && status != "blocked" && status != "deferred" {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked, deferred\n", status)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter := types.StaleFilter{
|
||||
@@ -108,7 +108,7 @@ func displayStaleIssues(issues []*types.Issue, days int) {
|
||||
}
|
||||
func init() {
|
||||
staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days")
|
||||
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)")
|
||||
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked|deferred)")
|
||||
staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show")
|
||||
staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
|
||||
rootCmd.AddCommand(staleCmd)
|
||||
|
||||
+69
-58
@@ -11,24 +11,15 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// StatusOutput represents the complete status output
|
||||
type StatusOutput struct {
|
||||
Summary *StatusSummary `json:"summary"`
|
||||
Summary *types.Statistics `json:"summary"`
|
||||
RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"`
|
||||
}
|
||||
|
||||
// StatusSummary represents counts by state
|
||||
type StatusSummary struct {
|
||||
TotalIssues int `json:"total_issues"`
|
||||
OpenIssues int `json:"open_issues"`
|
||||
InProgressIssues int `json:"in_progress_issues"`
|
||||
BlockedIssues int `json:"blocked_issues"`
|
||||
ClosedIssues int `json:"closed_issues"`
|
||||
ReadyIssues int `json:"ready_issues"`
|
||||
}
|
||||
|
||||
// RecentActivitySummary represents activity from git history
|
||||
type RecentActivitySummary struct {
|
||||
HoursTracked int `json:"hours_tracked"`
|
||||
@@ -43,11 +34,13 @@ type RecentActivitySummary struct {
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
GroupID: "views",
|
||||
Short: "Show issue database overview",
|
||||
Long: `Show a quick snapshot of the issue database state.
|
||||
Aliases: []string{"stats"},
|
||||
Short: "Show issue database overview and statistics",
|
||||
Long: `Show a quick snapshot of the issue database state and statistics.
|
||||
|
||||
This command provides a summary of issue counts by state (open, in_progress,
|
||||
blocked, closed), ready work, and recent activity over the last 24 hours from git history.
|
||||
blocked, closed), ready work, extended statistics (tombstones, pinned issues,
|
||||
average lead time), and recent activity over the last 24 hours from git history.
|
||||
|
||||
Similar to how 'git status' shows working tree state, 'bd status' gives you
|
||||
a quick overview of your issue database without needing multiple queries.
|
||||
@@ -59,13 +52,15 @@ Use cases:
|
||||
- Daily standup reference
|
||||
|
||||
Examples:
|
||||
bd status # Show summary
|
||||
bd status # Show summary with activity
|
||||
bd status --no-activity # Skip git activity (faster)
|
||||
bd status --json # JSON format output
|
||||
bd status --assigned # Show issues assigned to current user
|
||||
bd status --all # Show all issues (same as default)`,
|
||||
bd stats # Alias for bd status`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
showAll, _ := cmd.Flags().GetBool("all")
|
||||
showAssigned, _ := cmd.Flags().GetBool("assigned")
|
||||
noActivity, _ := cmd.Flags().GetBool("no-activity")
|
||||
jsonFormat, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
// Override global jsonOutput if --json flag is set
|
||||
@@ -109,28 +104,23 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary
|
||||
summary := &StatusSummary{
|
||||
TotalIssues: stats.TotalIssues,
|
||||
OpenIssues: stats.OpenIssues,
|
||||
InProgressIssues: stats.InProgressIssues,
|
||||
BlockedIssues: stats.BlockedIssues,
|
||||
ClosedIssues: stats.ClosedIssues,
|
||||
ReadyIssues: stats.ReadyIssues,
|
||||
// Filter by assignee if requested (overrides stats with filtered counts)
|
||||
if showAssigned {
|
||||
stats = getAssignedStatistics(actor)
|
||||
if stats == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent activity from git history (last 24 hours)
|
||||
// Get recent activity from git history (last 24 hours) unless --no-activity
|
||||
var recentActivity *RecentActivitySummary
|
||||
recentActivity = getGitActivity(24)
|
||||
|
||||
// Filter by assignee if requested
|
||||
if showAssigned {
|
||||
// Get filtered statistics for assigned issues
|
||||
summary = getAssignedStatus(actor)
|
||||
if !noActivity {
|
||||
recentActivity = getGitActivity(24)
|
||||
}
|
||||
|
||||
output := &StatusOutput{
|
||||
Summary: summary,
|
||||
Summary: stats,
|
||||
RecentActivity: recentActivity,
|
||||
}
|
||||
|
||||
@@ -140,25 +130,43 @@ Examples:
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Println("\nIssue Database Status")
|
||||
fmt.Println("=====================")
|
||||
fmt.Printf("\nSummary:\n")
|
||||
fmt.Printf(" Total Issues: %d\n", summary.TotalIssues)
|
||||
fmt.Printf(" Open: %d\n", summary.OpenIssues)
|
||||
fmt.Printf(" In Progress: %d\n", summary.InProgressIssues)
|
||||
fmt.Printf(" Blocked: %d\n", summary.BlockedIssues)
|
||||
fmt.Printf(" Closed: %d\n", summary.ClosedIssues)
|
||||
fmt.Printf(" Ready to Work: %d\n", summary.ReadyIssues)
|
||||
// Human-readable colorized output using semantic ui package
|
||||
fmt.Printf("\n%s Issue Database Status\n\n", ui.RenderAccent("📊"))
|
||||
fmt.Printf("Summary:\n")
|
||||
fmt.Printf(" Total Issues: %d\n", stats.TotalIssues)
|
||||
fmt.Printf(" Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
|
||||
fmt.Printf(" In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
|
||||
fmt.Printf(" Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
|
||||
fmt.Printf(" Closed: %d\n", stats.ClosedIssues)
|
||||
fmt.Printf(" Ready to Work: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
|
||||
|
||||
// Extended statistics (only show if non-zero)
|
||||
hasExtended := stats.TombstoneIssues > 0 || stats.PinnedIssues > 0 ||
|
||||
stats.EpicsEligibleForClosure > 0 || stats.AverageLeadTime > 0
|
||||
if hasExtended {
|
||||
fmt.Printf("\nExtended:\n")
|
||||
if stats.TombstoneIssues > 0 {
|
||||
fmt.Printf(" Deleted: %d (tombstones)\n", stats.TombstoneIssues)
|
||||
}
|
||||
if stats.PinnedIssues > 0 {
|
||||
fmt.Printf(" Pinned: %d\n", stats.PinnedIssues)
|
||||
}
|
||||
if stats.EpicsEligibleForClosure > 0 {
|
||||
fmt.Printf(" Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
|
||||
}
|
||||
if stats.AverageLeadTime > 0 {
|
||||
fmt.Printf(" Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
|
||||
}
|
||||
}
|
||||
|
||||
if recentActivity != nil {
|
||||
fmt.Printf("\nRecent Activity (last %d hours, from git history):\n", recentActivity.HoursTracked)
|
||||
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
|
||||
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
|
||||
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
|
||||
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
|
||||
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
|
||||
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
|
||||
fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked)
|
||||
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
|
||||
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
|
||||
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
|
||||
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
|
||||
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
|
||||
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
|
||||
}
|
||||
|
||||
// Show hint for more details
|
||||
@@ -268,8 +276,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
|
||||
return activity
|
||||
}
|
||||
|
||||
// getAssignedStatus returns status summary for issues assigned to a specific user
|
||||
func getAssignedStatus(assignee string) *StatusSummary {
|
||||
// getAssignedStatistics returns statistics for issues assigned to a specific user
|
||||
func getAssignedStatistics(assignee string) *types.Statistics {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -287,7 +295,7 @@ func getAssignedStatus(assignee string) *StatusSummary {
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := &StatusSummary{
|
||||
stats := &types.Statistics{
|
||||
TotalIssues: len(issues),
|
||||
}
|
||||
|
||||
@@ -295,13 +303,15 @@ func getAssignedStatus(assignee string) *StatusSummary {
|
||||
for _, issue := range issues {
|
||||
switch issue.Status {
|
||||
case types.StatusOpen:
|
||||
summary.OpenIssues++
|
||||
stats.OpenIssues++
|
||||
case types.StatusInProgress:
|
||||
summary.InProgressIssues++
|
||||
stats.InProgressIssues++
|
||||
case types.StatusBlocked:
|
||||
summary.BlockedIssues++
|
||||
stats.BlockedIssues++
|
||||
case types.StatusDeferred:
|
||||
stats.DeferredIssues++
|
||||
case types.StatusClosed:
|
||||
summary.ClosedIssues++
|
||||
stats.ClosedIssues++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,15 +321,16 @@ func getAssignedStatus(assignee string) *StatusSummary {
|
||||
}
|
||||
readyIssues, err := store.GetReadyWork(ctx, readyFilter)
|
||||
if err == nil {
|
||||
summary.ReadyIssues = len(readyIssues)
|
||||
stats.ReadyIssues = len(readyIssues)
|
||||
}
|
||||
|
||||
return summary
|
||||
return stats
|
||||
}
|
||||
|
||||
func init() {
|
||||
statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)")
|
||||
statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user")
|
||||
statusCmd.Flags().Bool("no-activity", false, "Skip git activity tracking (faster)")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
+19
-29
@@ -112,19 +112,9 @@ func TestStatusCommand(t *testing.T) {
|
||||
t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues)
|
||||
}
|
||||
|
||||
// Test status output structures
|
||||
summary := &StatusSummary{
|
||||
TotalIssues: stats.TotalIssues,
|
||||
OpenIssues: stats.OpenIssues,
|
||||
InProgressIssues: stats.InProgressIssues,
|
||||
BlockedIssues: stats.BlockedIssues,
|
||||
ClosedIssues: stats.ClosedIssues,
|
||||
ReadyIssues: stats.ReadyIssues,
|
||||
}
|
||||
|
||||
// Test JSON marshaling
|
||||
// Test JSON marshaling with full Statistics
|
||||
output := &StatusOutput{
|
||||
Summary: summary,
|
||||
Summary: stats,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
@@ -178,7 +168,7 @@ func TestGetGitActivity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAssignedStatus(t *testing.T) {
|
||||
func TestGetAssignedStatistics(t *testing.T) {
|
||||
// Create a temporary directory for the test database
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, ".beads", "test.db")
|
||||
@@ -202,7 +192,7 @@ func TestGetAssignedStatus(t *testing.T) {
|
||||
t.Fatalf("Failed to set issue prefix: %v", err)
|
||||
}
|
||||
|
||||
// Set global store and rootCtx for getAssignedStatus
|
||||
// Set global store and rootCtx for getAssignedStatistics
|
||||
oldRootCtx := rootCtx
|
||||
rootCtx = ctx
|
||||
defer func() { rootCtx = oldRootCtx }()
|
||||
@@ -239,29 +229,29 @@ func TestGetAssignedStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test getAssignedStatus for Alice
|
||||
summary := getAssignedStatus("alice")
|
||||
if summary == nil {
|
||||
t.Fatal("getAssignedStatus returned nil")
|
||||
// Test getAssignedStatistics for Alice
|
||||
stats := getAssignedStatistics("alice")
|
||||
if stats == nil {
|
||||
t.Fatal("getAssignedStatistics returned nil")
|
||||
}
|
||||
|
||||
if summary.TotalIssues != 2 {
|
||||
t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues)
|
||||
if stats.TotalIssues != 2 {
|
||||
t.Errorf("Expected 2 issues for alice, got %d", stats.TotalIssues)
|
||||
}
|
||||
if summary.OpenIssues != 1 {
|
||||
t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues)
|
||||
if stats.OpenIssues != 1 {
|
||||
t.Errorf("Expected 1 open issue for alice, got %d", stats.OpenIssues)
|
||||
}
|
||||
if summary.InProgressIssues != 1 {
|
||||
t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues)
|
||||
if stats.InProgressIssues != 1 {
|
||||
t.Errorf("Expected 1 in-progress issue for alice, got %d", stats.InProgressIssues)
|
||||
}
|
||||
|
||||
// Test for Bob
|
||||
bobSummary := getAssignedStatus("bob")
|
||||
if bobSummary == nil {
|
||||
t.Fatal("getAssignedStatus returned nil for bob")
|
||||
bobStats := getAssignedStatistics("bob")
|
||||
if bobStats == nil {
|
||||
t.Fatal("getAssignedStatistics returned nil for bob")
|
||||
}
|
||||
|
||||
if bobSummary.TotalIssues != 1 {
|
||||
t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues)
|
||||
if bobStats.TotalIssues != 1 {
|
||||
t.Errorf("Expected 1 issue for bob, got %d", bobStats.TotalIssues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# bd-shim v1
|
||||
# bd-hooks-version: 0.31.0
|
||||
#
|
||||
# bd (beads) post-checkout hook - thin shim
|
||||
#
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# bd-shim v1
|
||||
# bd-hooks-version: 0.31.0
|
||||
#
|
||||
# bd (beads) post-merge hook - thin shim
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# bd-shim v1
|
||||
# bd-hooks-version: 0.30.7
|
||||
# bd-hooks-version: 0.31.0
|
||||
#
|
||||
# bd (beads) pre-commit hook - thin shim
|
||||
#
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# bd-shim v1
|
||||
# bd-hooks-version: 0.31.0
|
||||
#
|
||||
# bd (beads) pre-push hook - thin shim
|
||||
#
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var undeferCmd = &cobra.Command{
|
||||
Use: "undefer [id...]",
|
||||
Short: "Undefer one or more issues (restore to open)",
|
||||
Long: `Undefer issues to restore them to open status.
|
||||
|
||||
This brings issues back from the icebox so they can be worked on again.
|
||||
Issues will appear in 'bd ready' if they have no blockers.
|
||||
|
||||
Examples:
|
||||
bd undefer bd-abc # Undefer a single issue
|
||||
bd undefer bd-abc bd-def # Undefer multiple issues`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("undefer")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs first
|
||||
var resolvedIDs []string
|
||||
if daemonClient != nil {
|
||||
for _, id := range args {
|
||||
resolveArgs := &rpc.ResolveIDArgs{ID: id}
|
||||
resp, err := daemonClient.ResolveID(resolveArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var resolvedID string
|
||||
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resolvedIDs = append(resolvedIDs, resolvedID)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
undeferredIssues := []*types.Issue{}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
for _, id := range resolvedIDs {
|
||||
status := string(types.StatusOpen)
|
||||
updateArgs := &rpc.UpdateArgs{
|
||||
ID: id,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
resp, err := daemonClient.Update(updateArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
undeferredIssues = append(undeferredIssues, &issue)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), id)
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput && len(undeferredIssues) > 0 {
|
||||
outputJSON(undeferredIssues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to direct storage access
|
||||
if store == nil {
|
||||
fmt.Fprintln(os.Stderr, "Error: database not initialized")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, id := range args {
|
||||
fullID, err := utils.ResolvePartialID(ctx, store, id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"status": string(types.StatusOpen),
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", fullID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
issue, _ := store.GetIssue(ctx, fullID)
|
||||
if issue != nil {
|
||||
undeferredIssues = append(undeferredIssues, issue)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), fullID)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule auto-flush if any issues were undeferred
|
||||
if len(args) > 0 {
|
||||
markDirtyAndScheduleFlush()
|
||||
}
|
||||
|
||||
if jsonOutput && len(undeferredIssues) > 0 {
|
||||
outputJSON(undeferredIssues)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(undeferCmd)
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
var (
|
||||
// Version is the current version of bd (overridden by ldflags at build time)
|
||||
Version = "0.30.7"
|
||||
Version = "0.31.0"
|
||||
// Build can be set via ldflags at compile time
|
||||
Build = "dev"
|
||||
// Commit and branch the git revision the binary was built from (optional ldflag)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "beads-mcp"
|
||||
version = "0.30.7"
|
||||
version = "0.31.0"
|
||||
description = "MCP server for beads issue tracker."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -4,4 +4,4 @@ This package provides an MCP (Model Context Protocol) server that exposes
|
||||
beads (bd) issue tracker functionality to MCP Clients.
|
||||
"""
|
||||
|
||||
__version__ = "0.30.7"
|
||||
__version__ = "0.31.0"
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Literal, Any
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# Type aliases for issue statuses, types, and dependencies
|
||||
IssueStatus = Literal["open", "in_progress", "blocked", "closed"]
|
||||
IssueStatus = Literal["open", "in_progress", "blocked", "deferred", "closed"]
|
||||
IssueType = Literal["bug", "feature", "task", "epic", "chore"]
|
||||
DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"]
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
|
||||
"name": "list",
|
||||
"description": "List all issues with optional filters",
|
||||
"parameters": {
|
||||
"status": "open|in_progress|blocked|closed (optional)",
|
||||
"status": "open|in_progress|blocked|deferred|closed (optional)",
|
||||
"priority": "int 0-4 (optional)",
|
||||
"issue_type": "bug|feature|task|epic|chore (optional)",
|
||||
"assignee": "str (optional)",
|
||||
@@ -413,7 +413,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
|
||||
"description": "Update an existing issue",
|
||||
"parameters": {
|
||||
"issue_id": "str (required)",
|
||||
"status": "open|in_progress|blocked|closed (optional)",
|
||||
"status": "open|in_progress|blocked|deferred|closed (optional)",
|
||||
"priority": "int 0-4 (optional)",
|
||||
"assignee": "str (optional)",
|
||||
"title": "str (optional)",
|
||||
|
||||
@@ -317,7 +317,7 @@ async def beads_ready_work(
|
||||
|
||||
|
||||
async def beads_list_issues(
|
||||
status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, closed)"] = None,
|
||||
status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, deferred, closed)"] = None,
|
||||
priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None,
|
||||
issue_type: Annotated[IssueType | None, "Filter by type (bug, feature, task, epic, chore)"] = None,
|
||||
assignee: Annotated[str | None, "Filter by assignee"] = None,
|
||||
@@ -392,7 +392,7 @@ async def beads_create_issue(
|
||||
|
||||
async def beads_update_issue(
|
||||
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"],
|
||||
status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, closed)"] = None,
|
||||
status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, deferred, closed)"] = None,
|
||||
priority: Annotated[int | None, "New priority (0-4)"] = None,
|
||||
assignee: Annotated[str | None, "New assignee"] = None,
|
||||
title: Annotated[str | None, "New title"] = None,
|
||||
|
||||
@@ -205,8 +205,9 @@ type (
|
||||
const (
|
||||
StatusOpen = types.StatusOpen
|
||||
StatusInProgress = types.StatusInProgress
|
||||
StatusClosed = types.StatusClosed
|
||||
StatusBlocked = types.StatusBlocked
|
||||
StatusDeferred = types.StatusDeferred
|
||||
StatusClosed = types.StatusClosed
|
||||
)
|
||||
|
||||
// IssueType constants
|
||||
|
||||
@@ -1033,7 +1033,7 @@ func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked.
|
||||
// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred.
|
||||
// The caller must hold at least a read lock.
|
||||
func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
|
||||
deps := m.dependencies[issueID]
|
||||
@@ -1053,7 +1053,7 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
|
||||
continue
|
||||
}
|
||||
switch blocker.Status {
|
||||
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked:
|
||||
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred:
|
||||
blockers = append(blockers, blocker.ID)
|
||||
}
|
||||
}
|
||||
@@ -1082,7 +1082,8 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
}
|
||||
|
||||
blockers := m.getOpenBlockers(issue.ID)
|
||||
if issue.Status != types.StatusBlocked && len(blockers) == 0 {
|
||||
// Issue is "blocked" if: status is blocked, status is deferred, or has open blockers
|
||||
if issue.Status != types.StatusBlocked && issue.Status != types.StatusDeferred && len(blockers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1219,13 +1220,17 @@ func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
||||
stats.InProgressIssues++
|
||||
case types.StatusClosed:
|
||||
stats.ClosedIssues++
|
||||
case types.StatusDeferred:
|
||||
stats.DeferredIssues++
|
||||
case types.StatusTombstone:
|
||||
stats.TombstoneIssues++
|
||||
case types.StatusPinned:
|
||||
stats.PinnedIssues++
|
||||
}
|
||||
}
|
||||
|
||||
// TotalIssues excludes tombstones (matches SQLite behavior)
|
||||
stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues
|
||||
stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues + stats.DeferredIssues + stats.PinnedIssues
|
||||
|
||||
// Second pass: calculate blocked and ready issues based on dependencies
|
||||
// An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues)
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
|
||||
FROM dependencies d
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
),
|
||||
|
||||
-- Step 2: Propagate blockage to all descendants via parent-child
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa
|
||||
COUNT(DISTINCT dt.dependent_id) as dependent_count
|
||||
FROM issues i
|
||||
LEFT JOIN dependent_tree dt ON i.id = dt.issue_id
|
||||
AND dt.dependent_status IN ('open', 'in_progress', 'blocked')
|
||||
AND dt.dependent_status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND dt.depth <= ?
|
||||
WHERE i.status = 'closed'
|
||||
AND i.closed_at IS NOT NULL
|
||||
@@ -163,7 +163,7 @@ func (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCa
|
||||
JOIN issues dep ON d.issue_id = dep.id
|
||||
WHERE d.depends_on_id = i.id
|
||||
AND d.type = 'blocks'
|
||||
AND dep.status IN ('open', 'in_progress', 'blocked')
|
||||
AND dep.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
)
|
||||
ORDER BY i.closed_at ASC
|
||||
`
|
||||
|
||||
@@ -505,3 +505,141 @@ func TestDetectCyclesMixedTypes(t *testing.T) {
|
||||
t.Errorf("Expected cycle of length 3, got %d", len(cycle))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesRelatesToNotACycle tests that bidirectional relates-to links are NOT reported as cycles
|
||||
// This is the fix for GitHub issue #661: relates-to relationships should be excluded from cycle detection
|
||||
// because they are inherently bidirectional ("see also" links) and don't affect work ordering.
|
||||
func TestDetectCyclesRelatesToNotACycle(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
issue1 := &types.Issue{Title: "Todo 1", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Todo 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Create bidirectional relates_to links (simulating what bd relate does)
|
||||
// issue1 relates_to issue2
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue1.ID, issue2.ID, types.DepRelatesTo)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// issue2 relates_to issue1 (the reverse link)
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue2.ID, issue1.ID, types.DepRelatesTo)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles - should find NONE because relates_to is excluded
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles for relates_to bidirectional links, but found %d cycles", len(cycles))
|
||||
for i, cycle := range cycles {
|
||||
t.Logf("Cycle %d:", i+1)
|
||||
for _, issue := range cycle {
|
||||
t.Logf(" - %s: %s", issue.ID, issue.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesRelatesToWithOtherCycle tests that relates-to is excluded but other cycles are still detected
|
||||
func TestDetectCyclesRelatesToWithOtherCycle(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create three issues
|
||||
issue1 := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Issue B", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Issue C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue3, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Create bidirectional relates_to between issue1 and issue2 (should NOT trigger cycle)
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue1.ID, issue2.ID, types.DepRelatesTo)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
||||
}
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue2.ID, issue1.ID, types.DepRelatesTo)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Create a real cycle with blocks: issue2 -> issue3 -> issue2
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue2.ID, issue3.ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert blocks dependency failed: %v", err)
|
||||
}
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue3.ID, issue2.ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert blocks dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles - should find the blocks cycle but NOT the relates_to "cycle"
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to find the blocks cycle, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains issue2 and issue3, but NOT issue1
|
||||
foundIDs := make(map[string]bool)
|
||||
for _, cycle := range cycles {
|
||||
for _, issue := range cycle {
|
||||
foundIDs[issue.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
|
||||
t.Errorf("Expected cycle to contain issue2 and issue3. Found: %v", foundIDs)
|
||||
}
|
||||
|
||||
// Verify issue1 is NOT in the cycle (it's only connected via relates-to)
|
||||
if foundIDs[issue1.ID] {
|
||||
t.Errorf("issue1 should NOT be in cycle (only connected via relates-to), but was found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,9 +613,12 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
|
||||
}
|
||||
|
||||
// DetectCycles finds circular dependencies and returns the actual cycle paths
|
||||
// Note: relates-to dependencies are excluded because they are intentionally bidirectional
|
||||
// ("see also" relationships) and do not represent problematic cycles.
|
||||
func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
|
||||
// Use recursive CTE to find cycles with full paths
|
||||
// We track the path as a string to work around SQLite's lack of arrays
|
||||
// Exclude relates-to dependencies since they are inherently bidirectional
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
WITH RECURSIVE paths AS (
|
||||
SELECT
|
||||
@@ -625,6 +628,7 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
|
||||
issue_id || '→' || depends_on_id as path,
|
||||
0 as depth
|
||||
FROM dependencies
|
||||
WHERE type != 'relates-to'
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -636,7 +640,8 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
|
||||
p.depth + 1
|
||||
FROM dependencies d
|
||||
JOIN paths p ON d.issue_id = p.depends_on_id
|
||||
WHERE p.depth < ?
|
||||
WHERE d.type != 'relates-to'
|
||||
AND p.depth < ?
|
||||
AND (d.depends_on_id = p.start_id OR p.path NOT LIKE '%' || d.depends_on_id || '→%')
|
||||
)
|
||||
SELECT DISTINCT path as cycle_path
|
||||
|
||||
@@ -1527,9 +1527,10 @@ func TestDetectCycles_MultipleGraphs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection tests relates-to allows bidirectional links
|
||||
// even though they're technically cycles. The cycle prevention only skips relates-to during AddDependency.
|
||||
func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T) {
|
||||
// TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport tests relates-to allows bidirectional links
|
||||
// and DetectCycles correctly excludes them (they're "see also" links, not problematic cycles).
|
||||
// This was fixed in GH#661 - relates-to is explicitly excluded from cycle detection.
|
||||
func TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -1552,7 +1553,7 @@ func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T)
|
||||
t.Fatalf("AddDependency for relates-to failed: %v", err)
|
||||
}
|
||||
|
||||
// Add B relates-to A (this should succeed despite creating a cycle because relates-to skips cycle detection)
|
||||
// Add B relates-to A (this should succeed - relates-to skips cycle prevention)
|
||||
err = store.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: issueB.ID,
|
||||
DependsOnID: issueA.ID,
|
||||
@@ -1562,15 +1563,16 @@ func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T)
|
||||
t.Fatalf("AddDependency for reverse relates-to failed: %v", err)
|
||||
}
|
||||
|
||||
// DetectCycles will report the cycle even though AddDependency allowed it
|
||||
// DetectCycles should NOT report relates-to as cycles (GH#661 fix)
|
||||
// relates-to is inherently bidirectional ("see also") and doesn't affect work ordering
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
// Relates-to bidirectional creates cycles (may report multiple entry points for same cycle)
|
||||
if len(cycles) == 0 {
|
||||
t.Error("Expected at least 1 cycle detected for bidirectional relates-to")
|
||||
// relates-to bidirectional should NOT be reported as a cycle
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected 0 cycles for bidirectional relates-to (GH#661 fix), got %d", len(cycles))
|
||||
}
|
||||
|
||||
// Verify both directions exist
|
||||
|
||||
@@ -112,16 +112,18 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
||||
|
||||
// Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately)
|
||||
// (bd-6v2: also count pinned issues)
|
||||
// (bd-4jr: also count deferred issues)
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total,
|
||||
COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open,
|
||||
COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress,
|
||||
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed,
|
||||
COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred,
|
||||
COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
|
||||
COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned
|
||||
FROM issues
|
||||
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
|
||||
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get issue counts: %w", err)
|
||||
}
|
||||
@@ -132,9 +134,9 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked')
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
`).Scan(&stats.BlockedIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blocked count: %w", err)
|
||||
@@ -147,10 +149,10 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
||||
WHERE i.status = 'open'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dependencies d
|
||||
JOIN issues blocked ON d.depends_on_id = blocked.id
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.issue_id = i.id
|
||||
AND d.type = 'blocks'
|
||||
AND blocked.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
)
|
||||
`).Scan(&stats.ReadyIssues)
|
||||
if err != nil {
|
||||
|
||||
@@ -293,18 +293,19 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM issues blocker
|
||||
WHERE blocker.id = d.depends_on_id
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
)
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked')
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND i.pinned = 0
|
||||
AND (
|
||||
i.status = 'blocked'
|
||||
OR i.status = 'deferred'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM dependencies d2
|
||||
JOIN issues blocker ON d2.depends_on_id = blocker.id
|
||||
WHERE d2.issue_id = i.id
|
||||
AND d2.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
)
|
||||
)
|
||||
GROUP BY i.id
|
||||
|
||||
@@ -206,7 +206,7 @@ WITH RECURSIVE
|
||||
FROM dependencies d
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
),
|
||||
-- Propagate blockage to all descendants via parent-child
|
||||
blocked_transitively AS (
|
||||
@@ -236,8 +236,8 @@ SELECT
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked')
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked')
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
GROUP BY i.id;
|
||||
`
|
||||
|
||||
@@ -219,6 +219,7 @@ const (
|
||||
StatusOpen Status = "open"
|
||||
StatusInProgress Status = "in_progress"
|
||||
StatusBlocked Status = "blocked"
|
||||
StatusDeferred Status = "deferred" // Deliberately put on ice for later (bd-4jr)
|
||||
StatusClosed Status = "closed"
|
||||
StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8)
|
||||
StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2)
|
||||
@@ -227,7 +228,7 @@ const (
|
||||
// IsValid checks if the status value is valid (built-in statuses only)
|
||||
func (s Status) IsValid() bool {
|
||||
switch s {
|
||||
case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone, StatusPinned:
|
||||
case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -425,6 +426,7 @@ type Statistics struct {
|
||||
InProgressIssues int `json:"in_progress_issues"`
|
||||
ClosedIssues int `json:"closed_issues"`
|
||||
BlockedIssues int `json:"blocked_issues"`
|
||||
DeferredIssues int `json:"deferred_issues"` // Issues on ice (bd-4jr)
|
||||
ReadyIssues int `json:"ready_issues"`
|
||||
TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt)
|
||||
PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@beads/bd",
|
||||
"version": "0.30.7",
|
||||
"version": "0.31.0",
|
||||
"description": "Beads issue tracker - lightweight memory system for coding agents with native binary support",
|
||||
"main": "bin/bd.js",
|
||||
"bin": {
|
||||
|
||||
Reference in New Issue
Block a user