From e2a4404ef515479e9b1f8bac07cbc5455f7dd2b3 Mon Sep 17 00:00:00 2001 From: Jody Heavener Date: Tue, 19 Mar 2024 10:39:11 -0700 Subject: [PATCH] Add application parsing and validation, test issues --- script/application.go | 198 ++++++++++++++++ script/go.mod | 3 + script/go.sum | 17 ++ script/reviewer.go | 34 ++- .../{example.json => invalid-empty-body.json} | 2 +- script/test-issues/invalid-examples-1.json | 49 ++++ script/test-issues/invalid-no-responses.json | 49 ++++ script/test-issues/valid-event.json | 49 ++++ script/test-issues/valid-project.json | 49 ++++ script/test-issues/valid-team.json | 49 ++++ script/validator.go | 211 ++++++++++++++++++ 11 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 script/application.go rename script/test-issues/{example.json => invalid-empty-body.json} (98%) create mode 100644 script/test-issues/invalid-examples-1.json create mode 100644 script/test-issues/invalid-no-responses.json create mode 100644 script/test-issues/valid-event.json create mode 100644 script/test-issues/valid-project.json create mode 100644 script/test-issues/valid-team.json create mode 100644 script/validator.go diff --git a/script/application.go b/script/application.go new file mode 100644 index 00000000..6959adfe --- /dev/null +++ b/script/application.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/google/go-github/v60/github" +) + +type Project struct { + Name string `json:"name"` + Description string `json:"description"` + Contributors int `json:"contributors"` + HomeUrl string `json:"home_url"` + RepoUrl string `json:"repo_url,omitempty"` + LicenseType string `json:"license_type,omitempty"` + LicenseUrl string `json:"license_url,omitempty"` + IsEvent bool `json:"is_event"` + IsTeam bool `json:"is_team"` +} + +type Applicant struct { + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + Id int64 `json:"id"` +} + +type Application struct { + validator Validator `json:"-"` + sections map[string]string `json:"-"` + Problems []error `json:"-"` + + Account string `json:"account"` + Project Project `json:"project"` + Applicant Applicant `json:"applicant"` + CanContact bool `json:"can_contact"` + ApproverId int `json:"approver_id,omitempty"` + IssueNumber int `json:"issue_number"` + CreatedAt time.Time `json:"created_at"` +} + +func (a *Application) Parse(issue github.Issue) { + a.validator = Validator{} + + if strings.Contains(*issue.Title, "[project name]") { + a.validator.AddError("Application title", *issue.Title, "is missing project name") + } + + a.sections = a.extractSections(*issue.Body) + + if isTestingIssue() { + data, err := json.MarshalIndent(a.sections, "", "\t") + if err != nil { + log.Fatalf("Could not marshal Sections input data: %s", err.Error()) + } + + debugMessage("Parsed input data:", string(data)) + } + + a.CreatedAt = issue.CreatedAt.Time + a.IssueNumber = *issue.Number + a.Account = a.stringSection("Account URL", IsPresent, ParseAccountUrl) + a.boolSection("Non-commercial confirmation", IsPresent, ParseCheckbox, IsChecked) + + a.Project.IsTeam = a.boolSection("Team application", ParseCheckbox) + a.Project.IsEvent = a.boolSection("Event application", ParseCheckbox) + + isProject := !a.Project.IsTeam && !a.Project.IsEvent + + a.Project.Name = a.stringSection("Project name", IsPresent, IsRegularString) + a.Project.Description = a.stringSection("Short description", IsPresent, IsRegularString) + a.Project.Contributors = a.intSection("Number of team members/core contributors", IsPresent, IsRegularString) + a.Project.HomeUrl = a.stringSection("Homepage URL", IsPresent, IsUrl) + a.Project.RepoUrl = a.stringSection("Repository URL", IsUrl) + a.Project.LicenseType = a.stringSection("License type", When(isProject, IsPresent), IsRegularString) + a.Project.LicenseUrl = a.stringSection("License URL", When(isProject, IsPresent), IsUrl) + a.boolSection("Age confirmation", When(isProject, IsPresent), ParseCheckbox, When(isProject, IsChecked)) + + a.Applicant.Name = a.stringSection("Name", IsPresent, IsRegularString) + a.Applicant.Email = a.stringSection("Email", IsPresent, IsEmail) + a.Applicant.Role = a.stringSection("Project role", IsPresent, IsProjectRole) + a.Applicant.Id = *issue.User.ID + + a.stringSection("Profile or website", IsUrl) + a.stringSection("Additional comments") + + a.CanContact = a.boolSection("Can we contact you?", ParseCheckbox) + + if isTestingIssue() { + debugMessage("Application data:", a.GetData()) + } + + for _, err := range a.validator.Errors { + a.Problems = append(a.Problems, fmt.Errorf(err.Error())) + } +} + +func (a *Application) IsValid() bool { + return len(a.Problems) == 0 +} + +func (a *Application) GetData() string { + data, err := json.MarshalIndent(a, "", "\t") + if err != nil { + log.Fatalf("Could not marshal Application data: %s", err.Error()) + } + + return string(data) +} + +func (a *Application) extractSections(body string) map[string]string { + sections := make(map[string]string) + + lines := strings.Split(body, "\n") + var currentHeader string + contentBuilder := strings.Builder{} + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, "### ") { + if currentHeader != "" { + sections[currentHeader] = strings.TrimSpace(contentBuilder.String()) + contentBuilder.Reset() + } + currentHeader = strings.TrimSpace(trimmedLine[4:]) + } else if currentHeader != "" { + contentBuilder.WriteString(line + "\n") + } + } + + if currentHeader != "" { + sections[currentHeader] = strings.TrimSpace(contentBuilder.String()) + } + + return sections +} + +func (a *Application) stringSection(sectionName string, callbacks ...ValidatorCallback) string { + value, exists := a.sections[sectionName] + + if !exists { + a.validator.AddError(sectionName, value, "was not completed for application") + return value + } + + // everything gets passed through ParseInput first + callbacks = append([]ValidatorCallback{ParseInput}, callbacks...) + + for _, callback := range callbacks { + pass, newValue, message := callback(value) + value = newValue + + if !pass { + a.validator.AddError(sectionName, value, message) + break + } + } + + return value +} + +func (a *Application) intSection(sectionName string, callbacks ...ValidatorCallback) int { + value := a.stringSection(sectionName, callbacks...) + + // don't bother proceeding if there's already an error parsing the string + if a.validator.HasError(sectionName) { + return 0 + } + + pass, number, message := ParseNumber(value) + if !pass { + a.validator.AddError(sectionName, fmt.Sprintf("%d", number), message) + return 0 + } + + return number +} + +func (a *Application) boolSection(sectionName string, callbacks ...ValidatorCallback) bool { + value := a.stringSection(sectionName, callbacks...) + + // don't bother proceeding if there's already an error parsing the string + if a.validator.HasError(sectionName) { + return false + } + + pass, boolean, message := ParseBool(value) + if !pass { + a.validator.AddError(sectionName, fmt.Sprintf("%t", boolean), message) + return false + } + + return boolean +} diff --git a/script/go.mod b/script/go.mod index e9c6787a..e55eaede 100644 --- a/script/go.mod +++ b/script/go.mod @@ -3,11 +3,14 @@ module 1password-open-source-processor go 1.19 require ( + github.com/PuerkitoBio/goquery v1.9.1 github.com/google/go-github/v60 v60.0.0 + github.com/russross/blackfriday/v2 v2.1.0 golang.org/x/oauth2 v0.18.0 ) require ( + github.com/andybalholm/cascadia v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/script/go.sum b/script/go.sum index afc394b2..6243587f 100644 --- a/script/go.sum +++ b/script/go.sum @@ -1,3 +1,7 @@ +github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= +github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -11,35 +15,48 @@ github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCy github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= diff --git a/script/reviewer.go b/script/reviewer.go index 8bf3da32..87f8c2bf 100644 --- a/script/reviewer.go +++ b/script/reviewer.go @@ -3,23 +3,43 @@ package main import ( "fmt" "log" + "strings" ) type Reviewer struct { - GitHub GitHub + gitHub GitHub + application Application } func (r *Reviewer) Review() { - r.GitHub = GitHub{} + r.gitHub = GitHub{} + r.application = Application{} - if err := r.GitHub.Init(); err != nil { - r.PrintErrorAndExit(err) + if err := r.gitHub.Init(); err != nil { + r.printErrorAndExit(err) } - // TODO: parse and validate the issue's body contents - fmt.Println(r.GitHub.Issue) + r.application.Parse(*r.gitHub.Issue) + + if isTestingIssue() { + if r.application.IsValid() { + debugMessage("Application has no problems") + } else { + debugMessage("Application problems:", r.renderProblems()) + } + } } -func (r *Reviewer) PrintErrorAndExit(err error) { +func (r *Reviewer) printErrorAndExit(err error) { log.Fatalf("Error reviewing issue: %s\n", err.Error()) } + +func (r *Reviewer) renderProblems() string { + var problemStrings []string + + for _, err := range r.application.Problems { + problemStrings = append(problemStrings, fmt.Sprintf("- %s", err.Error())) + } + + return strings.Join(problemStrings, "\n") +} diff --git a/script/test-issues/example.json b/script/test-issues/invalid-empty-body.json similarity index 98% rename from script/test-issues/example.json rename to script/test-issues/invalid-empty-body.json index 3d05e252..bb0e3b49 100644 --- a/script/test-issues/example.json +++ b/script/test-issues/invalid-empty-body.json @@ -3,7 +3,7 @@ "number": 6, "state": "open", "locked": false, - "title": "Application for Some Project", + "title": "Application for Foo", "body": "", "user": { "login": "wendyappleseed", diff --git a/script/test-issues/invalid-examples-1.json b/script/test-issues/invalid-examples-1.json new file mode 100644 index 00000000..fdad5d99 --- /dev/null +++ b/script/test-issues/invalid-examples-1.json @@ -0,0 +1,49 @@ +{ + "id": 1801650328, + "number": 6, + "state": "open", + "locked": false, + "title": "Application for [project name]", + "body": "### Account URL\n\nfoo\n\n### Non-commercial confirmation\n\n- [ ] No, this account won't be used for commercial activity\n\n### Team application\n\n- [ ] Yes, this application is for a team\n\n### Event application\n\n- [ ] Yes, this application is for an event\n\n### Project name\n\nTestDB 🎁\n\n### Short description\n\n[TestDB](https://testdb.com) is a free and open source, community-based forum software project.\n\n### Number of team members/core contributors\n\nfoo\n\n### Homepage URL\n\n@wendyappleed\n\n### Repository URL\n\nhttps://github.com/wendyappleed/test-db\n\n### License type\n\nMIT\n\n### License URL\n\nhttps://github.com/wendyappleed/test-db/blob/main/LICENSE.md\n\n### Age confirmation\n\n- [X] Yes, this project is at least 30 days old\n\n### Name\n\nWendy Appleseed\n\n### Email\n\nwendyappleseed@example.com\n\n### Project role\n\nLead Dev\n\n### Profile or website\n\nhttps://github.com/wendyappleseed/\n\n### Can we contact you?\n\n- [X] Yes, you may contact me\n\n### Additional comments\n\nThank you! ✨", + "user": { + "login": "wendyappleseed", + "id": 38230737, + "node_id": "MDQ6VXNlcjYzOTIwNDk=", + "avatar_url": "https://avatars.githubusercontent.com/u/38230737?v=4", + "html_url": "https://github.com/wendyappleseed", + "gravatar_id": "", + "type": "User", + "site_admin": false, + "url": "https://api.github.com/users/wendyappleseed", + "events_url": "https://api.github.com/users/wendyappleseed/events{/privacy}", + "following_url": "https://api.github.com/users/wendyappleseed/following{/other_user}", + "followers_url": "https://api.github.com/users/wendyappleseed/followers", + "gists_url": "https://api.github.com/users/wendyappleseed/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/wendyappleseed/orgs", + "received_events_url": "https://api.github.com/users/wendyappleseed/received_events", + "repos_url": "https://api.github.com/users/wendyappleseed/repos", + "starred_url": "https://api.github.com/users/wendyappleseed/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wendyappleseed/subscriptions" + }, + "comments": 11, + "closed_at": "2023-07-13T05:03:51Z", + "created_at": "2023-07-12T19:49:35Z", + "updated_at": "2023-07-13T05:03:51Z", + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6", + "html_url": "https://github.com/wendyappleseed/1password-teams-open-source/issues/6", + "comments_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/comments", + "events_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/events", + "labels_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/labels{/name}", + "repository_url": "https://api.github.com/repos/1Password/1password-teams-open-source", + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/reactions" + }, + "node_id": "I_kwDOJ6JE6M5rYwCY" +} diff --git a/script/test-issues/invalid-no-responses.json b/script/test-issues/invalid-no-responses.json new file mode 100644 index 00000000..785176f0 --- /dev/null +++ b/script/test-issues/invalid-no-responses.json @@ -0,0 +1,49 @@ +{ + "id": 1801650328, + "number": 6, + "state": "open", + "locked": false, + "title": "Application for [project name]", + "body": "### Account URL\n\n_No response_\n\n### Non-commercial confirmation\n\n- [ ] No, this account won't be used for commercial activity\n\n### Team application\n\n- [ ] Yes, this application is for a team\n\n### Event application\n\n- [ ] Yes, this application is for an event\n\n### Project name\n\n_No response_\n\n### Short description\n\n_No response_\n\n### Number of team members/core contributors\n\n_No response_\n\n### Homepage URL\n\n_No response_\n\n### Repository URL\n\n_No response_\n\n### License type\n\n_No response_\n\n### License URL\n\n_No response_\n\n### Age confirmation\n\n- [ ] Yes, this project is at least 30 days old\n\n### Name\n\n_No response_\n\n### Email\n\n_No response_\n\n### Project role\n\nNone\n\n### Profile or website\n\n_No response_\n\n### Can we contact you?\n\n- [ ] Yes, you may contact me\n\n### Additional comments\n\n_No response_", + "user": { + "login": "wendyappleseed", + "id": 38230737, + "node_id": "MDQ6VXNlcjYzOTIwNDk=", + "avatar_url": "https://avatars.githubusercontent.com/u/38230737?v=4", + "html_url": "https://github.com/wendyappleseed", + "gravatar_id": "", + "type": "User", + "site_admin": false, + "url": "https://api.github.com/users/wendyappleseed", + "events_url": "https://api.github.com/users/wendyappleseed/events{/privacy}", + "following_url": "https://api.github.com/users/wendyappleseed/following{/other_user}", + "followers_url": "https://api.github.com/users/wendyappleseed/followers", + "gists_url": "https://api.github.com/users/wendyappleseed/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/wendyappleseed/orgs", + "received_events_url": "https://api.github.com/users/wendyappleseed/received_events", + "repos_url": "https://api.github.com/users/wendyappleseed/repos", + "starred_url": "https://api.github.com/users/wendyappleseed/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wendyappleseed/subscriptions" + }, + "comments": 11, + "closed_at": "2023-07-13T05:03:51Z", + "created_at": "2023-07-12T19:49:35Z", + "updated_at": "2023-07-13T05:03:51Z", + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6", + "html_url": "https://github.com/wendyappleseed/1password-teams-open-source/issues/6", + "comments_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/comments", + "events_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/events", + "labels_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/labels{/name}", + "repository_url": "https://api.github.com/repos/1Password/1password-teams-open-source", + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/reactions" + }, + "node_id": "I_kwDOJ6JE6M5rYwCY" +} diff --git a/script/test-issues/valid-event.json b/script/test-issues/valid-event.json new file mode 100644 index 00000000..b07c0e38 --- /dev/null +++ b/script/test-issues/valid-event.json @@ -0,0 +1,49 @@ +{ + "id": 1801650328, + "number": 6, + "state": "open", + "locked": false, + "title": "Application for FooConf", + "body": "### Account URL\n\nfooconf.1password.eu\n\n### Non-commercial confirmation\n\n- [X] No, this account won't be used for commercial activity\n\n### Team application\n\n- [ ] Yes, this application is for a team\n\n### Event application\n\n- [X] Yes, this application is for an event\n\n### Project name\n\nFooConf\n\n### Short description\n\n**Foo Conference** is a non-profit conference targeting Designers and Frontend engineers taking place yearly in Zurich.\n\nAs a not-for-profit community event we rely on volunteers to enable us to run a great event.\n\n### Number of team members/core contributors\n\n11\n\n### Homepage URL\n\nhttps://fooconf.com/\n\n### Repository URL\n\n_No response_\n\n### License type\n\n_No response_\n\n### License URL\n\n_No response_\n\n### Age confirmation\n\n- [ ] Yes, this project is at least 30 days old\n\n### Name\n\nWendy Appleseed\n\n### Email\n\nwendyappleseed@example.com\n\n### Project role\n\nOrganizer or Admin\n\n### Profile or website\n\n_No response_\n\n### Can we contact you?\n\n- [X] Yes, you may contact me\n\n### Additional comments\n\n_No response_", + "user": { + "login": "wendyappleseed", + "id": 38230737, + "node_id": "MDQ6VXNlcjYzOTIwNDk=", + "avatar_url": "https://avatars.githubusercontent.com/u/38230737?v=4", + "html_url": "https://github.com/wendyappleseed", + "gravatar_id": "", + "type": "User", + "site_admin": false, + "url": "https://api.github.com/users/wendyappleseed", + "events_url": "https://api.github.com/users/wendyappleseed/events{/privacy}", + "following_url": "https://api.github.com/users/wendyappleseed/following{/other_user}", + "followers_url": "https://api.github.com/users/wendyappleseed/followers", + "gists_url": "https://api.github.com/users/wendyappleseed/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/wendyappleseed/orgs", + "received_events_url": "https://api.github.com/users/wendyappleseed/received_events", + "repos_url": "https://api.github.com/users/wendyappleseed/repos", + "starred_url": "https://api.github.com/users/wendyappleseed/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wendyappleseed/subscriptions" + }, + "comments": 11, + "closed_at": "2023-07-13T05:03:51Z", + "created_at": "2023-07-12T19:49:35Z", + "updated_at": "2023-07-13T05:03:51Z", + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6", + "html_url": "https://github.com/wendyappleseed/1password-teams-open-source/issues/6", + "comments_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/comments", + "events_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/events", + "labels_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/labels{/name}", + "repository_url": "https://api.github.com/repos/1Password/1password-teams-open-source", + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/reactions" + }, + "node_id": "I_kwDOJ6JE6M5rYwCY" +} diff --git a/script/test-issues/valid-project.json b/script/test-issues/valid-project.json new file mode 100644 index 00000000..932089a1 --- /dev/null +++ b/script/test-issues/valid-project.json @@ -0,0 +1,49 @@ +{ + "id": 1801650328, + "number": 6, + "state": "open", + "locked": false, + "title": "Application for TestDB", + "body": "### Account URL\n\ntestdb.1password.com\n\n### Non-commercial confirmation\n\n- [X] No, this account won't be used for commercial activity\n\n### Team application\n\n- [ ] Yes, this application is for a team\n\n### Event application\n\n- [ ] Yes, this application is for an event\n\n### Project name\n\nTestDB\n\n### Short description\n\nTestDB is a free and open source, community-based forum software project.\n\n### Number of team members/core contributors\n\n1\n\n### Homepage URL\n\nhttps://github.com/wendyappleed/test-db\n\n### Repository URL\n\nhttps://github.com/wendyappleed/test-db\n\n### License type\n\nMIT\n\n### License URL\n\nhttps://github.com/wendyappleed/test-db/blob/main/LICENSE.md\n\n### Age confirmation\n\n- [X] Yes, this project is at least 30 days old\n\n### Name\n\nWendy Appleseed\n\n### Email\n\nwendyappleseed@example.com\n\n### Project role\n\nCore Maintainer\n\n### Profile or website\n\nhttps://github.com/wendyappleseed/\n\n### Can we contact you?\n\n- [X] Yes, you may contact me\n\n### Additional comments\n\nThank you!", + "user": { + "login": "wendyappleseed", + "id": 38230737, + "node_id": "MDQ6VXNlcjYzOTIwNDk=", + "avatar_url": "https://avatars.githubusercontent.com/u/38230737?v=4", + "html_url": "https://github.com/wendyappleseed", + "gravatar_id": "", + "type": "User", + "site_admin": false, + "url": "https://api.github.com/users/wendyappleseed", + "events_url": "https://api.github.com/users/wendyappleseed/events{/privacy}", + "following_url": "https://api.github.com/users/wendyappleseed/following{/other_user}", + "followers_url": "https://api.github.com/users/wendyappleseed/followers", + "gists_url": "https://api.github.com/users/wendyappleseed/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/wendyappleseed/orgs", + "received_events_url": "https://api.github.com/users/wendyappleseed/received_events", + "repos_url": "https://api.github.com/users/wendyappleseed/repos", + "starred_url": "https://api.github.com/users/wendyappleseed/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wendyappleseed/subscriptions" + }, + "comments": 11, + "closed_at": "2023-07-13T05:03:51Z", + "created_at": "2023-07-12T19:49:35Z", + "updated_at": "2023-07-13T05:03:51Z", + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6", + "html_url": "https://github.com/wendyappleseed/1password-teams-open-source/issues/6", + "comments_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/comments", + "events_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/events", + "labels_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/labels{/name}", + "repository_url": "https://api.github.com/repos/1Password/1password-teams-open-source", + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/reactions" + }, + "node_id": "I_kwDOJ6JE6M5rYwCY" +} diff --git a/script/test-issues/valid-team.json b/script/test-issues/valid-team.json new file mode 100644 index 00000000..600c63db --- /dev/null +++ b/script/test-issues/valid-team.json @@ -0,0 +1,49 @@ +{ + "id": 1801650328, + "number": 6, + "state": "open", + "locked": false, + "title": "Application for AcmeCo", + "body": "### Account URL\n\nacmeco.1password.com\n\n### Non-commercial confirmation\n\n- [X] No, this account won't be used for commercial activity\n\n### Team application\n\n- [X] Yes, this application is for a team\n\n### Event application\n\n- [ ] Yes, this application is for an event\n\n### Project name\n\nAcmeCo Team\n\n### Short description\n\nAcmeCo is an open, collaborative team to improve user experiences and inclusiveness of open source software.\r\n\r\nThe Acme community consists of an international team of partners, individuals, and institutions focused on designing inclusive, flexible, customizable, user-centered interfaces.\n\n### Number of team members/core contributors\n\n27\n\n### Homepage URL\n\nhttps://acme.co/\n\n### Repository URL\n\nhttps://github.com/acmeco/example\n\n### License type\n\n_No response_\n\n### License URL\n\n_No response_\n\n### Age confirmation\n\n- [ ] Yes, this project is at least 30 days old\n\n### Name\n\nWendy Appleseed\n\n### Email\n\nwendyappleseed@example.com\n\n### Project role\n\nFounder or Owner\n\n### Profile or website\n\nhttps://github.com/wendyappleseed/\n\n### Can we contact you?\n\n- [X] Yes, you may contact me\n\n### Additional comments\n\nThanks for this program!", + "user": { + "login": "wendyappleseed", + "id": 38230737, + "node_id": "MDQ6VXNlcjYzOTIwNDk=", + "avatar_url": "https://avatars.githubusercontent.com/u/38230737?v=4", + "html_url": "https://github.com/wendyappleseed", + "gravatar_id": "", + "type": "User", + "site_admin": false, + "url": "https://api.github.com/users/wendyappleseed", + "events_url": "https://api.github.com/users/wendyappleseed/events{/privacy}", + "following_url": "https://api.github.com/users/wendyappleseed/following{/other_user}", + "followers_url": "https://api.github.com/users/wendyappleseed/followers", + "gists_url": "https://api.github.com/users/wendyappleseed/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/wendyappleseed/orgs", + "received_events_url": "https://api.github.com/users/wendyappleseed/received_events", + "repos_url": "https://api.github.com/users/wendyappleseed/repos", + "starred_url": "https://api.github.com/users/wendyappleseed/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wendyappleseed/subscriptions" + }, + "comments": 11, + "closed_at": "2023-07-13T05:03:51Z", + "created_at": "2023-07-12T19:49:35Z", + "updated_at": "2023-07-13T05:03:51Z", + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6", + "html_url": "https://github.com/wendyappleseed/1password-teams-open-source/issues/6", + "comments_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/comments", + "events_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/events", + "labels_url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/labels{/name}", + "repository_url": "https://api.github.com/repos/1Password/1password-teams-open-source", + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "url": "https://api.github.com/repos/1Password/1password-teams-open-source/issues/6/reactions" + }, + "node_id": "I_kwDOJ6JE6M5rYwCY" +} diff --git a/script/validator.go b/script/validator.go new file mode 100644 index 00000000..fcf505cf --- /dev/null +++ b/script/validator.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "fmt" + "net/mail" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/russross/blackfriday/v2" +) + +var ( + accountUrlRegex = regexp.MustCompile(`^(https?:\/\/)?[\w.-]+\.1password\.(com|ca|eu)\/?$`) + urlRegex = regexp.MustCompile(`https?://[^\s]+`) + emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + emojiRegex = regexp.MustCompile(`[\x{1F300}-\x{1F5FF}\x{1F600}-\x{1F64F}\x{1F680}-\x{1F6FF}\x{1F700}-\x{1F77F}\x{1F780}-\x{1F7FF}\x{1F800}-\x{1F8FF}\x{1F900}-\x{1F9FF}\x{1FA00}-\x{1FA6F}\x{1FA70}-\x{1FAFF}\x{1FB00}-\x{1FBFF}]+`) + applicantRoles = []string{"Founder or Owner", "Team Member or Employee", "Project Lead", "Core Maintainer", "Developer", "Organizer or Admin", "Program Manager"} +) + +type ValidationError struct { + Section string + Value string + Message string +} + +type ValidatorCallback func(string) (bool, string, string) + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Section, e.Message) +} + +type Validator struct { + Errors []ValidationError +} + +func (v *Validator) AddError(section, value, message string) { + v.Errors = append(v.Errors, ValidationError{ + Section: section, + Value: value, + Message: message, + }) +} + +func (v *Validator) HasError(section string) bool { + for _, err := range v.Errors { + if err.Section == section { + return true + } + } + return false +} + +// Parsing and validation utilities + +func When(condition bool, callback ValidatorCallback) ValidatorCallback { + if condition { + return callback + } + + return func(value string) (bool, string, string) { + return true, value, "" + } +} + +func ParseInput(value string) (bool, string, string) { + if value == "" || value == "_No response_" || value == "None" { + return true, "", "" + } + + return true, value, "" +} + +func ParseAccountUrl(value string) (bool, string, string) { + if accountUrlRegex.Match([]byte(value)) { + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + value = "https://" + value + } + + u, err := url.Parse(value) + if err != nil { + return false, value, err.Error() + } + + return true, u.Hostname(), "" + } else { + return false, value, "is an invalid 1Password account URL" + } +} + +func ParseCheckbox(value string) (bool, string, string) { + value = strings.TrimLeft(strings.ToLower(value), "- ") + + if strings.HasPrefix(value, "[x]") { + return true, "true", "" + } else if strings.HasPrefix(value, "[]") || strings.HasPrefix(value, "[ ]") { + return true, "false", "" + } + + return false, value, "could not parse checkbox" +} + +func ParseNumber(value string) (bool, int, string) { + cleanedString := "" + + for _, char := range value { + if char >= '0' && char <= '9' { + cleanedString += string(char) + } + } + + parsedNumber, err := strconv.Atoi(cleanedString) + + if err != nil { + return false, 0, "could not be parsed into a number" + } + + return true, parsedNumber, "" +} + +func ParseBool(value string) (bool, bool, string) { + parsedBool, err := strconv.ParseBool(value) + + if err != nil { + return false, false, "could not be parsed into a boolean" + } + + return true, parsedBool, "" +} + +func IsPresent(value string) (bool, string, string) { + if value == "" { + return false, value, "is empty" + } + + return true, value, "" +} + +func IsEmail(value string) (bool, string, string) { + if value == "" { + return true, value, "" + } + + if _, err := mail.ParseAddress(value); err == nil { + return true, value, "" + } + + return false, value, "is an invalid email" +} + +func IsUrl(value string) (bool, string, string) { + if value == "" { + return true, value, "" + } + + parsedURL, err := url.ParseRequestURI(value) + if err != nil { + return false, value, "is an invalid URL" + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return false, value, "must use \"http\" or \"https\" scheme" + } + + return true, value, "" +} + +func IsRegularString(value string) (bool, string, string) { + // strip all formattig, except for newlines + html := blackfriday.Run([]byte(value)) + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(html)) + if err != nil { + return false, value, err.Error() + } + value = strings.TrimSpace(doc.Text()) + + if urlRegex.MatchString(value) { + return false, value, "cannot contain URLs" + } + + if emailRegex.MatchString(value) { + return false, value, "cannot contain email addresses" + } + + if emojiRegex.MatchString(value) { + return false, value, "cannot contain emoji characters" + } + + return true, value, "" +} + +func IsProjectRole(value string) (bool, string, string) { + for _, item := range applicantRoles { + if item == value { + return true, value, "" + } + } + + return false, value, "is an invalid project role" +} + +func IsChecked(value string) (bool, string, string) { + if value != "true" { + return false, value, "must be checked" + } + + return true, value, "" +}