fierj
PublicTiny personal git forge
eec8b3c5e34928aa9a9719f9d12e5d2cd85f0fc6
diff --git a/activitypub.go b/activitypub.go
new file mode 100644
index 0000000..1e905b6
--- /dev/null
+++ b/activitypub.go
@@ -0,0 +1,53 @@
+package main
+
+import "log/slog"
+
+const apContext = "https://www.w3.org/ns/activitystreams"
+
+type APObject struct {
+ Context string `json:"@context"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Published string `json:"published,omitempty"`
+ AttributedTo string `json:"attributedTo,omitempty"`
+ Content string `json:"content,omitempty"`
+ URL string `json:"url,omitempty"`
+ To []string `json:"to,omitempty"`
+ CC []string `json:"cc,omitempty"`
+}
+
+func actorURL(host, repoName string) string {
+ return "https://" + host + "/" + repoName + "/ap/actor"
+}
+
+func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
+ if cfg.Host == "" {
+ return
+ }
+ actor := actorURL(cfg.Host, repoName)
+ threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+ note := APObject{
+ Context: apContext,
+ ID: threadURL,
+ Type: "Note",
+ Published: t.Created,
+ AttributedTo: actor,
+ Content: t.Title + "\n\n" + t.Body,
+ URL: threadURL,
+ To: []string{"https://www.w3.org/ns/activitystreams#Public"},
+ CC: []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+ }
+ activity := map[string]any{
+ "@context": apContext,
+ "id": threadURL + "/activity",
+ "type": "Create",
+ "actor": actor,
+ "published": t.Created,
+ "to": []string{"https://www.w3.org/ns/activitystreams#Public"},
+ "cc": []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+ "object": note,
+ }
+ // TODO: implement actual delivery to inboxes of followers.
+ slog.Info("ap: would deliver Create activity", "actor", actor, "thread", threadURL)
+ _ = activity
+}
diff --git a/handlers.go b/handlers.go
deleted file mode 100644
index e3e5f51..0000000
--- a/handlers.go
+++ /dev/null
@@ -1,447 +0,0 @@
-package main
-
-import (
- "fmt"
- "html/template"
- "log/slog"
- "net"
- "net/http"
- "sort"
- "strings"
- "time"
-)
-
-type BreadcrumbItem struct {
- Name string
- URL string
-}
-
-var funcMap = template.FuncMap{
- "pathJoin": func(parts ...string) string {
- var nonEmpty []string
- for _, p := range parts {
- if p != "" {
- nonEmpty = append(nonEmpty, p)
- }
- }
- return strings.Join(nonEmpty, "/")
- },
- "shortHash": func(h string) string {
- if len(h) > 8 {
- return h[:8]
- }
- return h
- },
- "hasSuffix": strings.HasSuffix,
- "timeAgo": func(ts string) string {
- t, err := time.Parse(time.RFC3339, ts)
- if err != nil {
- return ts
- }
- d := time.Since(t)
- switch {
- case d < time.Minute:
- return "just now"
- case d < time.Hour:
- m := int(d.Minutes())
- if m == 1 {
- return "1 minute ago"
- }
- return fmt.Sprintf("%d minutes ago", m)
- case d < 24*time.Hour:
- h := int(d.Hours())
- if h == 1 {
- return "1 hour ago"
- }
- return fmt.Sprintf("%d hours ago", h)
- case d < 30*24*time.Hour:
- days := int(d.Hours() / 24)
- if days == 1 {
- return "1 day ago"
- }
- return fmt.Sprintf("%d days ago", days)
- case d < 365*24*time.Hour:
- months := int(d.Hours() / 24 / 30)
- if months == 1 {
- return "1 month ago"
- }
- return fmt.Sprintf("%d months ago", months)
- default:
- return ts[:10]
- }
- },
-}
-
-func renderError(w http.ResponseWriter, tmpl *template.Template, code int, message string) {
- w.WriteHeader(code)
- tmpl.ExecuteTemplate(w, "error.html", map[string]any{
- "Code": code,
- "Message": message,
- })
-}
-
-func Breadcrumb(path string) []BreadcrumbItem {
- if path == "" {
- return nil
- }
- parts := strings.Split(path, "/")
- items := make([]BreadcrumbItem, len(parts))
- cumulative := ""
- for i, p := range parts {
- if i > 0 {
- cumulative += "/"
- }
- cumulative += p
- items[i] = BreadcrumbItem{Name: p, URL: cumulative}
- }
- return items
-}
-
-func withUser(r *http.Request, data map[string]any) map[string]any {
- if data == nil {
- data = map[string]any{}
- }
- data["User"] = User(r)
- return data
-}
-
-// checkRepoAccess returns true if the current user can view this repo.
-// Private repos require login. Writes 404 or redirects to login if denied.
-func checkRepoAccess(w http.ResponseWriter, r *http.Request, git *Git) bool {
- if git.IsPrivate() && User(r) == "" {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return false
- }
- return true
-}
-
-func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- repos, err := ListRepos(cfg)
- if err != nil {
- renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
- return
- }
- // Filter private repos for anonymous users.
- user := User(r)
- visible := make([]*Git, 0, len(repos))
- for _, repo := range repos {
- if !repo.IsPrivate() || user != "" {
- visible = append(visible, repo)
- }
- }
- tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
- "Repos": visible,
- "User": user,
- })
- }
-}
-
-func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- type FileEntry struct {
- TreeEntry
- LastCommit *Commit
- }
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- if !checkRepoAccess(w, r, git) {
- return
- }
- treePath := r.PathValue("path")
- ref := r.PathValue("ref")
- if ref == "" {
- ref = git.DefaultBranch()
- }
- cloneSSH := ""
- cloneHTTPS := ""
- if cfg.Host != "" {
- cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
- // Include port in SSH URL if non-standard
- if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
- cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
- } else {
- cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
- }
- }
- if git.IsEmpty() {
- tmpl.ExecuteTemplate(w, "tree.html", withUser(r, map[string]any{
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "Path": "",
- "Entries": []FileEntry{},
- "CloneSSH": cloneSSH,
- "CloneHTTPS": cloneHTTPS,
- "Description": git.Description(),
- "Branches": []string{},
- "Tags": []string{},
- "Commits": 0,
- "Empty": true,
- }))
- return
- }
- entries, err := git.List(ref, treePath)
- if err != nil {
- renderError(w, tmpl, http.StatusInternalServerError, "failed to list tree: "+err.Error())
- return
- }
- // directories first
- sort.Slice(entries, func(i, j int) bool {
- if entries[i].Type != entries[j].Type {
- return entries[i].Type == "tree"
- }
- return entries[i].Name < entries[j].Name
- })
-
- fileEntries := []FileEntry{}
- for _, e := range entries {
- fp := e.Name
- if treePath != "" {
- fp = treePath + "/" + e.Name
- }
- fe := FileEntry{TreeEntry: e, LastCommit: git.LastCommit(ref, fp)}
- fileEntries = append(fileEntries, fe)
- }
- readme := git.Readme(ref, treePath)
- tmpl.ExecuteTemplate(w, "tree.html", withUser(r, map[string]any{
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "Path": treePath,
- "Breadcrumb": Breadcrumb(treePath),
- "Entries": fileEntries,
- "CloneSSH": cloneSSH,
- "CloneHTTPS": cloneHTTPS,
- "Description": git.Description(),
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Commits": git.CommitCount(ref),
- "Readme": readme,
- "ActiveTab": "code",
- }))
- }
-}
-
-func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- if !checkRepoAccess(w, r, git) {
- return
- }
- filePath := r.PathValue("path")
- ref := r.PathValue("ref")
- if ref == "" {
- ref = git.DefaultBranch()
- }
-
- content, err := git.Blob(ref, filePath)
- if err != nil {
- renderError(w, tmpl, http.StatusNotFound, err.Error())
- return
- }
- tmpl.ExecuteTemplate(w, "blob.html", withUser(r, map[string]any{
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "Path": filePath,
- "Breadcrumb": Breadcrumb(filePath),
- "Content": content,
- "Description": git.Description(),
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Commits": git.CommitCount(ref),
- "ActiveTab": "code",
- }))
- }
-}
-
-func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- if !checkRepoAccess(w, r, git) {
- return
- }
- ref := r.PathValue("ref")
- commits, err := git.Log(ref, 50)
- if err != nil {
- renderError(w, tmpl, http.StatusInternalServerError, err.Error())
- return
- }
- data := map[string]any{
- "User": User(r),
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "CommitList": commits,
- "Description": git.Description(),
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Commits": git.CommitCount(ref),
- "ActiveTab": "commits",
- }
- tmpl.ExecuteTemplate(w, "log.html", data)
- }
-}
-
-func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- if !checkRepoAccess(w, r, git) {
- return
- }
- ref := r.PathValue("ref")
- commit, err := git.Diff(ref)
- if err != nil || len(commit) == 0 {
- renderError(w, tmpl, http.StatusNotFound, "commit not found")
- return
- }
- if err := tmpl.ExecuteTemplate(w, "commit.html", withUser(r, map[string]any{
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "Diff": commit,
- "Hash": ref,
- "Description": git.Description(),
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Commits": git.CommitCount(ref),
- "ActiveTab": "commits",
- })); err != nil {
- slog.Error("failed to execute template", "error", err)
- }
- }
-}
-
-func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if User(r) == "" {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- tmpl.ExecuteTemplate(w, "new_repo.html", map[string]any{"User": User(r)})
- }
-}
-
-func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if User(r) == "" {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- name := strings.TrimSpace(r.FormValue("name"))
- desc := strings.TrimSpace(r.FormValue("description"))
- importURL := strings.TrimSpace(r.FormValue("import_url"))
-
- if importURL != "" {
- importedName, err := ImportRepo(cfg, importURL, desc)
- if err != nil {
- renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
- return
- }
- http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
- return
- }
-
- if name == "" {
- renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
- return
- }
- if err := InitRepo(cfg, name, desc); err != nil {
- renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
- return
- }
- http.Redirect(w, r, "/"+name, http.StatusSeeOther)
- }
-}
-
-func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- if !checkRepoAccess(w, r, git) {
- return
- }
- ref := git.DefaultBranch()
- tmpl.ExecuteTemplate(w, "refs.html", withUser(r, map[string]any{
- "Repo": git.Repo(),
- "IsPrivate": git.IsPrivate(),
- "Ref": ref,
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Description": git.Description(),
- "Commits": git.CommitCount(ref),
- "ActiveTab": "refs",
- }))
- }
-}
-
-func SettingsGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if User(r) == "" {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- meta := git.LoadMeta()
- ref := git.DefaultBranch()
- tmpl.ExecuteTemplate(w, "settings.html", map[string]any{
- "Repo": git.Repo(),
- "Ref": ref,
- "IsPrivate": meta.IsPrivate,
- "Description": meta.Description,
- "Branches": git.Branches(),
- "Tags": git.Tags(),
- "Commits": git.CommitCount(ref),
- "AuthorizedKeys": strings.Join(meta.AuthorizedKeys, "\n"),
- "ProtectedBranches": strings.Join(meta.ProtectedBranches, "\n"),
- "ActiveTab": "settings",
- "User": User(r),
- })
- }
-}
-
-func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if User(r) == "" {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
- meta := git.LoadMeta()
- meta.Description = strings.TrimSpace(r.FormValue("description"))
- meta.IsPrivate = r.FormValue("is_private") == "true"
-
- // Parse authorized keys: one key per line, skip empty lines
- rawKeys := strings.TrimSpace(r.FormValue("authorized_keys"))
- if rawKeys != "" {
- meta.AuthorizedKeys = nil
- for _, k := range strings.Split(rawKeys, "\n") {
- k = strings.TrimSpace(k)
- if k != "" {
- meta.AuthorizedKeys = append(meta.AuthorizedKeys, k)
- }
- }
- } else {
- meta.AuthorizedKeys = nil
- }
-
- // Parse protected branches
- rawBranches := strings.TrimSpace(r.FormValue("protected_branches"))
- if rawBranches != "" {
- meta.ProtectedBranches = nil
- for _, b := range strings.Split(rawBranches, "\n") {
- b = strings.TrimSpace(b)
- if b != "" {
- meta.ProtectedBranches = append(meta.ProtectedBranches, b)
- }
- }
- } else {
- meta.ProtectedBranches = nil
- }
-
- if err := git.SaveMeta(meta); err != nil {
- renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
- return
- }
- http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
- }
-}
diff --git a/handlers_repo.go b/handlers_repo.go
new file mode 100644
index 0000000..e3e5f51
--- /dev/null
+++ b/handlers_repo.go
@@ -0,0 +1,447 @@
+package main
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+)
+
+type BreadcrumbItem struct {
+ Name string
+ URL string
+}
+
+var funcMap = template.FuncMap{
+ "pathJoin": func(parts ...string) string {
+ var nonEmpty []string
+ for _, p := range parts {
+ if p != "" {
+ nonEmpty = append(nonEmpty, p)
+ }
+ }
+ return strings.Join(nonEmpty, "/")
+ },
+ "shortHash": func(h string) string {
+ if len(h) > 8 {
+ return h[:8]
+ }
+ return h
+ },
+ "hasSuffix": strings.HasSuffix,
+ "timeAgo": func(ts string) string {
+ t, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ return ts
+ }
+ d := time.Since(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ m := int(d.Minutes())
+ if m == 1 {
+ return "1 minute ago"
+ }
+ return fmt.Sprintf("%d minutes ago", m)
+ case d < 24*time.Hour:
+ h := int(d.Hours())
+ if h == 1 {
+ return "1 hour ago"
+ }
+ return fmt.Sprintf("%d hours ago", h)
+ case d < 30*24*time.Hour:
+ days := int(d.Hours() / 24)
+ if days == 1 {
+ return "1 day ago"
+ }
+ return fmt.Sprintf("%d days ago", days)
+ case d < 365*24*time.Hour:
+ months := int(d.Hours() / 24 / 30)
+ if months == 1 {
+ return "1 month ago"
+ }
+ return fmt.Sprintf("%d months ago", months)
+ default:
+ return ts[:10]
+ }
+ },
+}
+
+func renderError(w http.ResponseWriter, tmpl *template.Template, code int, message string) {
+ w.WriteHeader(code)
+ tmpl.ExecuteTemplate(w, "error.html", map[string]any{
+ "Code": code,
+ "Message": message,
+ })
+}
+
+func Breadcrumb(path string) []BreadcrumbItem {
+ if path == "" {
+ return nil
+ }
+ parts := strings.Split(path, "/")
+ items := make([]BreadcrumbItem, len(parts))
+ cumulative := ""
+ for i, p := range parts {
+ if i > 0 {
+ cumulative += "/"
+ }
+ cumulative += p
+ items[i] = BreadcrumbItem{Name: p, URL: cumulative}
+ }
+ return items
+}
+
+func withUser(r *http.Request, data map[string]any) map[string]any {
+ if data == nil {
+ data = map[string]any{}
+ }
+ data["User"] = User(r)
+ return data
+}
+
+// checkRepoAccess returns true if the current user can view this repo.
+// Private repos require login. Writes 404 or redirects to login if denied.
+func checkRepoAccess(w http.ResponseWriter, r *http.Request, git *Git) bool {
+ if git.IsPrivate() && User(r) == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return false
+ }
+ return true
+}
+
+func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ repos, err := ListRepos(cfg)
+ if err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
+ return
+ }
+ // Filter private repos for anonymous users.
+ user := User(r)
+ visible := make([]*Git, 0, len(repos))
+ for _, repo := range repos {
+ if !repo.IsPrivate() || user != "" {
+ visible = append(visible, repo)
+ }
+ }
+ tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
+ "Repos": visible,
+ "User": user,
+ })
+ }
+}
+
+func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ type FileEntry struct {
+ TreeEntry
+ LastCommit *Commit
+ }
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ treePath := r.PathValue("path")
+ ref := r.PathValue("ref")
+ if ref == "" {
+ ref = git.DefaultBranch()
+ }
+ cloneSSH := ""
+ cloneHTTPS := ""
+ if cfg.Host != "" {
+ cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
+ // Include port in SSH URL if non-standard
+ if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
+ cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
+ } else {
+ cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
+ }
+ }
+ if git.IsEmpty() {
+ tmpl.ExecuteTemplate(w, "tree.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Path": "",
+ "Entries": []FileEntry{},
+ "CloneSSH": cloneSSH,
+ "CloneHTTPS": cloneHTTPS,
+ "Description": git.Description(),
+ "Branches": []string{},
+ "Tags": []string{},
+ "Commits": 0,
+ "Empty": true,
+ }))
+ return
+ }
+ entries, err := git.List(ref, treePath)
+ if err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, "failed to list tree: "+err.Error())
+ return
+ }
+ // directories first
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].Type != entries[j].Type {
+ return entries[i].Type == "tree"
+ }
+ return entries[i].Name < entries[j].Name
+ })
+
+ fileEntries := []FileEntry{}
+ for _, e := range entries {
+ fp := e.Name
+ if treePath != "" {
+ fp = treePath + "/" + e.Name
+ }
+ fe := FileEntry{TreeEntry: e, LastCommit: git.LastCommit(ref, fp)}
+ fileEntries = append(fileEntries, fe)
+ }
+ readme := git.Readme(ref, treePath)
+ tmpl.ExecuteTemplate(w, "tree.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Path": treePath,
+ "Breadcrumb": Breadcrumb(treePath),
+ "Entries": fileEntries,
+ "CloneSSH": cloneSSH,
+ "CloneHTTPS": cloneHTTPS,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "Readme": readme,
+ "ActiveTab": "code",
+ }))
+ }
+}
+
+func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ filePath := r.PathValue("path")
+ ref := r.PathValue("ref")
+ if ref == "" {
+ ref = git.DefaultBranch()
+ }
+
+ content, err := git.Blob(ref, filePath)
+ if err != nil {
+ renderError(w, tmpl, http.StatusNotFound, err.Error())
+ return
+ }
+ tmpl.ExecuteTemplate(w, "blob.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Path": filePath,
+ "Breadcrumb": Breadcrumb(filePath),
+ "Content": content,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "code",
+ }))
+ }
+}
+
+func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ ref := r.PathValue("ref")
+ commits, err := git.Log(ref, 50)
+ if err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+ return
+ }
+ data := map[string]any{
+ "User": User(r),
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "CommitList": commits,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "commits",
+ }
+ tmpl.ExecuteTemplate(w, "log.html", data)
+ }
+}
+
+func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ ref := r.PathValue("ref")
+ commit, err := git.Diff(ref)
+ if err != nil || len(commit) == 0 {
+ renderError(w, tmpl, http.StatusNotFound, "commit not found")
+ return
+ }
+ if err := tmpl.ExecuteTemplate(w, "commit.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Diff": commit,
+ "Hash": ref,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "commits",
+ })); err != nil {
+ slog.Error("failed to execute template", "error", err)
+ }
+ }
+}
+
+func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if User(r) == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ tmpl.ExecuteTemplate(w, "new_repo.html", map[string]any{"User": User(r)})
+ }
+}
+
+func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if User(r) == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("name"))
+ desc := strings.TrimSpace(r.FormValue("description"))
+ importURL := strings.TrimSpace(r.FormValue("import_url"))
+
+ if importURL != "" {
+ importedName, err := ImportRepo(cfg, importURL, desc)
+ if err != nil {
+ renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
+ return
+ }
+ http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
+ return
+ }
+
+ if name == "" {
+ renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
+ return
+ }
+ if err := InitRepo(cfg, name, desc); err != nil {
+ renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
+ return
+ }
+ http.Redirect(w, r, "/"+name, http.StatusSeeOther)
+ }
+}
+
+func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ ref := git.DefaultBranch()
+ tmpl.ExecuteTemplate(w, "refs.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Description": git.Description(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "refs",
+ }))
+ }
+}
+
+func SettingsGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if User(r) == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ meta := git.LoadMeta()
+ ref := git.DefaultBranch()
+ tmpl.ExecuteTemplate(w, "settings.html", map[string]any{
+ "Repo": git.Repo(),
+ "Ref": ref,
+ "IsPrivate": meta.IsPrivate,
+ "Description": meta.Description,
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "AuthorizedKeys": strings.Join(meta.AuthorizedKeys, "\n"),
+ "ProtectedBranches": strings.Join(meta.ProtectedBranches, "\n"),
+ "ActiveTab": "settings",
+ "User": User(r),
+ })
+ }
+}
+
+func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if User(r) == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ meta := git.LoadMeta()
+ meta.Description = strings.TrimSpace(r.FormValue("description"))
+ meta.IsPrivate = r.FormValue("is_private") == "true"
+
+ // Parse authorized keys: one key per line, skip empty lines
+ rawKeys := strings.TrimSpace(r.FormValue("authorized_keys"))
+ if rawKeys != "" {
+ meta.AuthorizedKeys = nil
+ for _, k := range strings.Split(rawKeys, "\n") {
+ k = strings.TrimSpace(k)
+ if k != "" {
+ meta.AuthorizedKeys = append(meta.AuthorizedKeys, k)
+ }
+ }
+ } else {
+ meta.AuthorizedKeys = nil
+ }
+
+ // Parse protected branches
+ rawBranches := strings.TrimSpace(r.FormValue("protected_branches"))
+ if rawBranches != "" {
+ meta.ProtectedBranches = nil
+ for _, b := range strings.Split(rawBranches, "\n") {
+ b = strings.TrimSpace(b)
+ if b != "" {
+ meta.ProtectedBranches = append(meta.ProtectedBranches, b)
+ }
+ }
+ } else {
+ meta.ProtectedBranches = nil
+ }
+
+ if err := git.SaveMeta(meta); err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
+ return
+ }
+ http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
+ }
+}
diff --git a/handlers_threads.go b/handlers_threads.go
new file mode 100644
index 0000000..b9ea03f
--- /dev/null
+++ b/handlers_threads.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "path/filepath"
+ "strings"
+)
+
+func repoPath(cfg Config, repoName string) string {
+ return filepath.Join(cfg.Dir, repoName+".git")
+}
+
+func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ state := r.URL.Query().Get("state")
+ if state == "" {
+ state = "open"
+ }
+ ref := git.DefaultBranch()
+ rp := repoPath(cfg, git.Name)
+ threads, openCount, closedCount := listThreadsFiltered(rp, state)
+ tmpl.ExecuteTemplate(w, "threads.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Threads": threads,
+ "State": state,
+ "OpenCount": openCount,
+ "ClosedCount": closedCount,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "threads",
+ }))
+ }
+}
+
+func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ ref := git.DefaultBranch()
+ tmpl.ExecuteTemplate(w, "thread_new.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "threads",
+ }))
+ }
+}
+
+func ThreadNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ title := strings.TrimSpace(r.FormValue("title"))
+ body := strings.TrimSpace(r.FormValue("body"))
+ if title == "" {
+ renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+ return
+ }
+ author, authorName := threadAuthor(r)
+ rp := repoPath(cfg, git.Name)
+ t, err := createThread(rp, title, body, author, authorName)
+ if err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+ return
+ }
+ // Deliver to ActivityPub followers (non-blocking).
+ go deliverThreadCreate(cfg, git.Name, t)
+ http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, t.ID), http.StatusSeeOther)
+ }
+}
+
+func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ threadID := r.PathValue("threadId")
+ rp := repoPath(cfg, git.Name)
+ t, err := loadThread(rp, threadID)
+ if err != nil {
+ renderError(w, tmpl, http.StatusNotFound, "Thread not found")
+ return
+ }
+ ref := git.DefaultBranch()
+ tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
+ "Repo": git.Repo(),
+ "IsPrivate": git.IsPrivate(),
+ "Ref": ref,
+ "Thread": t,
+ "Description": git.Description(),
+ "Branches": git.Branches(),
+ "Tags": git.Tags(),
+ "Commits": git.CommitCount(ref),
+ "ActiveTab": "threads",
+ }))
+ }
+}
+
+func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ threadID := r.PathValue("threadId")
+ body := strings.TrimSpace(r.FormValue("body"))
+ if body == "" {
+ http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
+ return
+ }
+ author, authorName := threadAuthor(r)
+ rp := repoPath(cfg, git.Name)
+ if err := addReply(rp, threadID, body, author, authorName); err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
+ }
+}
+
+func threadAuthor(r *http.Request) (author, displayName string) {
+ author = User(r)
+ if author == "" {
+ author = "anonymous"
+ }
+ return author, author
+}
+
+func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {
+ body := strings.TrimSpace(r.FormValue("body"))
+ rp := repoPath(cfg, repoName)
+
+ // If the user wrote a comment, save it before changing state.
+ if body != "" {
+ author, authorName := threadAuthor(r)
+ if err := addReply(rp, threadID, body, author, authorName); err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+ return
+ }
+ }
+
+ if err := action(rp, threadID); err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", repoName, threadID), http.StatusSeeOther)
+}
+
+func ThreadClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, closeThread)
+ }
+}
+
+func ThreadReopenPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+ if !checkRepoAccess(w, r, git) {
+ return
+ }
+ closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, reopenThread)
+ }
+}
diff --git a/main.go b/main.go
index 62393c2..098f36e 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,14 @@ func main() {
mux.HandleFunc("GET /{repo}/settings", SettingsGet(cfg, tmpl))
mux.HandleFunc("POST /{repo}/settings", SettingsPost(cfg, tmpl))
+ mux.HandleFunc("GET /{repo}/threads", ThreadsList(cfg, tmpl))
+ mux.HandleFunc("GET /{repo}/threads/new", ThreadNewGet(cfg, tmpl))
+ mux.HandleFunc("POST /{repo}/threads/new", ThreadNewPost(cfg, tmpl))
+ mux.HandleFunc("GET /{repo}/threads/{threadId}", ThreadView(cfg, tmpl))
+ mux.HandleFunc("POST /{repo}/threads/{threadId}/reply", ThreadReplyPost(cfg, tmpl))
+ mux.HandleFunc("POST /{repo}/threads/{threadId}/close", ThreadClosePost(cfg, tmpl))
+ mux.HandleFunc("POST /{repo}/threads/{threadId}/reopen", ThreadReopenPost(cfg, tmpl))
+
// Wrap with auth middleware and setup redirect.
var handler http.Handler = mux
if len(cookieSecret) > 0 {
diff --git a/templates/thread_new.html b/templates/thread_new.html
new file mode 100644
index 0000000..ac0b93b
--- /dev/null
+++ b/templates/thread_new.html
@@ -0,0 +1,21 @@
+{{template "head" .}}
+{{define "title"}}new thread - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-new-form">
+ <h2>New Thread</h2>
+ <form method="POST" action="/{{.Repo}}/threads/new">
+ <div class="form-group">
+ <label for="title">Title</label>
+ <input type="text" id="title" name="title" placeholder="Brief summary" required>
+ </div>
+ <div class="form-group">
+ <label for="body">Description</label>
+ <textarea id="body" name="body" rows="8" placeholder="Detailed description (markdown supported)"></textarea>
+ </div>
+ <button type="submit" class="btn btn-primary">Create thread</button>
+ </form>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/thread_view.html b/templates/thread_view.html
new file mode 100644
index 0000000..e5d6969
--- /dev/null
+++ b/templates/thread_view.html
@@ -0,0 +1,56 @@
+{{template "head" .}}
+{{define "title"}}{{.Thread.Title}} - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-detail">
+ <div class="thread-detail-header">
+ <h2>{{.Thread.Title}}</h2>
+ <span class="thread-state thread-state-{{.Thread.State}}">{{.Thread.State}}</span>
+ </div>
+ <p class="thread-meta-line">
+ <strong>{{.Thread.AuthorName}}</strong> opened this thread · {{timeAgo .Thread.Created}}
+ </p>
+</div>
+
+<!-- Original post -->
+<div class="thread-comment">
+ <div class="thread-comment-header">
+ <strong>{{.Thread.AuthorName}}</strong>
+ <span>{{timeAgo .Thread.Created}}</span>
+ </div>
+ <div class="thread-comment-body markdown-body">{{.Thread.Body}}</div>
+</div>
+
+<!-- Replies -->
+{{range .Thread.Replies}}
+<div class="thread-comment">
+ <div class="thread-comment-header">
+ <strong>{{.AuthorName}}</strong>
+ <span>{{timeAgo .Created}}</span>
+ </div>
+ <div class="thread-comment-body markdown-body">{{.Body}}</div>
+</div>
+{{end}}
+
+<!-- Reply form -->
+<div class="thread-reply-form">
+ <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply">
+ <div class="form-group">
+ <textarea name="body" rows="4" placeholder="Leave a comment..."></textarea>
+ </div>
+ <div class="thread-actions">
+ {{if eq .Thread.State "open"}}
+ <button type="submit" class="btn btn-primary">Comment</button>
+ <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close" class="btn btn-secondary">Close
+ thread</button>
+ {{else}}
+ <button type="submit" class="btn btn-primary">Comment</button>
+ <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen" class="btn btn-secondary">Reopen
+ thread</button>
+ {{end}}
+ </div>
+ </form>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/threads.html b/templates/threads.html
new file mode 100644
index 0000000..3b52c9c
--- /dev/null
+++ b/templates/threads.html
@@ -0,0 +1,62 @@
+{{template "head" .}}
+{{define "title"}}threads - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="threads-header">
+ <div class="threads-state-toggle">
+ <a href="/{{.Repo}}/threads?state=open" {{if eq .State "open" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
+ </svg>
+ {{.OpenCount}} Open
+ </a>
+ <a href="/{{.Repo}}/threads?state=closed" {{if eq .State "closed" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
+ </svg>
+ {{.ClosedCount}} Closed
+ </a>
+ </div>
+ <a href="/{{.Repo}}/threads/new" class="btn btn-primary">New thread</a>
+</div>
+
+{{if .Threads}}
+<div class="thread-list">
+ {{range .Threads}}
+ <div class="thread-list-item">
+ <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
+ {{if eq .State "open"}}
+ <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
+ {{else}}
+ <path
+ d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />{{end}}
+ </svg>
+ <div class="thread-list-content">
+ <a href="/{{$.Repo}}/threads/{{.ID}}" class="thread-title">{{.Title}}</a>
+ <span class="thread-meta">opened by {{.AuthorName}} · {{timeAgo .Updated}}</span>
+ </div>
+ {{if .Replies}}
+ <span class="thread-reply-count">
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25Z" />
+ </svg>
+ {{len .Replies}}
+ </span>
+ {{end}}
+ </div>
+ {{end}}
+</div>
+{{else}}
+<div class="empty-state">
+ <p>No {{.State}} threads yet.</p>
+</div>
+{{end}}
+{{template "foot" .}}
+
diff --git a/threads.go b/threads.go
new file mode 100644
index 0000000..9e9fe97
--- /dev/null
+++ b/threads.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+var threadLocks sync.Map
+
+func lockThread(threadPath string) func() {
+ mu := &sync.Mutex{}
+ actual, _ := threadLocks.LoadOrStore(threadPath, mu)
+ mu = actual.(*sync.Mutex)
+ mu.Lock()
+ return func() { mu.Unlock() }
+}
+
+type Thread struct {
+ ID string `json:"id"`
+ State string `json:"state"` // "open" or "closed"
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Author string `json:"author"` // AP actor URI or "admin"
+ AuthorName string `json:"author_name"` // display name
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+ Replies []Reply `json:"replies"`
+}
+
+type Reply struct {
+ ID string `json:"id"`
+ Body string `json:"body"`
+ Author string `json:"author"`
+ AuthorName string `json:"author_name"`
+ Created string `json:"created"`
+}
+
+func threadsDir(repoPath string) string {
+ return filepath.Join(repoPath, ".forge", "threads")
+}
+
+func newULID() string {
+ t := time.Now().UnixMilli()
+ b := make([]byte, 5)
+ rand.Read(b)
+ return fmt.Sprintf("%012x%x", t, b)
+}
+
+func createThread(repoPath, title, body, author, authorName string) (*Thread, error) {
+ dir := threadsDir(repoPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ t := &Thread{
+ ID: newULID(),
+ State: "open",
+ Title: title,
+ Body: body,
+ Author: author,
+ AuthorName: authorName,
+ Created: now,
+ Updated: now,
+ Replies: []Reply{},
+ }
+
+ return t, writeThread(repoPath, t)
+}
+
+func addReply(repoPath, threadID, body, author, authorName string) error {
+ threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+ unlock := lockThread(threadPath)
+ defer unlock()
+
+ t, err := loadThread(repoPath, threadID)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ t.Replies = append(t.Replies, Reply{
+ ID: newULID(),
+ Body: body,
+ Author: author,
+ AuthorName: authorName,
+ Created: now,
+ })
+ t.Updated = now
+
+ return writeThread(repoPath, t)
+}
+
+func closeThread(repoPath, threadID string) error {
+ threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+ unlock := lockThread(threadPath)
+ defer unlock()
+
+ t, err := loadThread(repoPath, threadID)
+ if err != nil {
+ return err
+ }
+ t.State = "closed"
+ t.Updated = time.Now().UTC().Format(time.RFC3339)
+ return writeThread(repoPath, t)
+}
+
+func reopenThread(repoPath, threadID string) error {
+ threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+ unlock := lockThread(threadPath)
+ defer unlock()
+
+ t, err := loadThread(repoPath, threadID)
+ if err != nil {
+ return err
+ }
+ t.State = "open"
+ t.Updated = time.Now().UTC().Format(time.RFC3339)
+ return writeThread(repoPath, t)
+}
+
+func loadThread(repoPath, threadID string) (*Thread, error) {
+ p := filepath.Join(threadsDir(repoPath), threadID+".json")
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return nil, err
+ }
+ var t Thread
+ if err := json.Unmarshal(data, &t); err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+// listThreadsFiltered returns threads matching the given state, plus total open/closed counts.
+// A single pass over the files avoids the double-scan of separate list+count calls.
+func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
+ dir := threadsDir(repoPath)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, 0, 0
+ }
+
+ var threads []Thread
+ openCount, closedCount := 0, 0
+ for _, e := range entries {
+ if !strings.HasSuffix(e.Name(), ".json") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ if err != nil {
+ continue
+ }
+ var t Thread
+ if err := json.Unmarshal(data, &t); err != nil {
+ continue
+ }
+ if t.State == "open" {
+ openCount++
+ } else {
+ closedCount++
+ }
+ if state == "" || t.State == state {
+ threads = append(threads, t)
+ }
+ }
+
+ sort.Slice(threads, func(i, j int) bool {
+ return threads[i].Updated > threads[j].Updated
+ })
+ return threads, openCount, closedCount
+}
+
+func writeThread(repoPath string, t *Thread) error {
+ dir := threadsDir(repoPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(t, "", " ")
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(dir, t.ID+".json")
+ tmp := target + ".tmp"
+ if err := os.WriteFile(tmp, data, 0644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, target)
+}
+
+