tenseleyflow/shithub / aa6b691

Browse files

Align issue timeline flow

Authored by espadonne
SHA
aa6b691cafa7b419935221bf2c0eed19deb77d89
Parents
0129845
Tree
459dc40

6 changed files

StatusFile+-
M internal/issues/issues.go 23 2
M internal/issues/issues_test.go 39 0
M internal/web/handlers/repo/issues.go 237 39
M internal/web/render/octicons.go 18 0
M internal/web/static/css/shithub.css 97 8
M internal/web/templates/repo/issue_view.html 158 74
internal/issues/issues.gomodified
@@ -14,6 +14,7 @@ import (
14
 	"errors"
14
 	"errors"
15
 	"fmt"
15
 	"fmt"
16
 	"log/slog"
16
 	"log/slog"
17
+	"strconv"
17
 	"strings"
18
 	"strings"
18
 	"time"
19
 	"time"
19
 
20
 
@@ -48,6 +49,7 @@ var (
48
 	ErrEmptyTitle        = errors.New("issues: title is required")
49
 	ErrEmptyTitle        = errors.New("issues: title is required")
49
 	ErrTitleTooLong      = errors.New("issues: title too long (max 256)")
50
 	ErrTitleTooLong      = errors.New("issues: title too long (max 256)")
50
 	ErrBodyTooLong       = errors.New("issues: body too long")
51
 	ErrBodyTooLong       = errors.New("issues: body too long")
52
+	ErrEmptyComment      = errors.New("issues: comment body is required")
51
 	ErrCommentTooLong    = errors.New("issues: comment too long")
53
 	ErrCommentTooLong    = errors.New("issues: comment too long")
52
 	ErrIssueLocked       = errors.New("issues: issue is locked")
54
 	ErrIssueLocked       = errors.New("issues: issue is locked")
53
 	ErrCommentRateLimit  = errors.New("issues: comment rate limit exceeded")
55
 	ErrCommentRateLimit  = errors.New("issues: comment rate limit exceeded")
@@ -174,7 +176,10 @@ type CommentCreateParams struct {
174
 // and indexes references. Returns the fresh comment.
176
 // and indexes references. Returns the fresh comment.
175
 func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) {
177
 func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) {
176
 	body := strings.TrimSpace(p.Body)
178
 	body := strings.TrimSpace(p.Body)
177
-	if body == "" || len(body) > 65535 {
179
+	if body == "" {
180
+		return issuesdb.IssueComment{}, ErrEmptyComment
181
+	}
182
+	if len(body) > 65535 {
178
 		return issuesdb.IssueComment{}, ErrCommentTooLong
183
 		return issuesdb.IssueComment{}, ErrCommentTooLong
179
 	}
184
 	}
180
 	if deps.Limiter != nil && p.AuthorUserID != 0 {
185
 	if deps.Limiter != nil && p.AuthorUserID != 0 {
@@ -252,6 +257,18 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb
252
 // SetState closes or reopens an issue and emits an `closed` /
257
 // SetState closes or reopens an issue and emits an `closed` /
253
 // `reopened` event.
258
 // `reopened` event.
254
 func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error {
259
 func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error {
260
+	return setState(ctx, deps, actorUserID, issueID, newState, reason, 0)
261
+}
262
+
263
+// SetStateWithComment is used by the issue comment form when the submit
264
+// button both creates a comment and changes state. The timeline event stores
265
+// the comment id so the UI can keep the state badge adjacent to the comment
266
+// that caused it.
267
+func SetStateWithComment(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string, commentID int64) error {
268
+	return setState(ctx, deps, actorUserID, issueID, newState, reason, commentID)
269
+}
270
+
271
+func setState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string, commentID int64) error {
255
 	if newState != "open" && newState != "closed" {
272
 	if newState != "open" && newState != "closed" {
256
 		return errors.New("issues: state must be open or closed")
273
 		return errors.New("issues: state must be open or closed")
257
 	}
274
 	}
@@ -287,11 +304,15 @@ func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newSta
287
 	if newState == "open" {
304
 	if newState == "open" {
288
 		kind = "reopened"
305
 		kind = "reopened"
289
 	}
306
 	}
307
+	meta := emptyMeta
308
+	if commentID != 0 {
309
+		meta = []byte(`{"comment_id":` + strconv.FormatInt(commentID, 10) + `}`)
310
+	}
290
 	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
311
 	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
291
 		IssueID:     issueID,
312
 		IssueID:     issueID,
292
 		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
313
 		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
293
 		Kind:        kind,
314
 		Kind:        kind,
294
-		Meta:        emptyMeta,
315
+		Meta:        meta,
295
 	}); err != nil {
316
 	}); err != nil {
296
 		return err
317
 		return err
297
 	}
318
 	}
internal/issues/issues_test.gomodified
@@ -4,6 +4,7 @@ package issues_test
4
 
4
 
5
 import (
5
 import (
6
 	"context"
6
 	"context"
7
+	"encoding/json"
7
 	"io"
8
 	"io"
8
 	"log/slog"
9
 	"log/slog"
9
 	"strings"
10
 	"strings"
@@ -250,6 +251,44 @@ func TestSetState_EmitsEvent(t *testing.T) {
250
 	}
251
 	}
251
 }
252
 }
252
 
253
 
254
+func TestSetStateWithComment_LinksTimelineEvent(t *testing.T) {
255
+	pool, deps, uid, rid := setup(t)
256
+	ctx := context.Background()
257
+	row, err := issues.Create(ctx, deps, issues.CreateParams{
258
+		RepoID: rid, AuthorUserID: uid, Title: "close-with-comment", Body: "",
259
+	})
260
+	if err != nil {
261
+		t.Fatalf("Create: %v", err)
262
+	}
263
+	comment, err := issues.AddComment(ctx, deps, issues.CommentCreateParams{
264
+		IssueID: row.ID, AuthorUserID: uid, Body: "closing this out", IsCollab: true,
265
+	})
266
+	if err != nil {
267
+		t.Fatalf("AddComment: %v", err)
268
+	}
269
+	if err := issues.SetStateWithComment(ctx, deps, uid, row.ID, "closed", "", comment.ID); err != nil {
270
+		t.Fatalf("SetStateWithComment: %v", err)
271
+	}
272
+	iq := issuesdb.New()
273
+	events, err := iq.ListIssueEvents(ctx, pool, row.ID)
274
+	if err != nil {
275
+		t.Fatalf("ListIssueEvents: %v", err)
276
+	}
277
+	if len(events) != 1 {
278
+		t.Fatalf("got %d events, want 1", len(events))
279
+	}
280
+	if events[0].Kind != "closed" {
281
+		t.Fatalf("kind = %q, want closed", events[0].Kind)
282
+	}
283
+	var meta map[string]int64
284
+	if err := json.Unmarshal(events[0].Meta, &meta); err != nil {
285
+		t.Fatalf("meta json: %v", err)
286
+	}
287
+	if meta["comment_id"] != comment.ID {
288
+		t.Fatalf("comment_id = %d, want %d", meta["comment_id"], comment.ID)
289
+	}
290
+}
291
+
253
 func TestCreate_CrossReferenceCreatesEventOnTarget(t *testing.T) {
292
 func TestCreate_CrossReferenceCreatesEventOnTarget(t *testing.T) {
254
 	pool, deps, uid, rid := setup(t)
293
 	pool, deps, uid, rid := setup(t)
255
 	ctx := context.Background()
294
 	ctx := context.Background()
internal/web/handlers/repo/issues.gomodified
@@ -3,10 +3,13 @@
3
 package repo
3
 package repo
4
 
4
 
5
 import (
5
 import (
6
+	"encoding/json"
6
 	"errors"
7
 	"errors"
7
 	"net/http"
8
 	"net/http"
9
+	"sort"
8
 	"strconv"
10
 	"strconv"
9
 	"strings"
11
 	"strings"
12
+	"time"
10
 
13
 
11
 	"github.com/go-chi/chi/v5"
14
 	"github.com/go-chi/chi/v5"
12
 	"github.com/jackc/pgx/v5"
15
 	"github.com/jackc/pgx/v5"
@@ -266,38 +269,57 @@ func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) {
266
 	allLabels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
269
 	allLabels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
267
 	milestones, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
270
 	milestones, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
268
 
271
 
269
-	// Resolve usernames for comment authors.
272
+	usernames := map[int64]string{}
270
-	type commentRow struct {
273
+	usernameFor := func(id int64) string {
271
-		C          issuesdb.IssueComment
274
+		if id == 0 {
272
-		AuthorName string
275
+			return ""
273
-	}
276
+		}
274
-	cs := make([]commentRow, 0, len(comments))
277
+		if name, ok := usernames[id]; ok {
275
-	for _, c := range comments {
278
+			return name
276
-		cr := commentRow{C: c}
277
-		if c.AuthorUserID.Valid {
278
-			if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, c.AuthorUserID.Int64); err == nil {
279
-				cr.AuthorName = u.Username
280
-			}
281
 		}
279
 		}
282
-		cs = append(cs, cr)
280
+		if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, id); err == nil {
281
+			usernames[id] = u.Username
282
+			return u.Username
283
+		}
284
+		return ""
283
 	}
285
 	}
284
-	// Author username on the issue itself.
286
+
285
 	authorName := ""
287
 	authorName := ""
286
 	if issue.AuthorUserID.Valid {
288
 	if issue.AuthorUserID.Valid {
287
-		if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, issue.AuthorUserID.Int64); err == nil {
289
+		authorName = usernameFor(issue.AuthorUserID.Int64)
288
-			authorName = u.Username
289
-		}
290
 	}
290
 	}
291
 	viewer := middleware.CurrentUserFromContext(r.Context())
291
 	viewer := middleware.CurrentUserFromContext(r.Context())
292
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
292
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
293
 	pdeps := policy.Deps{Pool: h.d.Pool}
293
 	pdeps := policy.Deps{Pool: h.d.Pool}
294
 	repoRef := policy.NewRepoRefFromRepo(row)
294
 	repoRef := policy.NewRepoRefFromRepo(row)
295
-	stateRef := repoRef
295
+	stateRef := issueStateRepoRef(row, issue)
296
-	if issue.AuthorUserID.Valid {
297
-		stateRef.AuthorUserID = issue.AuthorUserID.Int64
298
-	}
299
 	canCommentAction := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueComment, repoRef).Allow
296
 	canCommentAction := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueComment, repoRef).Allow
