// teamhands.go — what happens when a repository changes hands.
//
// Putting a repository under a team grants every member whatever the
// membership floor and their team row carry, on that repository. Taking
// it out takes that away. Moving it between teams does both at once, to
// two different populations. None of that was told to anybody: the
// single-account path (account.caps) raises RFC 0005's vocabulary and
// puts a line in the person's inbox, and the TEAM path — which changes
// access for many people in one operation, and on a private repository
// changes the whole of it — was silent.
//
// So this file reuses the two functions account.caps already uses,
// per affected account, rather than inventing a team vocabulary. The
// words a person reads are the same whether their access changed
// because somebody edited their row or because a repository moved,
// which is the honest description in both cases: what changed is what
// they can reach.
package api
import (
"context"
"fmt"
"sort"
"strings"
)
// capsText renders an effective set as the string the vocabulary and
// the inbox already speak in. CapSet is an interface here (the api
// package does not import oltp's concrete type for this), so the text
// comes from its Stringer the way every other surface reads it.
func capsText(c CapSet) string {
if c == nil {
return ""
}
return fmt.Sprint(c)
}
// handsWatch is the access picture before a repository changes hands:
// the effective string of everybody who could be affected.
//
// Captured BEFORE the write, because "what changed" needs both sides
// and the store only holds one of them at a time.
type handsWatch struct {
repo string
before map[string]string
}
// watchHands snapshots the accounts whose access to repo could move:
// the members of every team named. Callers pass the team losing it and
// the team gaining it; either may be empty.
//
// Bounded by the teams' membership, not by the forge.
func (d Deps) watchHands(repo string, teams ...string) handsWatch {
w := handsWatch{repo: repo, before: map[string]string{}}
if d.CapsFor == nil || d.Teams == nil {
return w
}
for _, team := range teams {
if team == "" {
continue
}
members, err := d.Teams.TeamMembers(team)
if err != nil {
continue
}
for _, m := range members {
if _, seen := w.before[m.Account]; seen {
continue
}
w.before[m.Account] = capsText(d.CapsFor(m.Account, repo))
}
}
return w
}
// settled tells everybody whose access actually moved, and raises the
// vocabulary event for each.
//
// Called AFTER the write and never allowed to fail it: the repository
// has already changed hands, so an error here would report a change
// that did happen as one that did not. Accounts whose effective string
// is unchanged are silent — a member of both teams loses nothing when a
// repository moves between them, and must not be told they did.
func (d Deps) settled(ctx context.Context, w handsWatch, actor string) {
if d.CapsFor == nil || w.repo == "" {
return
}
names := make([]string, 0, len(w.before))
for account := range w.before {
names = append(names, account)
}
sort.Strings(names)
for _, account := range names {
before := w.before[account]
after := capsText(d.CapsFor(account, w.repo))
if before == after {
continue
}
d.raiseAccountChange(ctx, w.repo, account, before, after)
d.AccessChanged(ctx, actor, account, w.repo, before, after)
}
}
// wouldLose is who loses access to repo if it leaves team: the members
// whose only route to it is the membership floor. Somebody holding a
// string of their own on that repository keeps it and is not named.
//
// Shared by handsWarning (which says it after) and team.get (which says
// it before), so the sentence on the control and the sentence in the
// answer cannot describe one change two ways.
func (d Deps) wouldLose(team, repo string) []string {
if team == "" || d.Teams == nil {
return nil
}
members, err := d.Teams.TeamMembers(team)
if err != nil {
return nil
}
// On a public repository the categories already grant what the
// floor does, so nobody's access actually moves. Asked once for the
// repository rather than once per member.
if d.CapsFor != nil && d.CapsFor("", repo) != nil &&
d.CapsFor("", repo).HasAny(teamFloorLetters) {
return nil
}
var losing []string
for _, m := range members {
if d.Roster != nil {
if own, cerr := d.Roster.CapsOf(m.Account, repo); cerr == nil && own != "" {
continue
}
}
losing = append(losing, m.Account)
}
sort.Strings(losing)
return losing
}
// teamFloorLetters is what the membership floor carries that a reader
// would notice losing. Not the whole floor: a public repository grants
// most of it through its categories anyway, and the question here is
// whether leaving the team takes anything away at all.
const teamFloorLetters = "C"
// handsWarning names who loses a repository when it moves out of a
// team, for the op to return before anybody has to discover it.
//
// RFC 0017 set this rule for unbinding a companion — "MUST name who
// loses access before it happens" — on the reasoning that dropping
// access is the safe direction but not a quiet operation. A repository
// leaving a team is the same act on the same population, so it gets the
// same treatment. The forge knows exactly who: the team's members whose
// access to that repository came only through it.
func (d Deps) handsWarning(from, repo string) string {
losing := d.wouldLose(from, repo)
if len(losing) == 0 {
return ""
}
who := strings.Join(losing, ", ")
if len(losing) > 6 {
who = fmt.Sprintf("%s and %d more", strings.Join(losing[:6], ", "), len(losing)-6)
}
return fmt.Sprintf("%s left %s: %s %s access that came only through it",
repo, from, who, plural(len(losing), "loses", "lose"))
}
// plural picks the verb for a count, because "1 accounts lose" is the
// kind of thing a reader stops trusting the rest of the sentence over.
func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}