package main import ( "database/sql" "errors" "fmt" "html/template" "net/http" "strconv" "strings" "time" ) type Issue struct { ID int64 Number int64 Author User Title string Body string State string Created time.Time Updated time.Time Comments int } func (s *Store) CreateIssue(repo Repo, author User, title, body string) (Issue, error) { title = strings.TrimSpace(title) if title == "" || len(title) > 200 { return Issue{}, errors.New("Title is required and at most 200 characters") } if len(body) > 20000 { return Issue{}, errors.New("Body is too long") } now := time.Now().Unix() tx, err := s.db.Begin() if err != nil { return Issue{}, err } defer tx.Rollback() var number int64 tx.QueryRow(`select coalesce(max(number), 0) + 1 from issues where repo = ?`, repo.ID).Scan(&number) res, err := tx.Exec(`insert into issues(repo, number, author, title, body, created, updated) values (?, ?, ?, ?, ?, ?, ?)`, repo.ID, number, author.ID, title, body, now, now) if err != nil { return Issue{}, err } if err := tx.Commit(); err != nil { return Issue{}, err } id, _ := res.LastInsertId() return Issue{ID: id, Number: number, Author: author, Title: title, Body: body, State: "open"}, nil } const issueColumns = `i.id, i.number, i.title, i.body, i.state, i.created, i.updated, (select count(*) from issue_comments c where c.issue = i.id), u.id, u.name, u.created from issues i join users u on u.id = i.author` func scanIssues(rows *sql.Rows) ([]Issue, error) { defer rows.Close() var out []Issue for rows.Next() { var i Issue var created, updated, uCreated int64 if err := rows.Scan(&i.ID, &i.Number, &i.Title, &i.Body, &i.State, &created, &updated, &i.Comments, &i.Author.ID, &i.Author.Name, &uCreated); err != nil { return nil, err } i.Created, i.Updated, i.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(uCreated, 0) out = append(out, i) } return out, rows.Err() } func (s *Store) Issues(repo int64, state string) ([]Issue, error) { rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and (? = '' or i.state = ?) order by i.updated desc`, repo, state, state) if err != nil { return nil, err } return scanIssues(rows) } func (s *Store) Issue(repo, number int64) (Issue, error) { rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and i.number = ?`, repo, number) if err != nil { return Issue{}, err } issues, err := scanIssues(rows) if err != nil || len(issues) == 0 { return Issue{}, ErrNotFound } return issues[0], nil } func (s *Store) OpenIssueCount(repo int64) int { var n int s.db.QueryRow(`select count(*) from issues where repo = ? and state = 'open'`, repo).Scan(&n) return n } func (s *Store) SetIssueState(id int64, state string) { s.db.Exec(`update issues set state = ?, updated = ? where id = ?`, state, time.Now().Unix(), id) } func (s *Store) AddIssueComment(issue int64, author User, body string) error { body = strings.TrimSpace(body) if body == "" || len(body) > 20000 { return errors.New("Comment is empty or too long") } _, err := s.db.Exec(`insert into issue_comments(issue, author, body, created) values (?, ?, ?, ?)`, issue, author.ID, body, time.Now().Unix()) s.db.Exec(`update issues set updated = ? where id = ?`, time.Now().Unix(), issue) return err } func (s *Store) IssueComments(issue int64) ([]Comment, error) { rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from issue_comments c join users u on u.id = c.author where c.issue = ? order by c.id`, issue) if err != nil { return nil, err } defer rows.Close() var out []Comment for rows.Next() { var c Comment var created, uCreated int64 if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil { return nil, err } c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0) out = append(out, c) } return out, rows.Err() } func (s *Server) issues(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "issues" state := r.URL.Query().Get("state") if state == "" { state = "open" } if state == "all" { state = "" } issues, err := s.store.Issues(repo.ID, state) if err != nil { s.fail(w, r, 500, err.Error()) return } labels, _ := s.store.Labels(repo.ID) filter, _ := strconv.ParseInt(r.URL.Query().Get("label"), 10, 64) if filter != 0 { keep := s.store.ItemsWithLabel("issue", filter) var filtered []Issue for _, i := range issues { if keep[i.ID] { filtered = append(filtered, i) } } issues = filtered } itemLabels := map[int64][]Label{} for _, i := range issues { itemLabels[i.ID] = s.store.ItemLabels("issue", i.ID) } s.render(w, r, "issues", page{Title: repo.Path() + " issues", Data: map[string]any{"Page": rp, "Issues": issues, "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}}) } func (s *Server) newIssue(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") rp.Tab = "issues" if rp.User == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.render(w, r, "newissue", page{Title: "New issue", Data: map[string]any{"Page": rp}}) } func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") rp.Tab = "issues" user := rp.User if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if repo.Archived { s.fail(w, r, 403, "This repository is archived") return } issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body"))) if err != nil { w.WriteHeader(400) s.render(w, r, "newissue", page{Title: "New issue", Error: err.Error(), Data: map[string]any{"Page": rp, "IssueTitle": r.FormValue("title"), "Body": r.FormValue("body")}}) return } if repo.Owner.ID != user.ID { s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title), fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", user.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number)) } http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), http.StatusSeeOther) } func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest string) { rp, _, _ := s.repoPage(r, repo, "") rp.Tab = "issues" numStr, action, _ := strings.Cut(rest, "/") number, err := strconv.ParseInt(numStr, 10, 64) if err != nil { s.fail(w, r, 404, "No such issue") return } issue, err := s.store.Issue(repo.ID, number) if err != nil { s.fail(w, r, 404, "No such issue") return } user := rp.User canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID) back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number) if r.Method == "POST" { if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title) link := fmt.Sprintf("https://%s%s", s.host, back) switch action { case "comment": if repo.Archived { s.fail(w, r, 403, "This repository is archived") return } if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } for _, id := range []int64{issue.Author.ID, repo.Owner.ID} { if id != user.ID { s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link)) } } case "meta": s.setMeta(w, r, repo, "issue", issue.ID, back) return case "close": if canManage { s.store.SetIssueState(issue.ID, "closed") } case "reopen": if canManage { s.store.SetIssueState(issue.ID, "open") } default: s.fail(w, r, 404, "Page not found") return } http.Redirect(w, r, back, http.StatusSeeOther) return } comments, _ := s.store.IssueComments(issue.ID) rendered := make([]map[string]any, len(comments)) for i, c := range comments { rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))} } body := s.renderMarkdown([]byte(issue.Body)) s.render(w, r, "issue", page{Title: fmt.Sprintf("#%d %s", issue.Number, issue.Title), Error: r.URL.Query().Get("error"), Math: hasMath(body), Data: map[string]any{"Page": rp, "Issue": issue, "Body": body, "Comments": rendered, "CanManage": canManage, "Labels": s.store.ItemLabels("issue", issue.ID), "Assignees": s.store.Assignees("issue", issue.ID), "AllLabels": func() []Label { if !rp.Owner { return nil } l, _ := s.store.Labels(repo.ID) return l }(), "Assignable": func() []User { if !rp.Owner { return nil } return s.store.Assignable(repo) }()}}) }