gastownhall/beads · info · storage.ErrNotFound

%w: issue %s

Error message

%w: issue %s

What it means

GetIssueInTx looks up the ID in the issues table, then in wisps; if both return storage.ErrNotFound, it wraps storage.ErrNotFound with the issue ID. This is the canonical not-found signal — detect it with errors.Is. It means no issue or wisp with that ID exists in this database.

Source

Thrown at internal/storage/issueops/get_issue.go:33

// GetIssueInTx retrieves a single issue by ID within an existing transaction,
// including its labels. Automatically routes to the wisps/wisp_labels tables
// if the ID is an active wisp. Returns storage.ErrNotFound (wrapped) if the
// issue does not exist in either table.
func GetIssueInTx(ctx context.Context, tx DBTX, id string) (*types.Issue, error) {
	issue, err := getIssueFromTableInTx(ctx, tx, "issues", "labels", id)
	if err == nil {
		return issue, nil
	}
	if !errors.Is(err, storage.ErrNotFound) {
		return nil, err
	}

	issue, err = getIssueFromTableInTx(ctx, tx, "wisps", "wisp_labels", id)
	if err == nil {
		return issue, nil
	}
	if errors.Is(err, storage.ErrNotFound) {
		return nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
	}
	return nil, err
}

// missingOptionalIssueTable reports whether err is the absence of the optional
// issue plane the hydration query just read. The hydration FROM clause also
// carries sqlbuild.LeaseJoin, so a blanket table-not-exist check here folds a
// missing leases table into "row absent" — a wrong answer, not an empty one.
func missingOptionalIssueTable(err error, issueTable string) bool {
	return optionalBlockedTable(issueTable) && dberrors.IsMissingTable(err, issueTable)
}

func getIssueFromTableInTx(ctx context.Context, tx DBTX, issueTable, labelTable, id string) (*types.Issue, error) {
	//nolint:gosec // G201: issueTable is a hardcoded literal supplied by GetIssueInTx ("issues" or "wisps")
	row := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM %s %s WHERE id = ?`,
		IssueSelectColumns, issueTable, sqlbuild.LeaseJoin(issueTable)), id)
	issue, err := ScanIssueFrom(row)
	if err == sql.ErrNoRows || missingOptionalIssueTable(err, issueTable) {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Verify the ID with bd show <id> or by listing issues before the lookup
  2. Confirm you are connected to the database where the issue was created
  3. Branch on errors.Is(err, storage.ErrNotFound) to handle absence gracefully
  4. Re-create the issue if it was legitimately deleted

Example fix

// before
issue, err := issueops.GetIssueInTx(ctx, tx, id) // hard-fails on unknown ID
// after
issue, err := issueops.GetIssueInTx(ctx, tx, id)
if errors.Is(err, storage.ErrNotFound) {
    return fmt.Errorf("issue %q does not exist in this database", id)
}
if err != nil {
    return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// pre-check existence without labels/lease join
var exists bool
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) > 0 FROM issues WHERE id = ?`, id).Scan(&exists)

Type guard

func isIssueNotFound(err error) bool {
    return errors.Is(err, storage.ErrNotFound)
}

Try / catch

issue, err := issueops.GetIssueInTx(ctx, tx, id)
if isIssueNotFound(err) {
    return fmt.Errorf("issue %q not found; run 'bd list' to see available IDs", id)
}
if err != nil {
    return err
}

Prevention

When it happens

Trigger: Calling GetIssueInTx (directly or via ClaimIssueInTx, ClaimReadyIssueInTx, GetNewlyUnblockedByCloseInTx, buildDependencyTreeInTx, ExecuteUpdate) with an ID that exists in neither issues nor wisps.

Common situations: Typo'd or truncated issue ID; operating on a different database/clone than where the issue was created; issue deleted by another process; prefix-matched ID that resolved incorrectly.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/228c105ea5aad374. Report an issue: GitHub.