Go · 8710 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "errors"
7 "net/http"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/go-chi/chi/v5"
13
14 "github.com/tenseleyFlow/shithub/internal/auth/policy"
15 "github.com/tenseleyFlow/shithub/internal/issues"
16 "github.com/tenseleyFlow/shithub/internal/web/middleware"
17 )
18
19 // ─── labels ──────────────────────────────────────────────────────────
20
21 func (h *Handlers) labelsList(w http.ResponseWriter, r *http.Request) {
22 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
23 if !ok {
24 return
25 }
26 labels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
27 viewer := middleware.CurrentUserFromContext(r.Context())
28 actor := viewer.PolicyActor()
29 canManage := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueLabel, policy.NewRepoRefFromRepo(row)).Allow
30 w.Header().Set("Content-Type", "text/html; charset=utf-8")
31 _ = h.d.Render.RenderPage(w, r, "repo/labels", map[string]any{
32 "Title": "Labels · " + row.Name,
33 "Owner": owner.Username,
34 "Repo": row,
35 "Labels": labels,
36 "CanManageIssue": canManage,
37 "CSRFToken": middleware.CSRFTokenForRequest(r),
38 "RepoActions": h.repoActions(r, row.ID),
39 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
40 "CanSettings": h.canViewSettings(viewer),
41 "ActiveSubnav": "issues",
42 })
43 }
44
45 func (h *Handlers) labelCreate(w http.ResponseWriter, r *http.Request) {
46 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
47 if !ok {
48 return
49 }
50 if err := r.ParseForm(); err != nil {
51 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
52 return
53 }
54 _, err := issues.CreateLabel(r.Context(), h.issuesDeps(), issues.LabelCreateParams{
55 RepoID: row.ID,
56 Name: r.PostFormValue("name"),
57 Color: r.PostFormValue("color"),
58 Description: r.PostFormValue("description"),
59 })
60 if err != nil {
61 h.handleLabelError(w, r, err)
62 return
63 }
64 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
65 }
66
67 func (h *Handlers) labelUpdate(w http.ResponseWriter, r *http.Request) {
68 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
69 if !ok {
70 return
71 }
72 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
73 if err != nil {
74 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
75 return
76 }
77 if err := r.ParseForm(); err != nil {
78 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
79 return
80 }
81 if err := issues.UpdateLabel(r.Context(), h.issuesDeps(), issues.LabelUpdateParams{
82 ID: id,
83 Name: r.PostFormValue("name"),
84 Color: r.PostFormValue("color"),
85 Description: r.PostFormValue("description"),
86 }); err != nil {
87 h.handleLabelError(w, r, err)
88 return
89 }
90 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
91 }
92
93 func (h *Handlers) labelDelete(w http.ResponseWriter, r *http.Request) {
94 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
95 if !ok {
96 return
97 }
98 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
99 if err != nil {
100 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
101 return
102 }
103 if err := issues.DeleteLabel(r.Context(), h.issuesDeps(), id); err != nil {
104 h.handleLabelError(w, r, err)
105 return
106 }
107 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
108 }
109
110 func (h *Handlers) handleLabelError(w http.ResponseWriter, r *http.Request, err error) {
111 switch {
112 case errors.Is(err, issues.ErrLabelExists):
113 h.d.Render.HTTPError(w, r, http.StatusConflict, "label name already taken")
114 case errors.Is(err, issues.ErrLabelInvalidColor):
115 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "label color must be 6 hex chars")
116 default:
117 h.d.Logger.WarnContext(r.Context(), "labels: write", "error", err)
118 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
119 }
120 }
121
122 // ─── milestones ──────────────────────────────────────────────────────
123
124 func (h *Handlers) milestonesList(w http.ResponseWriter, r *http.Request) {
125 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
126 if !ok {
127 return
128 }
129 ms, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
130 viewer := middleware.CurrentUserFromContext(r.Context())
131 actor := viewer.PolicyActor()
132 canManage := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueLabel, policy.NewRepoRefFromRepo(row)).Allow
133 w.Header().Set("Content-Type", "text/html; charset=utf-8")
134 _ = h.d.Render.RenderPage(w, r, "repo/milestones", map[string]any{
135 "Title": "Milestones · " + row.Name,
136 "Owner": owner.Username,
137 "Repo": row,
138 "Milestones": ms,
139 "CanManageIssue": canManage,
140 "CSRFToken": middleware.CSRFTokenForRequest(r),
141 "RepoActions": h.repoActions(r, row.ID),
142 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
143 "CanSettings": h.canViewSettings(viewer),
144 "ActiveSubnav": "issues",
145 })
146 }
147
148 func (h *Handlers) milestoneCreate(w http.ResponseWriter, r *http.Request) {
149 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
150 if !ok {
151 return
152 }
153 if err := r.ParseForm(); err != nil {
154 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
155 return
156 }
157 due := parseDueOn(r.PostFormValue("due_on"))
158 _, err := issues.CreateMilestone(r.Context(), h.issuesDeps(), issues.MilestoneCreateParams{
159 RepoID: row.ID,
160 Title: r.PostFormValue("title"),
161 Description: r.PostFormValue("description"),
162 DueOn: due,
163 })
164 if err != nil {
165 h.handleMilestoneError(w, r, err)
166 return
167 }
168 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
169 }
170
171 func (h *Handlers) milestoneUpdate(w http.ResponseWriter, r *http.Request) {
172 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
173 if !ok {
174 return
175 }
176 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
177 if err != nil {
178 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
179 return
180 }
181 if err := r.ParseForm(); err != nil {
182 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
183 return
184 }
185 due := parseDueOn(r.PostFormValue("due_on"))
186 if err := issues.UpdateMilestone(r.Context(), h.issuesDeps(), issues.MilestoneUpdateParams{
187 ID: id,
188 Title: r.PostFormValue("title"),
189 Description: r.PostFormValue("description"),
190 DueOn: due,
191 }); err != nil {
192 h.handleMilestoneError(w, r, err)
193 return
194 }
195 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
196 }
197
198 func (h *Handlers) milestoneSetState(w http.ResponseWriter, r *http.Request) {
199 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
200 if !ok {
201 return
202 }
203 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
204 if err != nil {
205 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
206 return
207 }
208 state := strings.TrimSpace(r.PostFormValue("state"))
209 if err := issues.SetMilestoneState(r.Context(), h.issuesDeps(), id, state); err != nil {
210 h.handleMilestoneError(w, r, err)
211 return
212 }
213 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
214 }
215
216 func (h *Handlers) milestoneDelete(w http.ResponseWriter, r *http.Request) {
217 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
218 if !ok {
219 return
220 }
221 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
222 if err != nil {
223 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
224 return
225 }
226 if err := issues.DeleteMilestone(r.Context(), h.issuesDeps(), id); err != nil {
227 h.handleMilestoneError(w, r, err)
228 return
229 }
230 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
231 }
232
233 func (h *Handlers) handleMilestoneError(w http.ResponseWriter, r *http.Request, err error) {
234 switch {
235 case errors.Is(err, issues.ErrMilestoneExists):
236 h.d.Render.HTTPError(w, r, http.StatusConflict, "milestone title already taken")
237 default:
238 h.d.Logger.WarnContext(r.Context(), "milestones: write", "error", err)
239 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
240 }
241 }
242
243 // parseDueOn accepts a yyyy-mm-dd input from the form (HTML date input
244 // shape). Empty string clears the due date. Anything unparseable is
245 // treated as cleared so a malformed date doesn't 400 the form.
246 func parseDueOn(s string) *time.Time {
247 s = strings.TrimSpace(s)
248 if s == "" {
249 return nil
250 }
251 t, err := time.Parse("2006-01-02", s)
252 if err != nil {
253 return nil
254 }
255 return &t
256 }
257