Files
ipcr-forge/forge/state.go
T
Worph ca8f87050b
image / vet (push) Successful in 1m51s
image / image (push) Failing after 29s
Split listing support: Gitea and Radicle as separate apps
- setup: with RUNNER_DIR, fetch the runner registration token from Gitea's
  admin API (POST /admin/actions/runners/registration-token) instead of the
  gitea CLI on Gitea's volume; record secrets/setup-status (ok, unreachable,
  bad-credentials) and exit cleanly when Gitea cannot be used.
- mirror: Radicle is optional. Repositories wait until a node is there (key and
  control socket in RAD_HOME), re-checked every reconcile; RADICLE=off.
- page: /status.json; hide the Radicle parts without a node, banner when Gitea
  is unreachable or refuses the login.
- example workflow: runs-on: ipcr, the forge's own runner label.
- docs: two listings, split (IPCR-Forge) and all in one (IPCR-Forge-AIO).
2026-10-05 16:11:24 +00:00

124 lines
3.2 KiB
Go

package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
// Repository states, as shown on the page.
const (
stSynced = "synced" // Radicle has what Gitea has
stError = "error" // the last sync failed; retried on the next event or reconcile
stWaiting = "waiting" // empty repository, or no Radicle node yet: nothing to publish to
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
)
// repoState is what the forge service remembers per Gitea repository, keyed by Gitea's numeric ID so a
// rename or a transfer keeps its Radicle ID.
type repoState struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
RID string `json:"rid,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
State string `json:"state"`
Error string `json:"error,omitempty"`
LastSync time.Time `json:"last_sync,omitzero"`
// A digest of the refs last pushed: a reconcile that finds the same refs pushes nothing.
Refs string `json:"refs,omitempty"`
// The canonical-tags rule is in the repository's identity (see rad.go).
Crefs bool `json:"crefs,omitempty"`
}
type state struct {
mu sync.Mutex
path string
repos map[int64]*repoState
}
func loadState(path string) (*state, error) {
s := &state{path: path, repos: map[int64]*repoState{}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
var m map[string]*repoState
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
for _, r := range m {
s.repos[r.ID] = r
}
return s, nil
}
// get returns a copy of the repository's state (a zero one, with the ID set, when unknown).
func (s *state) get(id int64) repoState {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.repos[id]; ok {
return *r
}
return repoState{ID: id}
}
func (s *state) known(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.repos[id]
return ok
}
// put stores r and writes the file (renamed into place, so a reader never sees half of it).
func (s *state) put(r repoState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.repos[r.ID] = &r
m := make(map[string]*repoState, len(s.repos))
for id, v := range s.repos {
m[strconv.FormatInt(id, 10)] = v
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(filepath.Dir(s.path), "."+filepath.Base(s.path)+".new")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *state) ids() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0, len(s.repos))
for id := range s.repos {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// list is the page's view: every repository, by name, without internal bookkeeping.
func (s *state) list() []repoState {
s.mu.Lock()
out := make([]repoState, 0, len(s.repos))
for _, r := range s.repos {
v := *r
v.Refs = ""
out = append(out, v)
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
return out
}