300
 	canCommentThroughLock := policy.HasRoleAtLeast(r.Context(), pdeps, actor, repoRef, policy.RoleTriage)
297
 	canCommentThroughLock := policy.HasRoleAtLeast(r.Context(), pdeps, actor, repoRef, policy.RoleTriage)
298
+	canSetIssueState := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, stateRef).Allow
299
+	timeline := h.issueTimelineRows(comments, events, allLabels, milestones, usernameFor)
300
+	viewerAssigned := false
301
+	participants := map[string]struct{}{}
302
+	if authorName != "" {
303
+		participants[authorName] = struct{}{}
304
+	}
305
+	for _, c := range comments {
306
+		if c.AuthorUserID.Valid {
307
+			if name := usernameFor(c.AuthorUserID.Int64); name != "" {
308
+				participants[name] = struct{}{}
309
+			}
310
+		}
311
+	}
312
+	for _, a := range assignees {
313
+		participants[a.Username] = struct{}{}
314
+		if a.UserID == viewer.ID {
315
+			viewerAssigned = true
316
+		}
317
+	}
318
+	participantNames := make([]string, 0, len(participants))
319
+	for name := range participants {
320
+		participantNames = append(participantNames, name)
321
+	}
322
+	sort.Strings(participantNames)
301
 
323
 
302
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
324
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
303
 	_ = h.d.Render.RenderPage(w, r, "repo/issue_view", map[string]any{
325
 	_ = h.d.Render.RenderPage(w, r, "repo/issue_view", map[string]any{
@@ -306,14 +328,16 @@ func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) {
306
 		"Repo":                  row,
328
 		"Repo":                  row,
307
 		"Issue":                 issue,
329
 		"Issue":                 issue,
308
 		"AuthorName":            authorName,
330
 		"AuthorName":            authorName,
309
-		"Comments":              cs,
331
+		"CommentCount":          len(comments),
310
-		"Events":                events,
332
+		"Timeline":              timeline,
311
 		"Labels":                labels,
333
 		"Labels":                labels,
312
 		"Assignees":             assignees,
334
 		"Assignees":             assignees,
335
+		"Participants":          participantNames,
336
+		"ViewerAssigned":        viewerAssigned,
313
 		"AllLabels":             allLabels,
337
 		"AllLabels":             allLabels,
314
 		"Milestones":            milestones,
338
 		"Milestones":            milestones,
315
 		"CanComment":            canCommentAction && (!issue.Locked || canCommentThroughLock),
339
 		"CanComment":            canCommentAction && (!issue.Locked || canCommentThroughLock),
316
-		"CanSetIssueState":      policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, stateRef).Allow,
340
+		"CanSetIssueState":      canSetIssueState,
317
 		"CanEditIssueLabels":    policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
341
 		"CanEditIssueLabels":    policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
318
 		"CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow,
342
 		"CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow,
319
 		"CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
343
 		"CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
@@ -322,6 +346,155 @@ func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) {
322
 	})
346
 	})
323
 }
347
 }
324
 
348
 
