From 68054f7409a1ddf1cc4bf995c5960d0d133b0119 Mon Sep 17 00:00:00 2001 From: Ine Maria Nilssen Aanonsen Date: Fri, 6 Mar 2026 12:52:13 +0100 Subject: [PATCH 1/2] TASK BACKLOG REFINEMENT ISSUE --- cmd/pm/tasks/backlogRefinement.go | 80 +++++++----- internal/commands/issues/close.go | 27 ++-- pkg/web/components/close_issue_form.templ | 33 +++++ pkg/web/components/close_issue_form_templ.go | 79 ++++++++++++ pkg/web/handler/issues.go | 35 ++++++ pkg/web/handler/modals.go | 26 ++++ pkg/web/routes/dashboard.templ | 11 ++ pkg/web/routes/dashboard_templ.go | 125 +++++++++++-------- pkg/web/routes/issue_detail.templ | 11 ++ pkg/web/routes/issue_detail_templ.go | 51 +++++--- pkg/web/server/routes.go | 2 + 11 files changed, 377 insertions(+), 103 deletions(-) create mode 100644 pkg/web/components/close_issue_form.templ create mode 100644 pkg/web/components/close_issue_form_templ.go diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index be536b5..5e7e150 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "strings" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -12,12 +13,12 @@ const backlogRefinementDescription = `You are tasked with backlog refinement. The product backlog has become cluttered with old and unclear issues. You need to groom the backlog: -1. Review all issues in the backlog -2. Identify stale or obsolete issues (older items that are no longer relevant) -3. Update issue descriptions for clarity where needed -4. Close issues that are duplicates or no longer applicable -5. Reprioritize issues based on current business value -6. Ensure remaining issues are well-defined and actionable +1. Go to the backlog. +2. Find two issues that got the same name or describe the same problem. +3. Open one of these issues. +4. Select "Close issue" +5. Choose "Duplicate issue" as closing reason. +6. Save/close issue. Focus on making the backlog a reliable source of upcoming work.` @@ -47,60 +48,64 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). - Title("How many issues did you close or update during refinement?"). + Title("How many duplicate issues did you close during refinement?"). Options( - huh.NewOption("1-2", 1), - huh.NewOption("3-4", 2), - huh.NewOption("5+", 3), + huh.NewOption("1", 1), + huh.NewOption("2", 2), + huh.NewOption("3+", 3), ), ), ) } +func (t *BacklogRefinementTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty"} +} + func (t *BacklogRefinementTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err } refinementIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Old feature request: Fax integration"). - WithDescription("Allow sending reports via fax. DEPRECATED - nobody uses fax anymore"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), NewIssueBuilder(). WithTitle("User profile page"). - WithDescription("Create page for users to view profile. DUPLICATE of user-management epic"). + WithDescription("Create page for users to view and edit their profile"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Mobile app redesign"). - WithDescription("Redesign mobile interface with modern UI patterns. Still relevant, needs clarity"). + WithTitle("User profile page"). + WithDescription("Allow users to view their profile information"). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Fix login timeout"). + WithDescription("Login sometimes times out after 30 seconds"). WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("Legacy data export tool"). - WithDescription("Tool for exporting data in old format. OBSOLETE - format no longer supported"). - WithPriority(3). + WithTitle("Fix login timeout"). + WithDescription("Users report login requests timing out"). + WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("API v1 documentation"). - WithDescription("Document old API version. DEPRECATED - migrating to v2"). - WithPriority(3). + WithTitle("Mobile app redesign"). + WithDescription("Redesign mobile interface with modern UI patterns"). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). WithTitle("Customer feedback system"). - WithDescription("Build system for collecting user feedback. HIGH VALUE - prioritize"). + WithDescription("Build system for collecting user feedback"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). @@ -122,5 +127,22 @@ func (t *BacklogRefinementTask) Setup(ctx context.Context) error { func (t *BacklogRefinementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + var closedDuplicate *models.Issue + for _, issue := range issues { + if issue.Status == models.StatusClosed && + strings.Contains(strings.ToLower(issue.CloseReason), "duplicate") { + closedDuplicate = issue + break + } + } + + expect.Assert(closedDuplicate != nil, + "Expected one duplicate issue to be closed with 'Duplicate issue' as closing reason") + return expect.Complete() } diff --git a/internal/commands/issues/close.go b/internal/commands/issues/close.go index 3c89b10..7af4b79 100644 --- a/internal/commands/issues/close.go +++ b/internal/commands/issues/close.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" ) -// CloseCmd represents the close command, -// which allows users to close an existing issue by its ID. var CloseCmd = &cobra.Command{ Use: "close [id]", Short: "Close an existing issue", @@ -21,8 +19,6 @@ var CloseCmd = &cobra.Command{ ValidArgsFunction: completeIssues, } -// runCloseCmd executes the close command logic, -// which closes an issue by its ID after confirming with the user. func runCloseCmd(cmd *cobra.Command, args []string) error { closeID := args[0] @@ -42,14 +38,27 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("issue with ID %s not found", closeID) } - // Ask for closing reason - if err = huh.NewInput().Value(&issue.CloseReason). - Title("Reason for closing the issue?").WithTheme(huh.ThemeBase()).Run(); err != nil { + closeReason := "" + if err = huh.NewSelect[string]().Value(&closeReason). + Title("Reason for closing the issue?"). + Options( + huh.NewOption("Done", "Done"), + huh.NewOption("Duplicate issue", "Duplicate issue"), + huh.NewOption("Won't fix", "Won't fix"), + huh.NewOption("Obsolete", "Obsolete"), + huh.NewOption("Other", "Other"), + ).WithTheme(huh.ThemeBase()).Run(); err != nil { return fmt.Errorf("error getting close reason: %w", err) } - // Close the issue. - err = app.Issues.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") + if closeReason == "Other" { + if err = huh.NewInput().Value(&closeReason). + Title("Enter closing reason:").WithTheme(huh.ThemeBase()).Run(); err != nil { + return fmt.Errorf("error getting close reason: %w", err) + } + } + + err = app.Issues.CloseIssue(cmd.Context(), closeID, closeReason, "", "") if err != nil { return fmt.Errorf("error closing issue: %w", err) } diff --git a/pkg/web/components/close_issue_form.templ b/pkg/web/components/close_issue_form.templ new file mode 100644 index 0000000..41961df --- /dev/null +++ b/pkg/web/components/close_issue_form.templ @@ -0,0 +1,33 @@ +package components + +import "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +templ CloseIssueForm(props CloseIssueFormProps) { +
+ @base.Select(base.SelectProps{ + Name: "close_reason", + Label: "Reason for closing the issue", + Required: true, + Options: []base.SelectOption{ + {Label: "Done", Value: "Done"}, + {Label: "Duplicate issue", Value: "Duplicate issue"}, + {Label: "Won't fix", Value: "Won't fix"}, + {Label: "Obsolete", Value: "Obsolete"}, + {Label: "Other", Value: "Other"}, + }, + Size: "md", + }) + +
+} diff --git a/pkg/web/components/close_issue_form_templ.go b/pkg/web/components/close_issue_form_templ.go new file mode 100644 index 0000000..e46ee65 --- /dev/null +++ b/pkg/web/components/close_issue_form_templ.go @@ -0,0 +1,79 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package components + +//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 "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +func CloseIssueForm(props CloseIssueFormProps) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = base.Select(base.SelectProps{ + Name: "close_reason", + Label: "Reason for closing the issue", + Required: true, + Options: []base.SelectOption{ + {Label: "Done", Value: "Done"}, + {Label: "Duplicate issue", Value: "Duplicate issue"}, + {Label: "Won't fix", Value: "Won't fix"}, + {Label: "Obsolete", Value: "Obsolete"}, + {Label: "Other", Value: "Other"}, + }, + Size: "md", + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 76f1e94..3e81016 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -213,6 +213,41 @@ func DeleteIssue(w http.ResponseWriter, r *http.Request) { 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 + } + + issue := r.Context().Value(issueKey).(*models.Issue) + closeReason := r.FormValue("close_reason") + + if closeReason == "" { + if HTMX(r).IsHxRequest() { + HTMX(r).WriteString("
Closing reason is required
") + } 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() { + HTMX(r).WriteString("
Failed to close issue: " + err.Error() + "
") + } 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, diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index c4e7fc3..b3fd04b 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -72,3 +72,29 @@ func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { }) modal.Render(r.Context(), w) } + +func CloseIssueFormModal(w http.ResponseWriter, r *http.Request) { + issue := r.Context().Value(issueKey).(*models.Issue) + + if issue == nil { + http.Error(w, "Issue not found in context", http.StatusInternalServerError) + return + } + + if issue.Status == models.StatusClosed { + http.Error(w, "Issue is already closed", http.StatusBadRequest) + return + } + + modalContent := components.CloseIssueForm(components.CloseIssueFormProps{ + PostAction: "/issues/" + issue.ID + "/close", + }) + + modal := components.Modal(components.ModalProps{ + ID: "close-issue-modal", + Title: "Close Issue", + Content: modalContent, + Open: true, + }) + modal.Render(r.Context(), w) +} diff --git a/pkg/web/routes/dashboard.templ b/pkg/web/routes/dashboard.templ index 7dbf3c7..db4205e 100644 --- a/pkg/web/routes/dashboard.templ +++ b/pkg/web/routes/dashboard.templ @@ -55,6 +55,17 @@ templ DashboardContent(props DashboardProps) { > Edit + if props.SelectedIssue.Status != "closed" { + + } ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.SelectedIssue.ID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 61, Col: 52} + if props.SelectedIssue.Status != "closed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, 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, 6, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -164,12 +187,12 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -193,12 +216,12 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + 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, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -218,7 +241,7 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -242,13 +265,13 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "No issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "No issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -258,64 +281,64 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if selectedID == issue.ID { active = "bg-primary/10" } - var templ_7745c5c3_Var9 = []any{"hover cursor-pointer min-h-full w-full", active} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var10 = []any{"hover cursor-pointer min-h-full w-full", active} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-target=\"main\" hx-vals=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(`{"selected-issue": "` + issue.ID + `"}`) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 120, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 128, Col: 53} } _, 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, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-swap=\"innerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 121, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 131, Col: 43} } _, 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, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 132, Col: 46} + } + _, 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, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -323,7 +346,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -331,7 +354,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -339,7 +362,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/issue_detail.templ b/pkg/web/routes/issue_detail.templ index 676ed3b..1ba3128 100644 --- a/pkg/web/routes/issue_detail.templ +++ b/pkg/web/routes/issue_detail.templ @@ -32,6 +32,17 @@ templ IssueDetailContent(props IssueDetailProps) { > Edit + if props.Issue.Status != "closed" { + + } ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.Issue.ID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/issue_detail.templ`, Line: 38, Col: 43} + if props.Issue.Status != "closed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.Issue.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/issue_detail.templ`, Line: 49, Col: 43} + } + _, 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, 7, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -81,7 +104,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -92,7 +115,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -116,12 +139,12 @@ func IssueDetail(props IssueDetailProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var6 := 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 { @@ -139,7 +162,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { } return nil }) - templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 236d917..c1bf850 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -47,6 +47,8 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r.Get("/", handler.GetIssue) r.Patch("/", handler.UpdateIssue) r.Get("/edit", handler.EditIssueFormModal) + r.Get("/close", handler.CloseIssueFormModal) + r.Post("/close", handler.CloseIssue) r.Get("/assignee", handler.AssigneeFormModal) r.Patch("/assignee", handler.UpdateAssignee) r.Delete("/", handler.DeleteIssue) From e64950879d89cf6447348b6585944fc10c84d8dd Mon Sep 17 00:00:00 2001 From: Ine Maria Nilssen Aanonsen Date: Fri, 6 Mar 2026 21:04:58 +0100 Subject: [PATCH 2/2] FIXED COMMENTS. TASK BACKLOG REFINEMENT ISSUE --- cmd/pm/tasks/backlogRefinement.go | 3 ++- internal/commands/issues/close.go | 3 +++ pkg/web/components/assignee_form_templ.go | 2 +- pkg/web/components/base/input_templ.go | 2 +- pkg/web/components/base/range_templ.go | 2 +- pkg/web/components/base/select_templ.go | 2 +- pkg/web/components/base/table_templ.go | 2 +- pkg/web/components/base/textarea_templ.go | 2 +- pkg/web/components/close_issue_form.templ | 5 +++-- pkg/web/components/close_issue_form_templ.go | 8 ++++---- pkg/web/components/comment_templ.go | 2 +- pkg/web/components/header_templ.go | 2 +- pkg/web/components/icons_templ.go | 2 +- pkg/web/components/issue_detail_templ.go | 2 +- pkg/web/components/issue_templ.go | 2 +- pkg/web/components/layout_templ.go | 2 +- pkg/web/components/modal_templ.go | 2 +- pkg/web/components/search_templ.go | 2 +- pkg/web/components/sidebar_templ.go | 2 +- pkg/web/components/status_templ.go | 2 +- pkg/web/handler/issues.go | 13 +++++++++++-- pkg/web/handler/modals.go | 18 +++++++++--------- pkg/web/routes/dashboard_templ.go | 2 +- pkg/web/routes/issue_detail_templ.go | 2 +- pkg/web/routes/layout_templ.go | 2 +- 25 files changed, 51 insertions(+), 37 deletions(-) diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index 5e7e150..f1ed46b 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -48,6 +48,7 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). + Key("how-many-duplicate-issues"). Title("How many duplicate issues did you close during refinement?"). Options( huh.NewOption("1", 1), @@ -59,7 +60,7 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions } func (t *BacklogRefinementTask) QuestionnaireKeys(_ InterfaceType) []string { - return []string{"task_completed", "task_difficulty"} + return []string{"task_completed", "task_difficulty", "how-many-duplicate-issues"} } func (t *BacklogRefinementTask) Setup(ctx context.Context) error { diff --git a/internal/commands/issues/close.go b/internal/commands/issues/close.go index 7af4b79..ce4bfcb 100644 --- a/internal/commands/issues/close.go +++ b/internal/commands/issues/close.go @@ -56,6 +56,9 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { Title("Enter closing reason:").WithTheme(huh.ThemeBase()).Run(); err != nil { return fmt.Errorf("error getting close reason: %w", err) } + if closeReason == "" { + return fmt.Errorf("closing reason cannot be empty when selecting 'Other'") + } } err = app.Issues.CloseIssue(cmd.Context(), closeID, closeReason, "", "") diff --git a/pkg/web/components/assignee_form_templ.go b/pkg/web/components/assignee_form_templ.go index 06f3ea1..ae68a4f 100644 --- a/pkg/web/components/assignee_form_templ.go +++ b/pkg/web/components/assignee_form_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/input_templ.go b/pkg/web/components/base/input_templ.go index 9d787ce..1b68a0c 100644 --- a/pkg/web/components/base/input_templ.go +++ b/pkg/web/components/base/input_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/range_templ.go b/pkg/web/components/base/range_templ.go index 4ae6395..1a57ebe 100644 --- a/pkg/web/components/base/range_templ.go +++ b/pkg/web/components/base/range_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/select_templ.go b/pkg/web/components/base/select_templ.go index aab85ee..a7b5040 100644 --- a/pkg/web/components/base/select_templ.go +++ b/pkg/web/components/base/select_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/table_templ.go b/pkg/web/components/base/table_templ.go index b7544a0..7259528 100644 --- a/pkg/web/components/base/table_templ.go +++ b/pkg/web/components/base/table_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/textarea_templ.go b/pkg/web/components/base/textarea_templ.go index 32f9357..0d1bf07 100644 --- a/pkg/web/components/base/textarea_templ.go +++ b/pkg/web/components/base/textarea_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/close_issue_form.templ b/pkg/web/components/close_issue_form.templ index 41961df..7c6a1c2 100644 --- a/pkg/web/components/close_issue_form.templ +++ b/pkg/web/components/close_issue_form.templ @@ -7,11 +7,12 @@ type CloseIssueFormProps struct { } templ CloseIssueForm(props CloseIssueFormProps) { +
@base.Select(base.SelectProps{ Name: "close_reason", diff --git a/pkg/web/components/close_issue_form_templ.go b/pkg/web/components/close_issue_form_templ.go index e46ee65..1e2b421 100644 --- a/pkg/web/components/close_issue_form_templ.go +++ b/pkg/web/components/close_issue_form_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -35,20 +35,20 @@ func CloseIssueForm(props CloseIssueFormProps) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-target=\"#close-issue-error\" hx-swap=\"innerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/components/comment_templ.go b/pkg/web/components/comment_templ.go index e97390e..2542d51 100644 --- a/pkg/web/components/comment_templ.go +++ b/pkg/web/components/comment_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/header_templ.go b/pkg/web/components/header_templ.go index 4a6a7fa..a4c3217 100644 --- a/pkg/web/components/header_templ.go +++ b/pkg/web/components/header_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/icons_templ.go b/pkg/web/components/icons_templ.go index c8c421c..0df923b 100644 --- a/pkg/web/components/icons_templ.go +++ b/pkg/web/components/icons_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 300533c..7f08bce 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 7715cb9..249ffa7 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/layout_templ.go b/pkg/web/components/layout_templ.go index 28d4c95..a426fce 100644 --- a/pkg/web/components/layout_templ.go +++ b/pkg/web/components/layout_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/modal_templ.go b/pkg/web/components/modal_templ.go index ac4ec26..73f131e 100644 --- a/pkg/web/components/modal_templ.go +++ b/pkg/web/components/modal_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/search_templ.go b/pkg/web/components/search_templ.go index f27707f..6fc7f33 100644 --- a/pkg/web/components/search_templ.go +++ b/pkg/web/components/search_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/sidebar_templ.go b/pkg/web/components/sidebar_templ.go index a66fd90..f26a365 100644 --- a/pkg/web/components/sidebar_templ.go +++ b/pkg/web/components/sidebar_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/status_templ.go b/pkg/web/components/status_templ.go index 451a387..41694c7 100644 --- a/pkg/web/components/status_templ.go +++ b/pkg/web/components/status_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 3e81016..f7bfec9 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -2,6 +2,7 @@ package handler import ( "context" + "html" "net/http" "strings" @@ -219,11 +220,18 @@ func CloseIssue(w http.ResponseWriter, r *http.Request) { return } - issue := r.Context().Value(issueKey).(*models.Issue) + 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("
Closing reason is required
") } else { http.Error(w, "Closing reason is required", http.StatusBadRequest) @@ -233,7 +241,8 @@ func CloseIssue(w http.ResponseWriter, r *http.Request) { if err := App(r).Issues.CloseIssue(r.Context(), issue.ID, closeReason, "web", ""); err != nil { if HTMX(r).IsHxRequest() { - HTMX(r).WriteString("
Failed to close issue: " + err.Error() + "
") + w.WriteHeader(http.StatusInternalServerError) + HTMX(r).WriteString("
Failed to close issue: " + html.EscapeString(err.Error()) + "
") } else { http.Error(w, "Failed to close issue: "+err.Error(), http.StatusInternalServerError) } diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index b3fd04b..bf5101e 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -26,9 +26,9 @@ func CreateIssueFormModal(w http.ResponseWriter, r *http.Request) { } func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + 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 } @@ -52,9 +52,9 @@ func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { } func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + 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 } @@ -74,9 +74,9 @@ func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { } func CloseIssueFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + 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 } diff --git a/pkg/web/routes/dashboard_templ.go b/pkg/web/routes/dashboard_templ.go index 34ad856..6995da7 100644 --- a/pkg/web/routes/dashboard_templ.go +++ b/pkg/web/routes/dashboard_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/routes/issue_detail_templ.go b/pkg/web/routes/issue_detail_templ.go index 4752600..9eb6c60 100644 --- a/pkg/web/routes/issue_detail_templ.go +++ b/pkg/web/routes/issue_detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/routes/layout_templ.go b/pkg/web/routes/layout_templ.go index 6211e01..55d5c84 100644 --- a/pkg/web/routes/layout_templ.go +++ b/pkg/web/routes/layout_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present.