Align issue timeline flow
- SHA
aa6b691cafa7b419935221bf2c0eed19deb77d89- Parents
-
0129845 - Tree
459dc40
aa6b691
aa6b691cafa7b419935221bf2c0eed19deb77d890129845
459dc40| Status | File | + | - |
|---|---|---|---|
| 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 | 14 | "errors" |
| 15 | 15 | "fmt" |
| 16 | 16 | "log/slog" |
| 17 | + "strconv" | |
| 17 | 18 | "strings" |
| 18 | 19 | "time" |
| 19 | 20 | |
@@ -48,6 +49,7 @@ var ( | ||
| 48 | 49 | ErrEmptyTitle = errors.New("issues: title is required") |
| 49 | 50 | ErrTitleTooLong = errors.New("issues: title too long (max 256)") |
| 50 | 51 | ErrBodyTooLong = errors.New("issues: body too long") |
| 52 | + ErrEmptyComment = errors.New("issues: comment body is required") | |
| 51 | 53 | ErrCommentTooLong = errors.New("issues: comment too long") |
| 52 | 54 | ErrIssueLocked = errors.New("issues: issue is locked") |
| 53 | 55 | ErrCommentRateLimit = errors.New("issues: comment rate limit exceeded") |
@@ -174,7 +176,10 @@ type CommentCreateParams struct { | ||
| 174 | 176 | // and indexes references. Returns the fresh comment. |
| 175 | 177 | func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) { |
| 176 | 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 | 183 | return issuesdb.IssueComment{}, ErrCommentTooLong |
| 179 | 184 | } |
| 180 | 185 | if deps.Limiter != nil && p.AuthorUserID != 0 { |
@@ -252,6 +257,18 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb | ||
| 252 | 257 | // SetState closes or reopens an issue and emits an `closed` / |
| 253 | 258 | // `reopened` event. |
| 254 | 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 | 272 | if newState != "open" && newState != "closed" { |
| 256 | 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 | 304 | if newState == "open" { |
| 288 | 305 | kind = "reopened" |
| 289 | 306 | } |
| 307 | + meta := emptyMeta | |
| 308 | + if commentID != 0 { | |
| 309 | + meta = []byte(`{"comment_id":` + strconv.FormatInt(commentID, 10) + `}`) | |
| 310 | + } | |
| 290 | 311 | if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{ |
| 291 | 312 | IssueID: issueID, |
| 292 | 313 | ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0}, |
| 293 | 314 | Kind: kind, |
| 294 | - Meta: emptyMeta, | |
| 315 | + Meta: meta, | |
| 295 | 316 | }); err != nil { |
| 296 | 317 | return err |
| 297 | 318 | } |
internal/issues/issues_test.gomodified@@ -4,6 +4,7 @@ package issues_test | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "encoding/json" | |
| 7 | 8 | "io" |
| 8 | 9 | "log/slog" |
| 9 | 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 | 292 | func TestCreate_CrossReferenceCreatesEventOnTarget(t *testing.T) { |
| 254 | 293 | pool, deps, uid, rid := setup(t) |
| 255 | 294 | ctx := context.Background() |
internal/web/handlers/repo/issues.gomodified@@ -3,10 +3,13 @@ | ||
| 3 | 3 | package repo |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "encoding/json" | |
| 6 | 7 | "errors" |
| 7 | 8 | "net/http" |
| 9 | + "sort" | |
| 8 | 10 | "strconv" |
| 9 | 11 | "strings" |
| 12 | + "time" | |
| 10 | 13 | |
| 11 | 14 | "github.com/go-chi/chi/v5" |
| 12 | 15 | "github.com/jackc/pgx/v5" |
@@ -266,38 +269,57 @@ func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) { | ||
| 266 | 269 | allLabels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID) |
| 267 | 270 | milestones, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID) |
| 268 | 271 | |
| 269 | - // Resolve usernames for comment authors. | |
| 270 | - type commentRow struct { | |
| 271 | - C issuesdb.IssueComment | |
| 272 | - AuthorName string | |
| 273 | - } | |
| 274 | - cs := make([]commentRow, 0, len(comments)) | |
| 275 | - for _, c := range comments { | |
| 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 | - } | |
| 272 | + usernames := map[int64]string{} | |
| 273 | + usernameFor := func(id int64) string { | |
| 274 | + if id == 0 { | |
| 275 | + return "" | |
| 276 | + } | |
| 277 | + if name, ok := usernames[id]; ok { | |
| 278 | + return name | |
| 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 | 287 | authorName := "" |
| 286 | 288 | if issue.AuthorUserID.Valid { |
| 287 | - if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, issue.AuthorUserID.Int64); err == nil { | |
| 288 | - authorName = u.Username | |
| 289 | - } | |
| 289 | + authorName = usernameFor(issue.AuthorUserID.Int64) | |
| 290 | 290 | } |
| 291 | 291 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 292 | 292 | actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 293 | 293 | pdeps := policy.Deps{Pool: h.d.Pool} |
| 294 | 294 | repoRef := policy.NewRepoRefFromRepo(row) |
| 295 | - stateRef := repoRef | |
| 296 | - if issue.AuthorUserID.Valid { | |
| 297 | - stateRef.AuthorUserID = issue.AuthorUserID.Int64 | |
| 298 | - } | |
| 295 | + stateRef := issueStateRepoRef(row, issue) | |
| 299 | 296 | canCommentAction := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueComment, repoRef).Allow |
| 300 | 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 | 324 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 303 | 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 | 328 | "Repo": row, |
| 307 | 329 | "Issue": issue, |
| 308 | 330 | "AuthorName": authorName, |
| 309 | - "Comments": cs, | |
| 310 | - "Events": events, | |
| 331 | + "CommentCount": len(comments), | |
| 332 | + "Timeline": timeline, | |
| 311 | 333 | "Labels": labels, |
| 312 | 334 | "Assignees": assignees, |
| 335 | + "Participants": participantNames, | |
| 336 | + "ViewerAssigned": viewerAssigned, | |
| 313 | 337 | "AllLabels": allLabels, |
| 314 | 338 | "Milestones": milestones, |
| 315 | 339 | "CanComment": canCommentAction && (!issue.Locked || canCommentThroughLock), |
| 316 | - "CanSetIssueState": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, stateRef).Allow, | |
| 340 | + "CanSetIssueState": canSetIssueState, | |
| 317 | 341 | "CanEditIssueLabels": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow, |
| 318 | 342 | "CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow, |
| 319 | 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 | 498 | func (h *Handlers) loadIssueByNumber(w http.ResponseWriter, r *http.Request, repo reposdb.Repo) (issuesdb.Issue, bool) { |
| 326 | 499 | num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64) |
| 327 | 500 | if err != nil { |
@@ -357,7 +530,9 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) { | ||
| 357 | 530 | return |
| 358 | 531 | } |
| 359 | 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 | 537 | // IsCollab is the locked-issue bypass: triage+ on the repo can comment |
| 363 | 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 | 542 | actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 368 | 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{ | |
| 371 | - IssueID: issue.ID, | |
| 372 | - AuthorUserID: viewer.ID, | |
| 373 | - Body: body, | |
| 374 | - IsCollab: isCollab, | |
| 375 | - }) | |
| 376 | - if err != nil { | |
| 377 | - h.handleIssueWriteError(w, r, owner.Username, row, issue, err) | |
| 545 | + var commentID int64 | |
| 546 | + if body != "" { | |
| 547 | + c, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{ | |
| 548 | + IssueID: issue.ID, | |
| 549 | + AuthorUserID: viewer.ID, | |
| 550 | + Body: body, | |
| 551 | + IsCollab: isCollab, | |
| 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 | 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 | 579 | // Auto-watch on first involvement (S26). |
| 381 | 580 | _ = social.AutoWatchOnInvolvement(r.Context(), h.socialDeps(), viewer.ID, row.ID) |
| 382 | 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 | 598 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 400 | 599 | actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 401 | - repoRef := policy.NewRepoRefFromRepo(row) | |
| 402 | - if issue.AuthorUserID.Valid { | |
| 403 | - repoRef.AuthorUserID = issue.AuthorUserID.Int64 | |
| 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), "") | |
| 600 | + stateRef := issueStateRepoRef(row, issue) | |
| 601 | + if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueClose, stateRef); !dec.Allow { | |
| 602 | + h.d.Render.HTTPError(w, r, policy.Maybe404(dec, stateRef, actor), "") | |
| 407 | 603 | return |
| 408 | 604 | } |
| 409 | 605 | state := strings.TrimSpace(r.PostFormValue("state")) |
@@ -519,6 +715,8 @@ func (h *Handlers) handleIssueWriteError(w http.ResponseWriter, r *http.Request, | ||
| 519 | 715 | h.d.Render.HTTPError(w, r, http.StatusLocked, "issue is locked") |
| 520 | 716 | case errors.Is(err, issues.ErrCommentRateLimit): |
| 521 | 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 | 720 | case errors.Is(err, issues.ErrCommentTooLong): |
| 523 | 721 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "comment too long") |
| 524 | 722 | default: |
internal/web/render/octicons.gomodified@@ -31,6 +31,24 @@ func BuiltinOcticons() OcticonResolver { | ||
| 31 | 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 | 32 | "alert": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 33 | 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 | 52 | // S29: notification bell for the top-bar inbox link. |
| 35 | 53 | "bell": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 36 | 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 | 1394 | .shithub-issues-assignees { font-size: 0.85rem; } |
| 1395 | 1395 | .shithub-issues-empty { color: var(--fg-muted); padding: 2rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; } |
| 1396 | 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; } | |
| 1398 | -.shithub-issue-meta { color: var(--fg-muted); margin: 0.5rem 0 1rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } | |
| 1399 | -.shithub-issue-grid { display: grid; grid-template-columns: 1fr 16rem; gap: 1.5rem; } | |
| 1400 | -@media (max-width: 768px) { .shithub-issue-grid { grid-template-columns: 1fr; } } | |
| 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-title { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; margin: 0; } | |
| 1399 | +.shithub-issue-head-actions { display: flex; gap: 0.5rem; flex: 0 0 auto; } | |
| 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 | 1404 | .shithub-comment { |
| 1402 | 1405 | border: 1px solid var(--border-default); |
| 1403 | 1406 | border-radius: 6px; |
@@ -1412,8 +1415,42 @@ code { | ||
| 1412 | 1415 | color: var(--fg-muted); |
| 1413 | 1416 | } |
| 1414 | 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; } | |
| 1416 | -.shithub-event-kind { text-transform: lowercase; } | |
| 1418 | +.shithub-event { | |
| 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 | 1454 | .shithub-comment-form { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; } |
| 1418 | 1455 | .shithub-comment-form textarea, .shithub-issue-form textarea, .shithub-issue-form input[type=text] { |
| 1419 | 1456 | font: inherit; padding: 0.5rem; border: 1px solid var(--border-default); border-radius: 6px; width: 100%; |
@@ -1423,8 +1460,60 @@ code { | ||
| 1423 | 1460 | .shithub-form-row { display: flex; flex-direction: column; gap: 0.25rem; } |
| 1424 | 1461 | .shithub-form-row span { font-weight: 600; font-size: 0.9rem; } |
| 1425 | 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); } | |
| 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; } | |
| 1463 | +.shithub-form-actions-start { justify-content: flex-start; } | |
| 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 | 1517 | .shithub-issue-signedout { color: var(--fg-muted); padding: 1rem; text-align: center; border: 1px dashed var(--border-default); border-radius: 6px; } |
| 1429 | 1518 | .shithub-label { |
| 1430 | 1519 | display: inline-block; |
internal/web/templates/repo/issue_view.htmlmodified@@ -1,27 +1,33 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | 2 | <section class="shithub-issue-view"> |
| 3 | 3 | <header class="shithub-issue-view-head"> |
| 4 | - <h1 class="shithub-issue-title"> | |
| 5 | - <span>{{ .Issue.Title }}</span> | |
| 6 | - <span class="shithub-issue-num">#{{ .Issue.Number }}</span> | |
| 7 | - </h1> | |
| 4 | + <div class="shithub-issue-title-row"> | |
| 5 | + <h1 class="shithub-issue-title"> | |
| 6 | + <span>{{ .Issue.Title }}</span> | |
| 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 | 13 | <div class="shithub-issue-meta"> |
| 9 | 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 | 16 | </span> |
| 12 | 17 | {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }} |
| 13 | 18 | opened this issue |
| 14 | 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 }} | |
| 16 | - {{ if .Issue.Locked }}<span class="shithub-pill">locked</span>{{ end }} | |
| 20 | + · {{ .CommentCount }} comment{{ if ne .CommentCount 1 }}s{{ end }} | |
| 21 | + {{ if .Issue.Locked }}<span class="shithub-pill">{{ octicon "lock" }} locked</span>{{ end }} | |
| 17 | 22 | </div> |
| 18 | 23 | </header> |
| 19 | 24 | |
| 20 | 25 | <div class="shithub-issue-grid"> |
| 21 | 26 | <article class="shithub-issue-thread"> |
| 22 | - <div class="shithub-comment"> | |
| 27 | + <div class="shithub-comment shithub-issue-body-comment"> | |
| 23 | 28 | <div class="shithub-comment-head"> |
| 24 | 29 | {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }} |
| 30 | + opened | |
| 25 | 31 | <time datetime="{{ .Issue.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Issue.CreatedAt.Time }}</time> |
| 26 | 32 | </div> |
| 27 | 33 | <div class="shithub-comment-body markdown-body"> |
@@ -29,24 +35,43 @@ | ||
| 29 | 35 | </div> |
| 30 | 36 | </div> |
| 31 | 37 | |
| 32 | - {{ range .Comments }} | |
| 33 | - <div class="shithub-comment"> | |
| 34 | - <div class="shithub-comment-head"> | |
| 35 | - {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }} | |
| 36 | - commented | |
| 37 | - <time datetime="{{ .C.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .C.CreatedAt.Time }}</time> | |
| 38 | + {{ range .Timeline }} | |
| 39 | + {{ if eq .Type "comment" }} | |
| 40 | + <div class="shithub-comment"> | |
| 41 | + <div class="shithub-comment-head"> | |
| 42 | + {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }} | |
| 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 | 49 | </div> |
| 39 | - <div class="shithub-comment-body markdown-body"> | |
| 40 | - {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }} | |
| 50 | + {{ else }} | |
| 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 | 73 | </div> |
| 42 | - </div> | |
| 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> | |
| 74 | + {{ end }} | |
| 50 | 75 | {{ end }} |
| 51 | 76 | |
| 52 | 77 | {{ if .CanComment }} |
@@ -54,14 +79,14 @@ | ||
| 54 | 79 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 55 | 80 | <label> |
| 56 | 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 | 83 | </label> |
| 59 | 84 | <div class="shithub-form-actions"> |
| 60 | 85 | {{ if .CanSetIssueState }} |
| 61 | 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 | 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 | 90 | {{ end }} |
| 66 | 91 | {{ end }} |
| 67 | 92 | <button type="submit" class="shithub-button shithub-button-primary">Comment</button> |
@@ -87,72 +112,131 @@ | ||
| 87 | 112 | |
| 88 | 113 | <aside class="shithub-issue-sidebar"> |
| 89 | 114 | <section> |
| 90 | - <h3>Labels</h3> | |
| 91 | - {{ if .Labels }} | |
| 92 | - {{ range .Labels }}<span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>{{ end }} | |
| 93 | - {{ else }}<p class="shithub-muted">None yet</p>{{ end }} | |
| 94 | - {{ if .CanEditIssueLabels }} | |
| 95 | - <details> | |
| 96 | - <summary>Edit labels</summary> | |
| 97 | - <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels"> | |
| 98 | - <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 99 | - {{ $current := .Labels }} | |
| 100 | - {{ range .AllLabels }} | |
| 101 | - {{ $id := .ID }} | |
| 102 | - <label class="shithub-label-pick"> | |
| 103 | - <input type="checkbox" name="label_ids" value="{{ .ID }}" | |
| 104 | - {{ range $current }}{{ if eq .ID $id }}checked{{ end }}{{ end }}> | |
| 105 | - <span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span> | |
| 106 | - </label> | |
| 115 | + <div class="shithub-sidebar-heading"> | |
| 116 | + <h3>Assignees</h3> | |
| 117 | + {{ if .CanEditIssueAssignees }} | |
| 118 | + <details class="shithub-sidebar-editor"> | |
| 119 | + <summary aria-label="Edit assignees">{{ octicon "gear" }}</summary> | |
| 120 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-popover"> | |
| 121 | + <strong>Select assignees</strong> | |
| 122 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 123 | + <input type="text" name="username" placeholder="Filter assignees" required> | |
| 124 | + <div class="shithub-form-actions shithub-form-actions-start"> | |
| 125 | + <button type="submit" name="mode" value="add" class="shithub-button">Add</button> | |
| 126 | + <button type="submit" name="mode" value="remove" class="shithub-button">Remove</button> | |
| 127 | + </div> | |
| 128 | + </form> | |
| 129 | + </details> | |
| 130 | + {{ end }} | |
| 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 | 143 | {{ end }} |
| 108 | - <button type="submit" class="shithub-button">Apply</button> | |
| 109 | - </form> | |
| 110 | - </details> | |
| 144 | + {{ end }} | |
| 111 | 145 | {{ end }} |
| 112 | 146 | </section> |
| 113 | 147 | |
| 114 | 148 | <section> |
| 115 | - <h3>Assignees</h3> | |
| 116 | - {{ if .Assignees }} | |
| 117 | - {{ range .Assignees }}<a href="/{{ .Username }}">@{{ .Username }}</a>{{ end }} | |
| 118 | - {{ else }}<p class="shithub-muted">No one assigned</p>{{ end }} | |
| 119 | - {{ if .CanEditIssueAssignees }} | |
| 120 | - <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-assignee-form"> | |
| 121 | - <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 122 | - <input type="text" name="username" placeholder="username" required> | |
| 123 | - <button type="submit" name="mode" value="add" class="shithub-button">Add</button> | |
| 124 | - <button type="submit" name="mode" value="remove" class="shithub-button">Remove</button> | |
| 125 | - </form> | |
| 126 | - {{ end }} | |
| 149 | + <div class="shithub-sidebar-heading"> | |
| 150 | + <h3>Labels</h3> | |
| 151 | + {{ if .CanEditIssueLabels }} | |
| 152 | + <details class="shithub-sidebar-editor"> | |
| 153 | + <summary aria-label="Edit labels">{{ octicon "gear" }}</summary> | |
| 154 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels" class="shithub-popover"> | |
| 155 | + <strong>Select labels</strong> | |
| 156 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 157 | + {{ $current := .Labels }} | |
| 158 | + {{ range .AllLabels }} | |
| 159 | + {{ $id := .ID }} | |
| 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 | 184 | </section> |
| 128 | 185 | |
| 129 | 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 | 204 | {{ if .Issue.MilestoneID.Valid }} |
| 132 | 205 | {{ $mid := .Issue.MilestoneID.Int64 }} |
| 133 | 206 | {{ range .Milestones }}{{ if eq .ID $mid }}<a href="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones">{{ .Title }}</a>{{ end }}{{ end }} |
| 134 | 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 | 208 | </section> |
| 146 | 209 | |
| 147 | - {{ if .CanLockIssue }} | |
| 148 | 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 | 234 | <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/lock"> |
| 151 | 235 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 152 | 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 | 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 | 240 | {{ end }} |
| 157 | 241 | </form> |
| 158 | 242 | </section> |