349
+type issueTimelineRow struct {
350
+	Type        string
351
+	C           issuesdb.IssueComment
352
+	E           issuesdb.IssueEvent
353
+	CreatedAt   time.Time
354
+	AuthorName  string
355
+	ActorName   string
356
+	Message     string
357
+	LabelName   string
358
+	LabelColor  string
359
+	CommentID   int64
360
+	LinkedState bool
361
+}
362
+
363
+func (h *Handlers) issueTimelineRows(
364
+	comments []issuesdb.IssueComment,
365
+	events []issuesdb.IssueEvent,
366
+	labels []issuesdb.Label,
367
+	milestones []issuesdb.Milestone,
368
+	usernameFor func(int64) string,
369
+) []issueTimelineRow {
370
+	labelByID := map[int64]issuesdb.Label{}
371
+	for _, l := range labels {
372
+		labelByID[l.ID] = l
373
+	}
374
+	milestoneByID := map[int64]issuesdb.Milestone{}
375
+	for _, m := range milestones {
376
+		milestoneByID[m.ID] = m
377
+	}
378
+	rows := make([]issueTimelineRow, 0, len(comments)+len(events))
379
+	for _, c := range comments {
380
+		row := issueTimelineRow{Type: "comment", C: c, CreatedAt: c.CreatedAt.Time}
381
+		if c.AuthorUserID.Valid {
382
+			row.AuthorName = usernameFor(c.AuthorUserID.Int64)
383
+		}
384
+		rows = append(rows, row)
385
+	}
386
+	for _, e := range events {
387
+		row := issueTimelineRow{
388
+			Type:      "event",
389
+			E:         e,
390
+			CreatedAt: e.CreatedAt.Time,
391
+			Message:   issueEventMessage(e.Kind),
392
+		}
393
+		if e.ActorUserID.Valid {
394
+			row.ActorName = usernameFor(e.ActorUserID.Int64)
395
+		}
396
+		meta := issueEventMeta(e.Meta)
397
+		if id := metaInt64(meta, "comment_id"); id != 0 {
398
+			row.CommentID = id
399
+			row.LinkedState = e.Kind == "closed" || e.Kind == "reopened"
400
+		}
401
+		if id := metaInt64(meta, "label_id"); id != 0 {
402
+			if l, ok := labelByID[id]; ok {
403
+				row.LabelName = l.Name
404
+				row.LabelColor = l.Color
405
+			}
406
+		}
407
+		if id := metaInt64(meta, "milestone_id"); id != 0 {
408
+			if m, ok := milestoneByID[id]; ok {
409
+				switch e.Kind {
410
+				case "milestoned":
411
+					row.Message = "added this to the " + m.Title + " milestone"
412
+				case "demilestoned":
413
+					row.Message = "removed this from the " + m.Title + " milestone"
414
+				}
415
+			}
416
+		}
417
+		if id := metaInt64(meta, "user_id"); id != 0 {
418
+			if name := usernameFor(id); name != "" {
419
+				switch e.Kind {
420
+				case "assigned":
421
+					row.Message = "assigned " + name
422
+				case "unassigned":
423
+					row.Message = "unassigned " + name
424
+				}
425
+			}
426
+		}
427
+		rows = append(rows, row)
428
+	}
429
+	sort.SliceStable(rows, func(i, j int) bool {
430
+		return rows[i].CreatedAt.Before(rows[j].CreatedAt)
431
+	})
432
+	return rows
433
+}
434
+
435
+func issueEventMeta(raw []byte) map[string]any {
436
+	var out map[string]any
437
+	if len(raw) == 0 {
438
+		return nil
439
+	}
440
+	if err := json.Unmarshal(raw, &out); err != nil {
441
+		return nil
442
+	}
443
+	return out
444
+}
445
+
446
+func metaInt64(meta map[string]any, key string) int64 {
447
+	if meta == nil {
448
+		return 0
449
+	}
450
+	switch v := meta[key].(type) {
451
+	case float64:
452
+		return int64(v)
453
+	case string:
454
+		n, _ := strconv.ParseInt(v, 10, 64)
455
+		return n
456
+	default:
457
+		return 0
458
+	}
459
+}
460
+
461
+func issueEventMessage(kind string) string {
462
+	switch kind {
463
+	case "closed":
464
+		return "closed this issue"
465
+	case "reopened":
466
+		return "reopened this issue"
467
+	case "locked":
468
+		return "locked this conversation"
469
+	case "unlocked":
470
+		return "unlocked this conversation"
471
+	case "labeled":
472
+		return "added a label"
473
+	case "unlabeled":
474
+		return "removed a label"
475
+	case "milestoned":
476
+		return "added this to a milestone"
477
+	case "demilestoned":
478
+		return "removed this from a milestone"
479
+	case "assigned":
480
+		return "assigned a user"
481
+	case "unassigned":
482
+		return "unassigned a user"
483
+	case "referenced":
484
+		return "referenced this issue"
485
+	default:
486
+		return kind
487
+	}
488
+}
489
+
490
+func issueStateRepoRef(row reposdb.Repo, issue issuesdb.Issue) policy.RepoRef {
491
+	ref := policy.NewRepoRefFromRepo(row)
492
+	if issue.AuthorUserID.Valid {
493
+		ref.AuthorUserID = issue.AuthorUserID.Int64
494
+	}
495
+	return ref
496
+}
497
+
325
 func (h *Handlers) loadIssueByNumber(w http.ResponseWriter, r *http.Request, repo reposdb.Repo) (issuesdb.Issue, bool) {
498
 func (h *Handlers) loadIssueByNumber(w http.ResponseWriter, r *http.Request, repo reposdb.Repo) (issuesdb.Issue, bool) {
326
 	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
499
 	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
327
 	if err != nil {
500
 	if err != nil {
@@ -357,7 +530,9 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) {
357
 		return
530
 		return
358
 	}
531
 	}
359
 	viewer := middleware.CurrentUserFromContext(r.Context())
532
 	viewer := middleware.CurrentUserFromContext(r.Context())
360
-	body := r.PostFormValue("body")
533
+	body := strings.TrimSpace(r.PostFormValue("body"))
534
+	state := strings.TrimSpace(r.PostFormValue("state"))
535
+	reason := strings.TrimSpace(r.PostFormValue("reason"))
361
 
536
 
362
 	// IsCollab is the locked-issue bypass: triage+ on the repo can comment
537
 	// IsCollab is the locked-issue bypass: triage+ on the repo can comment
363
 	// past a `locked=true` flag (the gate exists to silence drive-by
538
 	// past a `locked=true` flag (the gate exists to silence drive-by
@@ -367,16 +542,40 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) {
367
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
542
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
368
 	isCollab := policy.HasRoleAtLeast(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.NewRepoRefFromRepo(row), policy.RoleTriage)
543
 	isCollab := policy.HasRoleAtLeast(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.NewRepoRefFromRepo(row), policy.RoleTriage)
369
 
544
 
370
-	_, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{
545
+	var commentID int64
371
-		IssueID:      issue.ID,
546
+	if body != "" {
372
-		AuthorUserID: viewer.ID,
547
+		c, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{
373
-		Body:         body,
548
+			IssueID:      issue.ID,
374
-		IsCollab:     isCollab,
549
+			AuthorUserID: viewer.ID,
375
-	})
550
+			Body:         body,
376
-	if err != nil {
551
+			IsCollab:     isCollab,
377
-		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
552
+		})
553
+		if err != nil {
554
+			h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
555
+			return
556
+		}
557
+		commentID = c.ID
558
+	} else if state == "" {
559
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, issues.ErrEmptyComment)
378
 		return
560
 		return
379
 	}
