ikurotime / gitgud

public
Add pull requests: compare, open, comment, merge
DDavid committed on 2026-06-28 01:38 commit e98a50c
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)
+ prRepo := sqlite.NewPRRepo(db)
+ pullService := app.NewPullService(prRepo, gitReader, gitSvc)
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, issueService, gitAccess, gitBackend, sm)
+ handlers := web.NewHandlers(userService, repoService, browseService, issueService, pullService, gitAccess, gitBackend, sm)
handler := web.NewRouter(cfg, handlers)
log.Printf("listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, handler))
}
internal/app/pull_service.go +119 −0
+package app
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strings"
+
+ "gitgud/internal/domain"
+)
+
+type PullService struct {
+ prs domain.PullRequestRepository
+ reader domain.GitReader
+ git domain.GitService
+}
+
+func NewPullService(prs domain.PullRequestRepository, reader domain.GitReader, git domain.GitService) *PullService {
+ return &PullService{prs: prs, reader: reader, git: git}
+}
+
+func (s *PullService) Open(ctx context.Context, repo *domain.Repository, author *domain.User, title, body, base, head string) (*domain.PullRequest, error) {
+ if author == nil {
+ return nil, domain.ErrUnauthorized
+ }
+ title = strings.TrimSpace(title)
+ base = strings.TrimSpace(base)
+ head = strings.TrimSpace(head)
+
+ if title == "" {
+ return nil, fmt.Errorf("title is required: %w", domain.ErrValidation)
+ }
+ if base == "" || head == "" || base == head {
+ return nil, fmt.Errorf("base and head must be two different branches: %w", domain.ErrValidation)
+ }
+
+ branches, err := s.reader.Branches(ctx, repo.OwnerName, repo.Name)
+ if err != nil {
+ return nil, err
+ }
+ if !slices.Contains(branches, base) || !slices.Contains(branches, head) {
+ return nil, fmt.Errorf("base and head must be existing branches: %w", domain.ErrValidation)
+ }
+
+ pr := &domain.PullRequest{
+ RepoID: repo.ID,
+ AuthorID: author.ID,
+ Title: title,
+ Body: strings.TrimSpace(body),
+ BaseBranch: base,
+ HeadBranch: head,
+ State: domain.PROpen,
+ }
+ if err := s.prs.Create(ctx, pr); err != nil {
+ return nil, err
+ }
+ return pr, nil
+}
+
+func (s *PullService) Compare(ctx context.Context, repo *domain.Repository, base, head string) (*domain.Comparison, error) {
+ return s.reader.Compare(ctx, repo.OwnerName, repo.Name, base, head)
+}
+
+func (s *PullService) Comment(ctx context.Context, pr *domain.PullRequest, 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.prs.AddComment(ctx, &domain.PRComment{
+ PRID: pr.ID,
+ AuthorID: author.ID,
+ Body: body,
+ })
+}
+
+func (s *PullService) Merge(ctx context.Context, repo *domain.Repository, pr *domain.PullRequest, actor *domain.User) error {
+ if !CanMergePR(repo, actor) {
+ return domain.ErrPermission
+ }
+ if pr.State != domain.PROpen {
+ return fmt.Errorf("pull request is not open: %w", domain.ErrValidation)
+ }
+
+ msg := fmt.Sprintf("Merge pull request #%d from %s", pr.Number, pr.HeadBranch)
+ if err := s.git.Merge(ctx, repo.OwnerName, repo.Name, pr.BaseBranch, pr.HeadBranch, msg, actor.Username, actor.Email); err != nil {
+ return err
+ }
+ return s.prs.SetState(ctx, pr.ID, domain.PRMerged)
+}
+
+func (s *PullService) Close(ctx context.Context, repo *domain.Repository, pr *domain.PullRequest, actor *domain.User) error {
+ if !(actor != nil && (actor.ID == pr.AuthorID || actor.ID == repo.OwnerID)) {
+ return domain.ErrPermission
+ }
+ return s.prs.SetState(ctx, pr.ID, domain.PRClosed)
+}
+
+func (s *PullService) List(ctx context.Context, repoID int64, state domain.PRState) ([]*domain.PullRequest, error) {
+ return s.prs.List(ctx, repoID, state)
+}
+
+func (s *PullService) Get(ctx context.Context, repoID int64, number int) (*domain.PullRequest, error) {
+ return s.prs.ByNumber(ctx, repoID, number)
+}
+
+func (s *PullService) Comments(ctx context.Context, prID int64) ([]*domain.PRComment, error) {
+ return s.prs.Comments(ctx, prID)
+}
+
+func (s *PullService) Counts(ctx context.Context, repoID int64) (open, merged, closed int, err error) {
+ return s.prs.CountByState(ctx, repoID)
+}
+
+func CanMergePR(repo *domain.Repository, actor *domain.User) bool {
+ return actor != nil && actor.ID == repo.OwnerID
+}
internal/domain/ports.go +13 −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
+ Merge(ctx context.Context, owner, name, base, head, message, authorName, authorEmail 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)
+ Compare(ctx context.Context, owner, name, base, head string) (*Comparison, error)
+}
+
+type PullRequestRepository interface {
+ Create(ctx context.Context, pr *PullRequest) error
+ ByNumber(ctx context.Context, repoID int64, number int) (*PullRequest, error)
+ List(ctx context.Context, repoID int64, state PRState) ([]*PullRequest, error)
+ SetState(ctx context.Context, id int64, state PRState) error
+
+ AddComment(ctx context.Context, c *PRComment) error
+ Comments(ctx context.Context, prID int64) ([]*PRComment, error)
+ CountByState(ctx context.Context, repoID int64) (open, merged, closed int, err error)
}
internal/domain/pullrequest.go +40 −0
+package domain
+
+import "time"
+
+type PRState string
+
+const (
+ PROpen PRState = "open"
+ PRMerged PRState = "merged"
+ PRClosed PRState = "closed"
+)
+
+type PullRequest struct {
+ ID int64
+ RepoID int64
+ Number int
+ AuthorID int64
+ AuthorName string
+ Title string
+ Body string
+ BaseBranch string
+ HeadBranch string
+ State PRState
+ CreatedAt time.Time
+}
+
+type PRComment struct {
+ ID int64
+ PRID int64
+ AuthorID int64
+ AuthorName string
+ Body string
+ CreatedAt time.Time
+}
+
+type Comparison struct {
+ Commits []Commit
+ Files []FileDiff
+ Mergeable bool
+}
internal/infra/git/cli_git.go +38 −0
package git
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
+
+ "gitgud/internal/domain"
)
type CLIGit struct {
reposDir string
}
func NewCLIGit(reposDir string) CLIGit {
return CLIGit{reposDir: reposDir}
}
func (g CLIGit) InitBare(ctx context.Context, owner, name, defaultBranch string) error {
path, err := g.repoPath(owner, name)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "git", "init", "--bare",
"--initial-branch="+defaultBranch, path)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init: %v: %s", err, out)
}
cmd = exec.CommandContext(ctx, "git", "-C", path, "config", "http.receivepack", "true")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git config http.receivepack: %v: %s", err, out)
+ }
+ return nil
+}
+
+func (g CLIGit) Merge(ctx context.Context, owner, name, base, head, message, authorName, authorEmail string) error {
+ bare, err := g.repoPath(owner, name)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.MkdirTemp("", "gitgud-merge-*")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = tmp
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
+ "GIT_COMMITTER_NAME="+authorName, "GIT_COMMITTER_EMAIL="+authorEmail)
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ if out, err := run("clone", bare, "."); err != nil {
+ return fmt.Errorf("clone: %v: %s", err, out)
+ }
+ if out, err := run("checkout", base); err != nil {
+ return fmt.Errorf("checkout %s: %v: %s", base, err, out)
+ }
+ if _, err := run("merge", "--no-ff", "-m", message, "origin/"+head); err != nil {
+ return domain.ErrConflict
+ }
+ if out, err := run("push", "origin", base); err != nil {
+ return fmt.Errorf("push: %v: %s", err, out)
}
return nil
}
func (g CLIGit) RemovePath(ctx context.Context, owner, name string) error {
path, err := g.repoPath(owner, name)
if err != nil {
return err
}
return os.RemoveAll(path)
}
func (g CLIGit) repoPath(owner, name string) (string, error) {
return repoPath(g.reposDir, owner, name)
}
func repoPath(reposDir, owner, name string) (string, error) {
if err := safeSegment(owner); err != nil {
return "", err
}
if err := safeSegment(name); err != nil {
return "", err
}
return filepath.Join(reposDir, owner, name+".git"), nil
}
func safeSegment(s string) error {
if s == "" || s == "." || s == ".." ||
strings.HasPrefix(s, ".") || strings.ContainsAny(s, `/\`) {
return fmt.Errorf("invalid path segment %q", s)
}
return nil
}
internal/infra/git/gogit_reader.go +71 −5
package git
import (
"bytes"
"context"
"errors"
"io"
"path"
"sort"
"strings"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"gitgud/internal/domain"
)
type GoGitReader struct {
reposDir string
}
func NewGoGitReader(reposDir string) *GoGitReader {
return &GoGitReader{reposDir: reposDir}
}
func (g *GoGitReader) open(owner, name string) (*gogit.Repository, error) {
p, err := repoPath(g.reposDir, owner, name)
if err != nil {
return nil, err
}
repo, err := gogit.PlainOpen(p)
if err != nil {
return nil, mapErr(err)
}
return repo, nil
}
func (g *GoGitReader) resolveCommit(repo *gogit.Repository, ref string) (*object.Commit, error) {
if strings.TrimSpace(ref) == "" {
ref = "HEAD"
}
hash, err := repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, mapErr(err)
}
c, err := repo.CommitObject(*hash)
if err != nil {
return nil, mapErr(err)
}
return c, nil
}
func (g *GoGitReader) IsEmpty(ctx context.Context, owner, name string) (bool, error) {
repo, err := g.open(owner, name)
if err != nil {
return false, err
}
_, err = repo.Head()
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return true, nil
}
if err != nil {
return false, mapErr(err)
}
return false, nil
}
func (g *GoGitReader) Branches(ctx context.Context, owner, name string) ([]string, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, err
}
iter, err := repo.Branches()
if err != nil {
return nil, mapErr(err)
}
var names []string
err = iter.ForEach(func(ref *plumbing.Reference) error {
names = append(names, ref.Name().Short())
return nil
})
if err != nil {
return nil, mapErr(err)
}
sort.Strings(names)
return names, nil
}
func (g *GoGitReader) Tip(ctx context.Context, owner, name, ref string) (*domain.Commit, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, err
}
c, err := g.resolveCommit(repo, ref)
if err != nil {
return nil, err
}
return commitDTO(c), nil
}
func (g *GoGitReader) Tree(ctx context.Context, owner, name, ref, p string) ([]domain.TreeEntry, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, err
}
c, err := g.resolveCommit(repo, ref)
if err != nil {
return nil, err
}
tree, err := c.Tree()
if err != nil {
return nil, mapErr(err)
}
p = strings.Trim(p, "/")
if p != "" {
tree, err = tree.Tree(p)
if err != nil {
return nil, mapErr(err)
}
}
entries := make([]domain.TreeEntry, 0, len(tree.Entries))
for _, e := range tree.Entries {
isDir := e.Mode == filemode.Dir
var size int64
if !isDir {
if f, err := tree.TreeEntryFile(&e); err == nil {
size = f.Size
}
}
entries = append(entries, domain.TreeEntry{
Name: e.Name,
Path: path.Join(p, e.Name),
IsDir: isDir,
Size: size,
Mode: e.Mode.String(),
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return entries[i].Name < entries[j].Name
})
return entries, nil
}
func (g *GoGitReader) Blob(ctx context.Context, owner, name, ref, p string) (*domain.FileBlob, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, err
}
c, err := g.resolveCommit(repo, ref)
if err != nil {
return nil, err
}
tree, err := c.Tree()
if err != nil {
return nil, mapErr(err)
}
f, err := tree.File(strings.Trim(p, "/"))
if err != nil {
return nil, mapErr(err)
}
reader, err := f.Reader()
if err != nil {
return nil, mapErr(err)
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return nil, mapErr(err)
}
return &domain.FileBlob{
Path: strings.Trim(p, "/"),
Content: content,
IsBinary: isBinary(content),
Size: f.Size,
}, nil
}
func (g *GoGitReader) Log(ctx context.Context, owner, name, ref string, limit, offset int) ([]domain.Commit, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, err
}
c, err := g.resolveCommit(repo, ref)
if err != nil {
return nil, err
}
iter, err := repo.Log(&gogit.LogOptions{From: c.Hash})
if err != nil {
return nil, mapErr(err)
}
defer iter.Close()
var commits []domain.Commit
skipped := 0
err = iter.ForEach(func(commit *object.Commit) error {
if skipped < offset {
skipped++
return nil
}
if limit > 0 && len(commits) >= limit {
return storerStop
}
commits = append(commits, *commitDTO(commit))
return nil
})
if err != nil && !errors.Is(err, storerStop) {
return nil, mapErr(err)
}
return commits, nil
}
func (g *GoGitReader) CommitDiff(ctx context.Context, owner, name, hash string) (*domain.Commit, []domain.FileDiff, error) {
repo, err := g.open(owner, name)
if err != nil {
return nil, nil, err
}
c, err := repo.CommitObject(plumbing.NewHash(hash))
if err != nil {
return nil, nil, mapErr(err)
}
thisTree, err := c.Tree()
if err != nil {
return nil, nil, mapErr(err)
}
var parentTree *object.Tree
if parent, err := c.Parents().Next(); err == nil {
parentTree, err = parent.Tree()
if err != nil {
return nil, nil, mapErr(err)
}
} else if !errors.Is(err, io.EOF) {
return nil, nil, mapErr(err)
}
changes, err := object.DiffTree(parentTree, thisTree)
if err != nil {
return nil, nil, mapErr(err)
}
patch, err := changes.Patch()
if err != nil {
return nil, nil, mapErr(err)
}
+ return commitDTO(c), fileDiffs(patch), nil
+}
+
+func (g *GoGitReader) Compare(ctx context.Context, owner, name, base, head string) (*domain.Comparison, error) {
+ repo, err := g.open(owner, name)
+ if err != nil {
+ return nil, err
+ }
+ baseCommit, err := g.resolveCommit(repo, base)
+ if err != nil {
+ return nil, err
+ }
+ headCommit, err := g.resolveCommit(repo, head)
+ if err != nil {
+ return nil, err
+ }
+
+ var mb *object.Commit
+ if bases, err := baseCommit.MergeBase(headCommit); err == nil && len(bases) > 0 {
+ mb = bases[0]
+ }
+
+ var ahead []domain.Commit
+ iter, err := repo.Log(&gogit.LogOptions{From: headCommit.Hash})
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ err = iter.ForEach(func(c *object.Commit) error {
+ if mb != nil && c.Hash == mb.Hash {
+ return storerStop
+ }
+ ahead = append(ahead, *commitDTO(c))
+ return nil
+ })
+ iter.Close()
+ if err != nil && !errors.Is(err, storerStop) {
+ return nil, mapErr(err)
+ }
+
+ var baseTree *object.Tree
+ if mb != nil {
+ baseTree, err = mb.Tree()
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ }
+ headTree, err := headCommit.Tree()
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ changes, err := object.DiffTree(baseTree, headTree)
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ patch, err := changes.Patch()
+ if err != nil {
+ return nil, mapErr(err)
+ }
+
+ return &domain.Comparison{
+ Commits: ahead,
+ Files: fileDiffs(patch),
+ Mergeable: len(ahead) > 0,
+ }, nil
+}
+
+func fileDiffs(patch *object.Patch) []domain.FileDiff {
var diffs []domain.FileDiff
for _, fp := range patch.FilePatches() {
from, to := fp.Files()
p := ""
if to != nil {
p = to.Path()
} else if from != nil {
p = from.Path()
}
var sb strings.Builder
added, deleted := 0, 0
for _, chunk := range fp.Chunks() {
lines := strings.SplitAfter(chunk.Content(), "\n")
for _, line := range lines {
if line == "" {
continue
}
switch chunk.Type() {
- case 1: // Add
+ case 1:
sb.WriteString("+" + line)
added++
- case 2: // Delete
+ case 2:
sb.WriteString("-" + line)
deleted++
- default: // Equal
+ default:
sb.WriteString(" " + line)
}
}
}
diffs = append(diffs, domain.FileDiff{
Path: p,
Patch: sb.String(),
Added: added,
Deleted: deleted,
})
}
-
- return commitDTO(c), diffs, nil
+ return diffs
}
var storerStop = errors.New("stop")
func commitDTO(c *object.Commit) *domain.Commit {
return &domain.Commit{
Hash: c.Hash.String(),
ShortHash: c.Hash.String()[:7],
Message: c.Message,
Author: c.Author.Name,
Email: c.Author.Email,
When: c.Author.When,
}
}
func isBinary(content []byte) bool {
n := len(content)
if n > 8000 {
n = 8000
}
return bytes.IndexByte(content[:n], 0) >= 0
}
func mapErr(err error) error {
switch {
case errors.Is(err, gogit.ErrRepositoryNotExists),
errors.Is(err, plumbing.ErrReferenceNotFound),
errors.Is(err, plumbing.ErrObjectNotFound),
errors.Is(err, object.ErrFileNotFound),
errors.Is(err, object.ErrDirectoryNotFound),
errors.Is(err, object.ErrEntryNotFound):
return domain.ErrNotFound
default:
return err
}
}
internal/infra/persistence/sqlite/pr_repo.go +152 −0
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "gitgud/internal/domain"
+)
+
+type PRRepo struct {
+ db *sql.DB
+}
+
+func NewPRRepo(db *sql.DB) *PRRepo {
+ return &PRRepo{db: db}
+}
+
+func (r *PRRepo) Create(ctx context.Context, pr *domain.PullRequest) 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 pull_requests WHERE repo_id=?`, pr.RepoID).Scan(&next)
+ if err != nil {
+ return err
+ }
+ pr.Number = next
+
+ res, err := tx.ExecContext(ctx,
+ `INSERT INTO pull_requests(repo_id,number,author_id,title,body,base_branch,head_branch,state)
+ VALUES(?,?,?,?,?,?,?,?)`,
+ pr.RepoID, pr.Number, pr.AuthorID, pr.Title, pr.Body, pr.BaseBranch, pr.HeadBranch, string(pr.State))
+ if err != nil {
+ return err
+ }
+ pr.ID, _ = res.LastInsertId()
+ return tx.Commit()
+}
+
+func (r *PRRepo) ByNumber(ctx context.Context, repoID int64, number int) (*domain.PullRequest, error) {
+ const q = `SELECT p.id, p.repo_id, p.number, p.author_id, u.username, p.title, p.body, p.base_branch, p.head_branch, p.state, p.created_at
+FROM pull_requests p
+JOIN users u ON u.id = p.author_id
+WHERE p.repo_id = ? AND p.number = ?`
+
+ pr, err := scanPR(r.db.QueryRowContext(ctx, q, repoID, number))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrNotFound
+ }
+ return nil, err
+ }
+ return pr, nil
+}
+
+func (r *PRRepo) List(ctx context.Context, repoID int64, state domain.PRState) ([]*domain.PullRequest, error) {
+ q := `SELECT p.id, p.repo_id, p.number, p.author_id, u.username, p.title, p.body, p.base_branch, p.head_branch, p.state, p.created_at
+FROM pull_requests p
+JOIN users u ON u.id = p.author_id
+WHERE p.repo_id = ?`
+ args := []any{repoID}
+ if state != "" {
+ q += ` AND p.state = ?`
+ args = append(args, string(state))
+ }
+ q += ` ORDER BY p.number DESC`
+
+ rows, err := r.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var prs []*domain.PullRequest
+ for rows.Next() {
+ pr, err := scanPR(rows)
+ if err != nil {
+ return nil, err
+ }
+ prs = append(prs, pr)
+ }
+ return prs, rows.Err()
+}
+
+func (r *PRRepo) SetState(ctx context.Context, id int64, state domain.PRState) error {
+ _, err := r.db.ExecContext(ctx, `UPDATE pull_requests SET state=? WHERE id=?`, string(state), id)
+ return err
+}
+
+func (r *PRRepo) AddComment(ctx context.Context, c *domain.PRComment) error {
+ res, err := r.db.ExecContext(ctx,
+ `INSERT INTO pr_comments(pr_id,author_id,body) VALUES(?,?,?)`,
+ c.PRID, c.AuthorID, c.Body)
+ if err != nil {
+ return err
+ }
+ c.ID, _ = res.LastInsertId()
+ return nil
+}
+
+func (r *PRRepo) Comments(ctx context.Context, prID int64) ([]*domain.PRComment, error) {
+ const q = `SELECT c.id, c.pr_id, c.author_id, u.username, c.body, c.created_at
+FROM pr_comments c
+JOIN users u ON u.id = c.author_id
+WHERE c.pr_id = ?
+ORDER BY c.created_at, c.id`
+
+ rows, err := r.db.QueryContext(ctx, q, prID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var comments []*domain.PRComment
+ for rows.Next() {
+ var c domain.PRComment
+ if err := rows.Scan(&c.ID, &c.PRID, &c.AuthorID, &c.AuthorName, &c.Body, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ comments = append(comments, &c)
+ }
+ return comments, rows.Err()
+}
+
+func (r *PRRepo) CountByState(ctx context.Context, repoID int64) (open, merged, closed int, err error) {
+ const q = `SELECT
+ COALESCE(SUM(CASE WHEN state='open' THEN 1 ELSE 0 END),0),
+ COALESCE(SUM(CASE WHEN state='merged' THEN 1 ELSE 0 END),0),
+ COALESCE(SUM(CASE WHEN state='closed' THEN 1 ELSE 0 END),0)
+FROM pull_requests WHERE repo_id=?`
+ err = r.db.QueryRowContext(ctx, q, repoID).Scan(&open, &merged, &closed)
+ return open, merged, closed, err
+}
+
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanPR(row rowScanner) (*domain.PullRequest, error) {
+ var pr domain.PullRequest
+ err := row.Scan(&pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorName,
+ &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.State, &pr.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &pr, nil
+}
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
+ pulls *app.PullService
gitAccess *app.GitAccessService
gitBackend *git.Backend
sm *scs.SessionManager
}
-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 NewHandlers(users *app.UserService, repos *app.RepoService, browse *app.BrowseService, issues *app.IssueService, pulls *app.PullService, gitAccess *app.GitAccessService, gitBackend *git.Backend, sm *scs.SessionManager) *Handlers {
+ return &Handlers{users: users, repos: repos, browse: browse, issues: issues, pulls: pulls, 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/pull_handler.go +179 −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) pullsList(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+
+ state := r.URL.Query().Get("state")
+ var filter domain.PRState
+ switch state {
+ case "merged":
+ filter = domain.PRMerged
+ case "closed":
+ filter = domain.PRClosed
+ case "all":
+ filter = ""
+ default:
+ state = "open"
+ filter = domain.PROpen
+ }
+
+ prs, err := h.pulls.List(r.Context(), repo.ID, filter)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ open, merged, closed, err := h.pulls.Counts(r.Context(), repo.ID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ render(w, r, http.StatusOK, templates.PullsList(currentUser(r.Context()), repo, state, prs, open, merged, closed))
+}
+
+func (h *Handlers) comparePull(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+
+ branches, err := h.browse.Branches(r.Context(), repo)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+
+ base := r.URL.Query().Get("base")
+ if base == "" {
+ base = repo.DefaultBranch
+ }
+ head := r.URL.Query().Get("head")
+
+ var cmp *domain.Comparison
+ errMsg := ""
+ if head != "" && head != base {
+ cmp, err = h.pulls.Compare(r.Context(), repo, base, head)
+ if err != nil {
+ errMsg = "could not compare those branches"
+ }
+ }
+ render(w, r, http.StatusOK, templates.Compare(currentUser(r.Context()), repo, base, head, branches, cmp, errMsg))
+}
+
+func (h *Handlers) createPull(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ title := r.FormValue("title")
+ body := r.FormValue("body")
+ base := r.FormValue("base")
+ head := r.FormValue("head")
+
+ pr, err := h.pulls.Open(r.Context(), repo, currentUser(r.Context()), title, body, base, head)
+ if err != nil {
+ if errors.Is(err, domain.ErrValidation) {
+ branches, _ := h.browse.Branches(r.Context(), repo)
+ cmp, _ := h.pulls.Compare(r.Context(), repo, base, head)
+ msg := strings.TrimSuffix(err.Error(), ": "+domain.ErrValidation.Error())
+ render(w, r, http.StatusBadRequest, templates.Compare(currentUser(r.Context()), repo, base, head, branches, cmp, msg))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/pulls/"+strconv.Itoa(pr.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) pullDetail(w http.ResponseWriter, r *http.Request) {
+ repo, pr := h.loadPull(w, r)
+ if pr == nil {
+ return
+ }
+ h.renderPull(w, r, repo, pr, "", http.StatusOK)
+}
+
+func (h *Handlers) addPullComment(w http.ResponseWriter, r *http.Request) {
+ repo, pr := h.loadPull(w, r)
+ if pr == nil {
+ return
+ }
+ if err := h.pulls.Comment(r.Context(), pr, currentUser(r.Context()), r.FormValue("body")); err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/pulls/"+strconv.Itoa(pr.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) mergePull(w http.ResponseWriter, r *http.Request) {
+ repo, pr := h.loadPull(w, r)
+ if pr == nil {
+ return
+ }
+ err := h.pulls.Merge(r.Context(), repo, pr, currentUser(r.Context()))
+ if err == nil {
+ http.Redirect(w, r, repoURL(repo, "/pulls/"+strconv.Itoa(pr.Number)), http.StatusSeeOther)
+ return
+ }
+ if errors.Is(err, domain.ErrConflict) {
+ h.renderPull(w, r, repo, pr, "Cannot merge automatically — the branches conflict.", http.StatusConflict)
+ return
+ }
+ h.gitError(w, r, err)
+}
+
+func (h *Handlers) closePull(w http.ResponseWriter, r *http.Request) {
+ repo, pr := h.loadPull(w, r)
+ if pr == nil {
+ return
+ }
+ if err := h.pulls.Close(r.Context(), repo, pr, currentUser(r.Context())); err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoURL(repo, "/pulls/"+strconv.Itoa(pr.Number)), http.StatusSeeOther)
+}
+
+func (h *Handlers) renderPull(w http.ResponseWriter, r *http.Request, repo *domain.Repository, pr *domain.PullRequest, errMsg string, status int) {
+ cmp, _ := h.pulls.Compare(r.Context(), repo, pr.BaseBranch, pr.HeadBranch)
+ comments, err := h.pulls.Comments(r.Context(), pr.ID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ canMerge := pr.State == domain.PROpen && app.CanMergePR(repo, currentUser(r.Context())) && cmp != nil && cmp.Mergeable
+ render(w, r, status, templates.PullDetail(currentUser(r.Context()), repo, pr, cmp, comments, canMerge, errMsg))
+}
+
+func (h *Handlers) loadPull(w http.ResponseWriter, r *http.Request) (*domain.Repository, *domain.PullRequest) {
+ 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
+ }
+ pr, err := h.pulls.Get(r.Context(), repo.ID, number)
+ if err != nil {
+ h.gitError(w, r, err)
+ return nil, nil
+ }
+ return repo, pr
+}
internal/interface/web/router.go +9 −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)
+ r.Get("/{owner}/{repo}/pulls", h.pullsList)
+ r.Get("/{owner}/{repo}/compare", h.comparePull)
+ r.With(h.requireAuth).Get("/{owner}/{repo}/pulls/new", h.comparePull)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/pulls", h.createPull)
+ r.Get("/{owner}/{repo}/pulls/{number}", h.pullDetail)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/pulls/{number}/comments", h.addPullComment)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/pulls/{number}/merge", h.mergePull)
+ r.With(h.requireAuth).Post("/{owner}/{repo}/pulls/{number}/close", h.closePull)
+
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/compare.templ +52 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ Compare(user *domain.User, repo *domain.Repository, base, head string, branches []string, cmp *domain.Comparison, errMsg string) {
+ @Layout("Compare · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "pulls")
+ <h2 class="text-lg mb-3">Compare changes</h2>
+ if errMsg != "" {
+ <p class="text-red-600 mb-3">{ errMsg }</p>
+ }
+ <form method="get" action={ templ.SafeURL(repoPath(repo, "/compare")) } class="flex items-center gap-2 mb-4 text-sm">
+ <span>base</span>
+ <select name="base" class="border px-2 py-1">
+ @branchOptions(branches, base)
+ </select>
+ <span>←</span>
+ <span>compare</span>
+ <select name="head" class="border px-2 py-1">
+ <option value="">select branch</option>
+ @branchOptions(branches, head)
+ </select>
+ <button class="border px-3 py-1" type="submit">Compare</button>
+ </form>
+ if cmp != nil {
+ <div class="mb-4 text-sm text-gray-600">{ itoa(len(cmp.Commits)) } commit(s), { itoa(len(cmp.Files)) } file(s) changed</div>
+ if user != nil && cmp.Mergeable {
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/pulls")) } class="flex flex-col gap-3 mb-6 border p-3">
+ <input type="hidden" name="base" value={ base }/>
+ <input type="hidden" name="head" value={ head }/>
+ <input class="border px-2 py-1" type="text" name="title" placeholder="Title" value={ head }/>
+ <textarea class="border px-2 py-1" name="body" rows="5" placeholder="Description (markdown supported)"></textarea>
+ <div><button class="border px-3 py-1" type="submit">Create pull request</button></div>
+ </form>
+ }
+ @commitsList(repo, cmp.Commits)
+ @diffFiles(cmp.Files)
+ }
+ </div>
+ }
+}
+
+templ branchOptions(branches []string, selected string) {
+ for _, b := range branches {
+ if b == selected {
+ <option value={ b } selected>{ b }</option>
+ } else {
+ <option value={ b }>{ b }</option>
+ }
+ }
+}
internal/interface/web/templates/compare_templ.go +312 −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 Compare(user *domain.User, repo *domain.Repository, base, head string, branches []string, cmp *domain.Comparison, 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, "pulls").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\">Compare changes</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/compare.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=\"get\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 templ.SafeURL = templ.SafeURL(repoPath(repo, "/compare"))
+ _, 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 items-center gap-2 mb-4 text-sm\"><span>base</span> <select name=\"base\" class=\"border px-2 py-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = branchOptions(branches, base).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</select> <span>←</span> <span>compare</span> <select name=\"head\" class=\"border px-2 py-1\"><option value=\"\">select branch</option>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = branchOptions(branches, head).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select> <button class=\"border px-3 py-1\" type=\"submit\">Compare</button></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if cmp != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"mb-4 text-sm text-gray-600\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(cmp.Commits)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 27, Col: 68}
+ }
+ _, 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, 10, " commit(s), ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(cmp.Files)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 27, Col: 104}
+ }
+ _, 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, 11, " file(s) changed</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if user != nil && cmp.Mergeable {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(repoPath(repo, "/pulls"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"flex flex-col gap-3 mb-6 border p-3\"><input type=\"hidden\" name=\"base\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 30, Col: 51}
+ }
+ _, 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, 14, "\"> <input type=\"hidden\" name=\"head\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(head)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 31, Col: 51}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> <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_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(head)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 32, Col: 95}
+ }
+ _, 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, 16, "\"> <textarea class=\"border px-2 py-1\" name=\"body\" rows=\"5\" placeholder=\"Description (markdown supported)\"></textarea><div><button class=\"border px-3 py-1\" type=\"submit\">Create pull request</button></div></form>")
+ 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
+ }
+ templ_7745c5c3_Err = commitsList(repo, cmp.Commits).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = diffFiles(cmp.Files).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Compare · "+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 branchOptions(branches []string, selected 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_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ for _, b := range branches {
+ if b == selected {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<option value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(b)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 47, Col: 20}
+ }
+ _, 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, 21, "\" selected>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(b)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 47, Col: 35}
+ }
+ _, 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, 22, "</option>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<option value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(b)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 49, Col: 20}
+ }
+ _, 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, 24, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(b)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/compare.templ`, Line: 49, Col: 26}
+ }
+ _, 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, 25, "</option>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
internal/interface/web/templates/diff.templ +33 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ diffFiles(files []domain.FileDiff) {
+ for _, d := range files {
+ <div class="border mb-4">
+ <div class="bg-gray-100 px-3 py-1 text-sm flex justify-between">
+ <span class="font-mono">{ d.Path }</span>
+ <span class="text-xs">
+ <span class="text-green-700">+{ itoa(d.Added) }</span>
+ <span class="text-red-700">-{ itoa(d.Deleted) }</span>
+ </span>
+ </div>
+ <div class="font-mono text-xs overflow-x-auto">
+ for _, line := range diffLines(d.Patch) {
+ <div class={ "whitespace-pre px-3 " + diffLineClass(line) }>{ line }</div>
+ }
+ </div>
+ </div>
+ }
+}
+
+templ commitsList(repo *domain.Repository, commits []domain.Commit) {
+ <ul class="border mb-4">
+ for _, c := range commits {
+ <li class="border-b px-3 py-2 flex items-center justify-between">
+ <a href={ templ.SafeURL(repoPath(repo, "/commit/"+c.Hash)) }>{ firstLine(c.Message) }</a>
+ <code class="text-xs">{ c.ShortHash }</code>
+ </li>
+ }
+ </ul>
+}
internal/interface/web/templates/diff_templ.go +198 −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 diffFiles(files []domain.FileDiff) 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)
+ for _, d := range files {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"border mb-4\"><div class=\"bg-gray-100 px-3 py-1 text-sm flex justify-between\"><span class=\"font-mono\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Path)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 9, Col: 36}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"text-xs\"><span class=\"text-green-700\">+")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(d.Added))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 11, Col: 50}
+ }
+ _, 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> <span class=\"text-red-700\">-")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(d.Deleted))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 12, Col: 50}
+ }
+ _, 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></span></div><div class=\"font-mono text-xs overflow-x-auto\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, line := range diffLines(d.Patch) {
+ var templ_7745c5c3_Var5 = []any{"whitespace-pre px-3 " + diffLineClass(line)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 1, Col: 0}
+ }
+ _, 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, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(line)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 17, Col: 71}
+ }
+ _, 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func commitsList(repo *domain.Repository, commits []domain.Commit) 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_Var8 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var8 == nil {
+ templ_7745c5c3_Var8 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<ul class=\"border mb-4\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, c := range commits {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<li class=\"border-b px-3 py-2 flex items-center justify-between\"><a href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 templ.SafeURL = templ.SafeURL(repoPath(repo, "/commit/"+c.Hash))
+ _, 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, 11, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(firstLine(c.Message))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 28, Col: 87}
+ }
+ _, 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, 12, "</a> <code class=\"text-xs\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(c.ShortHash)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/diff.templ`, Line: 29, Col: 39}
+ }
+ _, 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, 13, "</code></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</ul>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
internal/interface/web/templates/pull_detail.templ +52 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ PullDetail(user *domain.User, repo *domain.Repository, pr *domain.PullRequest, cmp *domain.Comparison, comments []*domain.PRComment, canMerge bool, errMsg string) {
+ @Layout(pr.Title+" · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "pulls")
+ <div class="flex items-center gap-2 mb-1">
+ <h2 class="text-xl">{ pr.Title } <span class="text-gray-400">#{ itoa(pr.Number) }</span></h2>
+ <span class="text-xs border px-2 py-0.5">{ string(pr.State) }</span>
+ </div>
+ <div class="text-xs text-gray-500 mb-4">
+ { pr.AuthorName } wants to merge <code>{ pr.HeadBranch }</code> into <code>{ pr.BaseBranch }</code>
+ </div>
+ if errMsg != "" {
+ <p class="text-red-600 mb-3">{ errMsg }</p>
+ }
+ @commentCard(pr.AuthorName, fmtTime(pr.CreatedAt), pr.Body)
+ for _, c := range comments {
+ @commentCard(c.AuthorName, fmtTime(c.CreatedAt), c.Body)
+ }
+ <div class="border p-3 mb-4">
+ if pr.State == domain.PRMerged {
+ <div class="text-purple-700">This pull request was merged.</div>
+ } else if pr.State == domain.PRClosed {
+ <div class="text-gray-600">This pull request was closed.</div>
+ } else if canMerge {
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/pulls/"+itoa(pr.Number)+"/merge")) }>
+ <button class="border px-3 py-1 bg-green-700 text-white" type="submit">Merge pull request</button>
+ </form>
+ } else if user != nil {
+ <div class="text-gray-600">You cannot merge this pull request.</div>
+ } else {
+ <div class="text-gray-600"><a href="/login">Sign in</a> to act on this pull request.</div>
+ }
+ </div>
+ if cmp != nil {
+ <h3 class="text-md mb-2">Commits</h3>
+ @commitsList(repo, cmp.Commits)
+ <h3 class="text-md mb-2">Files changed</h3>
+ @diffFiles(cmp.Files)
+ }
+ if user != nil {
+ <form method="post" action={ templ.SafeURL(repoPath(repo, "/pulls/"+itoa(pr.Number)+"/comments")) } class="flex flex-col gap-2 mt-4">
+ <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>
+ }
+ </div>
+ }
+}
internal/interface/web/templates/pull_detail_templ.go +255 −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 PullDetail(user *domain.User, repo *domain.Repository, pr *domain.PullRequest, cmp *domain.Comparison, comments []*domain.PRComment, canMerge bool, 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, "pulls").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(pr.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 10, Col: 34}
+ }
+ _, 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(pr.Number))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 10, Col: 83}
+ }
+ _, 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(pr.State))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 11, Col: 63}
+ }
+ _, 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\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(pr.AuthorName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 14, Col: 19}
+ }
+ _, 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, " wants to merge <code>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(pr.HeadBranch)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 14, Col: 58}
+ }
+ _, 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, "</code> into <code>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(pr.BaseBranch)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 14, Col: 94}
+ }
+ _, 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, "</code></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if errMsg != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-red-600 mb-3\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pull_detail.templ`, Line: 17, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = commentCard(pr.AuthorName, fmtTime(pr.CreatedAt), pr.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
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"border p-3 mb-4\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if pr.State == domain.PRMerged {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-purple-700\">This pull request was merged.</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if pr.State == domain.PRClosed {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"text-gray-600\">This pull request was closed.</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if canMerge {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 templ.SafeURL = templ.SafeURL(repoPath(repo, "/pulls/"+itoa(pr.Number)+"/merge"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var10)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><button class=\"border px-3 py-1 bg-green-700 text-white\" type=\"submit\">Merge pull request</button></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if user != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"text-gray-600\">You cannot merge this pull request.</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"text-gray-600\"><a href=\"/login\">Sign in</a> to act on this pull request.</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if cmp != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<h3 class=\"text-md mb-2\">Commits</h3>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = commitsList(repo, cmp.Commits).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " <h3 class=\"text-md mb-2\">Files changed</h3>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = diffFiles(cmp.Files).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if user != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 templ.SafeURL = templ.SafeURL(repoPath(repo, "/pulls/"+itoa(pr.Number)+"/comments"))
+ _, 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, 22, "\" class=\"flex flex-col gap-2 mt-4\"><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
+ }
+ }
+ 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(pr.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
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
internal/interface/web/templates/pulls_list.templ +31 −0
+package templates
+
+import "gitgud/internal/domain"
+
+templ PullsList(user *domain.User, repo *domain.Repository, state string, prs []*domain.PullRequest, openCount, mergedCount, closedCount int) {
+ @Layout("Pull requests · "+repo.Name, user) {
+ <div class="max-w-3xl mx-auto">
+ @repoHeader(repo, "pulls")
+ <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, "/pulls?state=open")) }>{ itoa(openCount) } Open</a>
+ <a class={ stateTabClass(state, "merged") } href={ templ.SafeURL(repoPath(repo, "/pulls?state=merged")) }>{ itoa(mergedCount) } Merged</a>
+ <a class={ stateTabClass(state, "closed") } href={ templ.SafeURL(repoPath(repo, "/pulls?state=closed")) }>{ itoa(closedCount) } Closed</a>
+ </div>
+ <a class="border px-3 py-1 text-sm" href={ templ.SafeURL(repoPath(repo, "/compare")) }>New pull request</a>
+ </div>
+ if len(prs) == 0 {
+ <p class="text-gray-500">No pull requests to show.</p>
+ } else {
+ <ul class="border">
+ for _, p := range prs {
+ <li class="border-b px-3 py-2">
+ <a href={ templ.SafeURL(repoPath(repo, "/pulls/"+itoa(p.Number))) }>{ p.Title }</a>
+ <div class="text-xs text-gray-500">#{ itoa(p.Number) } · { string(p.State) } · { p.HeadBranch } → { p.BaseBranch } · by { p.AuthorName }</div>
+ </li>
+ }
+ </ul>
+ }
+ </div>
+ }
+}
internal/interface/web/templates/pulls_list_templ.go +321 −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 PullsList(user *domain.User, repo *domain.Repository, state string, prs []*domain.PullRequest, openCount, mergedCount, 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, "pulls").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/pulls_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, "/pulls?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/pulls_list.templ`, Line: 11, Col: 124}
+ }
+ _, 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, "merged")}
+ 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/pulls_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, "/pulls?state=merged"))
+ _, 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(mergedCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 12, Col: 130}
+ }
+ _, 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, " Merged</a> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 = []any{stateTabClass(state, "closed")}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 1, Col: 0}
+ }
+ _, 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, 12, "\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 templ.SafeURL = templ.SafeURL(repoPath(repo, "/pulls?state=closed"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var13)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(closedCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 13, Col: 130}
+ }
+ _, 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, 14, " Closed</a></div><a class=\"border px-3 py-1 text-sm\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 templ.SafeURL = templ.SafeURL(repoPath(repo, "/compare"))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var15)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">New pull request</a></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(prs) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p class=\"text-gray-500\">No pull requests to show.</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<ul class=\"border\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, p := range prs {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<li class=\"border-b px-3 py-2\"><a href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 templ.SafeURL = templ.SafeURL(repoPath(repo, "/pulls/"+itoa(p.Number)))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var16)))
+ 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_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 23, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a><div class=\"text-xs text-gray-500\">#")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Number))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 24, Col: 59}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(p.State))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 24, Col: 82}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadBranch)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 24, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " → ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.BaseBranch)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 24, Col: 123}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " · by ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(p.AuthorName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/pulls_list.templ`, Line: 24, Col: 146}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</ul>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Pull requests · "+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