- {{ $containerstyle := "py-1" }}
- {{ $linkstyle := "no-underline hover:underline" }}
+
+
+
+ {{ $containerstyle := "py-1" }}
+ {{ $linkstyle := "no-underline hover:underline" }}
-
-
-
- {{ range .Files }}
- {{ if not .IsFile }}
-
- {{ end }}
- {{ end }}
+
+
+
- {{ range .Files }}
- {{ if .IsFile }}
-
-
-
- {{ range .Commits }}
-
{{ timeFmt .LastCommit.Author.When }}
+
+
+ {{ end }}
+ {{ end }}
+
+ {{ range .Files }}
+ {{ if .IsFile }}
+
+ {{ end }}
+ {{ end }}
+
+
+ {{ range .Commits }}
+
+ "
+ >
+
+ {{ $messageParts := splitN .Message "\n\n" 2 }}
+
+
- {{ if gt (len $messageParts) 1 }}
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
- {{ end }}
-
-
-
-
- {{ slice .Hash.String 0 8 }}
-
-
-
- {{ .Author.Name }}
-
-
-
{{ timeFmt .Author.When }}
-
-
- {{ end }}
-
-
- {{- end -}}
-
-
+
{{ end }}
{{ define "repoAfter" }}
-{{- if .Readme }}
-
-{{- end -}}
+ {{- if .Readme }}
+
+ {{- end -}}
+
-
- clone
- git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}
-
+
+ clone
+
+ git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}
+
{{ end }}
diff --git a/appview/pages/templates/repo/issue.html b/appview/pages/templates/repo/issue.html
new file mode 100644
index 00000000..5b9e86f4
--- /dev/null
+++ b/appview/pages/templates/repo/issue.html
@@ -0,0 +1,107 @@
+{{ define "title" }}
+ {{ .Issue.Title }} ·
+ {{ .RepoInfo.FullName }}
+{{ end }}
+
+{{ define "repoContent" }}
+
+
+ {{ .Issue.Title }}
+ #{{ .Issue.IssueId }}
+
+
+
+
+
+ {{ $bgColor := "bg-gray-800" }}
+ {{ $icon := "ban" }}
+ {{ if eq .State "open" }}
+ {{ $bgColor = "bg-green-600" }}
+ {{ $icon = "circle-dot" }}
+ {{ end }}
+
+
+
+
+
+
+ {{ .State }}
+
+
+ opened by
+ {{ didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
+
+
+
+ {{ if .Issue.Body }}
+
+ {{ .Issue.Body | escapeHtml }}
+
+ {{ end }}
+
+
+
+
+ {{ if .LoggedInUser }}
+
+ {{ end }}
+
+ {{ if eq .LoggedInUser.Did .Issue.OwnerDid }}
+ {{ $action := "close" }}
+ {{ $icon := "circle-x" }}
+ {{ $hoverColor := "red" }}
+ {{ if eq .State "closed" }}
+ {{ $action = "reopen" }}
+ {{ $icon = "circle-dot" }}
+ {{ $hoverColor = "green" }}
+ {{ end }}
+
+ {{ end }}
+{{ end }}
diff --git a/appview/pages/templates/repo/issues.html b/appview/pages/templates/repo/issues.html
new file mode 100644
index 00000000..66137b57
--- /dev/null
+++ b/appview/pages/templates/repo/issues.html
@@ -0,0 +1,48 @@
+{{ define "title" }}issues | {{ .RepoInfo.FullName }}{{ end }}
+
+{{ define "repoContent" }}
+
+
+
+ {{ range .Issues }}
+
+
+
+
+ #{{ .IssueId }}
+
+ opened by
+ {{ .OwnerDid }}
+
+
+
+ {{ end }}
+
+{{ end }}
diff --git a/appview/pages/templates/repo/new-issue.html b/appview/pages/templates/repo/new-issue.html
new file mode 100644
index 00000000..fcc84e7e
--- /dev/null
+++ b/appview/pages/templates/repo/new-issue.html
@@ -0,0 +1,30 @@
+{{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }}
+
+{{ define "repoContent" }}
+
+{{ end }}
diff --git a/appview/state/middleware.go b/appview/state/middleware.go
index bb874901..bbb16c2a 100644
--- a/appview/state/middleware.go
+++ b/appview/state/middleware.go
@@ -187,6 +187,7 @@ func ResolveRepoKnot(s *State) Middleware {
}
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
+ ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
diff --git a/appview/state/repo.go b/appview/state/repo.go
index 24396cee..ee79e4e3 100644
--- a/appview/state/repo.go
+++ b/appview/state/repo.go
@@ -6,14 +6,17 @@ import (
"fmt"
"io"
"log"
+ "math/rand/v2"
"net/http"
"path"
+ "strconv"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/sotangled/tangled/appview/auth"
+ "github.com/sotangled/tangled/appview/db"
"github.com/sotangled/tangled/appview/pages"
"github.com/sotangled/tangled/types"
)
@@ -441,6 +444,7 @@ type FullyResolvedRepo struct {
Knot string
OwnerId identity.Identity
RepoName string
+ RepoAt string
}
func (f *FullyResolvedRepo) OwnerDid() string {
@@ -500,6 +504,234 @@ func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]page
return collaborators, nil
}
+func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issueId := chi.URLParam(r, "issue")
+ issueIdInt, err := strconv.Atoi(issueId)
+ if err != nil {
+ http.Error(w, "bad issue id", http.StatusBadRequest)
+ log.Println("failed to parse issue id", err)
+ return
+ }
+
+ issue, comments, err := s.db.GetIssueWithComments(f.RepoAt, issueIdInt)
+ if err != nil {
+ log.Println("failed to get issue and comments", err)
+ s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+ return
+ }
+
+ issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
+ if err != nil {
+ log.Println("failed to resolve issue owner", err)
+ }
+
+ s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
+ LoggedInUser: user,
+ RepoInfo: pages.RepoInfo{
+ OwnerDid: f.OwnerDid(),
+ OwnerHandle: f.OwnerHandle(),
+ Name: f.RepoName,
+ SettingsAllowed: settingsAllowed(s, user, f),
+ },
+ Issue: *issue,
+ Comments: comments,
+
+ IssueOwnerHandle: issueOwnerIdent.Handle.String(),
+ })
+
+}
+
+func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issueId := chi.URLParam(r, "issue")
+ issueIdInt, err := strconv.Atoi(issueId)
+ if err != nil {
+ http.Error(w, "bad issue id", http.StatusBadRequest)
+ log.Println("failed to parse issue id", err)
+ return
+ }
+
+ if user.Did == f.OwnerDid() {
+ err := s.db.CloseIssue(f.RepoAt, issueIdInt)
+ if err != nil {
+ log.Println("failed to close issue", err)
+ s.pages.Notice(w, "issues", "Failed to close issue. Try again later.")
+ return
+ }
+ s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+ return
+ } else {
+ log.Println("user is not the owner of the repo")
+ http.Error(w, "for biden", http.StatusUnauthorized)
+ return
+ }
+}
+
+func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issueId := chi.URLParam(r, "issue")
+ issueIdInt, err := strconv.Atoi(issueId)
+ if err != nil {
+ http.Error(w, "bad issue id", http.StatusBadRequest)
+ log.Println("failed to parse issue id", err)
+ return
+ }
+
+ if user.Did == f.OwnerDid() {
+ err := s.db.ReopenIssue(f.RepoAt, issueIdInt)
+ if err != nil {
+ log.Println("failed to reopen issue", err)
+ s.pages.Notice(w, "issues", "Failed to reopen issue. Try again later.")
+ return
+ }
+ s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+ return
+ } else {
+ log.Println("user is not the owner of the repo")
+ http.Error(w, "forbidden", http.StatusUnauthorized)
+ return
+ }
+}
+
+func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issueId := chi.URLParam(r, "issue")
+ issueIdInt, err := strconv.Atoi(issueId)
+ if err != nil {
+ http.Error(w, "bad issue id", http.StatusBadRequest)
+ log.Println("failed to parse issue id", err)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ body := r.FormValue("body")
+ if body == "" {
+ s.pages.Notice(w, "issue", "Body is required")
+ return
+ }
+
+ commentId := rand.IntN(1000000)
+ fmt.Println(commentId)
+ fmt.Println("comment id", commentId)
+
+ err := s.db.NewComment(&db.Comment{
+ OwnerDid: user.Did,
+ RepoAt: f.RepoAt,
+ Issue: issueIdInt,
+ CommentId: commentId,
+ Body: body,
+ })
+ if err != nil {
+ log.Println("failed to create comment", err)
+ s.pages.Notice(w, "issue-comment", "Failed to create comment.")
+ return
+ }
+
+ s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+ return
+ }
+}
+
+func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issues, err := s.db.GetIssues(f.RepoAt)
+ if err != nil {
+ log.Println("failed to get issues", err)
+ s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
+ return
+ }
+
+ s.pages.RepoIssues(w, pages.RepoIssuesParams{
+ LoggedInUser: s.auth.GetUser(r),
+ RepoInfo: pages.RepoInfo{
+ OwnerDid: f.OwnerDid(),
+ OwnerHandle: f.OwnerHandle(),
+ Name: f.RepoName,
+ SettingsAllowed: settingsAllowed(s, user, f),
+ },
+ Issues: issues,
+ })
+ return
+}
+
+func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
+ LoggedInUser: user,
+ RepoInfo: pages.RepoInfo{
+ Name: f.RepoName,
+ OwnerDid: f.OwnerDid(),
+ OwnerHandle: f.OwnerHandle(),
+ SettingsAllowed: settingsAllowed(s, user, f),
+ },
+ })
+ case http.MethodPost:
+ title := r.FormValue("title")
+ body := r.FormValue("body")
+
+ if title == "" || body == "" {
+ s.pages.Notice(w, "issue", "Title and body are required")
+ return
+ }
+
+ issueId, err := s.db.NewIssue(&db.Issue{
+ RepoAt: f.RepoAt,
+ Title: title,
+ Body: body,
+ OwnerDid: user.Did,
+ })
+ if err != nil {
+ log.Println("failed to create issue", err)
+ s.pages.Notice(w, "issue", "Failed to create issue.")
+ return
+ }
+
+ s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
+ return
+ }
+}
+
func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
repoName := chi.URLParam(r, "repo")
knot, ok := r.Context().Value("knot").(string)
@@ -513,10 +745,17 @@ func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
return nil, fmt.Errorf("malformed middleware")
}
+ repoAt, ok := r.Context().Value("repoAt").(string)
+ if !ok {
+ log.Println("malformed middleware")
+ return nil, fmt.Errorf("malformed middleware")
+ }
+
return &FullyResolvedRepo{
Knot: knot,
OwnerId: id,
RepoName: repoName,
+ RepoAt: repoAt,
}, nil
}
diff --git a/appview/state/state.go b/appview/state/state.go
index 0c714899..0edd6dfc 100644
--- a/appview/state/state.go
+++ b/appview/state/state.go
@@ -70,7 +70,6 @@ func Make() (*State, error) {
}
did := e.Did
- fmt.Println("got event", e.Commit.Collection, e.Commit.RKey, e.Commit.Record)
raw := json.RawMessage(e.Commit.Record)
switch e.Commit.Collection {
@@ -597,6 +596,8 @@ func (s *State) AddRepo(w http.ResponseWriter, r *http.Request) {
}
log.Println("created repo record: ", atresp.Uri)
+ repo.AtUri = atresp.Uri
+
err = s.db.AddRepo(repo)
if err != nil {
log.Println(err)
@@ -802,6 +803,16 @@ func (s *State) UserRouter() http.Handler {
r.Get("/tags", s.RepoTags)
r.Get("/blob/{ref}/*", s.RepoBlob)
+ r.Route("/issues", func(r chi.Router) {
+ r.Get("/", s.RepoIssues)
+ r.Get("/{issue}", s.RepoSingleIssue)
+ r.Get("/new", s.NewIssue)
+ r.Post("/new", s.NewIssue)
+ r.Post("/{issue}/comment", s.IssueComment)
+ r.Post("/{issue}/close", s.CloseIssue)
+ r.Post("/{issue}/reopen", s.ReopenIssue)
+ })
+
// These routes get proxied to the knot
r.Get("/info/refs", s.InfoRefs)
r.Post("/git-upload-pack", s.UploadPack)
diff --git a/go.mod b/go.mod
index 4e1525ce..ab5c6678 100644
--- a/go.mod
+++ b/go.mod
@@ -24,6 +24,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0
github.com/sethvargo/go-envconfig v1.1.0
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
+ golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
)
diff --git a/go.sum b/go.sum
index 32fdd9b3..526798aa 100644
--- a/go.sum
+++ b/go.sum
@@ -307,6 +307,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=