561
 	}
562
+	if state != "" {
563
+		stateRef := issueStateRepoRef(row, issue)
564
+		if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueClose, stateRef); !dec.Allow {
565
+			h.d.Render.HTTPError(w, r, policy.Maybe404(dec, stateRef, actor), "")
566
+			return
567
+		}
568
+		var err error
569
+		if commentID != 0 {
570
+			err = issues.SetStateWithComment(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason, commentID)
571
+		} else {
572
+			err = issues.SetState(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason)
573
+		}
574
+		if err != nil {
575
+			h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
576
+			return
577
+		}
578
+	}
380
 	// Auto-watch on first involvement (S26).
579
 	// Auto-watch on first involvement (S26).
381
 	_ = social.AutoWatchOnInvolvement(r.Context(), h.socialDeps(), viewer.ID, row.ID)
580
 	_ = social.AutoWatchOnInvolvement(r.Context(), h.socialDeps(), viewer.ID, row.ID)
382
 	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
581
 	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
@@ -398,12 +597,9 @@ func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) {
398
 	}
597
 	}
399
 	viewer := middleware.CurrentUserFromContext(r.Context())
598
 	viewer := middleware.CurrentUserFromContext(r.Context())
400
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
599
 	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
401
-	repoRef := policy.NewRepoRefFromRepo(row)
600
+	stateRef := issueStateRepoRef(row, issue)
402
-	if issue.AuthorUserID.Valid {
601
+	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueClose, stateRef); !dec.Allow {
403
-		repoRef.AuthorUserID = issue.AuthorUserID.Int64
602
+		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, stateRef, actor), "")
404
-	}
405
-	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueClose, repoRef); !dec.Allow {
406
-		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
407
 		return
603
 		return
408
 	}
604
 	}
409
 	state := strings.TrimSpace(r.PostFormValue("state"))
605
 	state := strings.TrimSpace(r.PostFormValue("state"))
@@ -519,6 +715,8 @@ func (h *Handlers) handleIssueWriteError(w http.ResponseWriter, r *http.Request,
519
 		h.d.Render.HTTPError(w, r, http.StatusLocked, "issue is locked")
715
 		h.d.Render.HTTPError(w, r, http.StatusLocked, "issue is locked")
520
 	case errors.Is(err, issues.ErrCommentRateLimit):
716
 	case errors.Is(err, issues.ErrCommentRateLimit):
521
 		h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
717
 		h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
718
+	case errors.Is(err, issues.ErrEmptyComment):
719
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "comment body required")
522
 	case errors.Is(err, issues.ErrCommentTooLong):
720
 	case errors.Is(err, issues.ErrCommentTooLong):
523
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "comment too long")
721
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "comment too long")
524
 	default:
722
 	default:
internal/web/render/octicons.gomodified
@@ -31,6 +31,24 @@ func BuiltinOcticons() OcticonResolver {
31
 			`><path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786z"/></svg>`),
31
 			`><path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786z"/></svg>`),
32
 		"alert": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
32
 		"alert": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
33
 			`><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575zM8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5zm1 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>`),
33
 			`><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575zM8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5zm1 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>`),
34
+		"issue-opened": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
35
+			`><path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-3.25a.75.75 0 0 1 .75.75v2.75a.75.75 0 0 1-1.5 0V5.5A.75.75 0 0 1 8 4.75ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>`),
36
+		"issue-closed": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
37
+			`><path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm11.03-1.78a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.97 8.78a.75.75 0 0 1 1.06-1.06l1.22 1.22 2.72-2.72a.75.75 0 0 1 1.06 0Z"/></svg>`),
38
+		"comment": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
39
+			`><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 11H8.06l-3.31 2.48A.75.75 0 0 1 3.5 12.88V11h-.75A1.75 1.75 0 0 1 1 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v6.5c0 .138.112.25.25.25h1.5a.75.75 0 0 1 .75.75v1.13l2.36-1.77a.75.75 0 0 1 .45-.15h5.44a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
40
+		"gear": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
41
+			`><path d="M8 0a1.5 1.5 0 0 1 1.45 1.13l.21.83c.23.08.45.18.66.3l.75-.44a1.5 1.5 0 0 1 1.93.3l.88.88a1.5 1.5 0 0 1 .3 1.93l-.44.75c.12.21.22.43.3.66l.83.21A1.5 1.5 0 0 1 16 8a1.5 1.5 0 0 1-1.13 1.45l-.83.21c-.08.23-.18.45-.3.66l.44.75a1.5 1.5 0 0 1-.3 1.93l-.88.88a1.5 1.5 0 0 1-1.93.3l-.75-.44c-.21.12-.43.22-.66.3l-.21.83A1.5 1.5 0 0 1 8 16a1.5 1.5 0 0 1-1.45-1.13l-.21-.83a5.36 5.36 0 0 1-.66-.3l-.75.44a1.5 1.5 0 0 1-1.93-.3L2.12 13a1.5 1.5 0 0 1-.3-1.93l.44-.75a5.36 5.36 0 0 1-.3-.66l-.83-.21A1.5 1.5 0 0 1 0 8c0-.69.47-1.29 1.13-1.45l.83-.21c.08-.23.18-.45.3-.66l-.44-.75a1.5 1.5 0 0 1 .3-1.93L3 2.12a1.5 1.5 0 0 1 1.93-.3l.75.44c.21-.12.43-.22.66-.3l.21-.83A1.5 1.5 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>`),
42
+		"lock": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
43
+			`><path d="M4.5 6V4a3.5 3.5 0 1 1 7 0v2h.75c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6Zm1.5 0h4V4a2 2 0 1 0-4 0Zm-2.25 1.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
44
+		"unlock": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
45
+			`><path d="M8 1.5A2.5 2.5 0 0 0 5.5 4v2h6.75c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4V4a4 4 0 0 1 7.8-1.26.75.75 0 0 1-1.42.47A2.5 2.5 0 0 0 8 1.5ZM3.75 7.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
46
+		"tag": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
47
+			`><path d="M1 2.75C1 1.784 1.784 1 2.75 1h4.586c.464 0 .909.184 1.237.513l5.914 5.914a1.75 1.75 0 0 1 0 2.475l-4.585 4.585a1.75 1.75 0 0 1-2.475 0L1.513 8.573A1.75 1.75 0 0 1 1 7.336Zm1.75-.25a.25.25 0 0 0-.25.25v4.586c0 .066.026.13.073.177l5.914 5.914a.25.25 0 0 0 .353 0l4.587-4.587a.25.25 0 0 0 0-.353L7.513 2.573a.25.25 0 0 0-.177-.073Zm2.25 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg>`),
48
+		"person": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
49
+			`><path d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm1.5 0a4 4 0 1 0-8 0 4 4 0 0 0 8 0ZM2 14.25C2 11.35 4.686 9 8 9s6 2.35 6 5.25a.75.75 0 0 1-1.5 0c0-2.02-2.01-3.75-4.5-3.75s-4.5 1.73-4.5 3.75a.75.75 0 0 1-1.5 0Z"/></svg>`),
50
+		"milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
51
+			`><path d="M8 0a.75.75 0 0 1 .75.75V2h3.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-3.5v1h4.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-5v.25a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM8.75 3.5v3h2.75v-3Zm0 7v3h3.75v-3Z"/></svg>`),
34
 		// S29: notification bell for the top-bar inbox link.
