Files
projax/store/mbrian_writer.go
mAi e43055b670 feat(store): Phase 6 Slice C — MBrianWriter HTTP client against the scoped write API
Full implementation of the write adapter as an HTTP client over mBrian's
/api/projax/* surface (final spec, m/mBrian#73 issuecomment-10720):

- do(): JSON request/response plumbing; bearer auth from the configured
  token; fails closed (503-style) on empty URL/token rather than sending
  an empty Bearer; maps non-2xx to *APIError (401/403/404/500/503) and
  wraps 404 as ErrNotFound. {error} message extracted from the body.
- Create: POST node (mints a fresh projax_origin uuid via crypto/rand —
  no uuid dep), writes child_of parent edges, materialises via the reader
  so the returned Item.ID is the live mBrian uuid + derived path. This is
  the create-child round-trip the slice-B half-flip broke.
- Update: PATCH node fields + syncParents() edge diff; read-back.
- Reparent/AddParent: child_of edge diff / idempotent add.
- SetPublic: read-modify-write the full public bundle (PATCH replaces the
  whole projax.public object on shallow-merge).
- SoftDeleteCascade: projax-side descendant resolution via the reader's
  derived paths, then per-node soft-delete (HTTP API is single-node).
- AddLink/AddLinkDated/DeleteLink: projax-* self-edges; edge metadata
  shaped so the reader's linkFromEdge round-trips (typed payload +
  projax_rel + ref_id + event_date + note). DeleteLink resolves the edge's
  (source,target,rel) from its id, with a guard that refuses to delete
  when >1 edge shares the tuple.

Documented four API gaps inline + in the file header for head to
reconcile before relying on them (verified current data hits none of the
active ones): G1 edge identity collapses multiple same-ref_type links per
item (latent — 0 in current data); G2 POST /edges has no note field;
G3 PATCH can't set pinned/archived node columns (captured in
metadata.projax, pending reader fallback); G4 no top-level metadata
passthrough on create.

Build + vet green. Writer is wired but unreachable until main.go flips the
backend (next commit) — no behaviour change yet.
2026-06-01 12:25:00 +02:00

706 lines
25 KiB
Go

package store
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Phase 6 Slice C — MBrianWriter is the live write-path adapter against
// mBrian's scoped HTTP write API. Per mbrian/head's mechanism call
// (option (c), 2026-06-01, m/mBrian#73): projax writes go through an HTTP
// API that reuses mBrian's db.ts slug-generation / collision-resolution /
// singleton logic, NOT raw SQL and NOT SECURITY DEFINER functions —
// reimplementing that logic in SQL would drift.
//
// So this is an HTTP CLIENT, not a pgx writer. Reads stay direct-DB
// (MBrianReader, slice B). The asymmetry is deliberate: reads are pure
// projections; writes must funnel through db.ts so projax-created nodes
// are byte-identical to UI / MCP / migration-script nodes.
//
// The scoped API enforces projax-ownership server-side (every node it
// touches must carry metadata.projax_origin) so a projax bug can never
// corrupt non-projax mBrian nodes (m's journal / contacts / health).
//
// Final surface (m/mBrian#73 issuecomment-10720):
// POST /api/projax/nodes {title, content_md?, aliases?, mai_managed?, projax_origin, projax:{...}} → 201 {id, slug}
// PATCH /api/projax/nodes/{id} {title?, content_md?, aliases?, projax?:{...partial}} → 200 {id, slug}
// DELETE /api/projax/nodes/{id} → 204 (soft-delete)
// POST /api/projax/edges {source, target, rel, metadata?} → 201|200 {id}
// DELETE /api/projax/edges {source, target, rel} → 204
// Bearer-token auth; base URL + token from PROJAX_MBRIAN_API_URL /
// PROJAX_MBRIAN_API_TOKEN (projax-side env names; the mBrian server reads
// the same secret under PROJAX_WRITE_TOKEN). Errors: 400 malformed,
// 401 bad token, 403 not projax-owned, 404 missing, 500 db.ts error,
// 503 token not configured server-side (fail-closed: backend not ready).
//
// KNOWN API GAPS flagged to head (m/mBrian#73), reconcile before relying
// on them in production:
// G1 (latent) — edges key on (source,target,rel) only. POST is
// idempotent on that tuple and DELETE removes by it, so an item
// cannot hold two links of the same ref_type (e.g. two dated docs,
// multiple gitea-issues, multiple calendars). Current data has zero
// such cases (verified), so this is latent, not an active break.
// G2 — POST /edges has no `note` field; AddLinkDated's note is captured
// in edge metadata.note instead of the edge.note column the reader
// reads. Notes added post-cutover won't surface as ItemLink.Note
// until the reader also reads metadata.note (or the API gains note).
// G3 (active) — PATCH exposes no pinned/archived; the reader reads those
// from node columns. This writer captures them in metadata.projax.
// {pinned,archived} so intent isn't lost, but a star/archive toggle
// won't round-trip until the reader falls back to metadata.projax
// (recommended — keeps pinned/archived alongside status/tags) or the
// API exposes the columns.
// G4 (minor) — Create has no arbitrary top-level metadata passthrough;
// CreateInput.Metadata keys outside the projax bundle are dropped
// (no current caller sets them).
// MBrianWriter satisfies store.ItemWriter by calling mBrian's scoped
// /api/projax/* HTTP write API. It holds a direct pool for the narrow
// read-backs the write path needs (materialising the created/updated
// Item, resolving an edge's source/target/rel from its id before a
// DELETE, diffing child_of edges on reparent) — reads stay direct-DB.
type MBrianWriter struct {
baseURL string
token string
http *http.Client
pool *pgxpool.Pool
}
// NewMBrianWriter wires the writer to mBrian's HTTP write API. baseURL is
// the scheme+host (e.g. https://mbrian.x.msbls.de); token is the shared
// bearer. pool is the same msupabase pool the reader uses, for the
// read-backs.
func NewMBrianWriter(baseURL, token string, pool *pgxpool.Pool) *MBrianWriter {
return &MBrianWriter{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 15 * time.Second},
pool: pool,
}
}
// Compile-time witness: MBrianWriter satisfies ItemWriter.
var _ ItemWriter = (*MBrianWriter)(nil)
// reader builds a read adapter over the same pool for write-path
// read-backs (materialise created/updated items, etc.).
func (w *MBrianWriter) reader() *MBrianReader { return NewMBrianReader(w.pool) }
// ====================================================================
// HTTP plumbing
// ====================================================================
// APIError is the typed error returned for any non-2xx response from the
// mBrian write API, carrying the HTTP status + server message so handlers
// can render it without substring-matching.
type APIError struct {
Status int
Message string
Op string // e.g. "POST /api/projax/nodes"
}
func (e *APIError) Error() string {
msg := e.Message
if msg == "" {
msg = http.StatusText(e.Status)
}
switch e.Status {
case http.StatusUnauthorized:
return fmt.Sprintf("mBrian write API %s: unauthorized (token missing or wrong)", e.Op)
case http.StatusForbidden:
return fmt.Sprintf("mBrian write API %s: target node is not projax-owned", e.Op)
case http.StatusServiceUnavailable:
return fmt.Sprintf("mBrian write API %s: write backend not ready (token not configured)", e.Op)
}
return fmt.Sprintf("mBrian write API %s: %d %s", e.Op, e.Status, msg)
}
// do issues one JSON request to the mBrian write API and decodes a JSON
// response into out (when out != nil and the response carries a body). A
// non-2xx status becomes an *APIError; a 404 additionally wraps
// ErrNotFound so callers branching on it keep working.
func (w *MBrianWriter) do(ctx context.Context, method, path string, body, out any) error {
op := method + " " + path
if w.baseURL == "" {
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_URL not set", Op: op}
}
if w.token == "" {
// Never send an empty Bearer — fail closed and legibly.
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_TOKEN not set", Op: op}
}
var reqBody io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("%s: marshal body: %w", op, err)
}
reqBody = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("%s: build request: %w", op, err)
}
req.Header.Set("Authorization", "Bearer "+w.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := w.http.Do(req)
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
apiErr := &APIError{Status: resp.StatusCode, Message: extractAPIMessage(raw), Op: op}
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("%w: %w", ErrNotFound, apiErr)
}
return apiErr
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s: decode response: %w", op, err)
}
}
return nil
}
// extractAPIMessage pulls the {"error": "..."} message the API returns on
// every non-2xx; falls back to the raw body when it isn't that shape.
func extractAPIMessage(raw []byte) string {
var env struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &env) == nil && env.Error != "" {
return env.Error
}
return strings.TrimSpace(string(raw))
}
// nodeWriteResponse is the {id, slug} shape POST/PATCH /nodes return.
type nodeWriteResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
}
// ====================================================================
// Item writes
// ====================================================================
// Create POSTs a new projax node, then writes its child_of parent edges,
// then materialises the result via the reader so the returned Item.ID is
// the live mBrian uuid + its derived path — the exact round-trip the
// slice-B half-flip broke (create-child against an mBrian-read parent).
func (w *MBrianWriter) Create(ctx context.Context, in CreateInput) (*Item, error) {
if len(in.Kind) == 0 {
return nil, errors.New("kind required")
}
if strings.TrimSpace(in.Title) == "" {
return nil, errors.New("title required")
}
// New (non-migrated) nodes need a non-empty projax_origin so the
// server's ownership gate accepts later PATCH/DELETE/edge ops. Mint a
// fresh uuid — the audit marker for projax-born nodes.
origin, err := newUUIDv4()
if err != nil {
return nil, fmt.Errorf("mint projax_origin: %w", err)
}
body := map[string]any{
"title": in.Title,
"content_md": in.ContentMD,
"mai_managed": containsString(in.Kind, "mai-managed"),
"projax_origin": origin,
"projax": projaxBundleForCreate(in),
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, err
}
// Parent links are child_of edges. Idempotent POST per parent.
for _, pid := range dedupe(in.ParentIDs) {
if pid == "" {
continue
}
if err := w.postEdge(ctx, resp.ID, pid, "child_of", nil); err != nil {
return nil, fmt.Errorf("create %s: add parent %s: %w", resp.ID, pid, err)
}
}
return w.reader().GetByID(ctx, resp.ID)
}
// Update PATCHes the node's editable fields, then reconciles its child_of
// edges to match in.ParentIDs (the detail-edit form ships parent_ids in
// the same submit), then materialises the updated Item via the reader.
func (w *MBrianWriter) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
body := map[string]any{
"title": in.Title,
"content_md": in.ContentMD,
"projax": projaxBundleForUpdate(in),
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
return nil, err
}
if err := w.syncParents(ctx, id, in.ParentIDs); err != nil {
return nil, fmt.Errorf("update %s: sync parents: %w", id, err)
}
return w.reader().GetByID(ctx, id)
}
// Reparent replaces the node's child_of parent set entirely.
func (w *MBrianWriter) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
if err := w.syncParents(ctx, id, parentIDs); err != nil {
return nil, fmt.Errorf("reparent %s: %w", id, err)
}
return w.reader().GetByID(ctx, id)
}
// AddParent appends one child_of edge without disturbing existing ones.
// POST /edges is idempotent, so a duplicate parent is a no-op.
func (w *MBrianWriter) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
if err := w.postEdge(ctx, id, parentID, "child_of", nil); err != nil {
return nil, fmt.Errorf("add parent %s→%s: %w", id, parentID, err)
}
return w.reader().GetByID(ctx, id)
}
// SetPublic flips public.enabled on each id. public lives nested under
// metadata.projax.public and PATCH replaces the whole `public` sub-object
// (shallow merge is at the projax-key level), so we read each item and
// re-send its full public bundle with enabled toggled — never clobber the
// description / urls / screenshots.
func (w *MBrianWriter) SetPublic(ctx context.Context, ids []string, public bool) error {
rd := w.reader()
for _, id := range ids {
it, err := rd.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("set public: load %s: %w", id, err)
}
body := map[string]any{
"projax": map[string]any{
"public": publicBundle(public, it.PublicDescription, it.PublicLiveURL, it.PublicSourceURL, it.PublicScreenshots),
},
}
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
return err
}
}
return nil
}
// SetPinned flips pinned on each id. GAP G3: pinned is a node column the
// PATCH surface doesn't expose, so we capture it in metadata.projax.pinned
// (best-effort, won't round-trip through the column-reading reader until
// that's reconciled — see the file header).
func (w *MBrianWriter) SetPinned(ctx context.Context, ids []string, pinned bool) error {
for _, id := range ids {
body := map[string]any{
"projax": map[string]any{"pinned": pinned},
}
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
return err
}
}
return nil
}
// SoftDelete soft-deletes one node (sets deleted_at; edges preserved).
func (w *MBrianWriter) SoftDelete(ctx context.Context, id string) error {
return w.do(ctx, http.MethodDelete, "/api/projax/nodes/"+id, nil, nil)
}
// SoftDeleteCascade soft-deletes the node and, when cascade is true, every
// descendant. Without cascade it refuses if any live descendant exists —
// same contract as *Store.SoftDeleteCascade. Descendants are resolved
// projax-side via the reader's derived paths (cycle-safe, depth-capped),
// then soft-deleted one node at a time (the HTTP API is single-node).
func (w *MBrianWriter) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
rd := w.reader()
it, err := rd.GetByID(ctx, id)
if err != nil {
return err
}
all, err := rd.ListAll(ctx)
if err != nil {
return err
}
descendants := descendantsOf(it, all)
if len(descendants) > 0 && !cascade {
return ErrHasLiveChildren
}
for _, d := range descendants {
if err := w.SoftDelete(ctx, d.ID); err != nil {
return fmt.Errorf("cascade soft-delete %s: %w", d.ID, err)
}
}
return w.SoftDelete(ctx, id)
}
// descendantsOf returns every item in all whose path sits strictly under
// one of target's primary paths, plus any direct child naming target as a
// parent. Mirrors *Store.SoftDeleteCascade's predicate without SQL.
func descendantsOf(target *Item, all []*Item) []*Item {
prefixes := make([]string, 0, len(target.Paths))
for _, p := range target.Paths {
prefixes = append(prefixes, p+".")
}
out := []*Item{}
for _, it := range all {
if it.ID == target.ID {
continue
}
hit := containsString(it.ParentIDs, target.ID)
if !hit {
for _, p := range it.Paths {
for _, pfx := range prefixes {
if strings.HasPrefix(p, pfx) {
hit = true
break
}
}
if hit {
break
}
}
}
if hit {
out = append(out, it)
}
}
return out
}
// ====================================================================
// Link writes — projax-* self-edges
// ====================================================================
// AddLink writes an external-reference self-edge (source==target==item,
// rel='projax-<refType>') carrying the typed payload in edge metadata so
// the reader's linkFromEdge can decode it. GAP G1: POST /edges is
// idempotent on (source,target,rel), so a second link of the same
// ref_type on one item returns the existing edge instead of a new one.
func (w *MBrianWriter) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
return w.addLink(ctx, itemID, refType, refID, rel, nil, nil, metadata)
}
// AddLinkDated is AddLink with an event_date + explicit note. GAP G2: the
// note rides in edge metadata (the API has no note field), so it won't
// surface as ItemLink.Note via the column-reading reader yet.
func (w *MBrianWriter) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
return w.addLink(ctx, itemID, refType, refID, rel, note, eventDate, metadata)
}
func (w *MBrianWriter) addLink(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
if rel == "" {
rel = "contains"
}
meta := edgeMetadataForLink(refType, refID, rel, note, eventDate, metadata)
edgeID, err := w.postEdgeReturningID(ctx, itemID, itemID, "projax-"+refType, meta)
if err != nil {
return nil, fmt.Errorf("add link %s on %s: %w", refType, itemID, err)
}
l := &ItemLink{
ID: edgeID,
ItemID: itemID,
RefType: refType,
RefID: refID,
Rel: rel,
Note: note,
Metadata: map[string]any{},
EventDate: eventDate,
CreatedAt: time.Time{}, // server-assigned; re-read via the reader if needed
}
maps.Copy(l.Metadata, metadata)
return l, nil
}
// DeleteLink removes a link self-edge by its edge id. The HTTP API deletes
// by (source,target,rel), so we resolve those from the edge id via a
// direct read-back. GAP G1 guard: if more than one edge shares that
// (source,target,rel) — only possible for >1 same-ref_type links on an
// item, which the current data never has — we refuse rather than delete
// all of them, surfacing the limitation instead of losing siblings.
func (w *MBrianWriter) DeleteLink(ctx context.Context, id string) error {
var source, target, rel string
err := w.pool.QueryRow(ctx,
`SELECT source_id::text, target_id::text, rel FROM mbrian.edges WHERE id = $1`, id).
Scan(&source, &target, &rel)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return fmt.Errorf("delete link %s: %w", id, ErrNotFound)
}
return fmt.Errorf("delete link %s: resolve edge: %w", id, err)
}
var siblings int
if err := w.pool.QueryRow(ctx,
`SELECT count(*) FROM mbrian.edges WHERE source_id = $1 AND target_id = $2 AND rel = $3`,
source, target, rel).Scan(&siblings); err != nil {
return fmt.Errorf("delete link %s: count siblings: %w", id, err)
}
if siblings > 1 {
return fmt.Errorf("delete link %s: %d edges share (source,target,rel=%s); the mBrian edge API cannot delete a single one (gap G1)", id, siblings, rel)
}
return w.deleteEdge(ctx, source, target, rel)
}
// ====================================================================
// Edge helpers
// ====================================================================
func (w *MBrianWriter) postEdge(ctx context.Context, source, target, rel string, metadata map[string]any) error {
_, err := w.postEdgeReturningID(ctx, source, target, rel, metadata)
return err
}
func (w *MBrianWriter) postEdgeReturningID(ctx context.Context, source, target, rel string, metadata map[string]any) (string, error) {
body := map[string]any{"source": source, "target": target, "rel": rel}
if metadata != nil {
body["metadata"] = metadata
}
var resp struct {
ID string `json:"id"`
}
if err := w.do(ctx, http.MethodPost, "/api/projax/edges", body, &resp); err != nil {
return "", err
}
return resp.ID, nil
}
func (w *MBrianWriter) deleteEdge(ctx context.Context, source, target, rel string) error {
body := map[string]any{"source": source, "target": target, "rel": rel}
return w.do(ctx, http.MethodDelete, "/api/projax/edges", body, nil)
}
// childOfTargets returns the current child_of parent ids for a node,
// read direct-DB (a write-path read-back, scoped to projax-managed nodes).
func (w *MBrianWriter) childOfTargets(ctx context.Context, id string) ([]string, error) {
rows, err := w.pool.Query(ctx,
`SELECT target_id::text FROM mbrian.edges WHERE source_id = $1 AND rel = 'child_of'`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// syncParents diffs the node's current child_of edges against the desired
// parent set, deleting removed edges and adding new ones. Reparent and the
// parent-changing part of Update both route through here.
func (w *MBrianWriter) syncParents(ctx context.Context, id string, desired []string) error {
want := map[string]bool{}
for _, p := range dedupe(desired) {
if p != "" {
want[p] = true
}
}
current, err := w.childOfTargets(ctx, id)
if err != nil {
return err
}
have := map[string]bool{}
for _, p := range current {
have[p] = true
}
for p := range have {
if !want[p] {
if err := w.deleteEdge(ctx, id, p, "child_of"); err != nil {
return err
}
}
}
for p := range want {
if !have[p] {
if err := w.postEdge(ctx, id, p, "child_of", nil); err != nil {
return err
}
}
}
return nil
}
// ====================================================================
// Payload shaping
// ====================================================================
// projaxBundleForCreate builds the metadata.projax object for a new node.
// The server stores it verbatim under metadata.projax. pinned is captured
// here per GAP G3.
func projaxBundleForCreate(in CreateInput) map[string]any {
kind := "project"
if containsString(in.Kind, "area") {
kind = "area"
}
status := in.Status
if status == "" {
status = "active"
}
b := map[string]any{
"kind": kind,
"status": status,
"tags": orEmpty(in.Tags),
"management": orEmpty(in.Management),
"public": map[string]any{},
"timeline_exclude": []string{},
"start_time": timePtrToJSON(in.StartTime),
"end_time": timePtrToJSON(in.EndTime),
}
if in.Pinned {
b["pinned"] = true
}
return b
}
// projaxBundleForUpdate builds the partial metadata.projax object for a
// PATCH. PATCH shallow-merges these keys into the existing projax bundle,
// so we send the full set the edit form owns (status/tags/management/
// public/timeline_exclude/start/end) + pinned/archived (GAP G3).
func projaxBundleForUpdate(in UpdateInput) map[string]any {
return map[string]any{
"status": in.Status,
"tags": orEmpty(in.Tags),
"management": orEmpty(in.Management),
"public": publicBundle(in.Public, in.PublicDescription, in.PublicLiveURL, in.PublicSourceURL, in.PublicScreenshots),
"timeline_exclude": orEmpty(in.TimelineExclude),
"start_time": timePtrToJSON(in.StartTime),
"end_time": timePtrToJSON(in.EndTime),
"pinned": in.Pinned,
"archived": in.Archived,
}
}
// publicBundle mirrors the reader's metadata.projax.public.* shape.
func publicBundle(enabled bool, description, liveURL, sourceURL string, screenshots []string) map[string]any {
return map[string]any{
"enabled": enabled,
"description": description,
"live_url": liveURL,
"source_url": sourceURL,
"screenshots": orEmpty(screenshots),
}
}
// edgeMetadataForLink produces the edge metadata the reader's linkFromEdge
// can decode back into an ItemLink: the typed per-ref_type payload, the
// free-form rel under projax_rel, the canonical ref_id, an optional
// event_date, the note (GAP G2), and any caller-supplied extras.
func edgeMetadataForLink(refType, refID, rel string, note *string, eventDate *time.Time, extra map[string]any) map[string]any {
m := map[string]any{}
maps.Copy(m, extra)
m["projax_rel"] = rel
m["ref_id"] = refID
switch refType {
case "caldav-list":
m["url"] = refID
case "gitea-repo":
if owner, repo, ok := splitOwnerRepo(refID); ok {
m["owner"], m["repo"] = owner, repo
}
case "gitea-issue":
if owner, repo, num, ok := splitOwnerRepoIssue(refID); ok {
m["owner"], m["repo"], m["number"] = owner, repo, num
}
case "mai-project":
m["mai_project_id"] = refID
case "url", "doc", "document", "note":
m["url"] = refID
}
if eventDate != nil {
m["event_date"] = eventDate.Format("2006-01-02")
}
if note != nil && *note != "" {
m["note"] = *note
}
return m
}
func splitOwnerRepo(s string) (string, string, bool) {
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func splitOwnerRepoIssue(s string) (string, string, int, bool) {
hash := strings.LastIndex(s, "#")
if hash < 0 {
return "", "", 0, false
}
owner, repo, ok := splitOwnerRepo(s[:hash])
if !ok {
return "", "", 0, false
}
var num int
if _, err := fmt.Sscanf(s[hash+1:], "%d", &num); err != nil || num <= 0 {
return "", "", 0, false
}
return owner, repo, num, true
}
// ====================================================================
// Small utilities
// ====================================================================
// newUUIDv4 mints a random RFC-4122 v4 uuid without pulling a uuid
// dependency — projax only needs the projax_origin audit marker to be a
// non-empty, unique-enough string.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func dedupe(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func orEmpty(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func timePtrToJSON(t *time.Time) any {
if t == nil {
return nil
}
return t.Format(time.RFC3339)
}