package control import ( "errors" "fmt" "io" "strconv" "strings" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) const maxBodyBytes = 64 << 10 // MaxCommentBytes is the most of a comment body a command reads. const MaxCommentBytes = maxBodyBytes func init() { register(Command{Path: []string{"issue", "create"}, Summary: "open an issue", Usage: "issue create --title [--body | --file -] [--format md|org] [--label ]... [--milestone ] [--assignee <user>]...", Flags: []Flag{ {"--title", "<t>", "the issue's title", ""}, {"--body", "<b>", "the issue's body", ""}, {"--file", "-", "read the body from stdin", ""}, {"--format", "md|org", "the body's markup", "md"}, {"--label", "<l>", "label to add, may repeat", ""}, {"--milestone", "<title>", "milestone to set", ""}, {"--assignee", "<user>", "user to assign, may repeat", ""}, }, Examples: []string{ `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`, "issue create krz/gitbay --title notes --file - < notes.md", "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc", }, ReadsStdin: true, Run: runIssueCreate}) register(Command{Path: []string{"issue", "list"}, Summary: "list issues", Usage: "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | issue list --query <name> | --q <query> [--limit <n>] [--cursor <c>]", Flags: []Flag{ {"--state", "open|closed|all", "which issues", "open"}, {"--label", "<l>", "only issues carrying this label", ""}, {"--assignee", "<user>", "only issues assigned to this user", ""}, {"--author", "<user>", "only issues opened by this user", ""}, {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""}, {"--search", "<text>", "match title and body", ""}, {"--query", "<name>", "a saved query, across repositories, in place of a repository and filters", ""}, {"--q", "<query>", "a query written out, as query save takes it", ""}, {"--limit", "<n>", "rows per page", ""}, {"--cursor", "<c>", "continue from the previous page", ""}, }, Examples: []string{ "issue list krz/gitbay --label bug --state all", "issue list krz/gitbay --assignee cmc", "issue list --query mine", `issue list --q "owner:krz is:open assignee:@me"`, }, ReadOnly: true, Run: runIssueList}) register(Command{Path: []string{"issue", "show"}, Summary: "show an issue with comments", Usage: "issue show <owner/name> <n>", Examples: []string{"issue show krz/gitbay 42"}, ReadOnly: true, Run: runIssueShow}) register(Command{Path: []string{"issue", "edit"}, Summary: "edit title or body", Usage: "issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", Flags: []Flag{ {"--title", "<t>", "the issue's new title", ""}, {"--body", "<b>", "the issue's new body", ""}, {"--file", "-", "read the new body from stdin", ""}, {"--format", "md|org", "the body's markup", ""}, }, Examples: []string{ `issue edit krz/gitbay 42 --title "crash on empty repo, take two"`, "issue edit krz/gitbay 42 --file - < notes.md", }, ReadsStdin: true, Run: runIssueEdit}) register(Command{Path: []string{"issue", "comment"}, Summary: "add a comment", Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", Flags: []Flag{ {"--message", "<m>", "the comment's text", ""}, {"--file", "-", "read the comment from stdin", ""}, {"--format", "md|org", "the comment's markup", "md"}, }, Examples: []string{ `issue comment krz/gitbay 42 --message "can't reproduce on main"`, "issue comment krz/gitbay 42 --file - < notes.md", }, ReadsStdin: true, Run: runIssueComment}) register(Command{Path: []string{"issue", "close"}, Summary: "close an issue", Usage: "issue close <owner/name> <n>", Examples: []string{"issue close krz/gitbay 42"}, Run: runIssueClose}) register(Command{Path: []string{"issue", "reopen"}, Summary: "reopen an issue", Usage: "issue reopen <owner/name> <n>", Examples: []string{"issue reopen krz/gitbay 42"}, Run: runIssueReopen}) register(Command{Path: []string{"issue", "label"}, Summary: "add or remove labels", Usage: "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", Flags: []Flag{ {"--add", "<l>", "label to add, may repeat", ""}, {"--remove", "<l>", "label to remove, may repeat", ""}, }, Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"}, Run: runIssueLabel}) register(Command{Path: []string{"issue", "assign"}, Summary: "add or remove assignees", Usage: "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", Flags: []Flag{ {"--add", "<user>", "user to assign, may repeat", ""}, {"--remove", "<user>", "user to unassign, may repeat", ""}, }, Examples: []string{"issue assign krz/gitbay 42 --add cmc"}, Run: runIssueAssign}) } // issueArgs parses "<owner/name> <n>" plus flags handled by the caller. func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) { repo, n, code := refArgs(c, args, perm, "issue") if code >= 0 { return repo, store.Issue{}, code } issue, err := c.Store.IssueByNumber(repo.ID, n) if errors.Is(err, store.ErrNotFound) { return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path()) } if err != nil { return repo, issue, c.fail(protocol.ExitFailure, "%v", err) } return repo, issue, -1 } // bodyFrom resolves --body/--message inline text or --file - (stdin). func bodyFrom(c *Ctx, inline, file string) (string, error) { if inline != "" && file != "" { return "", errors.New("give either an inline message or --file -, not both") } if file != "" { if file != "-" { return "", errors.New("--file only supports - (stdin) over ssh") } raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes)) return string(raw), err } return inline, nil } // markupFormat normalizes a --format value. Empty means the caller did not ask, // which the caller turns into "md" on create or "unchanged" on edit. func markupFormat(v string) (string, error) { switch strings.ToLower(strings.TrimSpace(v)) { case "": return "", nil case "md", "markdown": return "md", nil case "org", "org-mode": return "org", nil } return "", fmt.Errorf("unknown --format %q (want md or org)", v) } type issueOut struct { Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` Author string `json:"author"` Milestone string `json:"milestone,omitempty"` Labels []string `json:"labels,omitempty"` Assignees []string `json:"assignees,omitempty"` Body string `json:"body,omitempty"` BodyFormat string `json:"body_format,omitempty"` CreatedAt string `json:"created_at"` } func issueToOut(i store.Issue, withBody bool) issueOut { o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author, Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt} if withBody { o.Body = i.Body o.BodyFormat = i.BodyFormat } return o } // runIssueCreate opens an issue. The CLI opens $EDITOR for the body // when neither --body nor --file is given (cmd/gitbay's issueCmd, // editor: "issue"); over stock ssh the body must be one of the two. func runIssueCreate(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{ Values: []string{"--format", "--title", "--body", "--file", "--milestone"}, Multi: []string{"--label", "--assignee"}, MaxPos: 1, Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]..."}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") if path == "" || title == "" { return c.usage() } fmtName, err := markupFormat(format) if err != nil { return c.failInput(err) } if fmtName == "" { fmtName = "md" } // Anyone who can read the repo can file an issue. repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } // Filing an issue only needs read access; setting a label, milestone // or assignee on it needs the same write access issue label/issue // milestone/issue assign require. if len(f.List("--label")) > 0 || f.Value("--milestone") != "" || len(f.List("--assignee")) > 0 { grant, err := c.Store.AccessRole(repo.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "checking access: %v", err) } if !policy.CanWrite(c.User, repo, grant) { return c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path) } } // Resolve everything that can be refused before the issue exists, so // a typo in a milestone or an assignee creates nothing. var milestone store.Milestone if m := f.Value("--milestone"); m != "" { if milestone, err = c.Store.MilestoneByTitle(repo, m); err != nil { return milestoneErr(c, repo, m, err) } } assignees, code := resolveUsers(c, f.List("--assignee")) if code >= 0 { return code } b, err := bodyFrom(c, body, file) if err != nil { return c.failInput(err) } n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { notify(c, targets, notice{repo: repo, kind: "issue", number: n, subject: issueSubject(repo, n, title), action: fmt.Sprintf("opened issue #%d", n), excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) } issue, err := c.Store.IssueByNumber(repo.ID, n) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } notifyMentions(c, repo, issueThread, issue.ID, n, title, b) if labels := f.List("--label"); len(labels) > 0 { if _, code := labelIssue(c, repo, issue, labels, nil); code >= 0 { return code } } if milestone.ID != 0 { if err := recordItemMilestone(c, repo, "issue", n, milestone.ID, milestone.Title, func(id int64) error { return c.Store.SetIssueMilestone(issue.ID, id) }); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } if len(assignees) > 0 { if _, code := assignIssue(c, repo, issue, assignees, nil); code >= 0 { return code } } return c.emit(Created{Number: n}, func(w io.Writer) { fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) }) } func runIssueList(c *Ctx, args []string) int { if usesQuery(args) { args, p, code := parsePageFlags(c, args, "query", false) if code >= 0 { return code } fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } return listByQuery(c, fl, "issue", p) } args, p, code := parsePageFlags(c, args, "issue", true) if code >= 0 { return code } f := store.IssueFilter{State: "open"} fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path := fl.pos(0) if fl.Has("--state") { f.State = fl.Value("--state") } f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone") f.Search = fl.Value("--search") if fl.Has("--search") { if err := validQuery(f.Search); err != nil { return c.failInput(err) } } if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") { return c.usage() } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } f.Limit, f.Before = p.queryLimit(), p.keyInt() issues, err := c.Store.QueryIssues(repo.ID, f) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } issues, next := trimPage(p, issues, "issue", func(i store.Issue) string { return strconv.FormatInt(i.Number, 10) }) var ds []issueOut for _, i := range issues { ds = append(ds, issueToOut(i, false)) } return c.emitPage(p, ds, next, func(w io.Writer) { tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") for _, d := range ds { tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author)) } tb.flush() }) } func runIssueShow(c *Ctx, args []string) int { repo, issue, code := issueRef(c, args, policy.CanRead) if code >= 0 { return code } if len(args) != 2 { return c.usage() } comments, err := c.Store.ListIssueComments(issue.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } rx, err := c.Store.ReactionCounts("issue", issue.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } var cs []commentOut for _, cm := range comments { cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) } d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs} return c.emit(d, func(w io.Writer) { v := c.view(w) v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) v.fields( "author", d.Author+", "+c.when(d.CreatedAt), "assignees", strings.Join(d.Assignees, ", "), "labels", strings.Join(d.Labels, ", "), "milestone", d.Milestone, "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)), ) v.body(d.Body, d.BodyFormat) v.reactions(d.Reactions) events := false for _, cm := range cs { if cm.Kind != "system" { continue } if !events { io.WriteString(w, "\n") events = true } v.event(cm.Body, cm.BodyFormat, cm.CreatedAt) } for _, cm := range cs { if cm.Kind == "system" { continue } v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) v.reactions(cm.Reactions) } }) } func runIssueComment(c *Ctx, args []string) int { return runComment(c, args, issueThread, "issue", func(rest []string) (store.Repo, int64, int64, string, int) { repo, issue, code := issueRef(c, rest, policy.CanRead) return repo, issue.ID, issue.Number, issue.Title, code }, c.Store.AddIssueComment, c.Store.IssueParticipants) } func setIssueState(c *Ctx, args []string, state string) int { // Author may close/reopen their own issue; otherwise write access. repo, issue, code := issueRef(c, args, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if len(args) != 2 { return c.usage() } if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 { return code } if issue.State == state { return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state) } if err := c.Store.SetIssueState(issue.ID, state); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { verb := map[string]string{"open": "reopened", "closed": "closed"}[state] notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number, subject: issueSubject(repo, issue.Number, issue.Title), action: fmt.Sprintf("%s #%d", verb, issue.Number), path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) } return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) { fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state) }) } // editText parses --title/--body/--file -/--format and authorizes: author or // write. A nil format means the stored markup format stays as it is. extra // names further value flags a caller wants (mr edit's --superseded-by): // they are accepted and reported in the returned flags, and count toward // "at least one edit was given" alongside title/body/format. func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) { f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1, Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) if err != nil { return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err) } rest = f.Pos titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") haveTitle, haveBody := f.Has("--title"), f.Has("--body") if file != "" { b, err := bodyFrom(c, "", file) if err != nil { return nil, nil, nil, nil, flags{}, c.failInput(err) } bodyV, haveBody = b, true } fmtName, err := markupFormat(formatV) if err != nil { return nil, nil, nil, nil, flags{}, c.failInput(err) } anyExtra := false for _, e := range extra { if f.Has(e) { anyExtra = true break } } if !haveTitle && !haveBody && fmtName == "" && !anyExtra { return nil, nil, nil, nil, flags{}, c.usage() } if haveTitle { if strings.TrimSpace(titleV) == "" { return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty") } title = &titleV } if haveBody { body = &bodyV } if fmtName != "" { format = &fmtName } return rest, title, body, format, f, -1 } func runIssueEdit(c *Ctx, args []string) int { rest, title, body, format, _, code := editText(c, args, "issue") if code >= 0 { return code } repo, issue, code := issueRef(c, rest, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 { return code } if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number)) if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number, subject: issueSubject(repo, issue.Number, issue.Title), action: fmt.Sprintf("edited #%d", issue.Number), path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) } return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number) }) } func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") } // addRemoveFlags parses repeated --add/--remove flags. func addRemoveFlags(args []string) (rest, adds, removes []string, err error) { f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1}) if err != nil { return nil, nil, nil, err } rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove") return rest, adds, removes, nil } func runIssueLabel(c *Ctx, args []string) int { rest, adds, removes, err := addRemoveFlags(args) if err != nil { return c.failInput(err) } if len(adds)+len(removes) == 0 { return c.usage() } repo, issue, code := issueRef(c, rest, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } labels, code := labelIssue(c, repo, issue, adds, removes) if code >= 0 { return code } return c.emit(map[string]any{"number": issue.Number, "labels": labels}, func(w io.Writer) { fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(labels, ", ")) }) } // labelIssue adds and removes labels on issue and records the // issue.labeled event, returning the labels it carries afterwards. It // backs issue label and issue create --label. func labelIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []string) ([]string, int) { for _, l := range adds { if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } } for _, l := range removes { if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil { if errors.Is(err, store.ErrNotFound) { return nil, c.fail(protocol.ExitNotFound, "%v", err) } return nil, c.fail(protocol.ExitFailure, "%v", err) } } updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) if err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels))) return updated.Labels, -1 } func runIssueAssign(c *Ctx, args []string) int { rest, adds, removes, err := addRemoveFlags(args) if err != nil { return c.failInput(err) } if len(adds)+len(removes) == 0 { return c.usage() } repo, issue, code := issueRef(c, rest, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } add, code := resolveUsers(c, adds) if code >= 0 { return code } remove, code := resolveUsers(c, removes) if code >= 0 { return code } assignees, code := assignIssue(c, repo, issue, add, remove) if code >= 0 { return code } return c.emit(map[string]any{"number": issue.Number, "assignees": assignees}, func(w io.Writer) { fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(assignees, ", ")) }) } // resolveUsers looks up every name, failing on the first that does not // exist, so a typo changes nothing. func resolveUsers(c *Ctx, names []string) ([]store.User, int) { users := make([]store.User, 0, len(names)) for _, name := range names { u, err := c.Store.UserByUsername(name) if errors.Is(err, store.ErrNotFound) { return nil, c.fail(protocol.ExitNotFound, "no such user %q", name) } if err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } users = append(users, u) } return users, -1 } // assignIssue adds and removes assignees on issue, records the // issue.assigned event and tells each newly added account, returning the // assignees afterwards. It backs issue assign and issue create --assignee. func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []store.User) ([]string, int) { // issue is the read from before the update, so its Assignees are who // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING // and returns nil whether or not it inserted, and the notice below is // for accounts newly added: a client reconciling the list by // re-sending the whole set must not notify on every save. assigned := make(map[string]bool, len(issue.Assignees)) for _, name := range issue.Assignees { assigned[name] = true } var added []int64 for _, u := range adds { if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } if assigned[u.Username] { continue } assigned[u.Username] = true added = append(added, u.ID) } for _, u := range removes { if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil { if errors.Is(err, store.ErrNotFound) { return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username) } return nil, c.fail(protocol.ExitFailure, "%v", err) } } updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) if err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned", fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees))) if len(added) > 0 { // direct, as a mention is: an assignment is addressed to someone, // and widening it to watchers would tell them "assigned you". // Removals file nothing, and notify drops the actor, so assigning // yourself is silent. notify(c, added, notice{repo: repo, kind: "issue", number: issue.Number, direct: true, subject: issueSubject(repo, issue.Number, issue.Title), action: fmt.Sprintf("assigned you to #%d", issue.Number), path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) } return updated.Assignees, -1 }