52
 		// S29: notification bell for the top-bar inbox link.
35
 		"bell": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
53
 		"bell": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
36
 			`><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16zM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.519 1.519 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.018.018 0 0 0-.003.01.017.017 0 0 0 .002.012.017.017 0 0 0 .015.005h10.964a.017.017 0 0 0 .016-.005.018.018 0 0 0 0-.022l-1.703-2.555a1.749 1.749 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5z"/></svg>`),
54
 			`><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16zM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.519 1.519 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.018.018 0 0 0-.003.01.017.017 0 0 0 .002.012.017.017 0 0 0 .015.005h10.964a.017.017 0 0 0 .016-.005.018.018 0 0 0 0-.022l-1.703-2.555a1.749 1.749 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5z"/></svg>`),
internal/web/static/css/shithub.cssmodified
@@ -1394,10 +1394,13 @@ code {
1394
 .shithub-issues-assignees { font-size: 0.85rem; }
1394
 .shithub-issues-assignees { font-size: 0.85rem; }
1395
 .shithub-issues-empty { color: var(--fg-muted); padding: 2rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; }
1395
 .shithub-issues-empty { color: var(--fg-muted); padding: 2rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; }
1396
 .shithub-issue-num { color: var(--fg-muted); font-weight: 400; margin-left: 0.5rem; }
1396
 .shithub-issue-num { color: var(--fg-muted); font-weight: 400; margin-left: 0.5rem; }
1397
-.shithub-issue-title { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
1397
+.shithub-issue-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; border-bottom: 1px solid var(--border-default); padding-bottom: 0.75rem; }
1398
-.shithub-issue-meta { color: var(--fg-muted); margin: 0.5rem 0 1rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
1398
+.shithub-issue-title { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; margin: 0; }
1399
-.shithub-issue-grid { display: grid; grid-template-columns: 1fr 16rem; gap: 1.5rem; }
1399
+.shithub-issue-head-actions { display: flex; gap: 0.5rem; flex: 0 0 auto; }
1400
-@media (max-width: 768px) { .shithub-issue-grid { grid-template-columns: 1fr; } }
1400
+.shithub-issue-meta { color: var(--fg-muted); margin: 0.75rem 0 1.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
1401
+.shithub-issue-meta .shithub-pill, .shithub-comment-form .shithub-button, .shithub-sidebar-button { display: inline-flex; align-items: center; justify-content: center; gap: 0.25rem; }
1402
+.shithub-issue-grid { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: 1.5rem; }
1403
+@media (max-width: 900px) { .shithub-issue-grid { grid-template-columns: 1fr; } }
1401
 .shithub-comment {
1404
 .shithub-comment {
1402
   border: 1px solid var(--border-default);
1405
   border: 1px solid var(--border-default);
1403
   border-radius: 6px;
1406
   border-radius: 6px;
@@ -1412,8 +1415,42 @@ code {
1412
   color: var(--fg-muted);
1415
   color: var(--fg-muted);
1413
 }
1416
 }
1414
 .shithub-comment-body { padding: 0.75rem; }
1417
 .shithub-comment-body { padding: 0.75rem; }
1415
-.shithub-event { color: var(--fg-muted); font-size: 0.85rem; padding: 0.4rem 0.75rem; border-left: 2px solid var(--border-default); margin-left: 0.75rem; }
1418
+.shithub-event {
1416
-.shithub-event-kind { text-transform: lowercase; }
1419
+  color: var(--fg-muted);
1420
+  font-size: 0.85rem;
1421
+  display: flex;
1422
+  align-items: center;
1423
+  gap: 0.5rem;
1424
+  padding: 0.55rem 0;
1425
+  margin: 0 0 1rem 1.2rem;
1426
+  position: relative;
1427
+}
1428
+.shithub-event::before {
1429
+  content: "";
1430
+  position: absolute;
1431
+  top: -1rem;
1432
+  bottom: -1rem;
1433
+  left: 0.6rem;
1434
+  border-left: 2px solid var(--border-default);
1435
+  z-index: 0;
1436
+}
1437
+.shithub-event-icon {
1438
+  width: 1.3rem;
1439
+  height: 1.3rem;
1440
+  border: 1px solid var(--border-default);
1441
+  border-radius: 50%;
1442
+  background: var(--canvas-default);
1443
+  color: var(--fg-muted);
1444
+  display: inline-flex;
1445
+  align-items: center;
1446
+  justify-content: center;
1447
+  position: relative;
1448
+  z-index: 1;
1449
+  flex: 0 0 auto;
1450
+}
1451
+.shithub-event-icon svg { width: 0.8rem; height: 0.8rem; }
1452
+.shithub-event-linked .shithub-event-icon { color: var(--fg-muted); }
1453
+.shithub-event a { font-weight: 600; }
1417
 .shithub-comment-form { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
1454
 .shithub-comment-form { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
1418
 .shithub-comment-form textarea, .shithub-issue-form textarea, .shithub-issue-form input[type=text] {
1455
 .shithub-comment-form textarea, .shithub-issue-form textarea, .shithub-issue-form input[type=text] {
1419
   font: inherit; padding: 0.5rem; border: 1px solid var(--border-default); border-radius: 6px; width: 100%;
1456
   font: inherit; padding: 0.5rem; border: 1px solid var(--border-default); border-radius: 6px; width: 100%;
@@ -1423,8 +1460,60 @@ code {
1423
 .shithub-form-row { display: flex; flex-direction: column; gap: 0.25rem; }
1460
 .shithub-form-row { display: flex; flex-direction: column; gap: 0.25rem; }
1424
 .shithub-form-row span { font-weight: 600; font-size: 0.9rem; }
1461
 .shithub-form-row span { font-weight: 600; font-size: 0.9rem; }
1425
 .shithub-form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
1462
 .shithub-form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
1426
-.shithub-issue-sidebar section { padding: 0.75rem 0; border-bottom: 1px solid var(--border-default); }
1463
+.shithub-form-actions-start { justify-content: flex-start; }
1427
-.shithub-issue-sidebar h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-muted); margin: 0 0 0.5rem; }
1464
+.shithub-issue-sidebar section { padding: 0.75rem 0; border-bottom: 1px solid var(--border-default); position: relative; }
1465
+.shithub-sidebar-heading { display: flex; justify-content: space-between; gap: 0.75rem; align-items: center; margin-bottom: 0.5rem; }
1466
+.shithub-issue-sidebar h3 { font-size: 0.85rem; color: var(--fg-muted); margin: 0; }
1467
+.shithub-sidebar-icon, .shithub-sidebar-editor > summary {
1468
+  color: var(--fg-muted);
1469
+  display: inline-flex;
1470
+  align-items: center;
1471
+  justify-content: center;
1472
+  width: 1.25rem;
1473
+  height: 1.25rem;
1474
+  cursor: pointer;
1475
+}
1476
+.shithub-sidebar-editor > summary { list-style: none; }
1477
+.shithub-sidebar-editor > summary::-webkit-details-marker { display: none; }
1478
+.shithub-popover {
1479
+  position: absolute;
1480
+  right: 0;
1481
+  top: 2rem;
1482
+  z-index: 20;
1483
+  min-width: 17rem;
1484
+  display: flex;
1485
+  flex-direction: column;
1486
+  gap: 0.55rem;
1487
+  padding: 0.75rem;
1488
+  background: var(--canvas-default);
1489
+  border: 1px solid var(--border-default);
1490
+  border-radius: 8px;
1491
+  box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
1492
+}
1493
+.shithub-popover input[type=text], .shithub-popover select {
1494
+  font: inherit;
1495
+  width: 100%;
1496
+  padding: 0.45rem 0.5rem;
1497
+  border: 1px solid var(--border-default);
1498
+  border-radius: 6px;
1499
+  background: var(--canvas-default);
1500
+  color: var(--fg-default);
1501
+}
1502
+.shithub-inline-form { display: inline; }
1503
+.shithub-link-button {
1504
+  border: 0;
1505
+  padding: 0;
1506
+  background: transparent;
1507
+  color: var(--accent-emphasis, #0969da);
1508
+  font: inherit;
1509
+  cursor: pointer;
1510
+  display: inline-flex;
1511
+  gap: 0.35rem;
1512
+  align-items: center;
1513
+}
1514
+.shithub-sidebar-button { width: 100%; }
1515
+.shithub-participant { display: inline-block; margin: 0 0.35rem 0.35rem 0; }
1516
+.shithub-issue-actions form { margin: 0.25rem 0; }
1428
 .shithub-issue-signedout { color: var(--fg-muted); padding: 1rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; }
1517
 .shithub-issue-signedout { color: var(--fg-muted); padding: 1rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; }
1429
 .shithub-label {
1518
 .shithub-label {
1430
   display: inline-block;
1519
   display: inline-block;
internal/web/templates/repo/issue_view.htmlmodified
@@ -1,27 +1,33 @@
1
 {{ define "page" -}}
1
 {{ define "page" -}}
2
 <section class="shithub-issue-view">
2
 <section class="shithub-issue-view">
3
   <header class="shithub-issue-view-head">
3
   <header class="shithub-issue-view-head">
4
-    <h1 class="shithub-issue-title">
4
+    <div class="shithub-issue-title-row">
5
-      <span>{{ .Issue.Title }}</span>
5
+      <h1 class="shithub-issue-title">
6
-      <span class="shithub-issue-num">#{{ .Issue.Number }}</span>
6
+        <span>{{ .Issue.Title }}</span>
7
-    </h1>
7
+        <span class="shithub-issue-num">#{{ .Issue.Number }}</span>
8
+      </h1>
9
+      <div class="shithub-issue-head-actions">
10
+        <a href="/{{ .Owner }}/{{ .Repo.Name }}/issues/new" class="shithub-button shithub-button-primary">New issue</a>
11
+      </div>
12
+    </div>
8
     <div class="shithub-issue-meta">
13
     <div class="shithub-issue-meta">
9
       <span class="shithub-pill shithub-issues-state-{{ printf "%s" .Issue.State }}">
14
       <span class="shithub-pill shithub-issues-state-{{ printf "%s" .Issue.State }}">
10
-        {{ if eq (printf "%s" .Issue.State) "open" }}● Open{{ else }}✓ Closed{{ end }}
15
+        {{ if eq (printf "%s" .Issue.State) "open" }}{{ octicon "issue-opened" }} Open{{ else }}{{ octicon "issue-closed" }} Closed{{ end }}
11
       </span>
16
       </span>
12
       {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
17
       {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
13
       opened this issue
18
       opened this issue
14
       <time datetime="{{ .Issue.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Issue.CreatedAt.Time }}</time>
19
       <time datetime="{{ .Issue.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Issue.CreatedAt.Time }}</time>
15
-      · {{ len .Comments }} comment{{ if ne (len .Comments) 1 }}s{{ end }}
20
+      · {{ .CommentCount }} comment{{ if ne .CommentCount 1 }}s{{ end }}
16
-      {{ if .Issue.Locked }}<span class="shithub-pill">locked</span>{{ end }}
21
+      {{ if .Issue.Locked }}<span class="shithub-pill">{{ octicon "lock" }} locked</span>{{ end }}
17
     </div>
22
     </div>
18
   </header>
23
   </header>
19
 
24
 
20
   <div class="shithub-issue-grid">
25
   <div class="shithub-issue-grid">
21
     <article class="shithub-issue-thread">
26
     <article class="shithub-issue-thread">
22
-      <div class="shithub-comment">
27
+      <div class="shithub-comment shithub-issue-body-comment">
23
         <div class="shithub-comment-head">
28
         <div class="shithub-comment-head">
24
           {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
29
           {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
30
+          opened
25
           <time datetime="{{ .Issue.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Issue.CreatedAt.Time }}</time>
31
           <time datetime="{{ .Issue.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Issue.CreatedAt.Time }}</time>
26
         </div>
32
         </div>
27
         <div class="shithub-comment-body markdown-body">
33
         <div class="shithub-comment-body markdown-body">
@@ -29,24 +35,43 @@
29
         </div>
35
         </div>
30
       </div>
36
       </div>
31
 
37
 
32
-      {{ range .Comments }}
38
+      {{ range .Timeline }}
33
-      <div class="shithub-comment">
39
+        {{ if eq .Type "comment" }}
34
-        <div class="shithub-comment-head">
40
+        <div class="shithub-comment">
35
-          {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
41
+          <div class="shithub-comment-head">
36
-          commented
42
+            {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
37
-          <time datetime="{{ .C.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .C.CreatedAt.Time }}</time>
43
+            commented
44
+            <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time>
45
+          </div>
46
+          <div class="shithub-comment-body markdown-body">
47
+            {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
48
+          </div>
38
         </div>
49
         </div>
39
-        <div class="shithub-comment-body markdown-body">
50
+        {{ else }}
40
-          {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
51
+        <div class="shithub-event {{ if .LinkedState }}shithub-event-linked{{ end }}">
52
+          <span class="shithub-event-icon" aria-hidden="true">
53
+            {{ if eq .E.Kind "closed" }}{{ octicon "issue-closed" }}
54
+            {{ else if eq .E.Kind "reopened" }}{{ octicon "issue-opened" }}
55
+            {{ else if eq .E.Kind "locked" }}{{ octicon "lock" }}
56
+            {{ else if eq .E.Kind "unlocked" }}{{ octicon "unlock" }}
57
+            {{ else if or (eq .E.Kind "labeled") (eq .E.Kind "unlabeled") }}{{ octicon "tag" }}
58
+            {{ else if or (eq .E.Kind "assigned") (eq .E.Kind "unassigned") }}{{ octicon "person" }}
59
+            {{ else if or (eq .E.Kind "milestoned") (eq .E.Kind "demilestoned") }}{{ octicon "milestone" }}
60
+            {{ else }}{{ octicon "comment" }}{{ end }}
61
+          </span>
62
+          <span>
63
+            {{ if .ActorName }}<a href="/{{ .ActorName }}">{{ .ActorName }}</a>{{ else }}Someone{{ end }}
64
+            {{ if .LabelName }}
65
+              {{ if eq .E.Kind "labeled" }}added the{{ else }}removed the{{ end }}
66
+              <span class="shithub-label" style="background-color: #{{ .LabelColor }}">{{ .LabelName }}</span>
67
+              label
68
+            {{ else }}
69
+              {{ .Message }}
70
+            {{ end }}
71
+            <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time>
72
+          </span>
41
         </div>
73
         </div>
42
-      </div>
74
+        {{ end }}
43
-      {{ end }}
44
-
45
-      {{ range .Events }}
46
-      <div class="shithub-event">
47
-        <span class="shithub-event-kind">{{ .Kind }}</span>
48
-        <time datetime="{{ .CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt.Time }}</time>
49
-      </div>
50
       {{ end }}
75
       {{ end }}
51
 
76
 
52
       {{ if .CanComment }}
77
       {{ if .CanComment }}
@@ -54,14 +79,14 @@
54
         <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
79
         <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
55
         <label>
80
         <label>
56
           <span>Add a comment</span>
81
           <span>Add a comment</span>
57
-          <textarea name="body" rows="6" maxlength="65535" required></textarea>
82
+          <textarea name="body" rows="6" maxlength="65535" placeholder="Leave a comment"></textarea>
58
         </label>
83
         </label>
59
         <div class="shithub-form-actions">
84
         <div class="shithub-form-actions">
60
           {{ if .CanSetIssueState }}
85
           {{ if .CanSetIssueState }}
61
           {{ if eq (printf "%s" .Issue.State) "open" }}
86
           {{ if eq (printf "%s" .Issue.State) "open" }}
62
-          <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="closed" class="shithub-button">Close issue</button>
87
+          <button type="submit" name="state" value="closed" class="shithub-button">Close issue</button>
63
           {{ else }}
88
           {{ else }}
64
-          <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="open" class="shithub-button">Reopen issue</button>
89
+          <button type="submit" name="state" value="open" class="shithub-button">Reopen issue</button>
65
           {{ end }}
90
           {{ end }}
66
           {{ end }}
91
           {{ end }}
67
           <button type="submit" class="shithub-button shithub-button-primary">Comment</button>
92
           <button type="submit" class="shithub-button shithub-button-primary">Comment</button>
@@ -87,72 +112,131 @@
87
 
112
 
88
     <aside class="shithub-issue-sidebar">
113
     <aside class="shithub-issue-sidebar">
89
       <section>
114
       <section>
90
-        <h3>Labels</h3>
115
+        <div class="shithub-sidebar-heading">
91
-        {{ if .Labels }}
116
+          <h3>Assignees</h3>
92
-          {{ range .Labels }}<span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>{{ end }}
117
+          {{ if .CanEditIssueAssignees }}
93
-        {{ else }}<p class="shithub-muted">None yet</p>{{ end }}
118
+          <details class="shithub-sidebar-editor">
94
-        {{ if .CanEditIssueLabels }}
119
+            <summary aria-label="Edit assignees">{{ octicon "gear" }}</summary>
95
-        <details>
120
+            <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-popover">
96
-          <summary>Edit labels</summary>
121
+              <strong>Select assignees</strong>
97
-          <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels">
122
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
98
-            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
123
+              <input type="text" name="username" placeholder="Filter assignees" required>
99
-            {{ $current := .Labels }}
124
+              <div class="shithub-form-actions shithub-form-actions-start">
100
-            {{ range .AllLabels }}
125
+                <button type="submit" name="mode" value="add" class="shithub-button">Add</button>
101
-              {{ $id := .ID }}
126
+                <button type="submit" name="mode" value="remove" class="shithub-button">Remove</button>
102
-              <label class="shithub-label-pick">
127
+              </div>
103
-                <input type="checkbox" name="label_ids" value="{{ .ID }}"
128
+            </form>
104
-                  {{ range $current }}{{ if eq .ID $id }}checked{{ end }}{{ end }}>
129
+          </details>
105
-                <span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>
130
+          {{ end }}
106
-              </label>
131
+        </div>
132
+        {{ if .Assignees }}
133
+          {{ range .Assignees }}<a href="/{{ .Username }}">@{{ .Username }}</a>{{ end }}
134
+        {{ else }}
135
+          <p class="shithub-muted">No one</p>
136
+          {{ if and .CanEditIssueAssignees .Viewer.ID }}
137
+            {{ if not .ViewerAssigned }}
138
+            <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-inline-form">
139
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
140
+              <input type="hidden" name="username" value="{{ .Viewer.Username }}">
141
+              <button type="submit" name="mode" value="add" class="shithub-link-button">Assign yourself</button>
142
+            </form>
107
             {{ end }}
143
             {{ end }}
108
-            <button type="submit" class="shithub-button">Apply</button>
144
+          {{ end }}
109
-          </form>
110
-        </details>
111
         {{ end }}
145
         {{ end }}
112
       </section>
146
       </section>
113
 
147
 
114
       <section>
148
       <section>
115
-        <h3>Assignees</h3>
149
+        <div class="shithub-sidebar-heading">
116
-        {{ if .Assignees }}
150
+          <h3>Labels</h3>
117
-          {{ range .Assignees }}<a href="/{{ .Username }}">@{{ .Username }}</a>{{ end }}
151
+          {{ if .CanEditIssueLabels }}
118
-        {{ else }}<p class="shithub-muted">No one assigned</p>{{ end }}
152
+          <details class="shithub-sidebar-editor">
119
-        {{ if .CanEditIssueAssignees }}
153
+            <summary aria-label="Edit labels">{{ octicon "gear" }}</summary>
120
-        <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-assignee-form">
154
+            <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels" class="shithub-popover">
121
-          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
155
+              <strong>Select labels</strong>
122
-          <input type="text" name="username" placeholder="username" required>
156
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
123
-          <button type="submit" name="mode" value="add" class="shithub-button">Add</button>
157
+              {{ $current := .Labels }}
124
-          <button type="submit" name="mode" value="remove" class="shithub-button">Remove</button>
158
+              {{ range .AllLabels }}
125
-        </form>
159
+                {{ $id := .ID }}
126
-        {{ end }}
160
+                <label class="shithub-label-pick">
161
+                  <input type="checkbox" name="label_ids" value="{{ .ID }}"
162
+                    {{ range $current }}{{ if eq .ID $id }}checked{{ end }}{{ end }}>
163
+                  <span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>
164
+                </label>
165
+              {{ end }}
166
+              <button type="submit" class="shithub-button">Apply</button>
167
+            </form>
168
+          </details>
169
+          {{ end }}
170
+        </div>
171
+        {{ if .Labels }}
172
+          {{ range .Labels }}<span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>{{ end }}
173
+        {{ else }}<p class="shithub-muted">No labels</p>{{ end }}
174
+      </section>
175
+
176
+      <section>
177
+        <div class="shithub-sidebar-heading"><h3>Type</h3><span class="shithub-sidebar-icon">{{ octicon "gear" }}</span></div>
178
+        <p class="shithub-muted">No type</p>
179
+      </section>
180
+
181
+      <section>
182
+        <div class="shithub-sidebar-heading"><h3>Projects</h3><span class="shithub-sidebar-icon">{{ octicon "gear" }}</span></div>
183
+        <p class="shithub-muted">No projects</p>
127
       </section>
184
       </section>
128
 
185
 
129
       <section>
186
       <section>
130
-        <h3>Milestone</h3>
187
+        <div class="shithub-sidebar-heading">
188
+          <h3>Milestone</h3>
189
+          {{ if .CanEditIssueMilestone }}
190
+          <details class="shithub-sidebar-editor">
191
+            <summary aria-label="Edit milestone">{{ octicon "gear" }}</summary>
192
+            <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/milestone" class="shithub-popover">
193
+              <strong>Select milestone</strong>
194
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
195
+              <select name="milestone_id">
196
+                <option value="0">No milestone</option>
197
+                {{ range .Milestones }}<option value="{{ .ID }}">{{ .Title }}</option>{{ end }}
198
+              </select>
199
+              <button type="submit" class="shithub-button">Apply</button>
200
+            </form>
201
+          </details>
202
+          {{ end }}
203
+        </div>
131
         {{ if .Issue.MilestoneID.Valid }}
204
         {{ if .Issue.MilestoneID.Valid }}
132
           {{ $mid := .Issue.MilestoneID.Int64 }}
205
           {{ $mid := .Issue.MilestoneID.Int64 }}
133
           {{ range .Milestones }}{{ if eq .ID $mid }}<a href="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones">{{ .Title }}</a>{{ end }}{{ end }}
206
           {{ range .Milestones }}{{ if eq .ID $mid }}<a href="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones">{{ .Title }}</a>{{ end }}{{ end }}
134
         {{ else }}<p class="shithub-muted">No milestone</p>{{ end }}
207
         {{ else }}<p class="shithub-muted">No milestone</p>{{ end }}
135
-        {{ if .CanEditIssueMilestone }}
136
-        <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/milestone">
137
-          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
138
-          <select name="milestone_id">
139
-            <option value="0">— None —</option>
140
-            {{ range .Milestones }}<option value="{{ .ID }}">{{ .Title }}</option>{{ end }}
141
-          </select>
142
-          <button type="submit" class="shithub-button">Set</button>
143
-        </form>
144
-        {{ end }}
145
       </section>
208
       </section>
146
 
209
 
147
-      {{ if .CanLockIssue }}
148
       <section>
210
       <section>
149
-        <h3>Lock</h3>
211
+        <div class="shithub-sidebar-heading"><h3>Relationships</h3><span class="shithub-sidebar-icon">{{ octicon "gear" }}</span></div>
212
+        <p class="shithub-muted">None yet</p>
213
+      </section>
214
+
215
+      <section>
216
+        <div class="shithub-sidebar-heading"><h3>Development</h3><span class="shithub-sidebar-icon">{{ octicon "gear" }}</span></div>
217
+        <p><a href="#">Create a branch</a> for this issue or link a pull request.</p>
218
+      </section>
219
+
220
+      <section>
221
+        <div class="shithub-sidebar-heading"><h3>Notifications</h3><a href="#" class="shithub-muted">Customize</a></div>
222
+        <button type="button" class="shithub-button shithub-sidebar-button">{{ octicon "bell" }} Unsubscribe</button>
223
+      </section>
224
+
225
+      <section>
226
+        <h3>Participants</h3>
227
+        {{ if .Participants }}
228
+          {{ range .Participants }}<a href="/{{ . }}" class="shithub-participant">@{{ . }}</a>{{ end }}
229
+        {{ else }}<p class="shithub-muted">None yet</p>{{ end }}
230
+      </section>
231
+
232
+      {{ if .CanLockIssue }}
233
+      <section class="shithub-issue-actions">
150
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/lock">
234
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/lock">
151
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
235
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
152
           {{ if .Issue.Locked }}
236
           {{ if .Issue.Locked }}
153
-          <button type="submit" name="lock" value="false" class="shithub-button">Unlock</button>
237
+          <button type="submit" name="lock" value="false" class="shithub-link-button">{{ octicon "unlock" }} Unlock conversation</button>
154
           {{ else }}
238
           {{ else }}
155
-          <button type="submit" name="lock" value="true" class="shithub-button">Lock conversation</button>
239
+          <button type="submit" name="lock" value="true" class="shithub-link-button">{{ octicon "lock" }} Lock conversation</button>
156
           {{ end }}
240
           {{ end }}
157
         </form>
241
         </form>
158
       </section>
242
       </section>