7 Commits

Author SHA1 Message Date
63044a697d feat: change node ownership to namespace instead of creator
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 44s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 51s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m5s
Build and Push Docker Container / build-and-push (push) Failing after 10m5s
When a node is created in a namespace, the namespace now owns it rather than the creator. This allows namespaces to manage content ownership. Namespace nodes themselves remain owned by their creator. Users retain transitive ownership through their default namespace: user→has_ownership→namespace→has_ownership→node.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:41:13 +02:00
ea8a9ca0c3 feat: add global user namespace for shared access
Create a special _global namespace that every new user automatically
gets can_create_rel access to (inclusive of can_read). This provides
a shared space where all users can see and interact with published nodes.

- ensureGlobalNamespace creates _global on first use with self-ownership
- ensureUser grants can_create_rel to each new user on the global namespace
- User visibility still relies on existing post-BFS global readability

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:31:04 +02:00
bcd52e0ae6 update project page url for package
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 59s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 59s
Build and Push Docker Container / build-and-push (push) Failing after 10m8s
2026-04-02 05:09:02 +02:00
4912f37688 remove claude markdown 2026-04-02 05:08:44 +02:00
f2521be158 feat: add due date filters for list command
- --due: show only nodes with a due date set
- --due-within N: show only nodes due within N days (includes overdue)

Implemented in service layer with post-fetch filtering, threaded through
API client and server, and exposed via CLI flags.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:06:35 +02:00
8357c80e75 feat: add due date to list sorting priority
Sort nodes by: status → priority → due date (soonest first).
Nodes without due dates sort last.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:57:51 +02:00
a30c703efa feat: add colored due date indicators to list and show views
- List view: show relative due date (+12d, -3d, today) after namespace
- Show view: colorize full due date based on urgency
- Color scheme: red (overdue), yellow (due within 3 days), green (4+ days)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:54:15 +02:00
10 changed files with 198 additions and 57 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.ax
quicknote.md
plan.md
CLAUDE.md

View File

