Files
LazyPM/pkg/web/handler/issues.go
Robin Olsen d900a5fcd3 Merge pull request #75 from LazyBachelor/LPM-140
LPM-140 The Sprinting Update
Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer.

Changes:

Introduces sprint entities in storage and exposes sprint operations via IssueService.
Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes.
Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
2026-03-23 19:26:04 +01:00

353 lines
9.6 KiB
Go

package handler
import (
"context"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/routes"
"github.com/go-chi/chi/v5"
)
const issueKey = "issue"
const commentsKey = "comments"
type IssueForm struct {
Title string `form:"title" validate:"required,max=255"`
Description string `form:"description" validate:"max=2000"`
Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked closed"`
IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"`
Priority int `form:"priority" validate:"gte=0,lte=4"`
}
type UpdateIssueForm struct {
Title *string `form:"title" validate:"omitempty,max=255"`
Description *string `form:"description" validate:"omitempty,max=2000"`
Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked closed"`
CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"`
IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"`
Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"`
Assignee *string `form:"assignee" validate:"omitempty,max=100"`
}
func CreateIssue(w http.ResponseWriter, r *http.Request) {
app := App(r)
hx := HTMX(r)
form, err := ParseForm[IssueForm](r)
if err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
if err := ValidateForm(form); err != nil {
if hx.IsHxRequest() {
hx.WriteString("<div class=\"alert alert-error\">Please fix the form errors: " + err.Error() + "</div>")
} else {
http.Error(w, "Validation error: "+err.Error(), http.StatusUnprocessableEntity)
}
return
}
issue := form.toIssue()
issue.CreatedBy = "Me"
if err := app.Issues.CreateIssue(r.Context(), issue, "Me"); err != nil {
http.Error(w, "Failed to create issue: "+err.Error(), http.StatusInternalServerError)
return
}
if hx.IsHxRequest() {
// Check if 'from' parameter says board view
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
} else {
w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID)
}
return
}
w.Header().Set("Content-Type", "application/json")
hx.WriteJSON(issue)
}
func ListIssues(w http.ResponseWriter, r *http.Request) {
app := App(r)
hx := HTMX(r)
issues, err := app.Issues.SearchIssues(r.Context(), "", models.IssueFilter{})
if err != nil {
http.Error(w, "Failed to retrieve issues", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
hx.WriteJSON(issues)
}
func IssueCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app := App(r)
id := chi.URLParam(r, "id")
issue, err := app.Issues.GetIssue(r.Context(), id)
if err != nil {
http.Error(w, "Error getting issue: "+err.Error(), http.StatusNotFound)
return
}
if issue == nil {
http.Error(w, "Issue not found", http.StatusNotFound)
return
}
comments, err := app.Issues.GetIssueComments(r.Context(), issue.ID)
if err != nil {
http.Error(w, "Error getting comments: "+err.Error(), http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), issueKey, issue)
ctx = context.WithValue(ctx, commentsKey, comments)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetIssue(w http.ResponseWriter, r *http.Request) {
issue := r.Context().Value(issueKey).(*models.Issue)
comments := r.Context().Value(commentsKey).([]*models.Comment)
hx := HTMX(r)
if strings.Contains(r.Header.Get("Accept"), "text/html") && !hx.IsHxRequest() {
routes.IssueDetailPage(issue, comments).Render(r.Context(), w)
return
}
w.Header().Set("Content-Type", "application/json")
hx.WriteJSON(issue)
}
func UpdateIssue(w http.ResponseWriter, r *http.Request) {
issue := r.Context().Value(issueKey).(*models.Issue)
app := App(r)
hx := HTMX(r)
form, err := ParseForm[UpdateIssueForm](r)
if err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
if err := ValidateForm(form); err != nil {
if hx.IsHxRequest() {
hx.WriteString("<div>Please fix the form errors</div>")
} else {
http.Error(w, "Validation error: "+err.Error(), http.StatusUnprocessableEntity)
}
return
}
changes := form.toChanges()
if err := app.Issues.UpdateIssue(r.Context(), issue.ID, changes, ""); err != nil {
http.Error(w, "Failed to update issue", http.StatusInternalServerError)
return
}
ctx := r.Context()
addToSprint := r.FormValue("add_to_sprint")
removeFromSprint := r.FormValue("remove_from_sprint")
if addToSprint != "" {
sprintNum := 0
fmt.Sscanf(addToSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.AddIssueToSprint(ctx, issue.ID, sprintNum)
}
} else if removeFromSprint != "" {
sprintNum := 0
fmt.Sscanf(removeFromSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.RemoveIssueFromSprint(ctx, issue.ID, sprintNum)
}
}
issue, err = app.Issues.GetIssue(r.Context(), issue.ID)
if err != nil {
http.Error(w, "Failed to retrieve updated issue", http.StatusInternalServerError)
return
}
if hx.IsHxRequest() {
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
redirectURL := "/?board=true"
if strings.Contains(referer, "sprint=") {
if parsedURL, err := url.Parse(referer); err == nil {
if sprintParam := parsedURL.Query().Get("sprint"); sprintParam != "" {
redirectURL = redirectURL + "&sprint=" + sprintParam
}
}
}
w.Header().Set("HX-Redirect", redirectURL)
} else {
w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID)
}
return
}
w.Header().Set("Content-Type", "application/json")
hx.WriteJSON(issue)
}
func UpdateAssignee(w http.ResponseWriter, r *http.Request) {
issue := r.Context().Value(issueKey).(*models.Issue)
assignMe := r.FormValue("assign_me")
assignee := ""
if assignMe != "" {
assignee = "Me"
}
if err := App(r).Issues.UpdateIssue(r.Context(), issue.ID, map[string]any{"assignee": assignee}, ""); err != nil {
http.Error(w, "Failed to update assignee", http.StatusInternalServerError)
return
}
issue, err := App(r).Issues.GetIssue(r.Context(), issue.ID)
if err != nil {
http.Error(w, "Failed to retrieve updated issue", http.StatusInternalServerError)
return
}
if HTMX(r).IsHxRequest() {
// Check if we're in board view
referer := r.Header.Get("Referer")
boardView := strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true&selected-issue="+issue.ID)
} else {
w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID)
}
return
}
w.Header().Set("Content-Type", "application/json")
HTMX(r).WriteJSON(issue)
}
func DeleteIssue(w http.ResponseWriter, r *http.Request) {
issue := r.Context().Value(issueKey).(*models.Issue)
if err := App(r).Issues.DeleteIssue(r.Context(), issue.ID); err != nil {
http.Error(w, "Failed to delete issue", http.StatusInternalServerError)
return
}
if HTMX(r).IsHxRequest() {
// Check if 'from' parameter indicates board view
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
} else {
w.Header().Set("HX-Redirect", "/")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func CloseIssue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
issueVal := r.Context().Value(issueKey)
issue, ok := issueVal.(*models.Issue)
if !ok || issue == nil {
http.Error(w, "Issue not found in context", http.StatusInternalServerError)
return
}
closeReason := r.FormValue("close_reason")
if closeReason == "" {
if HTMX(r).IsHxRequest() {
w.WriteHeader(http.StatusBadRequest)
HTMX(r).WriteString("<div class=\"alert alert-error\">Closing reason is required</div>")
} else {
http.Error(w, "Closing reason is required", http.StatusBadRequest)
}
return
}
if err := App(r).Issues.CloseIssue(r.Context(), issue.ID, closeReason, "web", ""); err != nil {
if HTMX(r).IsHxRequest() {
w.WriteHeader(http.StatusInternalServerError)
HTMX(r).WriteString("<div class=\"alert alert-error\">Failed to close issue: " + html.EscapeString(err.Error()) + "</div>")
} else {
http.Error(w, "Failed to close issue: "+err.Error(), http.StatusInternalServerError)
}
return
}
if HTMX(r).IsHxRequest() {
w.Header().Set("HX-Refresh", "true")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (f *IssueForm) toIssue() *models.Issue {
return &models.Issue{
Title: f.Title,
Description: f.Description,
Status: f.Status,
IssueType: f.IssueType,
Priority: f.Priority,
}
}
func (f *UpdateIssueForm) toChanges() map[string]any {
changes := make(map[string]any)
if f.Title != nil {
changes["title"] = *f.Title
}
if f.Description != nil {
changes["description"] = *f.Description
}
if f.Status != nil {
changes["status"] = *f.Status
}
if f.CloseReason != nil {
changes["close_reason"] = *f.CloseReason
}
if f.IssueType != nil {
changes["issue_type"] = *f.IssueType
}
if f.Priority != nil {
changes["priority"] = *f.Priority
}
if f.Assignee != nil {
changes["assignee"] = *f.Assignee
}
return changes
}