/
main.go
587 lines (536 loc) · 19.3 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/alexflint/go-arg"
"github.com/go-git/go-git/v5"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/joho/godotenv"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
type repoStruct struct {
HTMLURL string `json:"html_url"`
Name string `json:"name"`
Description string `json:"description"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
type gistStruct struct {
ID string `json:"id"`
GitPullURL string `json:"git_pull_url"`
Public bool `json:"public"`
Description string `json:"description"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
var backedUp = map[string]map[string]map[string]string{}
var (
currentDirectory, _ = os.Getwd()
backupDirectory string
)
type BackupCmd struct {
Targets []string `arg:"positional" help:"What to backup. Options are: repos; stars; gists"`
CreateList bool `arg:"-c,--create-list" help:"Create a list of repositories"`
NoClone bool `arg:"-n,--no-clone" help:"Don't clone the repositories"`
Repeat bool `arg:"-r,--repeat, env:REPEAT" help:"Repeat the backup process every 24 hours"`
BackupDirectory string `arg:"-d,--backup-directory, env:BACKUP_DIRECTORY" help:"Directory to place backups in"`
MaxBackups int `arg:"-m,--max-backups, env:MAX_BACKUPS" help:"Maximum number of backups to keep. Setting to -1 will cause no backups to be deleted" default:"10"`
}
var args struct {
Backup *BackupCmd `arg:"subcommand:backup" help:"Backup your GitHub data"`
// Environment variables
Token string `arg:"env:GITHUB_TOKEN" help:"GitHub token"`
// Misc flags
LogLevel string `arg:"--log-level, env:LOG_LEVEL" help:"\"debug\", \"info\", \"warning\", \"error\", or \"fatal\"" default:"info"`
LogColor bool `arg:"--log-color, env:LOG_COLOR" help:"Force colored logs" default:"false"`
}
func main() {
// Command line stuff
godotenv.Load()
arg.MustParse(&args)
logrus.SetOutput(os.Stdout)
logrus.SetFormatter(&logrus.TextFormatter{PadLevelText: true, DisableQuote: true, ForceColors: args.LogColor, DisableColors: !args.LogColor})
if args.LogLevel == "debug" {
logrus.SetLevel(logrus.DebugLevel)
// Enable line numbers in debug logs - Doesn't help too much since a fatal error still needs to be debugged
// logrus.SetReportCaller(true)
} else if args.LogLevel == "info" {
logrus.SetLevel(logrus.InfoLevel)
} else if args.LogLevel == "warning" {
logrus.SetLevel(logrus.WarnLevel)
} else if args.LogLevel == "error" {
logrus.SetLevel(logrus.ErrorLevel)
} else if args.LogLevel == "fatal" {
logrus.SetLevel(logrus.FatalLevel)
} else {
logrus.SetLevel(logrus.InfoLevel)
}
var err error
var parentDirectory string
// A default backup directory is set to the current directory + /backups
parentDirectory = filepath.Join(currentDirectory, "backups")
backupDirectory = setBackupDirectory(parentDirectory)
switch {
case args.Backup != nil:
if len(args.Backup.Targets) == 0 {
logrus.Fatal("No targets specified")
}
if args.Backup.BackupDirectory != "" {
logrus.Info("Backup directory set to " + args.Backup.BackupDirectory)
parentDirectory = args.Backup.BackupDirectory
}
for {
// If there are too many backups, delete the oldest ones
err = rollBackups(parentDirectory, args.Backup.MaxBackups)
if err != nil {
logrus.Fatal(err)
}
if args.Backup.Repeat {
logrus.Info("Starting backup process. Repeat is enabled. Backups will be placed in " + parentDirectory + ".")
} else {
logrus.Info("Starting backup process. Backup will be placed in " + parentDirectory + ".")
}
// Set up the backup directory
backupDirectory = setBackupDirectory(parentDirectory)
// Repo backup
backedUp, err = backupRepos(args.Backup.Targets, !args.Backup.NoClone, args.Token)
if err != nil {
logrus.Fatal(err)
}
// List creation
if args.Backup.CreateList {
if err := createList(backedUp); err != nil {
logrus.Fatal(err)
}
}
// If the backup process is not set to repeat, exit. If it is, sleep for 24 hours and repeat
// If a custom sleep timing is implemented, this will need to be changed
if !args.Backup.Repeat {
break
} else {
logrus.Info("Sleeping for 24 hours")
time.Sleep(24 * time.Hour)
// time.Sleep(1 * time.Second) // For testing purposes
}
}
default:
mainMenu()
}
}
func createList(repos map[string]map[string]map[string]string) error {
logrus.Info("Creating list of repositories")
// Write the repos to a JSON file
if _, err := os.Stat(backupDirectory); !os.IsExist(err) {
// Maybe just make a file instead of a directory, for now, a directory is fine
if err := os.MkdirAll(backupDirectory, 0600); err != nil {
return err
}
}
file, err := os.OpenFile(filepath.Join(backupDirectory, "list.json"), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(repos); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func backupRepos(repoTypes []string, clone bool, token string) (map[string]map[string]map[string]string, error) {
url := "https://api.github.com/user"
response, err := ghRequest(url, token)
if err != nil {
return nil, err
}
content, err := responseContent(response.Body)
if err != nil {
return nil, err
}
user := gjson.Get(content, "login")
// Repos is a map of repo names. This is just to store repos in {repoTypes: [<id or full name>: {"description": repoDescription, "url": <html_url or git_pull_url>}, <id or full name>...]} format. So a map of maps
repos := map[string]map[string]map[string]string{}
for _, repoType := range repoTypes {
// Declaring variables so that they can be used in the switch statement and outside of it
var (
// repoSlice is a slice of repoStructs
repoSlice []repoStruct
gistSlice []gistStruct
url string
neededPages int
)
// Create a map for the repo type ( to prevent a panic: assignment to entry in nil map)
repos[repoType] = map[string]map[string]string{}
if repoType == "repos" {
url = "https://api.github.com/user/repos?per_page=100&page="
neededPages, err = calculateNeededPages("repos", token)
if err != nil {
return nil, err
}
logrus.Info("Getting list of repositories for " + user.String())
} else if repoType == "stars" {
url = "https://api.github.com/user/starred?per_page=100&page="
neededPages, err = calculateNeededPages("stars", token)
if err != nil {
return nil, err
}
logrus.Info("Getting list of starred repositories for " + user.String())
} else if repoType == "gists" {
url = "https://api.github.com/gists?per_page=100&page="
neededPages, err = calculateNeededPages("gists", token)
if err != nil {
return nil, err
}
logrus.Info("Getting list of gists for " + user.String())
}
for i := 1; i <= neededPages; i++ {
if repoType == "gists" {
response, err := ghRequest(url+strconv.Itoa(i), token)
if err != nil {
return nil, err
}
gistJSON, err := responseContent(response.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(gistJSON), &gistSlice); err != nil {
return nil, err
}
for i := 0; i < len(gistSlice); i++ {
owner := gistSlice[i].Owner.Login
if clone {
cloneDirectory := filepath.Join(backupDirectory, repoType, owner, gistSlice[i].ID)
logrus.Infof("Cloning %v (iteration %v) to %v\n", gistSlice[i].ID, i+1, cloneDirectory)
logrus.Debug("URL: " + gistSlice[i].GitPullURL)
_, err = git.PlainClone(cloneDirectory, false, &git.CloneOptions{
URL: gistSlice[i].GitPullURL,
Auth: &githttp.BasicAuth{
Username: user.String(),
Password: token,
},
})
if err != nil {
return nil, err
}
}
// Set the description and url of the gist
repos[repoType][gistSlice[i].ID] = map[string]string{}
// There is no title for gists, so just use the ID and don't set the name
repos[repoType][gistSlice[i].ID]["description"] = gistSlice[i].Description
repos[repoType][gistSlice[i].ID]["url"] = gistSlice[i].GitPullURL
}
} else if repoType == "repos" || repoType == "stars" {
response, err := ghRequest(url+strconv.Itoa(i), token)
if err != nil {
return nil, err
}
repoJSON, err := responseContent(response.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(repoJSON), &repoSlice); err != nil {
return nil, err
}
for i := 0; i < len(repoSlice); i++ {
owner := repoSlice[i].Owner.Login
if clone {
cloneDirectory := filepath.Join(backupDirectory, repoType, owner, repoSlice[i].Name)
logrus.Infof("Cloning %v (iteration %v) to %v\n", repoSlice[i].Name, i+1, cloneDirectory)
logrus.Debug("URL: " + repoSlice[i].HTMLURL)
_, err = git.PlainClone(cloneDirectory, false, &git.CloneOptions{
URL: repoSlice[i].HTMLURL,
Auth: &githttp.BasicAuth{
Username: user.String(), // anything except an empty string
Password: token,
},
})
if err != nil {
return nil, err
}
}
// Set the description and url of the repo
repos[repoType][repoSlice[i].Name] = map[string]string{}
// Perhaps use <user uuid>/<repo uuid> instead of <user>/<repo>?
repos[repoType][repoSlice[i].Name]["name"] = repoSlice[i].Name
repos[repoType][repoSlice[i].Name]["description"] = repoSlice[i].Description
repos[repoType][repoSlice[i].Name]["url"] = repoSlice[i].HTMLURL
}
}
}
}
return repos, nil
}
func calculateNeededPages(whichRepos string, token string) (int, error) {
perPage := 100 // Max is 100
if whichRepos == "repos" {
response, err := ghRequest("https://api.github.com/user", token)
if err != nil {
return 0, err
}
json, err := responseContent(response.Body)
if err != nil {
return 0, err
}
publicRepos := gjson.Get(json, "public_repos")
privateRepos := gjson.Get(json, "total_private_repos")
totalRepos := publicRepos.Num + privateRepos.Num
logrus.Info("Total repositories: " + strconv.Itoa(int(totalRepos)))
neededPages := math.Ceil(totalRepos / float64(perPage))
logrus.Info("Total pages needed:" + strconv.Itoa(int(neededPages)))
return int(neededPages), nil
} else if whichRepos == "stars" {
total := 0
var starSlice []repoStruct
var pageNumber int
// If the length of the slice is 0, the request prior to the one just made was the last page
for len(starSlice) == perPage || pageNumber == 0 {
pageNumber++
url := "https://api.github.com/user/starred?page=" + strconv.Itoa(pageNumber) + "&per_page=" + strconv.Itoa(perPage)
requestContent, err := ghRequest(url, loadToken())
if err != nil {
return 0, err
}
starJSON, err := responseContent(requestContent.Body)
if err != nil {
return 0, err
}
if err := json.Unmarshal([]byte(starJSON), &starSlice); err != nil {
return 0, err
}
total += len(starSlice)
}
logrus.Info("Total starred repositories: " + strconv.Itoa(total))
logrus.Info("Total pages needed:" + strconv.Itoa(pageNumber))
return pageNumber, nil
} else if whichRepos == "gists" {
total := 0
var gistSlice []gistStruct
var pageNumber int
for len(gistSlice) == perPage || pageNumber == 0 {
pageNumber++
url := "https://api.github.com/gists?page=" + strconv.Itoa(pageNumber) + "&per_page=" + strconv.Itoa(perPage)
requestContent, err := ghRequest(url, loadToken())
if err != nil {
return 0, err
}
gistJSON, err := responseContent(requestContent.Body)
if err != nil {
return 0, err
}
if err := json.Unmarshal([]byte(gistJSON), &gistSlice); err != nil {
return 0, err
}
total += len(gistSlice)
}
logrus.Info("Total gists: " + strconv.Itoa(total))
logrus.Info("Total pages needed: " + strconv.Itoa(pageNumber))
return pageNumber, nil
}
// This functions as an else statement since the function will return before this point if the argument is valid.
return 0, errors.New("invalid argument (must be \"repos\", \"stars\", or \"gists\")")
}
func responseContent(responseBody io.ReadCloser) (string, error) {
bytes, err := io.ReadAll(responseBody)
if err != nil {
return "", err
}
return string(bytes), nil
}
func ghRequest(url, token string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "token "+token)
// req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Accept", "testing-github-api")
response, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
if response.StatusCode == 401 {
return nil, errors.New("unauthorized, check your token")
} else {
return nil, errors.New("something went wrong, status code is not \"200 OK\", response status code: " + response.Status)
}
}
return response, nil
}
func loadToken() string {
return args.Token
}
// Menus
func mainMenu() {
fmt.Println(`1) Backup repositories
2) Exit`)
reader := bufio.NewReader(os.Stdin)
menuSelection, _ := reader.ReadString('\n')
menuSelection = strings.TrimSpace(menuSelection)
switch menuSelection {
case "1":
backupMenu()
case "2":
os.Exit(0)
}
}
func backupMenu() {
fmt.Println(`What should this program backup?
1) Your public and private repositories
2) Your starred repositories
3) Both`)
reader := bufio.NewReader(os.Stdin)
backupSelection, _ := reader.ReadString('\n')
backupSelection = strings.TrimSpace(backupSelection)
switch backupSelection {
case "1":
backupRepos([]string{"repos"}, true, args.Token)
case "2":
backupRepos([]string{"stars"}, true, args.Token)
case "3":
backupRepos([]string{"repos", "stars"}, true, args.Token)
default:
logrus.Fatalln("Invalid selection")
}
}
func setBackupDirectory(parentDirectory string) string {
// Set dateToday to the current date and time in the fomrat of MM-DD-YYYY_HH-MM-SS
dateToday := time.Now().Format("01-02-2006_15-04-05")
backupDirectory = filepath.Join(parentDirectory, "github-backup-from-"+dateToday)
return backupDirectory
}
func calculateOldestBackup(parentDirectory string) (string, error) {
// Backup directory names are in the format of "github-backup-from-MM-DD-YYYY_HH-MM-SS"
// Get the oldest backup directory
// Get a list of all the directories in the parent directory
ioReader, err := os.ReadDir(parentDirectory)
if err != nil {
return "", err
}
// Get the date and time of each backup directory
var backupDirectories []map[string]string
for _, file := range ioReader {
if file.IsDir() {
// Set the full path and the name of the directory
fullPath := filepath.Join(parentDirectory, file.Name())
name := file.Name()
// Check if the directory is a backup directory
if strings.HasPrefix(name, "github-backup-from-") {
// Get the date and time from the directory name
dateTime := strings.TrimPrefix(name, "github-backup-from-")
date := strings.Split(dateTime, "_")[0]
time := strings.Split(dateTime, "_")[1]
// Separate the date and time into year, month, and day - hour, minute, and second. Convert into integers via
year := strings.Split(date, "-")[2]
month := strings.Split(date, "-")[0]
day := strings.Split(date, "-")[1]
hour := strings.Split(time, "-")[0]
minute := strings.Split(time, "-")[1]
second := strings.Split(time, "-")[2]
// Add the directory to the map
backupDirectories = append(backupDirectories, map[string]string{
"fullPath": fullPath,
"year": year,
"month": month,
"day": day,
"hour": hour,
"minute": minute,
"second": second})
}
}
}
// Sort the backup directories by date and time by getting the days since June 13, 2022 (the date of the first commit to this repository)
// Subtract the date and time from June 13, 2022 at 00:00:00
// The oldest backup directory will be the one with the lowest number
// The newest backup directory will be the one with the highest number
creationDate := time.Date(2022, 6, 13, 0, 0, 0, 0, time.UTC)
for i, backupDirectory := range backupDirectories {
// Convert the date and times into integers
// Create a new map to store the integers
backupDirectoryIntegers := map[string]int{}
for key, value := range backupDirectory {
if key == "year" || key == "month" || key == "day" || key == "hour" || key == "minute" || key == "second" {
backupDirectoryIntegers[key], err = strconv.Atoi(value)
if err != nil {
return "", err
}
// Turn the date and time into a time.Time object
backupDate := time.Date(
backupDirectoryIntegers["year"],
time.Month(backupDirectoryIntegers["month"]),
backupDirectoryIntegers["day"],
backupDirectoryIntegers["hour"],
backupDirectoryIntegers["minute"],
backupDirectoryIntegers["second"],
0, time.UTC)
// Subtract the date and time from June 13, 2022 at 00:00:00
daysSinceCreation := backupDate.Sub(creationDate).Seconds() / (60 * 60 * 24) // Convert seconds to minutes, then hours, then days to get an accurate number of days
// Add the days since creation to the backup directory map
backupDirectories[i]["daysSinceCreation"] = strconv.FormatFloat(daysSinceCreation, 'f', -1, 64)
// 'f' means that the number will be formatted without an exponent, -1 means that the precision will be as high as possible, 64 means that the number will be a float64.
}
}
}
// Sort the backup directories by days since creation
// sort.Slice basically sorts the backup directories by the days since creation. The anonymous function is the sorting function. It takes two indexes of the backup directories and compares them. The returned value is a boolean. If the value is true, then the first index will be before the second index. If the value is false, then the second index will be before the first index.
sort.Slice(backupDirectories, func(i, j int) bool { // i and j are the indexes of the backup directories. So i would be 0 and j would be 1, then i would be 1 and j would be 2, etc.
return backupDirectories[i]["daysSinceCreation"] < backupDirectories[j]["daysSinceCreation"]
})
return backupDirectories[0]["fullPath"], nil
}
func rollBackups(parentDirectory string, maxBackups int) error {
// Check if parentDirectory is exists, if it doesn't, return nil (maybe create it?)
_, err := os.Stat(parentDirectory)
if os.IsNotExist(err) {
// Maybe create the directory?
return nil
} else if err != nil {
return err
}
// Get a list of all the directories in the parent directory
directories, err := os.ReadDir(parentDirectory)
if err != nil {
return err
}
// Get the number of backup directories
backupDirectories := 0
for _, file := range directories {
if file.IsDir() {
// Check if the directory is a backup directory
if strings.HasPrefix(file.Name(), "github-backup-from-") {
backupDirectories++
}
}
}
if backupDirectories > maxBackups {
// Get the oldest backup directory
oldestBackup, err := calculateOldestBackup(parentDirectory)
if err != nil {
return err
}
logrus.Info("Deleting the oldest backup directory: " + oldestBackup)
// Delete the oldest backup directory
err = os.RemoveAll(oldestBackup)
if err != nil {
return err
}
}
return nil
}