@@ -1,47 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Axolotl (`ax`) is a CLI-native issue tracker built in Go, using a local SQLite file (`.ax.db`) as its database. It's designed for use by individuals and AI agents, with JSON output support for machine integration.
## Commands
```bash
go build -o ax . # Build the binary
go test ./... # Run all tests (e2e_test.go covers most functionality)
go test -run TestName . # Run a single test by name
```
## Architecture
The codebase has three distinct layers:
### 1. `cmd/` — CLI layer (Cobra)
Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. Also contains `output.go` for colored terminal output and JSON serialization.
### 2. `service/` — Business logic
`NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces:
- Permission model via `getPermContext()` — BFS from the user's own node following permission rels
- Blocker validation (can't close an issue with open blockers)
- `@mention` extraction → automatic edge creation
- Single-value relation enforcement (`assignee`, `in_namespace`)
- Auto-creation of referenced user/namespace nodes
### 3. `store/` — Persistence and configuration
`GraphStore` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. Also contains `Config` for user settings, aliases, and session management.
## Core Data Model
**Node**: a graph node with a 5-char ID, title, content, `Tags []string`, and `Relations map[string][]string`.
**Property tags** use the `_key::value` pattern: `_type::issue|note|user|namespace`, `_status::open|done`, `_prio::high|medium|low`.
**Relation types** (`models/rel_type.go`): `blocks`, `subtask`, `related`, `assignee` (single-value), `in_namespace` (single-value), `created`, `mentions`, `can_read`, `can_create_rel`, `can_write`, `has_ownership`.
**Permission model**: Four inclusive levels (14). Transitive via BFS from user's self-owned node. `can_read`=1, `can_create_rel`=2, `can_write`=3, `has_ownership`=4. Creator auto-gets `has_ownership` on every new node. Users self-own. Deleting a node cascades to all nodes it owns. User/namespace nodes are globally readable.
## Config
The CLI searches upward from CWD for an `.ax` directory (like git), falling back to `~/.config/ax/` for config and `~/.local/share/ax/` for data. The `AX_USER` env var overrides the configured username. The database file `ax.db` is similarly discovered by walking upward.

View File

@@ -4,7 +4,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc="CLI-native issue tracker using SQLite"
arch=('x86_64' 'aarch64')
url="https://g.eliaskohout.de/eliaskohout/axolotl"
url="https://g.eliaskohout.de/eliaskohout/ax"
license=('MIT')
makedepends=('go')
source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz")

View File

@@ -11,6 +11,8 @@ import (
var lTags, lRels []string
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
var lDue bool
var lDueWithin int
var listCmd = &cobra.Command{
Use: "list", Short: "List nodes",
@@ -22,6 +24,11 @@ var listCmd = &cobra.Command{
}
var filter service.ListFilter
filter.HasDueDate = lDue
if lDueWithin >= 0 {
n := lDueWithin
filter.DueWithin = &n
}
// --tag is an alias for a label filter with no target.
for _, tag := range lTags {
@@ -76,4 +83,6 @@ func init() {
f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
f.StringVar(&lMention, "mention", "", "filter by mention")
f.BoolVar(&lDue, "due", false, "filter to nodes with a due date")
f.IntVar(&lDueWithin, "due-within", -1, "filter to nodes due within N days (includes overdue)")
}

View File

@@ -7,8 +7,10 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strings"
"time"
"github.com/fatih/color"
)
@@ -74,7 +76,21 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
if si != sj {
return statusRanks[si] > statusRanks[sj]
}
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
pi, pj := prioRanks[nodes[i].GetProperty("prio")], prioRanks[nodes[j].GetProperty("prio")]
if pi != pj {
return pi > pj
}
di, dj := nodes[i].DueDate, nodes[j].DueDate
if di == nil && dj == nil {
return false
}
if di == nil {
return false
}
if dj == nil {
return true
}
return di.Before(dj.Time) // soonest due date first
})
for _, n := range nodes {
@@ -89,13 +105,14 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s",
fmt.Fprintf(w, " %s %s %s %s %s %s %s",
cDim.Sprint(n.ID),
render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
dueDateShort(n.DueDate),
)
tags := n.GetDisplayTags()
if len(tags) > 0 {
@@ -117,7 +134,7 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
if n.DueDate != nil {
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate.Format("2006-01-02"))
fmt.Fprintf(w, " Due: %s\n", dueDateLong(n.DueDate))
}
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
@@ -202,6 +219,57 @@ func render(rm RenderMap, key string, short bool) string {
return v.c.Sprint(v.l)
}
// dueDateShort returns a "+12d" / "-3d" style string colored by urgency.
func dueDateShort(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var label string
if days == 0 {
label = "today"
} else if days > 0 {
label = fmt.Sprintf("+%dd", days)
} else {
label = fmt.Sprintf("%dd", days)
}
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprint(label)
}
// dueDateLong returns the full date string colored by urgency.
func dueDateLong(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprintf("%s %s", iconCalendar, d.Format("2006-01-02"))
}
func truncate(s string, max int) string {
if len(s) <= max {
return s

View File

@@ -117,8 +117,26 @@ func TestPermissions(t *testing.T) {
if !userNode.HasRelation("has_ownership", aliceUserID) {
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
}
if !userNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations)
// Nodes are now owned by the namespace they belong to, not directly by the creator.
// Alice's default namespace is owned by alice, so she retains transitive ownership.
if userNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("alice should not directly own her node (namespace owns it), got relations: %v", userNode.Relations)
}
// Find alice's default namespace and verify it owns the node.
namespaces := alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json"))
var aliceNsID string
for _, ns := range namespaces {
if ns.Title == "alice" {
aliceNsID = ns.ID
}
}
if aliceNsID == "" {
t.Fatal("could not find alice's default namespace")
}
nsOut := alice.mustAx("show", aliceNsID, "--json")
nsNode := alice.parseNode(nsOut)
if !nsNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("expected alice's namespace to own her node, got relations: %v", nsNode.Relations)
}
})

View File

@@ -6,6 +6,7 @@ import (
"axolotl/store"
"encoding/json"
"net/http"
"strconv"
"strings"
)
@@ -84,6 +85,14 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
if v := q.Get("mention"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
}
if q.Get("has_due_date") == "true" {
filter.HasDueDate = true
}
if v := q.Get("due_within"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filter.DueWithin = &n
}
}
nodes, err := svc.List(filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
)
type apiClient struct {
@@ -78,6 +79,12 @@ func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q.Add("rel", string(r.Type)+":"+r.Target)
}
}
if filter.HasDueDate {
q.Set("has_due_date", "true")
}
if filter.DueWithin != nil {
q.Set("due_within", strconv.Itoa(*filter.DueWithin))
}
path := "/nodes"
if len(q) > 0 {
path += "?" + q.Encode()

View File

@@ -57,6 +57,8 @@ type UpdateInput struct {
// Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct {
Rels []RelInput
HasDueDate bool // when true, only return nodes that have a due date set
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)
}
// RelInput is a typed, directed rel with a target that may be a name or node ID.

View File

@@ -234,6 +234,26 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
result = append(result, n)
}
}
if filter.HasDueDate || filter.DueWithin != nil {
now := time.Now().UTC().Truncate(24 * time.Hour)
filtered := result[:0]
for _, n := range result {
if n.DueDate == nil {
continue
}
if filter.DueWithin != nil {
due := n.DueDate.UTC().Truncate(24 * time.Hour)
cutoff := now.AddDate(0, 0, *filter.DueWithin)
if due.After(cutoff) {
continue
}
}
filtered = append(filtered, n)
}
result = filtered
}
return result, nil
}
@@ -344,8 +364,9 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Edge rels.
// Edge rels. Track the namespace the node is placed in for ownership.
hasCreated := false
var actualNsID string
for _, ri := range input.Rels {
if ri.Target == "" {
continue // already stored as tag
@@ -357,6 +378,9 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
if err != nil {
return err
}
if ri.Type == models.RelInNamespace {
actualNsID = resolved
}
if ri.Type == models.RelHasOwnership {
// Ownership transfer: remove existing owner of the target.
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
@@ -378,6 +402,7 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
actualNsID = nsID
}
// Default created.
@@ -391,12 +416,19 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Grant creator ownership of the new node.
// Grant ownership of the new node.
// Namespace nodes are owned by their creator. All other nodes are owned
// by the namespace they belong to — the user retains transitive ownership
// through the namespace's own ownership chain (e.g. user→owns→default-ns→owns→node).
creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil {
ownerID := creatorID
if tmp.GetProperty("type") != "namespace" && actualNsID != "" {
ownerID = actualNsID
}
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
return err
}
@@ -790,6 +822,37 @@ func (s *nodeServiceImpl) resolveUserRef(st store.GraphStore, ref string) (strin
return s.ensureUser(st, ref)
}
const globalNamespace = "_global"
func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, error) {
nsID, err := s.resolveIDByNameAndType(st, globalNamespace, "namespace")
if err != nil {
return "", err
}
if nsID != "" {
return nsID, nil
}
id, err := st.GenerateID()
if err != nil {
return "", err
}
now := time.Now().UTC().Format(time.RFC3339)
if err := st.AddNode(id, globalNamespace, "", nil, now, now); err != nil {
return "", err
}
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
// Self-owned so no single user controls it.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
}
return id, nil
}
func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) {
userID, err := s.resolveIDByNameAndType(st, username, "user")
if err != nil {
@@ -813,6 +876,17 @@ func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (stri
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
}
// Every user gets can_create_rel access to the global namespace (inclusive
// of can_read), providing a shared space where all users can see and interact with
// nodes that are published there. User nodes themselves are already
// globally readable via the post-BFS identity override in getPermContext.
globalNsID, err := s.ensureGlobalNamespace(st)
if err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelCanCreateRel), globalNsID); err != nil {
return "", err
}
return id, nil
}