Skip to content

Commit e71f02f

Browse files
DaedalusGmrnuggetunknwon
authored andcommitted
Add src users clean to src-cli (#826)
* register users clean * added usage statistics to User Node type * mark * define worker function for user removal * before implementing time.Parse(time.RFC3339, payload.UsageStatistics.LastActiveTime) * now computes time since last active * added utility function structure * initialize array to store users to be deleted * working query to remove users and flags * added a user verification to command * corrects logic around removeNeverActive flag * better warning messaging * formating warning * add flag to skip verify check * commented out placeholder code and added TODO comments * addressed many review concerns, added lower bound on -days flag, made better table * Update cmd/src/users.go Co-authored-by: Thorsten Ball <mrnugget@gmail.com> * Update cmd/src/users_clean.go Co-authored-by: Thorsten Ball <mrnugget@gmail.com> * Update cmd/src/users_clean.go Co-authored-by: Thorsten Ball <mrnugget@gmail.com> * correct variable naming bug * remove unused params in get users query * Update cmd/src/users.go Co-authored-by: Joe Chen <jc@unknwon.io> * admins must be explcitly removed * commit dependencies * camel case * ensure clean doesnt clean the user issuing the command Co-authored-by: Thorsten Ball <mrnugget@gmail.com> Co-authored-by: Joe Chen <jc@unknwon.io>
1 parent 1011302 commit e71f02f

File tree

4 files changed

+234
-6
lines changed

4 files changed

+234
-6
lines changed

cmd/src/users.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The commands are:
2020
get gets a user
2121
create creates a user account
2222
delete deletes a user account
23+
clean deletes inactive users
2324
tag add/remove a tag on a user
2425
2526
Use "src users [command] -h" for more information about a command.
@@ -57,7 +58,11 @@ fragment UserFields on User {
5758
}
5859
emails {
5960
email
60-
verified
61+
verified
62+
}
63+
usageStatistics {
64+
lastActiveTime
65+
lastActiveCodeHostIntegrationTime
6166
}
6267
url
6368
}
@@ -71,11 +76,17 @@ type User struct {
7176
Organizations struct {
7277
Nodes []Org
7378
}
74-
Emails []UserEmail
75-
URL string
79+
Emails []UserEmail
80+
UsageStatistics UserUsageStatistics
81+
URL string
7682
}
7783

7884
type UserEmail struct {
7985
Email string
8086
Verified bool
8187
}
88+
89+
type UserUsageStatistics struct {
90+
LastActiveTime string
91+
LastActiveCodeHostIntegrationTime string
92+
}

cmd/src/users_clean.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"strings"
9+
"time"
10+
11+
"github.com/jedib0t/go-pretty/v6/table"
12+
13+
"github.com/sourcegraph/src-cli/internal/api"
14+
)
15+
16+
func init() {
17+
usage := `
18+
This command removes users from a Sourcegraph instance who have been inactive for 60 or more days. Admin accounts are omitted by default.
19+
20+
Examples:
21+
22+
$ src users clean -days 182
23+
24+
$ src users clean -remove-admin -remove-never-active
25+
`
26+
27+
flagSet := flag.NewFlagSet("clean", flag.ExitOnError)
28+
usageFunc := func() {
29+
fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src users %s':\n", flagSet.Name())
30+
flagSet.PrintDefaults()
31+
fmt.Println(usage)
32+
}
33+
var (
34+
daysToDelete = flagSet.Int("days", 60, "Days threshold on which to remove users, must be 60 days or greater and defaults to this value ")
35+
removeAdmin = flagSet.Bool("remove-admin", false, "clean admin accounts")
36+
removeNoLastActive = flagSet.Bool("remove-never-active", false, "removes users with null lastActive value")
37+
skipConfirmation = flagSet.Bool("force", false, "skips user confirmation step allowing programmatic use")
38+
apiFlags = api.NewFlags(flagSet)
39+
)
40+
41+
handler := func(args []string) error {
42+
if err := flagSet.Parse(args); err != nil {
43+
return err
44+
}
45+
if *daysToDelete < 60 {
46+
fmt.Println("-days flag must be set to 60 or greater")
47+
return nil
48+
}
49+
50+
ctx := context.Background()
51+
client := cfg.apiClient(apiFlags, flagSet.Output())
52+
53+
currentUserQuery := `
54+
query {
55+
currentUser {
56+
username
57+
}
58+
}
59+
`
60+
var currentUserResult struct {
61+
Data struct {
62+
CurrentUser struct {
63+
Username string
64+
}
65+
}
66+
}
67+
if ok, err := cfg.apiClient(apiFlags, flagSet.Output()).NewRequest(currentUserQuery, nil).DoRaw(context.Background(), &currentUserResult); err != nil || !ok {
68+
return err
69+
}
70+
fmt.Println(currentUserResult)
71+
72+
usersQuery := `
73+
query Users() {
74+
users() {
75+
nodes {
76+
...UserFields
77+
}
78+
}
79+
}
80+
` + userFragment
81+
82+
// get users to delete
83+
var usersResult struct {
84+
Users struct {
85+
Nodes []User
86+
}
87+
}
88+
if ok, err := client.NewRequest(usersQuery, nil).Do(ctx, &usersResult); err != nil || !ok {
89+
return err
90+
}
91+
fmt.Println(usersResult)
92+
93+
usersToDelete := make([]UserToDelete, 0)
94+
for _, user := range usersResult.Users.Nodes {
95+
daysSinceLastUse, wasLastActive, err := computeDaysSinceLastUse(user)
96+
if err != nil {
97+
return err
98+
}
99+
// never remove user issuing command
100+
if user.Username == currentUserResult.Data.CurrentUser.Username {
101+
continue
102+
}
103+
if !wasLastActive && !*removeNoLastActive {
104+
continue
105+
}
106+
if !*removeAdmin && user.SiteAdmin {
107+
continue
108+
}
109+
if daysSinceLastUse <= *daysToDelete && wasLastActive {
110+
continue
111+
}
112+
deleteUser := UserToDelete{user, daysSinceLastUse}
113+
114+
usersToDelete = append(usersToDelete, deleteUser)
115+
}
116+
117+
if *skipConfirmation {
118+
for _, user := range usersToDelete {
119+
if err := removeUser(user.User, client, ctx); err != nil {
120+
return err
121+
}
122+
}
123+
return nil
124+
}
125+
126+
// confirm and remove users
127+
if confirmed, _ := confirmUserRemoval(usersToDelete); !confirmed {
128+
fmt.Println("Aborting removal")
129+
return nil
130+
} else {
131+
fmt.Println("REMOVING USERS")
132+
for _, user := range usersToDelete {
133+
if err := removeUser(user.User, client, ctx); err != nil {
134+
return err
135+
}
136+
}
137+
}
138+
139+
return nil
140+
}
141+
142+
// Register the command.
143+
usersCommands = append(usersCommands, &command{
144+
flagSet: flagSet,
145+
handler: handler,
146+
usageFunc: usageFunc,
147+
})
148+
}
149+
150+
// computes days since last usage from current day and time and UsageStatistics.LastActiveTime, uses time.Parse
151+
func computeDaysSinceLastUse(user User) (timeDiff int, wasLastActive bool, _ error) {
152+
// handle for null lastActiveTime returned from
153+
if user.UsageStatistics.LastActiveTime == "" {
154+
wasLastActive = false
155+
return 0, wasLastActive, nil
156+
}
157+
timeLast, err := time.Parse(time.RFC3339, user.UsageStatistics.LastActiveTime)
158+
if err != nil {
159+
return 0, false, err
160+
}
161+
timeDiff = int(time.Since(timeLast).Hours() / 24)
162+
163+
return timeDiff, true, err
164+
}
165+
166+
// Issue graphQL api request to remove user
167+
func removeUser(user User, client api.Client, ctx context.Context) error {
168+
query := `mutation DeleteUser($user: ID!) {
169+
deleteUser(user: $user) {
170+
alwaysNil
171+
}
172+
}`
173+
vars := map[string]interface{}{
174+
"user": user.ID,
175+
}
176+
if ok, err := client.NewRequest(query, vars).Do(ctx, nil); err != nil || !ok {
177+
return err
178+
}
179+
return nil
180+
}
181+
182+
type UserToDelete struct {
183+
User User
184+
DaysSinceLastUse int
185+
}
186+
187+
// Verify user wants to remove users with table of users and a command prompt for [y/N]
188+
func confirmUserRemoval(usersToRemove []UserToDelete) (bool, error) {
189+
fmt.Printf("Users to remove from instance at %s\n", cfg.Endpoint)
190+
t := table.NewWriter()
191+
t.SetOutputMirror(os.Stdout)
192+
t.AppendHeader(table.Row{"Username", "Email", "Days Since Last Active"})
193+
for _, user := range usersToRemove {
194+
if len(user.User.Emails) > 0 {
195+
t.AppendRow([]interface{}{user.User.Username, user.User.Emails[0].Email, user.DaysSinceLastUse})
196+
t.AppendSeparator()
197+
} else {
198+
t.AppendRow([]interface{}{user.User.Username, "", user.DaysSinceLastUse})
199+
t.AppendSeparator()
200+
}
201+
}
202+
t.SetStyle(table.StyleRounded)
203+
t.Render()
204+
input := ""
205+
for strings.ToLower(input) != "y" && strings.ToLower(input) != "n" {
206+
fmt.Printf("Do you wish to proceed with user removal [y/N]: ")
207+
if _, err := fmt.Scanln(&input); err != nil {
208+
return false, err
209+
}
210+
}
211+
return strings.ToLower(input) == "y", nil
212+
}

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/google/go-cmp v0.5.7
1313
github.com/grafana/regexp v0.0.0-20220304100321-149c8afcd6cb
1414
github.com/hexops/autogold v1.3.0
15+
github.com/jedib0t/go-pretty/v6 v6.3.7
1516
github.com/jig/teereadcloser v0.0.0-20181016160506-953720c48e05
1617
github.com/json-iterator/go v1.1.12
1718
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@@ -22,7 +23,7 @@ require (
2223
github.com/sourcegraph/jsonx v0.0.0-20200629203448-1a936bd500cf
2324
github.com/sourcegraph/scip v0.2.0
2425
github.com/sourcegraph/sourcegraph/lib v0.0.0-20220816103048-5fb36f9b800c
25-
github.com/stretchr/testify v1.7.2
26+
github.com/stretchr/testify v1.7.4
2627
golang.org/x/net v0.0.0-20220526153639-5463443f8c37
2728
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
2829
google.golang.org/protobuf v1.28.0

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0Gqw
199199
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
200200
github.com/jdxcode/netrc v0.0.0-20210204082910-926c7f70242a h1:d4+I1YEKVmWZrgkt6jpXBnLgV2ZjO0YxEtLDdfIZfH4=
201201
github.com/jdxcode/netrc v0.0.0-20210204082910-926c7f70242a/go.mod h1:Zi/ZFkEqFHTm7qkjyNJjaWH4LQA9LQhGJyF0lTYGpxw=
202+
github.com/jedib0t/go-pretty/v6 v6.3.7 h1:H3Ulkf7h6A+p0HgKBGzgDn0bZIupRbKKWF4pO4Bs7iA=
203+
github.com/jedib0t/go-pretty/v6 v6.3.7/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI=
202204
github.com/jhump/gopoet v0.0.0-20190322174617-17282ff210b3/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI=
203205
github.com/jhump/gopoet v0.1.0/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI=
204206
github.com/jhump/goprotoc v0.5.0/go.mod h1:VrbvcYrQOrTi3i0Vf+m+oqQWk9l72mjkJCYo7UvLHRQ=
@@ -387,14 +389,16 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM
387389
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
388390
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
389391
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
392+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
390393
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
391394
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
392395
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
393396
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
394397
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
395398
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
396-
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
397-
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
399+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
400+
github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM=
401+
github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
398402
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
399403
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
400404
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=

0 commit comments

Comments
 (0)