Add issues: list, create, comment, close/reopen
DDavid committed on 2026-06-28 01:33 commit e9a21c8
cmd/server/main.go +3 −1
package main
import (
"log"
"net/http"
"gitgud/internal/app"
"gitgud/internal/infra/config"
"gitgud/internal/infra/git"
"gitgud/internal/infra/persistence/sqlite"
"gitgud/internal/infra/security"
"gitgud/internal/infra/session"
"gitgud/internal/interface/web"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := sqlite.Open(cfg.DBPath())
if err != nil {
log.Fatal(err)
}
defer db.Close()
hasher := security.NewBcryptHasher()
userRepo := sqlite.NewUserRepo(db)
userService := app.NewUserService(userRepo, hasher)
gitSvc := git.NewCLIGit(cfg.ReposDir())
gitReader := git.NewGoGitReader(cfg.ReposDir())
repoRepo := sqlite.NewRepoRepo(db)
repoService := app.NewRepoService(repoRepo, gitSvc)
browseService := app.NewBrowseService(repoRepo, gitReader)
+ issueRepo := sqlite.NewIssueRepo(db)
+ issueService := app.NewIssueService(issueRepo)
gitAccess := app.NewGitAccessService(repoRepo)
gitBackend, err := git.NewBackend(cfg.ReposDir())
if err != nil {
log.Fatal(err)
}
sm := session.NewSessionManager(db)
- handlers := web.NewHandlers(userService, repoService, browseService, gitAccess, gitBackend, sm)
+ handlers := web.NewHandlers(userService, repoService, browseService, issueService, gitAccess, gitBackend, sm)
handler := web.NewRouter(cfg, handlers)
log.Printf("listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, handler))
}
internal/app/issue_service.go +100 −0
+package app
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "gitgud/internal/domain"
+)
+
+type IssueService struct {
+ issues domain.IssueRepository
+}
+
+func NewIssueService(issues domain.IssueRepository) *IssueService {
+ return &IssueService{issues: issues}
+}
+
+func (s *IssueService) Open(ctx context.Context, repo *domain.Repository, author *domain.User, title, body string) (*domain.Issue, error) {
+ if author == nil {
+ return nil, domain.ErrUnauthorized
+ }
+ title = strings.TrimSpace(title)
+ if title == "" {
+ return nil, fmt.Errorf("title is required: %w", domain.ErrValidation)
+ }
+
+ issue := &domain.Issue{
+ RepoID: repo.ID,
+ AuthorID: author.ID,
+ Title: title,
+ Body: strings.TrimSpace(body),
+ State: domain.IssueOpen,
+ }
+ if err := s.issues.Create(ctx, issue); err != nil {
+ return nil, err
+ }
+ return issue, nil
+}
+
+func (s *IssueService) Comment(ctx context.Context, issue *domain.Issue, author *domain.User, body string) error {
+ if author == nil {
+ return domain.ErrUnauthorized
+ }
+ body = strings.TrimSpace(body)
+ if body == "" {
+ return fmt.Errorf("comment is required: %w", domain.ErrValidation)
+ }
+ return s.issues.AddComment(ctx, &domain.IssueComment{
+ IssueID: issue.ID,
+ AuthorID: author.ID,
+ Body: body,
+ })
+}
+
+func (s *IssueService) Close(ctx context.Context, repo *domain.Repository, issue *domain.Issue, actor *domain.User) error {
+ if !canModifyIssue(repo, issue, actor) {
+ return domain.ErrPermission
+ }
+ return s.issues.SetState(ctx, issue.ID, domain.IssueClosed)
+}
+
+func (s *IssueService) Reopen(ctx context.Context, repo *domain.Repository, issue *domain.Issue, actor *domain.User) error {
+ if !canModifyIssue(repo, issue, actor) {
+ return domain.ErrPermission
+ }
+ return s.issues.SetState(ctx, issue.ID, domain.IssueOpen)
+}
+
+func (s *IssueService) List(ctx context.Context, repoID int64, state domain.IssueState) ([]*domain.Issue, error) {
+ return s.issues.List(ctx, repoID, state)
+}
+
+func (s *IssueService) Get(ctx context.Context, repoID int64, number int) (*domain.Issue, []*domain.IssueComment, error) {
+ issue, err := s.issues.ByNumber(ctx, repoID, number)
+ if err != nil {
+ return nil, nil, err
+ }
+ comments, err := s.issues.Comments(ctx, issue.ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return issue, comments, nil
+}
+
+func (s *IssueService) Comments(ctx context.Context, issueID int64) ([]*domain.IssueComment, error) {
+ return s.issues.Comments(ctx, issueID)
+}
+
+func (s *IssueService) Counts(ctx context.Context, repoID int64) (open, closed int, err error) {
+ return s.issues.CountByState(ctx, repoID)
+}
+
+func CanModifyIssue(repo *domain.Repository, issue *domain.Issue, actor *domain.User) bool {
+ return canModifyIssue(repo, issue, actor)
+}
+
+func canModifyIssue(repo *domain.Repository, issue *domain.Issue, actor *domain.User) bool {
+ return actor != nil && (actor.ID == issue.AuthorID || actor.ID == repo.OwnerID)
+}
internal/domain/issue.go +31 −0
+package domain
+
+import "time"
+
+type IssueState string
+
+const (
+ IssueOpen IssueState = "open"
+ IssueClosed IssueState = "closed"
+)
+
+type Issue struct {
+ ID int64
+ RepoID int64
+ Number int
+ AuthorID int64
+ AuthorName string
+ Title string
+ Body string
+ State IssueState
+ CreatedAt time.Time
+}
+
+type IssueComment struct {
+ ID int64
+ IssueID int64
+ AuthorID int64
+ AuthorName string
+ Body string
+ CreatedAt time.Time
+}
internal/domain/ports.go +11 −0
package domain
import "context"
type UserRepository interface {
Create(ctx context.Context, u *User) error
ByUsername(ctx context.Context, name string) (*User, error)
ByID(ctx context.Context, id string) (*User, error)
}
type PasswordHasher interface {
Hash(plain string) (string, error)
Compare(hash, plain string) error
}
type RepositoryRepository interface {
Create(ctx context.Context, r *Repository) error
Delete(ctx context.Context, id int64) error
ByOwnerAndName(ctx context.Context, owner, name string) (*Repository, error)
ListByOwner(ctx context.Context, ownerID int64) ([]*Repository, error)
}
type GitService interface {
InitBare(ctx context.Context, owner, name, defaultBranch string) error
RemovePath(ctx context.Context, owner, name string) error
}
+type IssueRepository interface {
+ Create(ctx context.Context, i *Issue) error
+ ByNumber(ctx context.Context, repoID int64, number int) (*Issue, error)
+ List(ctx context.Context, repoID int64, state IssueState) ([]*Issue, error)
+ SetState(ctx context.Context, id int64, state IssueState) error
+
+ AddComment(ctx context.Context, c *IssueComment) error
+ Comments(ctx context.Context, issueID int64) ([]*IssueComment, error)
+ CountByState(ctx context.Context, repoID int64) (open, closed int, err error)
+}
+
type GitReader interface {
IsEmpty(ctx context.Context, owner, name string) (bool, error)
Branches(ctx context.Context, owner, name string) ([]string, error)
Tip(ctx context.Context, owner, name, ref string) (*Commit, error)
Tree(ctx context.Context, owner, name, ref, path string) ([]TreeEntry, error)
Blob(ctx context.Context, owner, name, ref, path string) (*FileBlob, error)
Log(ctx context.Context, owner, name, ref string, limit, offset int) ([]Commit, error)
CommitDiff(ctx context.Context, owner, name, hash string) (*Commit, []FileDiff, error)
}
internal/infra/persistence/sqlite/issue_repo.go +139 −0
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "gitgud/internal/domain"
+)
+
+type IssueRepo struct {
+ db *sql.DB
+}
+
+func NewIssueRepo(db *sql.DB) *IssueRepo {
+ return &IssueRepo{db: db}
+}
+
+func (r *IssueRepo) Create(ctx context.Context, i *domain.Issue) error {
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var next int
+ err = tx.QueryRowContext(ctx,
+ `SELECT COALESCE(MAX(number),0)+1 FROM issues WHERE repo_id=?`, i.RepoID).Scan(&next)
+ if err != nil {
+ return err
+ }
+ i.Number = next
+
+ res, err := tx.ExecContext(ctx,
+ `INSERT INTO issues(repo_id,number,author_id,title,body,state) VALUES(?,?,?,?,?,?)`,
+ i.RepoID, i.Number, i.AuthorID, i.Title, i.Body, string(i.State))
+ if err != nil {
+ return err
+ }
+ i.ID, _ = res.LastInsertId()
+ return tx.Commit()
+}
+
+func (r *IssueRepo) ByNumber(ctx context.Context, repoID int64, number int) (*domain.Issue, error) {
+ const q = `SELECT i.id, i.repo_id, i.number, i.author_id, u.username, i.title, i.body, i.state, i.created_at
+FROM issues i
+JOIN users u ON u.id = i.author_id
+WHERE i.repo_id = ? AND i.number = ?`
+
+ var i domain.Issue
+ err := r.db.QueryRowContext(ctx, q, repoID, number).Scan(
+ &i.ID, &i.RepoID, &i.Number, &i.AuthorID, &i.AuthorName, &i.Title, &i.Body, &i.State, &i.CreatedAt)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrNotFound
+ }
+ return nil, err
+ }
+ return &i, nil
+}
+
+func (r *IssueRepo) List(ctx context.Context, repoID int64, state domain.IssueState) ([]*domain.Issue, error) {
+ q := `SELECT i.id, i.repo_id, i.number, i.author_id, u.username, i.title, i.body, i.state, i.created_at
+FROM issues i
+JOIN users u ON u.id = i.author_id
+WHERE i.repo_id = ?`
+ args := []any{repoID}
+ if state != "" {
+ q += ` AND i.state = ?`
+ args = append(args, string(state))
+ }
+ q += ` ORDER BY i.number DESC`
+
+ rows, err := r.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var issues []*domain.Issue
+ for rows.Next() {
+ var i domain.Issue
+ if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.AuthorID, &i.AuthorName,
+ &i.Title, &i.Body, &i.State, &i.CreatedAt); err != nil {
+ return nil, err
+ }
+ issues = append(issues, &i)
+ }
+ return issues, rows.Err()
+}
+
+func (r *IssueRepo) SetState(ctx context.Context, id int64, state domain.IssueState) error {
+ _, err := r.db.ExecContext(ctx, `UPDATE issues SET state=? WHERE id=?`, string(state), id)
+ return err
+}
+
+func (r *IssueRepo) AddComment(ctx context.Context, c *domain.IssueComment) error {
+ res, err := r.db.ExecContext(ctx,
+ `INSERT INTO issue_comments(issue_id,author_id,body) VALUES(?,?,?)`,
+ c.IssueID, c.AuthorID, c.Body)
+ if err != nil {
+ return err
+ }
+ c.ID, _ = res.LastInsertId()
+ return nil
+}
+
+func (r *IssueRepo) Comments(ctx context.Context, issueID int64) ([]*domain.IssueComment, error) {
+ const q = `SELECT c.id, c.issue_id, c.author_id, u.username, c.body, c.created_at
+FROM issue_comments c
+JOIN users u ON u.id = c.author_id
+WHERE c.issue_id = ?
+ORDER BY c.created_at, c.id`
+
+ rows, err := r.db.QueryContext(ctx, q, issueID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var comments []*domain.IssueComment
+ for rows.Next() {
+ var c domain.IssueComment
+ if err := rows.Scan(&c.ID, &c.IssueID, &c.AuthorID, &c.AuthorName, &c.Body, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ comments = append(comments, &c)
+ }
+ return comments, rows.Err()
+}
+
+func (r *IssueRepo) CountByState(ctx context.Context, repoID int64) (open, closed int, err error) {
+ const q = `SELECT
+ COALESCE(SUM(CASE WHEN state='open' THEN 1 ELSE 0 END),0),
+ COALESCE(SUM(CASE WHEN state='closed' THEN 1 ELSE 0 END),0)
+FROM issues WHERE repo_id=?`
+ err = r.db.QueryRowContext(ctx, q, repoID).Scan(&open, &closed)
+ return open, closed, err
+}
internal/interface/web/browse_handler.go +6 −3
package web
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi"
"gitgud/internal/domain"
"gitgud/internal/interface/web/presenter"
"gitgud/internal/interface/web/templates"
)
func (h *Handlers) repoTreeRoot(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
h.renderTree(w, r, repo, "", "")
}
func (h *Handlers) repoTree(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
h.renderTree(w, r, repo, chi.URLParam(r, "ref"), chi.URLParam(r, "*"))
}
func (h *Handlers) renderTree(w http.ResponseWriter, r *http.Request, repo *domain.Repository, ref, path string) {
ctx := r.Context()
empty, err := h.browse.IsEmpty(ctx, repo)
if err != nil {
h.gitError(w, r, err)
return
}
if empty {
render(w, r, http.StatusOK, templates.RepoHome(currentUser(ctx), repo))
return
}
if ref == "" {
ref = repo.DefaultBranch
}
branches, err := h.browse.Branches(ctx, repo)
if err != nil {
h.gitError(w, r, err)
return
}
entries, err := h.browse.Tree(ctx, repo, ref, path)
if err != nil {
h.gitError(w, r, err)
return
}
readme := ""
for _, e := range entries {
if !e.IsDir && strings.EqualFold(e.Name, "README.md") {
if blob, err := h.browse.Blob(ctx, repo, ref, e.Path); err == nil && !blob.IsBinary {
readme = presenter.RenderMarkdown(blob.Content)
}
break
}
}
render(w, r, http.StatusOK, templates.BrowseTree(currentUser(ctx), repo, ref, path, branches, entries, readme))
}
func (h *Handlers) repoBlob(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
ref := chi.URLParam(r, "ref")
path := chi.URLParam(r, "*")
blob, err := h.browse.Blob(r.Context(), repo, ref, path)
if err != nil {
h.gitError(w, r, err)
return
}
if ref == "" {
ref = repo.DefaultBranch
}
highlighted := ""
if !blob.IsBinary {
highlighted = presenter.Highlight(string(blob.Content), blob.Path)
}
render(w, r, http.StatusOK, templates.BrowseBlob(currentUser(r.Context()), repo, ref, blob, highlighted))
}
func (h *Handlers) repoRaw(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
blob, err := h.browse.Blob(r.Context(), repo, chi.URLParam(r, "ref"), chi.URLParam(r, "*"))
if err != nil {
h.gitError(w, r, err)
return
}
ctype := http.DetectContentType(blob.Content)
if !blob.IsBinary {
ctype = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ctype)
w.Write(blob.Content)
}
func (h *Handlers) repoCommits(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
ref := chi.URLParam(r, "ref")
commits, err := h.browse.Log(r.Context(), repo, ref, 50, 0)
if err != nil {
h.gitError(w, r, err)
return
}
if ref == "" {
ref = repo.DefaultBranch
}
render(w, r, http.StatusOK, templates.Commits(currentUser(r.Context()), repo, ref, commits))
}
func (h *Handlers) repoCommit(w http.ResponseWriter, r *http.Request) {
repo := h.viewRepo(w, r)
if repo == nil {
return
}
commit, diffs, err := h.browse.CommitDiff(r.Context(), repo, chi.URLParam(r, "hash"))
if err != nil {
h.gitError(w, r, err)
return
}
render(w, r, http.StatusOK, templates.CommitDetail(currentUser(r.Context()), repo, commit, diffs))
}
func (h *Handlers) viewRepo(w http.ResponseWriter, r *http.Request) *domain.Repository {
repo, err := h.browse.Repo(r.Context(), chi.URLParam(r, "owner"), chi.URLParam(r, "repo"), currentUser(r.Context()))
if err != nil {
h.notFound(w, r)
return nil
}
return repo
}
func (h *Handlers) gitError(w http.ResponseWriter, r *http.Request, err error) {
- if errors.Is(err, domain.ErrNotFound) {
+ switch {
+ case errors.Is(err, domain.ErrNotFound):
h.notFound(w, r)
- return
+ case errors.Is(err, domain.ErrPermission):
+ http.Error(w, "forbidden", http.StatusForbidden)
+ default:
+ http.Error(w, err.Error(), http.StatusInternalServerError)
}
- http.Error(w, err.Error(), http.StatusInternalServerError)
}
internal/interface/web/issue_handler.go +146 −0
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi"
+
+ "gitgud/internal/app"
+ "gitgud/internal/domain"
+ "gitgud/internal/interface/web/templates"
+)
+
+func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+
+ state := r.URL.Query().Get("state")
+ var filter domain.IssueState
+ switch state {
+ case "closed":
+ filter = domain.IssueClosed
+ case "all":
+ filter = ""
+ default:
+ state = "open"
+ filter = domain.IssueOpen
+ }
+
+ issues, err := h.issues.List(r.Context(), repo.ID, filter)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ open, closed, err := h.issues.Counts(r.Context(), repo.ID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ render(w, r, http.StatusOK, templates.IssuesList(currentUser(r.Context()), repo, state, issues, open, closed))
+}
+
+func (h *Handlers) newIssue(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ render(w, r, http.StatusOK, templates.IssueNew(currentUser(r.Context()), repo, "", "", ""))
+}
+
+func (h *Handlers) createIssue(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ title := r.FormValue("title")
+ body := r.FormValue("body")
+
+ issue, err := h.issues.Open(r.Context(), repo, currentUser(r.Context()), title, body)
+ if err != nil {
+ if errors.Is(err, domain.ErrValidation) {
+ msg := strings.TrimSuffix(err.Error(), ": "+domain.ErrValidation.Error())
+ render(w, r, http.StatusBadRequest, templates.IssueNew(currentUser(r.Context()), repo, title, body, msg))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/issues/"+strconv.Itoa(issue.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) issueDetail(w http.ResponseWriter, r *http.Request) {
+ repo, issue := h.loadIssue(w, r)
+ if issue == nil {
+ return
+ }
+ comments, err := h.issues.Comments(r.Context(), issue.ID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ canModify := app.CanModifyIssue(repo, issue, currentUser(r.Context()))
+ render(w, r, http.StatusOK, templates.IssueDetail(currentUser(r.Context()), repo, issue, comments, canModify))
+}
+
+func (h *Handlers) addIssueComment(w http.ResponseWriter, r *http.Request) {
+ repo, issue := h.loadIssue(w, r)
+ if issue == nil {
+ return
+ }
+ if err := h.issues.Comment(r.Context(), issue, currentUser(r.Context()), r.FormValue("body")); err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/issues/"+strconv.Itoa(issue.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) closeIssue(w http.ResponseWriter, r *http.Request) {
+ repo, issue := h.loadIssue(w, r)
+ if issue == nil {
+ return
+ }
+ if err := h.issues.Close(r.Context(), repo, issue, currentUser(r.Context())); err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/issues/"+strconv.Itoa(issue.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) reopenIssue(w http.ResponseWriter, r *http.Request) {
+ repo, issue := h.loadIssue(w, r)
+ if issue == nil {
+ return
+ }
+ if err := h.issues.Reopen(r.Context(), repo, issue, currentUser(r.Context())); err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/issues/"+strconv.Itoa(issue.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) loadIssue(w http.ResponseWriter, r *http.Request) (*domain.Repository, *domain.Issue) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return nil, nil
+ }
+ number, err := strconv.Atoi(chi.URLParam(r, "number"))
+ if err != nil {
+ h.notFound(w, r)
+ return nil, nil
+ }
+ issue, _, err := h.issues.Get(r.Context(), repo.ID, number)
+ if err != nil {
+ h.gitError(w, r, err)
+ return nil, nil
+ }
+ return repo, issue
+}
+
+func repoURL(repo *domain.Repository, sub string) string {
+ return "/" + repo.OwnerName + "/" + repo.Name + sub
+}
internal/interface/web/middleware.go +3 −2
package web
import (
"context"
"net/http"
"strconv"
"github.com/alexedwards/scs/v2"
"gitgud/internal/app"
"gitgud/internal/domain"
"gitgud/internal/infra/git"
)
const sessionUserIDKey = "user_id"
type ctxKey int
const userCtxKey ctxKey = iota
type Handlers struct {
users *app.UserService
repos *app.RepoService
browse *app.BrowseService
+ issues *app.IssueService
gitAccess *app.GitAccessService
gitBackend *git.Backend
sm *scs.SessionManager
}
-func NewHandlers(users *app.UserService, repos *app.RepoService, browse *app.BrowseService, gitAccess *app.GitAccessService, gitBackend *git.Backend, sm *scs.SessionManager) *Handlers {
- return &Handlers{users: users, repos: repos, browse: browse, gitAccess: gitAccess, gitBackend: gitBackend, sm: sm}
+func NewHandlers(users *app.UserService, repos *app.RepoService, browse *app.BrowseService, issues *app.IssueService, gitAccess *app.GitAccessService, gitBackend *git.Backend, sm *scs.SessionManager) *Handlers {
+ return &Handlers{users: users, repos: repos, browse: browse, issues: issues, gitAccess: gitAccess, gitBackend: gitBackend, sm: sm}
}
func (h *Handlers) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id := h.sm.GetInt64(r.Context(), sessionUserIDKey); id != 0 {
if u, err := h.users.ByID(r.Context(), strconv.FormatInt(id, 10)); err == nil {
r = r.WithContext(context.WithValue(r.Context(), userCtxKey, u))
}
}
next.ServeHTTP(w, r)
})
}
func (h *Handlers) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if currentUser(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func currentUser(ctx context.Context) *domain.User {
u, _ := ctx.Value(userCtxKey).(*domain.User)
return u
}
internal/interface/web/router.go +8 −0
package web
import (
"embed"
"net/http"
"github.com/a-h/templ"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"gitgud/internal/infra/config"
)
var staticFS embed.FS
func NewRouter(cfg config.Config, h *Handlers) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer)
r.Use(h.sm.LoadAndSave)
r.Use(h.withUser)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
r.Handle("/static/*", http.StripPrefix("/static", http.FileServer((http.FS(staticFS)))))
r.Get("/register", h.showRegister)
r.Post("/register", h.doRegister)
r.Get("/login", h.showLogin)
r.Post("/login", h.doLogin)
r.Post("/logout", h.doLogout)
r.With(h.requireAuth).Get("/new", h.showNewRepo)
r.With(h.requireAuth).Post("/new", h.createRepo)
r.Get("/{owner}/{repo}/info/refs", h.gitHTTP)
r.Post("/{owner}/{repo}/git-upload-pack", h.gitHTTP)
r.Post("/{owner}/{repo}/git-receive-pack", h.gitHTTP)
r.Get("/", h.dashboard)
r.Get("/{owner}", h.profile)
r.Get("/{owner}/{repo}", h.repoTreeRoot)
r.Get("/{owner}/{repo}/tree/{ref}", h.repoTree)
r.Get("/{owner}/{repo}/tree/{ref}/*", h.repoTree)
r.Get("/{owner}/{repo}/blob/{ref}/*", h.repoBlob)
r.Get("/{owner}/{repo}/raw/{ref}/*", h.repoRaw)
r.Get("/{owner}/{repo}/commits/{ref}", h.repoCommits)
r.Get("/{owner}/{repo}/commit/{hash}", h.repoCommit)
+ r.Get("/{owner}/{repo}/issues", h.issuesList)
+ r.With(h.requireAuth).Get("/{owner}/{repo}/issues/new", h.newIssue)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/issues", h.createIssue)
+ r.Get("/{owner}/{repo}/issues/{number}", h.issueDetail)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/issues/{number}/comments", h.addIssueComment)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/issues/{number}/close", h.closeIssue)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/issues/{number}/reopen", h.reopenIssue)
+
return r
}
func render(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := c.Render(r.Context(), w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
internal/interface/web/templates/helpers.go +24 −0
package templates
import (
"fmt"
"strconv"
"strings"
+ "time"
"gitgud/internal/domain"
+ "gitgud/internal/interface/web/presenter"
)
+
+func markdown(s string) string {
+ return presenter.RenderMarkdown([]byte(s))
+}
+
+func fmtTime(t time.Time) string {
+ return t.Format("2006-01-02 15:04")
+}
+
+func stateAction(state domain.IssueState) string {
+ if state == domain.IssueOpen {
+ return "/close"
+ }
+ return "/reopen"
+}
+
+func stateTabClass(current, want string) string {
+ if current == want {
+ return "font-semibold"
+ }
+ return "text-gray-600"
+}
func cloneInstructions(repo *domain.Repository) string {
url := "http://localhost:8080/" + repo.OwnerName + "/" + repo.Name + ".git"
return fmt.Sprintf(`git clone %s
cd %s
echo "# %s" > README.md
git add README.md
git commit -m "first commit"
git push -u origin %s`, url, repo.Name, repo.Name, repo.DefaultBranch)
}
type Crumb struct {
Name string
Href string
}
func treeCrumbs(repo *domain.Repository, ref, p string) []Crumb {
base := "/" + repo.OwnerName + "/" + repo.Name + "/tree/" + ref
crumbs := []Crumb{{Name: repo.Name, Href: base}}
if p = strings.Trim(p, "/"); p != "" {
acc := base
for _, seg := range strings.Split(p, "/") {
acc += "/" + seg
crumbs = append(crumbs, Crumb{Name: seg, Href: acc})
}
}
return crumbs
}
func entryHref(repo *domain.Repository, ref string, e domain.TreeEntry) string {
kind := "blob"
if e.IsDir {
kind = "tree"
}
return "/" + repo.OwnerName + "/" + repo.Name + "/" + kind + "/" + ref + "/" + e.Path
}
func repoPath(repo *domain.Repository, sub string) string {
return "/" + repo.OwnerName + "/" + repo.Name + sub
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
func itoa(n int) string {
return strconv.Itoa(n)
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func diffLineClass(line string) string {
switch {
case strings.HasPrefix(line, "+"):
return "bg-green-100"
case strings.HasPrefix(line, "-"):
return "bg-red-100"
default:
return ""
}
}
func diffLines(patch string) []string {
return strings.Split(strings.TrimRight(patch, "\n"), "\n")
}
internal/interface/web/templates/issue_detail.templ +48 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ IssueDetail(user *domain.User, repo *domain.Repository, issue *domain.Issue, comments []*domain.IssueComment, canModify bool) {
+ @Layout(issue.Title+" · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "issues")
+ <div class="flex items-center gap-2 mb-1">
+ <h2 class="text-xl">{ issue.Title } <span class="text-gray-400">#{ itoa(issue.Number) }</span></h2>
+ <span class="text-xs border px-2 py-0.5">{ string(issue.State) }</span>
+ </div>
+ <div class="text-xs text-gray-500 mb-4">opened by { issue.AuthorName } on { fmtTime(issue.CreatedAt) }</div>
+ @commentCard(issue.AuthorName, fmtTime(issue.CreatedAt), issue.Body)
+ for _, c := range comments {
+ @commentCard(c.AuthorName, fmtTime(c.CreatedAt), c.Body)
+ }
+ if canModify {
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/issues/"+itoa(issue.Number)+stateAction(issue.State))) } class="mb-4">
+ if issue.State == domain.IssueOpen {
+ <button class="border px-3 py-1 text-sm" type="submit">Close issue</button>
+ } else {
+ <button class="border px-3 py-1 text-sm" type="submit">Reopen issue</button>
+ }
+ </form>
+ }
+ if user != nil {
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/issues/"+itoa(issue.Number)+"/comments")) } class="flex flex-col gap-2">
+ <textarea class="border px-2 py-1" name="body" rows="4" placeholder="Leave a comment (markdown supported)"></textarea>
+ <div>
+ <button class="border px-3 py-1" type="submit">Comment</button>
+ </div>
+ </form>
+ } else {
+ <p class="text-sm text-gray-500"><a href="/login">Sign in</a> to comment.</p>
+ }
+ </div>
+ }
+}
+
+templ commentCard(author, when, body string) {
+ <div class="border mb-3">
+ <div class="bg-gray-100 px-3 py-1 text-xs text-gray-600">{ author } commented on { when }</div>
+ <div class="px-3 py-2">
+ @templ.Raw(markdown(body))
+ </div>
+ </div>
+}
internal/interface/web/templates/issue_detail_templ.go +260 −0
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.865
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "gitgud/internal/domain"
+
+func IssueDetail(user *domain.User, repo *domain.Repository, issue *domain.Issue, comments []*domain.IssueComment, canModify bool) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"max-w-3xl mx-auto\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = repoHeader(repo, "issues").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex items-center gap-2 mb-1\"><h2 class=\"text-xl\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 10, Col: 37}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " <span class=\"text-gray-400\">#")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(issue.Number))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 10, Col: 89}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></h2><span class=\"text-xs border px-2 py-0.5\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.State))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 11, Col: 66}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></div><div class=\"text-xs text-gray-500 mb-4\">opened by ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.AuthorName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 13, Col: 71}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " on ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmtTime(issue.CreatedAt))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 13, Col: 103}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = commentCard(issue.AuthorName, fmtTime(issue.CreatedAt), issue.Body).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, c := range comments {
+ templ_7745c5c3_Err = commentCard(c.AuthorName, fmtTime(c.CreatedAt), c.Body).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if canModify {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues/"+itoa(issue.Number)+stateAction(issue.State)))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var8)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"mb-4\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if issue.State == domain.IssueOpen {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<button class=\"border px-3 py-1 text-sm\" type=\"submit\">Close issue</button>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<button class=\"border px-3 py-1 text-sm\" type=\"submit\">Reopen issue</button>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if user != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues/"+itoa(issue.Number)+"/comments"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"flex flex-col gap-2\"><textarea class=\"border px-2 py-1\" name=\"body\" rows=\"4\" placeholder=\"Leave a comment (markdown supported)\"></textarea><div><button class=\"border px-3 py-1\" type=\"submit\">Comment</button></div></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"text-sm text-gray-500\"><a href=\"/login\">Sign in</a> to comment.</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout(issue.Title+" · "+repo.Name, user).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func commentCard(author, when, body string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var10 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var10 == nil {
+ templ_7745c5c3_Var10 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"border mb-3\"><div class=\"bg-gray-100 px-3 py-1 text-xs text-gray-600\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(author)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 43, Col: 67}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " commented on ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(when)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_detail.templ`, Line: 43, Col: 89}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"px-3 py-2\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(markdown(body)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
internal/interface/web/templates/issue_new.templ +22 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ IssueNew(user *domain.User, repo *domain.Repository, title, body, errMsg string) {
+ @Layout("New issue · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "issues")
+ <h2 class="text-lg mb-3">New issue</h2>
+ if errMsg != "" {
+ <p class="text-red-600 mb-3">{ errMsg }</p>
+ }
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/issues")) } class="flex flex-col gap-3">
+ <input class="border px-2 py-1" type="text" name="title" placeholder="Title" value={ title } autofocus/>
+ <textarea class="border px-2 py-1" name="body" rows="8" placeholder="Leave a description (markdown supported)">{ body }</textarea>
+ <div>
+ <button class="border px-3 py-1" type="submit">Create issue</button>
+ </div>
+ </form>
+ </div>
+ }
+}
internal/interface/web/templates/issue_new_templ.go +126 −0
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.865
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "gitgud/internal/domain"
+
+func IssueNew(user *domain.User, repo *domain.Repository, title, body, errMsg string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"max-w-3xl mx-auto\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = repoHeader(repo, "issues").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-lg mb-3\">New issue</h2>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if errMsg != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p class=\"text-red-600 mb-3\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_new.templ`, Line: 11, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"flex flex-col gap-3\"><input class=\"border px-2 py-1\" type=\"text\" name=\"title\" placeholder=\"Title\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_new.templ`, Line: 14, Col: 94}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" autofocus> <textarea class=\"border px-2 py-1\" name=\"body\" rows=\"8\" placeholder=\"Leave a description (markdown supported)\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(body)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issue_new.templ`, Line: 15, Col: 121}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</textarea><div><button class=\"border px-3 py-1\" type=\"submit\">Create issue</button></div></form></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("New issue · "+repo.Name, user).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
internal/interface/web/templates/issues_list.templ +32 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ IssuesList(user *domain.User, repo *domain.Repository, state string, issues []*domain.Issue, openCount, closedCount int) {
+ @Layout("Issues · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "issues")
+ <div class="flex items-center justify-between mb-3">
+ <div class="flex gap-4 text-sm">
+ <a class={ stateTabClass(state, "open") } href={ templ.SafeURL(repoPath(repo, "/issues?state=open")) }>{ itoa(openCount) } Open</a>
+ <a class={ stateTabClass(state, "closed") } href={ templ.SafeURL(repoPath(repo, "/issues?state=closed")) }>{ itoa(closedCount) } Closed</a>
+ </div>
+ if user != nil {
+ <a class="border px-3 py-1 text-sm" href={ templ.SafeURL(repoPath(repo, "/issues/new")) }>New issue</a>
+ }
+ </div>
+ if len(issues) == 0 {
+ <p class="text-gray-500">No issues to show.</p>
+ } else {
+ <ul class="border">
+ for _, i := range issues {
+ <li class="border-b px-3 py-2">
+ <a href={ templ.SafeURL(repoPath(repo, "/issues/"+itoa(i.Number))) }>{ i.Title }</a>
+ <div class="text-xs text-gray-500">#{ itoa(i.Number) } · { string(i.State) } · opened by { i.AuthorName }</div>
+ </li>
+ }
+ </ul>
+ }
+ </div>
+ }
+}
internal/interface/web/templates/issues_list_templ.go +261 −0
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.865
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "gitgud/internal/domain"
+
+func IssuesList(user *domain.User, repo *domain.Repository, state string, issues []*domain.Issue, openCount, closedCount int) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"max-w-3xl mx-auto\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = repoHeader(repo, "issues").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex items-center justify-between mb-3\"><div class=\"flex gap-4 text-sm\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 = []any{stateTabClass(state, "open")}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var3).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues?state=open"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var5)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(openCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 11, Col: 125}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " Open</a> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 = []any{stateTabClass(state, "closed")}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues?state=closed"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(closedCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 12, Col: 131}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " Closed</a></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if user != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a class=\"border px-3 py-1 text-sm\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues/new"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var11)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">New issue</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(issues) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"text-gray-500\">No issues to show.</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ul class=\"border\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, i := range issues {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<li class=\"border-b px-3 py-2\"><a href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 templ.SafeURL = templ.SafeURL(repoPath(repo, "/issues/"+itoa(i.Number)))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var12)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(i.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 24, Col: 85}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</a><div class=\"text-xs text-gray-500\">#")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i.Number))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 25, Col: 59}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(i.State))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 25, Col: 82}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " · opened by ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(i.AuthorName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/issues_list.templ`, Line: 25, Col: 112}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</ul>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Issues · "+repo.Name, user).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate