├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gcloudignore ├── account.go ├── admin.go ├── app.go ├── app.yaml ├── caching_transport.go ├── config │ ├── github-oauth.json.SAMPLE │ ├── session.json.SAMPLE │ ├── styles.json │ └── timezones.json ├── cron.yaml ├── digest.go ├── go.mod ├── go.sum ├── index.yaml ├── queue.yaml ├── recovery.go ├── repos.go ├── retrogit.go ├── session.go ├── static │ ├── admin.css │ ├── favicon.ico │ ├── images │ │ ├── body-shadow@2x.png │ │ ├── card-background.jpg │ │ ├── card-background@2x.jpg │ │ ├── header.jpg │ │ ├── header@2x.jpg │ │ ├── screenshot-thumbnail.png │ │ ├── screenshot-thumbnail@2x.png │ │ └── screenshot.png │ ├── main.css │ ├── octicons │ │ ├── LICENSE.txt │ │ ├── README.md │ │ ├── octicons-local.ttf │ │ ├── octicons.css │ │ ├── octicons.eot │ │ ├── octicons.less │ │ ├── octicons.svg │ │ ├── octicons.ttf │ │ ├── octicons.woff │ │ └── sprockets-octicons.scss │ ├── robots.txt │ └── settings.js ├── templates │ ├── base │ │ └── page.html │ ├── digest-admin.html │ ├── digest-email.html │ ├── digest-page.html │ ├── faq.html │ ├── github-auth-error-email.html │ ├── github-auth-error.html │ ├── index-signed-out.html │ ├── index.html │ ├── internal-error.html │ ├── repos-admin.html │ ├── settings.html │ ├── shared │ │ ├── digest.html │ │ ├── email-footer.html │ │ ├── flash.html │ │ └── user.html │ └── users-admin.html └── timezones.go ├── assets ├── favicon16.psd ├── favicon32.psd ├── github app logo.psd ├── perforated paper.psd ├── punch card commit corner.psd ├── punch card.psd ├── punch card@2x.psd └── screenshot.psd ├── deploy.sh └── dev.sh /.gitignore: -------------------------------------------------------------------------------- 1 | app/config/*oauth*.json 2 | app/config/session.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RetroGit 2 | 3 | Service that shows you your GitHub commits from previous years. Includes a mail digest to that you can see each day what you were up to in the past. 4 | 5 | It's currently running at [https://www.retrogit.com/](https://www.retrogit.com/). 6 | 7 | ## Running Locally 8 | 9 | First, [install the Go App Engine SDK](https://developers.google.com/appengine/downloads#Google_App_Engine_SDK_for_Go). 10 | 11 | Then, create `github-oauth.json` (you'll need to [register a new app](https://github.com/settings/applications/new) with GitHub) and `session.json` (with randomly-generated keys) files in the `config` directory, based on the sample files that are already there. 12 | 13 | Finally, run: 14 | 15 | ``` 16 | ./dev.sh 17 | ``` 18 | 19 | The server can the be accessed at [http://localhost:8080/](http://localhost:8080/). 20 | 21 | ## Deploying to App Engine 22 | 23 | ``` 24 | ./deploy.sh 25 | ``` 26 | -------------------------------------------------------------------------------- /app/.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out -------------------------------------------------------------------------------- /app/account.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/gob" 7 | "errors" 8 | "time" 9 | 10 | "google.golang.org/appengine/v2/datastore" 11 | 12 | "github.com/google/go-github/v62/github" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | type Account struct { 17 | GitHubUserId int64 `datastore:",noindex"` 18 | // The datastore API doesn't store maps, and the token contains one. We 19 | // therefore store a gob-serialized version instead. 20 | OAuthTokenSerialized []byte 21 | OAuthToken oauth2.Token `datastore:"-,"` 22 | TimezoneName string `datastore:",noindex"` 23 | TimezoneLocation *time.Location `datastore:"-,"` 24 | HasTimezoneSet bool `datastore:"-,"` 25 | ExcludedRepoIds []int64 `datastore:",noindex"` 26 | DigestEmailAddress string 27 | Frequency string 28 | WeeklyDay time.Weekday 29 | } 30 | 31 | func getAccount(c context.Context, githubUserId int64) (*Account, error) { 32 | key := datastore.NewKey(c, "Account", "", githubUserId, nil) 33 | account := new(Account) 34 | err := datastore.Get(c, key, account) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | err = initAccount(account) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return account, nil 44 | } 45 | 46 | func initAccount(account *Account) error { 47 | r := bytes.NewBuffer(account.OAuthTokenSerialized) 48 | err := gob.NewDecoder(r).Decode(&account.OAuthToken) 49 | if err != nil { 50 | return err 51 | } 52 | account.HasTimezoneSet = len(account.TimezoneName) > 0 53 | if !account.HasTimezoneSet { 54 | account.TimezoneName = "America/Los_Angeles" 55 | } 56 | if len(account.Frequency) == 0 { 57 | account.Frequency = "daily" 58 | } 59 | account.TimezoneLocation, err = time.LoadLocation(account.TimezoneName) 60 | if err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | 66 | func getAllAccounts(c context.Context) ([]Account, error) { 67 | q := datastore.NewQuery("Account") 68 | var accounts []Account 69 | _, err := q.GetAll(c, &accounts) 70 | if err != nil { 71 | return nil, err 72 | } 73 | for i := range accounts { 74 | err = initAccount(&accounts[i]) 75 | if err != nil { 76 | return nil, err 77 | } 78 | } 79 | return accounts, nil 80 | } 81 | 82 | func (account *Account) IsRepoIdExcluded(repoId int64) bool { 83 | for i := range account.ExcludedRepoIds { 84 | if account.ExcludedRepoIds[i] == repoId { 85 | return true 86 | } 87 | } 88 | return false 89 | } 90 | 91 | func (account *Account) Put(c context.Context) error { 92 | w := new(bytes.Buffer) 93 | err := gob.NewEncoder(w).Encode(&account.OAuthToken) 94 | if err != nil { 95 | return err 96 | } 97 | account.OAuthTokenSerialized = w.Bytes() 98 | key := datastore.NewKey(c, "Account", "", int64(account.GitHubUserId), nil) 99 | _, err = datastore.Put(c, key, account) 100 | return err 101 | } 102 | 103 | func (account *Account) Delete(c context.Context) error { 104 | key := datastore.NewKey(c, "Account", "", int64(account.GitHubUserId), nil) 105 | err := datastore.Delete(c, key) 106 | return err 107 | } 108 | 109 | func (account *Account) GetDigestEmailAddress(c context.Context, githubClient *github.Client) (string, error) { 110 | if len(account.DigestEmailAddress) > 0 { 111 | return account.DigestEmailAddress, nil 112 | } 113 | emails, _, err := githubClient.Users.ListEmails(c, nil) 114 | if err != nil { 115 | return "", err 116 | } 117 | // Prefer the primary, verified email 118 | for _, email := range emails { 119 | if email.Primary != nil && *email.Primary && 120 | email.Verified != nil && *email.Verified { 121 | return *email.Email, nil 122 | } 123 | } 124 | // Then the first verified email 125 | for _, email := range emails { 126 | if email.Verified != nil && *email.Verified { 127 | return *email.Email, nil 128 | } 129 | } 130 | // Then just the first email 131 | for _, email := range emails { 132 | return *email.Email, nil 133 | } 134 | return "", errors.New("No email addresses found in GitHub account") 135 | } 136 | -------------------------------------------------------------------------------- /app/admin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "sort" 6 | "strconv" 7 | 8 | "google.golang.org/appengine/v2" 9 | 10 | "github.com/google/go-github/v62/github" 11 | ) 12 | 13 | type AdminUserData struct { 14 | Account *Account 15 | User *github.User 16 | EmailAddress string 17 | } 18 | 19 | // sort.Interface implementation for sorting AdminUserDatas 20 | type AdminUserDataByGitHubUserId []*AdminUserData 21 | 22 | func (a AdminUserDataByGitHubUserId) Len() int { return len(a) } 23 | func (a AdminUserDataByGitHubUserId) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 24 | func (a AdminUserDataByGitHubUserId) Less(i, j int) bool { 25 | return a[i].Account.GitHubUserId < a[j].Account.GitHubUserId 26 | } 27 | 28 | func usersAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { 29 | c := appengine.NewContext(r) 30 | accounts, err := getAllAccounts(c) 31 | if err != nil { 32 | return InternalError(err, "Could not look up accounts") 33 | } 34 | 35 | ch := make(chan *AdminUserData) 36 | for i := range accounts { 37 | go func(account *Account) { 38 | githubClient := githubOAuthClient(c, account.OAuthToken) 39 | 40 | user, _, err := githubClient.Users.Get(c, "") 41 | 42 | emailAddress, err := account.GetDigestEmailAddress(c, githubClient) 43 | if err != nil { 44 | emailAddress = err.Error() 45 | } 46 | 47 | ch <- &AdminUserData{ 48 | Account: account, 49 | User: user, 50 | EmailAddress: emailAddress, 51 | } 52 | }(&accounts[i]) 53 | } 54 | 55 | users := make([]*AdminUserData, 0) 56 | for range accounts { 57 | select { 58 | case r := <-ch: 59 | users = append(users, r) 60 | } 61 | } 62 | sort.Sort(AdminUserDataByGitHubUserId(users)) 63 | 64 | var data = map[string]interface{}{ 65 | "Users": users, 66 | } 67 | return templates["users-admin"].Render(w, data) 68 | } 69 | 70 | func digestAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { 71 | userId, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64) 72 | if err != nil { 73 | return BadRequest(err, "Malformed user_id value") 74 | } 75 | c := appengine.NewContext(r) 76 | account, err := getAccount(c, userId) 77 | if account == nil { 78 | return BadRequest(err, "user_id does not point to an account") 79 | } 80 | if err != nil { 81 | return InternalError(err, "Could not look up account") 82 | } 83 | 84 | githubClient := githubOAuthClient(c, account.OAuthToken) 85 | 86 | digest, err := newDigest(c, githubClient, account) 87 | if err != nil { 88 | return GitHubFetchError(err, "digest") 89 | } 90 | digest.Redact() 91 | var data = map[string]interface{}{ 92 | "Digest": digest, 93 | } 94 | return templates["digest-admin"].Render(w, data) 95 | } 96 | 97 | func reposAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { 98 | userId, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64) 99 | if err != nil { 100 | return BadRequest(err, "Malformed user_id value") 101 | } 102 | c := appengine.NewContext(r) 103 | account, err := getAccount(c, userId) 104 | if account == nil { 105 | return BadRequest(err, "user_id does not point to an account") 106 | } 107 | if err != nil { 108 | return InternalError(err, "Could not look up account") 109 | } 110 | 111 | githubClient := githubOAuthClient(c, account.OAuthToken) 112 | 113 | user, _, err := githubClient.Users.Get(c, "") 114 | repos, reposErr := getRepos(c, githubClient, account, user) 115 | if err == nil { 116 | repos.Redact() 117 | } 118 | 119 | var data = map[string]interface{}{ 120 | "User": user, 121 | "Repos": repos, 122 | "ReposError": reposErr, 123 | } 124 | return templates["repos-admin"].Render(w, data) 125 | } 126 | 127 | func deleteAccountAdminHandler(w http.ResponseWriter, r *http.Request) *AppError { 128 | userId, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64) 129 | if err != nil { 130 | return BadRequest(err, "Malformed user_id value") 131 | } 132 | c := appengine.NewContext(r) 133 | account, err := getAccount(c, userId) 134 | if account == nil { 135 | return BadRequest(err, "user_id does not point to an account") 136 | } 137 | if err != nil { 138 | return InternalError(err, "Could not look up account") 139 | } 140 | 141 | account.Delete(c) 142 | return RedirectToRoute("users-admin") 143 | } 144 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io/ioutil" 9 | log_ "log" 10 | "net/http" 11 | "path/filepath" 12 | "strings" 13 | 14 | "google.golang.org/appengine/v2" 15 | "google.golang.org/appengine/v2/log" 16 | "google.golang.org/appengine/v2/mail" 17 | 18 | "github.com/google/go-github/v62/github" 19 | "github.com/gorilla/sessions" 20 | ) 21 | 22 | const ( 23 | AppErrorTypeInternal = iota 24 | AppErrorTypeTemplate 25 | AppErrorTypeGitHubFetch 26 | AppErrorTypeRedirect 27 | AppErrorTypeBadInput 28 | ) 29 | 30 | type AppError struct { 31 | Error error 32 | Message string 33 | Code int 34 | Type int 35 | } 36 | 37 | type AppSignedInState struct { 38 | Account *Account 39 | GitHubClient *github.Client 40 | session *sessions.Session 41 | request *http.Request 42 | responseWriter http.ResponseWriter 43 | } 44 | 45 | func (state *AppSignedInState) AddFlash(value interface{}) { 46 | state.session.AddFlash(value) 47 | state.saveSession() 48 | } 49 | 50 | func (state *AppSignedInState) Flashes() []interface{} { 51 | flashes := state.session.Flashes() 52 | if len(flashes) > 0 { 53 | state.saveSession() 54 | } 55 | return flashes 56 | } 57 | 58 | func (state *AppSignedInState) ClearSession() { 59 | state.session.Options.MaxAge = -1 60 | state.saveSession() 61 | } 62 | 63 | func (state *AppSignedInState) saveSession() { 64 | state.session.Save(state.request, state.responseWriter) 65 | } 66 | 67 | func GitHubFetchError(err error, fetchType string) *AppError { 68 | return &AppError{ 69 | Error: err, 70 | Message: fmt.Sprintf("Could not fetch %s data from GitHub", fetchType), 71 | Code: http.StatusInternalServerError, 72 | Type: AppErrorTypeGitHubFetch, 73 | } 74 | } 75 | 76 | func InternalError(err error, message string) *AppError { 77 | return &AppError{ 78 | Error: err, 79 | Message: message, 80 | Code: http.StatusInternalServerError, 81 | Type: AppErrorTypeInternal, 82 | } 83 | } 84 | 85 | func RedirectToUrl(url string) *AppError { 86 | return &AppError{ 87 | Error: nil, 88 | Message: url, 89 | Code: http.StatusFound, 90 | Type: AppErrorTypeRedirect, 91 | } 92 | } 93 | 94 | func BadRequest(err error, message string) *AppError { 95 | return &AppError{ 96 | Error: err, 97 | Message: message, 98 | Code: http.StatusBadRequest, 99 | Type: AppErrorTypeBadInput, 100 | } 101 | } 102 | 103 | func RedirectToRoute(routeName string, queryParameters ...map[string]string) *AppError { 104 | route := router.Get(routeName) 105 | if route == nil { 106 | return InternalError( 107 | errors.New("No such route"), 108 | fmt.Sprintf("Could not look up route '%s'", routeName)) 109 | } 110 | routeUrl, err := route.URL() 111 | if err != nil { 112 | return InternalError( 113 | errors.New("Could not get route URL"), 114 | fmt.Sprintf("Could not get route URL for route '%s'", routeName)) 115 | } 116 | if len(queryParameters) != 0 { 117 | routeUrlQuery := routeUrl.Query() 118 | for k, v := range queryParameters[0] { 119 | routeUrlQuery.Set(k, v) 120 | } 121 | routeUrl.RawQuery = routeUrlQuery.Encode() 122 | } 123 | return RedirectToUrl(routeUrl.String()) 124 | } 125 | 126 | func NotSignedIn(r *http.Request) *AppError { 127 | return RedirectToRoute("index", map[string]string{"continue_url": r.URL.String()}) 128 | } 129 | 130 | func Panic(panicData interface{}) *AppError { 131 | return InternalError( 132 | errors.New(fmt.Sprintf("Panic: %+v\n\n%s", panicData, stack(3))), 133 | "Panic") 134 | } 135 | 136 | type AppHandler func(http.ResponseWriter, *http.Request) *AppError 137 | 138 | func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 139 | defer panicRecovery(w, r) 140 | makeUncacheable(w) 141 | if e := fn(w, r); e != nil { 142 | handleAppError(e, w, r) 143 | } 144 | } 145 | 146 | type SignedInAppHandler func(http.ResponseWriter, *http.Request, *AppSignedInState) *AppError 147 | 148 | func (fn SignedInAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 149 | defer panicRecovery(w, r) 150 | makeUncacheable(w) 151 | session, _ := sessionStore.Get(r, sessionConfig.CookieName) 152 | userId, ok := session.Values[sessionConfig.UserIdKey].(int64) 153 | if !ok { 154 | handleAppError(NotSignedIn(r), w, r) 155 | return 156 | } 157 | c := appengine.NewContext(r) 158 | account, err := getAccount(c, userId) 159 | if account == nil || err != nil { 160 | handleAppError(NotSignedIn(r), w, r) 161 | return 162 | } 163 | 164 | githubClient := githubOAuthClient(c, account.OAuthToken) 165 | 166 | state := &AppSignedInState{ 167 | Account: account, 168 | GitHubClient: githubClient, 169 | session: session, 170 | responseWriter: w, 171 | request: r, 172 | } 173 | 174 | if e := fn(w, r, state); e != nil { 175 | handleAppError(e, w, r) 176 | } 177 | } 178 | 179 | func panicRecovery(w http.ResponseWriter, r *http.Request) { 180 | if panicData := recover(); panicData != nil { 181 | handleAppError(Panic(panicData), w, r) 182 | } 183 | } 184 | 185 | func makeUncacheable(w http.ResponseWriter) { 186 | w.Header().Set( 187 | "Cache-Control", "no-cache, no-store, max-age=0, must-revalidate") 188 | w.Header().Set("Expires", "0") 189 | } 190 | 191 | func handleAppError(e *AppError, w http.ResponseWriter, r *http.Request) { 192 | c := appengine.NewContext(r) 193 | if e.Type == AppErrorTypeGitHubFetch { 194 | if gitHubError, ok := (e.Error).(*github.ErrorResponse); ok { 195 | gitHubStatus := gitHubError.Response.StatusCode 196 | if gitHubStatus == http.StatusUnauthorized || 197 | gitHubStatus == http.StatusForbidden { 198 | var data = map[string]interface{}{ 199 | "ContinueUrl": r.URL, 200 | "IsForbidden": gitHubStatus == http.StatusForbidden, 201 | } 202 | 203 | e = templates["github-auth-error"].Render(w, data) 204 | if e != nil { 205 | handleAppError(e, w, r) 206 | } 207 | return 208 | } 209 | } else { 210 | log.Errorf(c, "GitHub fetch error was not of type github.ErrorResponse") 211 | } 212 | } else if e.Type == AppErrorTypeRedirect { 213 | http.Redirect(w, r, e.Message, e.Code) 214 | return 215 | } 216 | if e.Type != AppErrorTypeBadInput { 217 | log.Errorf(c, "%v", e.Error) 218 | if !appengine.IsDevAppServer() { 219 | sendAppErrorMail(e, r) 220 | } 221 | var data = map[string]interface{}{ 222 | "ShowDetails": appengine.IsDevAppServer(), 223 | "Error": e, 224 | } 225 | w.WriteHeader(e.Code) 226 | templateError := templates["internal-error"].Render(w, data) 227 | if templateError != nil { 228 | log.Errorf(c, "Error %s rendering error template.", templateError.Error.Error()) 229 | } 230 | return 231 | } else { 232 | log.Infof(c, "%v", e.Error) 233 | } 234 | http.Error(w, e.Message, e.Code) 235 | } 236 | 237 | func sendAppErrorMail(e *AppError, r *http.Request) { 238 | session, _ := sessionStore.Get(r, sessionConfig.CookieName) 239 | userId, _ := session.Values[sessionConfig.UserIdKey].(int64) 240 | 241 | errorMessage := &mail.Message{ 242 | Sender: "RetroGit Admin ", 243 | To: []string{"mihai.parparita@gmail.com"}, 244 | Subject: fmt.Sprintf("RetroGit Internal Error on %s", r.URL), 245 | Body: fmt.Sprintf(`Request URL: %s 246 | HTTP status code: %d 247 | Error type: %d 248 | User ID: %d 249 | 250 | Message: %s 251 | Error: %s`, 252 | r.URL, 253 | e.Code, 254 | e.Type, 255 | userId, 256 | e.Message, 257 | e.Error), 258 | } 259 | c := appengine.NewContext(r) 260 | err := mail.Send(c, errorMessage) 261 | if err != nil { 262 | log.Errorf(c, "Error %s sending error email.", err.Error()) 263 | } 264 | } 265 | 266 | type Template struct { 267 | *template.Template 268 | } 269 | 270 | func (t *Template) Render(w http.ResponseWriter, data map[string]interface{}, state ...*AppSignedInState) *AppError { 271 | if len(state) > 0 { 272 | data["Flashes"] = state[0].Flashes() 273 | } 274 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 275 | err := t.Execute(w, data) 276 | if err != nil { 277 | return &AppError{ 278 | Error: err, 279 | Message: fmt.Sprintf("Could not render template '%s'", t.Name()), 280 | Code: http.StatusInternalServerError, 281 | Type: AppErrorTypeTemplate, 282 | } 283 | } 284 | return nil 285 | } 286 | 287 | func loadTemplates() (templates map[string]*Template) { 288 | styles := loadStyles() 289 | funcMap := template.FuncMap{ 290 | "routeUrl": func(name string) (string, error) { 291 | url, err := router.Get(name).URL() 292 | if err != nil { 293 | return "", err 294 | } 295 | return url.String(), nil 296 | }, 297 | "absoluteRouteUrl": func(name string) (string, error) { 298 | url, err := router.Get(name).URL() 299 | if err != nil { 300 | return "", err 301 | } 302 | var baseUrl string 303 | if appengine.IsDevAppServer() { 304 | baseUrl = "http://localhost:8080" 305 | } else { 306 | baseUrl = "https://www.retrogit.com" 307 | } 308 | return baseUrl + url.String(), nil 309 | }, 310 | "absoluteUrlForPath": func(path string) string { 311 | if appengine.IsDevAppServer() { 312 | return "http://localhost:8080" + path 313 | } 314 | return "https://www.retrogit.com" + path 315 | }, 316 | "style": func(names ...string) (result template.CSS) { 317 | for _, name := range names { 318 | result += styles[name] 319 | } 320 | return 321 | }, 322 | } 323 | sharedFileNames, err := filepath.Glob("templates/shared/*.html") 324 | if err != nil { 325 | log_.Panicf("Could not read shared template file names %s", err.Error()) 326 | } 327 | templateFileNames, err := filepath.Glob("templates/*.html") 328 | if err != nil { 329 | log_.Panicf("Could not read template file names %s", err.Error()) 330 | } 331 | templates = make(map[string]*Template) 332 | for _, templateFileName := range templateFileNames { 333 | templateName := filepath.Base(templateFileName) 334 | templateName = strings.TrimSuffix(templateName, filepath.Ext(templateName)) 335 | fileNames := make([]string, 0, len(sharedFileNames)+2) 336 | // The base template has to come first, except for email ones, which 337 | // don't use it. 338 | if !strings.HasSuffix(templateName, "-email") { 339 | fileNames = append(fileNames, "templates/base/page.html") 340 | } 341 | fileNames = append(fileNames, templateFileName) 342 | fileNames = append(fileNames, sharedFileNames...) 343 | _, templateFileName = filepath.Split(fileNames[0]) 344 | parsedTemplate, err := template.New(templateFileName).Funcs(funcMap).ParseFiles(fileNames...) 345 | if err != nil { 346 | log_.Printf("Could not parse template files for %s: %s", templateFileName, err.Error()) 347 | } 348 | templates[templateName] = &Template{parsedTemplate} 349 | } 350 | return templates 351 | } 352 | 353 | func loadStyles() (result map[string]template.CSS) { 354 | stylesBytes, err := ioutil.ReadFile("config/styles.json") 355 | if err != nil { 356 | log_.Panicf("Could not read styles JSON: %s", err.Error()) 357 | } 358 | var stylesJson interface{} 359 | err = json.Unmarshal(stylesBytes, &stylesJson) 360 | result = make(map[string]template.CSS) 361 | if err != nil { 362 | log_.Printf("Could not parse styles JSON %s: %s", stylesBytes, err.Error()) 363 | return 364 | } 365 | var parse func(string, map[string]interface{}, *string) 366 | parse = func(path string, stylesJson map[string]interface{}, currentStyle *string) { 367 | if path != "" { 368 | path += "." 369 | } 370 | for k, v := range stylesJson { 371 | switch v.(type) { 372 | case string: 373 | *currentStyle += k + ":" + v.(string) + ";" 374 | case map[string]interface{}: 375 | nestedStyle := "" 376 | parse(path+k, v.(map[string]interface{}), &nestedStyle) 377 | result[path+k] = template.CSS(nestedStyle) 378 | default: 379 | log_.Printf("Unexpected type for %s in styles JSON, ignoring", k) 380 | } 381 | } 382 | } 383 | parse("", stylesJson.(map[string]interface{}), nil) 384 | return 385 | } 386 | -------------------------------------------------------------------------------- /app/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go122 2 | app_engine_apis: true 3 | handlers: 4 | - url: /static 5 | static_dir: static 6 | - url: /favicon.ico 7 | static_files: static/favicon.ico 8 | upload: static/favicon.ico 9 | - url: /robots.txt 10 | static_files: static/robots.txt 11 | upload: static/robots.txt 12 | - url: /digest/cron 13 | script: auto 14 | login: admin 15 | - url: /admin/.* 16 | script: auto 17 | login: admin 18 | - url: /.* 19 | script: auto 20 | secure: always 21 | automatic_scaling: 22 | min_idle_instances: automatic 23 | max_idle_instances: 1 24 | min_pending_latency: automatic 25 | max_pending_latency: 0.030s 26 | max_instances: 1 27 | -------------------------------------------------------------------------------- /app/caching_transport.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/md5" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/http/httputil" 12 | "strings" 13 | "time" 14 | 15 | "google.golang.org/appengine/v2/memcache" 16 | "google.golang.org/appengine/v2/log" 17 | ) 18 | 19 | // Simple http.RoundTripper implementation which wraps an existing transport and 20 | // caches all responses for GET and HEAD requests. Meant to speed up the 21 | // iteration cycle during development. 22 | type CachingTransport struct { 23 | Transport http.RoundTripper 24 | Context context.Context 25 | } 26 | 27 | func (t *CachingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { 28 | if req.Method != "GET" && req.Method != "HEAD" { 29 | return t.Transport.RoundTrip(req) 30 | } 31 | // The Go App Engine runtime has a 250 byte limit for memcache keys, so we 32 | // need to hash the URL to make sure we stay under it. 33 | cacheHash := md5.New() 34 | io.WriteString(cacheHash, req.URL.String()) 35 | 36 | authorizationHeaders, ok := req.Header["Authorization"] 37 | if ok { 38 | for i := range authorizationHeaders { 39 | io.WriteString(cacheHash, authorizationHeaders[i]) 40 | } 41 | } else { 42 | io.WriteString(cacheHash, "Unauthorized") 43 | } 44 | acceptHeaders, ok := req.Header["Accept"] 45 | if ok { 46 | for i := range acceptHeaders { 47 | io.WriteString(cacheHash, acceptHeaders[i]) 48 | } 49 | } 50 | cacheKey := fmt.Sprintf("CachingTransport:%x", cacheHash.Sum(nil)) 51 | 52 | cachedRespItem, err := memcache.Get(t.Context, cacheKey) 53 | if err != nil && err != memcache.ErrCacheMiss { 54 | log.Errorf(t.Context, "Error getting cached response: %v", err) 55 | return t.Transport.RoundTrip(req) 56 | } 57 | if err == nil { 58 | cacheRespBuffer := bytes.NewBuffer(cachedRespItem.Value) 59 | resp, err := http.ReadResponse(bufio.NewReader(cacheRespBuffer), req) 60 | if err == nil { 61 | return resp, nil 62 | } else { 63 | log.Errorf(t.Context, "Error readings bytes for cached response: %v", err) 64 | } 65 | } 66 | log.Infof(t.Context, "Fetching %s", req.URL) 67 | resp, err = t.Transport.RoundTrip(req) 68 | if err != nil || resp.StatusCode != 200 { 69 | return 70 | } 71 | respBytes, err := httputil.DumpResponse(resp, true) 72 | if err != nil { 73 | log.Errorf(t.Context, "Error dumping bytes for cached response: %v", err) 74 | return resp, nil 75 | } 76 | var expiration time.Duration = time.Hour 77 | if strings.HasPrefix(req.URL.Path, "/repos/") && 78 | (strings.HasSuffix(req.URL.Path, "/commits") || 79 | strings.HasSuffix(req.URL.Path, "/stats/contributors")) { 80 | expiration = 0 81 | } 82 | err = memcache.Set( 83 | t.Context, 84 | &memcache.Item{ 85 | Key: cacheKey, 86 | Value: respBytes, 87 | Expiration: expiration, 88 | }) 89 | if err != nil { 90 | log.Errorf(t.Context, "Error setting cached response for %s (cache key %s, %d bytes to cache): %v", 91 | req.URL, cacheKey, len(respBytes), err) 92 | } 93 | return resp, nil 94 | } 95 | -------------------------------------------------------------------------------- /app/config/github-oauth.json.SAMPLE: -------------------------------------------------------------------------------- 1 | { 2 | "ClientId": "REPLACE_ME", 3 | "ClientSecret": "REPLACE_ME", 4 | "RedirectURL": "https://REPLACE_ME/github/callback" 5 | } 6 | -------------------------------------------------------------------------------- /app/config/session.json.SAMPLE: -------------------------------------------------------------------------------- 1 | { 2 | "AuthenticationKey": "REPLACE_ME_WITH_A_32_BYTE_BASE_64_ENCODED_KEY_BYES", 3 | "EncryptionKey": "REPLACE_ME_WITH_A_32_BYTE_BASE_64_ENCODED_KEY_BYTES", 4 | "CookieName": "session", 5 | "UserIdKey": "user_id" 6 | } 7 | -------------------------------------------------------------------------------- /app/config/styles.json: -------------------------------------------------------------------------------- 1 | { 2 | "digest": { 3 | "font-family": "Consolas,\"Liberation Mono\",Menlo,Courier,monospace", 4 | "font-size": "10pt", 5 | "color": "#000", 6 | "max-width": "1102px", 7 | "margin": "0" 8 | }, 9 | "link": { 10 | "text-decoration": "none", 11 | "color": "#e73b31" 12 | }, 13 | "proportional": { 14 | "font-family": "Helvetica,Arial,sans-serif" 15 | }, 16 | "intro-paragraph": { 17 | "font-size": "12pt", 18 | "user-link": { 19 | "font-weight": "bold", 20 | "color": "#000" 21 | }, 22 | "user-avatar": { 23 | "vertical-align": "bottom", 24 | "padding-right": "3px" 25 | } 26 | }, 27 | "interval-header": { 28 | "font-size": "20pt", 29 | "font-weight": "bold", 30 | "margin": ".75em 0 .5em 0", 31 | "border-bottom": "dashed 1px #ccc" 32 | }, 33 | "repository-header": { 34 | "font-size": "16pt", 35 | "font-weight": "bold", 36 | "margin": ".5em 0", 37 | "link": { 38 | "color": "#b52e26" 39 | } 40 | }, 41 | "commit": { 42 | "background": "#fefcef", 43 | "border": "solid 1px #dddac8", 44 | "box-shadow": "1px 1px 2px rgba(0,0,0,.11)", 45 | "container": { 46 | "margin": "1em 0" 47 | }, 48 | "corner": { 49 | "width": "0", 50 | "max-height": "0", 51 | "float": "left", 52 | "cover": { 53 | "width": "0", 54 | "height": "0", 55 | "border-style": "solid", 56 | "border-width": "0 0 20px 10px", 57 | "border-color": "transparent transparent #dddac8 #fff" 58 | }, 59 | "border": { 60 | "margin": "1px 0 0 1px", 61 | "width": "0", 62 | "height": "0", 63 | "border-style": "solid", 64 | "border-width": "0 0 20px 10px", 65 | "border-color": "transparent transparent #fefcef transparent" 66 | } 67 | }, 68 | "title": { 69 | "padding": "10px", 70 | "margin": "0", 71 | "z-index": "2" 72 | }, 73 | "message": { 74 | "margin": "0", 75 | "padding": "0 10px 10px", 76 | "white-space": "pre-wrap" 77 | }, 78 | "footer": { 79 | "border-top": "dashed 1px #cac7b7", 80 | "margin": "0 10px", 81 | "padding": "10px 0", 82 | "date": { 83 | "color": "#666" 84 | }, 85 | "link": { 86 | "float": "right" 87 | } 88 | } 89 | }, 90 | "errors": { 91 | "background": "#fdd", 92 | "padding": "10px" 93 | }, 94 | "email-footer": { 95 | "color": "#999", 96 | "font-size": "9pt", 97 | "paragraph": { 98 | "margin": "0.5em 0" 99 | }, 100 | "link": { 101 | "text-decoration": "none", 102 | "color": "#4183c4" 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/config/timezones.json: -------------------------------------------------------------------------------- 1 | { 2 | "LocationNames": [ 3 | "America/New_York", 4 | "America/Chicago", 5 | "America/Denver", 6 | "America/Phoenix", 7 | "America/Los_Angeles", 8 | "America/Anchorage", 9 | "Pacific/Honolulu", 10 | "", 11 | "Africa/Abidjan", 12 | "Africa/Accra", 13 | "Africa/Addis_Ababa", 14 | "Africa/Algiers", 15 | "Africa/Asmara", 16 | "Africa/Bamako", 17 | "Africa/Bangui", 18 | "Africa/Banjul", 19 | "Africa/Bissau", 20 | "Africa/Blantyre", 21 | "Africa/Brazzaville", 22 | "Africa/Bujumbura", 23 | "Africa/Cairo", 24 | "Africa/Casablanca", 25 | "Africa/Ceuta", 26 | "Africa/Conakry", 27 | "Africa/Dakar", 28 | "Africa/Dar_es_Salaam", 29 | "Africa/Djibouti", 30 | "Africa/Douala", 31 | "Africa/El_Aaiun", 32 | "Africa/Freetown", 33 | "Africa/Gaborone", 34 | "Africa/Harare", 35 | "Africa/Johannesburg", 36 | "Africa/Juba", 37 | "Africa/Kampala", 38 | "Africa/Khartoum", 39 | "Africa/Kigali", 40 | "Africa/Kinshasa", 41 | "Africa/Lagos", 42 | "Africa/Libreville", 43 | "Africa/Lome", 44 | "Africa/Luanda", 45 | "Africa/Lubumbashi", 46 | "Africa/Lusaka", 47 | "Africa/Malabo", 48 | "Africa/Maputo", 49 | "Africa/Maseru", 50 | "Africa/Mbabane", 51 | "Africa/Mogadishu", 52 | "Africa/Monrovia", 53 | "Africa/Nairobi", 54 | "Africa/Ndjamena", 55 | "Africa/Niamey", 56 | "Africa/Nouakchott", 57 | "Africa/Ouagadougou", 58 | "Africa/Porto-Novo", 59 | "Africa/Sao_Tome", 60 | "Africa/Tripoli", 61 | "Africa/Tunis", 62 | "Africa/Windhoek", 63 | "America/Adak", 64 | "America/Anguilla", 65 | "America/Antigua", 66 | "America/Araguaina", 67 | "America/Argentina/Buenos_Aires", 68 | "America/Argentina/Catamarca", 69 | "America/Argentina/Cordoba", 70 | "America/Argentina/Jujuy", 71 | "America/Argentina/La_Rioja", 72 | "America/Argentina/Mendoza", 73 | "America/Argentina/Rio_Gallegos", 74 | "America/Argentina/Salta", 75 | "America/Argentina/San_Juan", 76 | "America/Argentina/San_Luis", 77 | "America/Argentina/Tucuman", 78 | "America/Argentina/Ushuaia", 79 | "America/Aruba", 80 | "America/Asuncion", 81 | "America/Atikokan", 82 | "America/Bahia", 83 | "America/Bahia_Banderas", 84 | "America/Barbados", 85 | "America/Belem", 86 | "America/Belize", 87 | "America/Blanc-Sablon", 88 | "America/Boa_Vista", 89 | "America/Bogota", 90 | "America/Boise", 91 | "America/Cambridge_Bay", 92 | "America/Campo_Grande", 93 | "America/Cancun", 94 | "America/Caracas", 95 | "America/Cayenne", 96 | "America/Cayman", 97 | "America/Chihuahua", 98 | "America/Costa_Rica", 99 | "America/Creston", 100 | "America/Cuiaba", 101 | "America/Curacao", 102 | "America/Danmarkshavn", 103 | "America/Dawson", 104 | "America/Dawson_Creek", 105 | "America/Detroit", 106 | "America/Dominica", 107 | "America/Edmonton", 108 | "America/Eirunepe", 109 | "America/El_Salvador", 110 | "America/Fortaleza", 111 | "America/Glace_Bay", 112 | "America/Godthab", 113 | "America/Goose_Bay", 114 | "America/Grand_Turk", 115 | "America/Grenada", 116 | "America/Guadeloupe", 117 | "America/Guatemala", 118 | "America/Guayaquil", 119 | "America/Guyana", 120 | "America/Halifax", 121 | "America/Havana", 122 | "America/Hermosillo", 123 | "America/Indiana/Indianapolis", 124 | "America/Indiana/Knox", 125 | "America/Indiana/Marengo", 126 | "America/Indiana/Petersburg", 127 | "America/Indiana/Tell_City", 128 | "America/Indiana/Vevay", 129 | "America/Indiana/Vincennes", 130 | "America/Indiana/Winamac", 131 | "America/Inuvik", 132 | "America/Iqaluit", 133 | "America/Jamaica", 134 | "America/Juneau", 135 | "America/Kentucky/Louisville", 136 | "America/Kentucky/Monticello", 137 | "America/Kralendijk", 138 | "America/La_Paz", 139 | "America/Lima", 140 | "America/Lower_Princes", 141 | "America/Maceio", 142 | "America/Managua", 143 | "America/Manaus", 144 | "America/Marigot", 145 | "America/Martinique", 146 | "America/Matamoros", 147 | "America/Mazatlan", 148 | "America/Menominee", 149 | "America/Merida", 150 | "America/Metlakatla", 151 | "America/Mexico_City", 152 | "America/Miquelon", 153 | "America/Moncton", 154 | "America/Monterrey", 155 | "America/Montevideo", 156 | "America/Montserrat", 157 | "America/Nassau", 158 | "America/Nipigon", 159 | "America/Nome", 160 | "America/Noronha", 161 | "America/North_Dakota/Beulah", 162 | "America/North_Dakota/Center", 163 | "America/North_Dakota/New_Salem", 164 | "America/Ojinaga", 165 | "America/Panama", 166 | "America/Pangnirtung", 167 | "America/Paramaribo", 168 | "America/Port_of_Spain", 169 | "America/Port-au-Prince", 170 | "America/Porto_Velho", 171 | "America/Puerto_Rico", 172 | "America/Rainy_River", 173 | "America/Rankin_Inlet", 174 | "America/Recife", 175 | "America/Regina", 176 | "America/Resolute", 177 | "America/Rio_Branco", 178 | "America/Santa_Isabel", 179 | "America/Santarem", 180 | "America/Santiago", 181 | "America/Santo_Domingo", 182 | "America/Sao_Paulo", 183 | "America/Scoresbysund", 184 | "America/Sitka", 185 | "America/St_Barthelemy", 186 | "America/St_Johns", 187 | "America/St_Kitts", 188 | "America/St_Lucia", 189 | "America/St_Thomas", 190 | "America/St_Vincent", 191 | "America/Swift_Current", 192 | "America/Tegucigalpa", 193 | "America/Thule", 194 | "America/Thunder_Bay", 195 | "America/Tijuana", 196 | "America/Toronto", 197 | "America/Tortola", 198 | "America/Vancouver", 199 | "America/Whitehorse", 200 | "America/Winnipeg", 201 | "America/Yakutat", 202 | "America/Yellowknife", 203 | "Antarctica/Casey", 204 | "Antarctica/Davis", 205 | "Antarctica/DumontDUrville", 206 | "Antarctica/Macquarie", 207 | "Antarctica/Mawson", 208 | "Antarctica/McMurdo", 209 | "Antarctica/Palmer", 210 | "Antarctica/Rothera", 211 | "Antarctica/Syowa", 212 | "Antarctica/Troll", 213 | "Antarctica/Vostok", 214 | "Arctic/Longyearbyen", 215 | "Asia/Aden", 216 | "Asia/Almaty", 217 | "Asia/Amman", 218 | "Asia/Anadyr", 219 | "Asia/Aqtau", 220 | "Asia/Aqtobe", 221 | "Asia/Ashgabat", 222 | "Asia/Baghdad", 223 | "Asia/Bahrain", 224 | "Asia/Baku", 225 | "Asia/Bangkok", 226 | "Asia/Beirut", 227 | "Asia/Bishkek", 228 | "Asia/Brunei", 229 | "Asia/Choibalsan", 230 | "Asia/Chongqing", 231 | "Asia/Colombo", 232 | "Asia/Damascus", 233 | "Asia/Dhaka", 234 | "Asia/Dili", 235 | "Asia/Dubai", 236 | "Asia/Dushanbe", 237 | "Asia/Gaza", 238 | "Asia/Harbin", 239 | "Asia/Hebron", 240 | "Asia/Ho_Chi_Minh", 241 | "Asia/Hong_Kong", 242 | "Asia/Hovd", 243 | "Asia/Irkutsk", 244 | "Asia/Jakarta", 245 | "Asia/Jayapura", 246 | "Asia/Jerusalem", 247 | "Asia/Kabul", 248 | "Asia/Kamchatka", 249 | "Asia/Karachi", 250 | "Asia/Kashgar", 251 | "Asia/Kathmandu", 252 | "Asia/Khandyga", 253 | "Asia/Kolkata", 254 | "Asia/Krasnoyarsk", 255 | "Asia/Kuala_Lumpur", 256 | "Asia/Kuching", 257 | "Asia/Kuwait", 258 | "Asia/Macau", 259 | "Asia/Magadan", 260 | "Asia/Makassar", 261 | "Asia/Manila", 262 | "Asia/Muscat", 263 | "Asia/Nicosia", 264 | "Asia/Novokuznetsk", 265 | "Asia/Novosibirsk", 266 | "Asia/Omsk", 267 | "Asia/Oral", 268 | "Asia/Phnom_Penh", 269 | "Asia/Pontianak", 270 | "Asia/Pyongyang", 271 | "Asia/Qatar", 272 | "Asia/Qyzylorda", 273 | "Asia/Rangoon", 274 | "Asia/Riyadh", 275 | "Asia/Sakhalin", 276 | "Asia/Samarkand", 277 | "Asia/Seoul", 278 | "Asia/Shanghai", 279 | "Asia/Singapore", 280 | "Asia/Taipei", 281 | "Asia/Tashkent", 282 | "Asia/Tbilisi", 283 | "Asia/Tehran", 284 | "Asia/Thimphu", 285 | "Asia/Tokyo", 286 | "Asia/Ulaanbaatar", 287 | "Asia/Urumqi", 288 | "Asia/Ust-Nera", 289 | "Asia/Vientiane", 290 | "Asia/Vladivostok", 291 | "Asia/Yakutsk", 292 | "Asia/Yekaterinburg", 293 | "Asia/Yerevan", 294 | "Atlantic/Azores", 295 | "Atlantic/Bermuda", 296 | "Atlantic/Canary", 297 | "Atlantic/Cape_Verde", 298 | "Atlantic/Faroe", 299 | "Atlantic/Madeira", 300 | "Atlantic/Reykjavik", 301 | "Atlantic/South_Georgia", 302 | "Atlantic/St_Helena", 303 | "Atlantic/Stanley", 304 | "Australia/Adelaide", 305 | "Australia/Brisbane", 306 | "Australia/Broken_Hill", 307 | "Australia/Currie", 308 | "Australia/Darwin", 309 | "Australia/Eucla", 310 | "Australia/Hobart", 311 | "Australia/Lindeman", 312 | "Australia/Lord_Howe", 313 | "Australia/Melbourne", 314 | "Australia/Perth", 315 | "Australia/Sydney", 316 | "Europe/Amsterdam", 317 | "Europe/Andorra", 318 | "Europe/Athens", 319 | "Europe/Belgrade", 320 | "Europe/Berlin", 321 | "Europe/Bratislava", 322 | "Europe/Brussels", 323 | "Europe/Bucharest", 324 | "Europe/Budapest", 325 | "Europe/Busingen", 326 | "Europe/Chisinau", 327 | "Europe/Copenhagen", 328 | "Europe/Dublin", 329 | "Europe/Gibraltar", 330 | "Europe/Guernsey", 331 | "Europe/Helsinki", 332 | "Europe/Isle_of_Man", 333 | "Europe/Istanbul", 334 | "Europe/Jersey", 335 | "Europe/Kaliningrad", 336 | "Europe/Kiev", 337 | "Europe/Lisbon", 338 | "Europe/Ljubljana", 339 | "Europe/London", 340 | "Europe/Luxembourg", 341 | "Europe/Madrid", 342 | "Europe/Malta", 343 | "Europe/Mariehamn", 344 | "Europe/Minsk", 345 | "Europe/Monaco", 346 | "Europe/Moscow", 347 | "Europe/Oslo", 348 | "Europe/Paris", 349 | "Europe/Podgorica", 350 | "Europe/Prague", 351 | "Europe/Riga", 352 | "Europe/Rome", 353 | "Europe/Samara", 354 | "Europe/San_Marino", 355 | "Europe/Sarajevo", 356 | "Europe/Simferopol", 357 | "Europe/Skopje", 358 | "Europe/Sofia", 359 | "Europe/Stockholm", 360 | "Europe/Tallinn", 361 | "Europe/Tirane", 362 | "Europe/Uzhgorod", 363 | "Europe/Vaduz", 364 | "Europe/Vatican", 365 | "Europe/Vienna", 366 | "Europe/Vilnius", 367 | "Europe/Volgograd", 368 | "Europe/Warsaw", 369 | "Europe/Zagreb", 370 | "Europe/Zaporozhye", 371 | "Europe/Zurich", 372 | "Indian/Antananarivo", 373 | "Indian/Chagos", 374 | "Indian/Christmas", 375 | "Indian/Cocos", 376 | "Indian/Comoro", 377 | "Indian/Kerguelen", 378 | "Indian/Mahe", 379 | "Indian/Maldives", 380 | "Indian/Mauritius", 381 | "Indian/Mayotte", 382 | "Indian/Reunion", 383 | "Pacific/Apia", 384 | "Pacific/Auckland", 385 | "Pacific/Chatham", 386 | "Pacific/Chuuk", 387 | "Pacific/Easter", 388 | "Pacific/Efate", 389 | "Pacific/Enderbury", 390 | "Pacific/Fakaofo", 391 | "Pacific/Fiji", 392 | "Pacific/Funafuti", 393 | "Pacific/Galapagos", 394 | "Pacific/Gambier", 395 | "Pacific/Guadalcanal", 396 | "Pacific/Guam", 397 | "Pacific/Johnston", 398 | "Pacific/Kiritimati", 399 | "Pacific/Kosrae", 400 | "Pacific/Kwajalein", 401 | "Pacific/Majuro", 402 | "Pacific/Marquesas", 403 | "Pacific/Midway", 404 | "Pacific/Nauru", 405 | "Pacific/Niue", 406 | "Pacific/Norfolk", 407 | "Pacific/Noumea", 408 | "Pacific/Pago_Pago", 409 | "Pacific/Palau", 410 | "Pacific/Pitcairn", 411 | "Pacific/Pohnpei", 412 | "Pacific/Port_Moresby", 413 | "Pacific/Rarotonga", 414 | "Pacific/Saipan", 415 | "Pacific/Tahiti", 416 | "Pacific/Tarawa", 417 | "Pacific/Tongatapu", 418 | "Pacific/Wake", 419 | "Pacific/Wallis" 420 | ] 421 | } 422 | -------------------------------------------------------------------------------- /app/cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - url: /digest/cron 3 | schedule: every day 13:00 4 | timezone: America/Los_Angeles 5 | -------------------------------------------------------------------------------- /app/digest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "google.golang.org/appengine/v2/log" 12 | 13 | "github.com/google/go-github/v62/github" 14 | ) 15 | 16 | const ( 17 | CommitDisplayDateFormat = "3:04pm" 18 | CommitDisplayDateFullFormat = "Monday January 2 3:04pm" 19 | DigestDisplayDateFormat = "January 2, 2006" 20 | DigestDisplayShortDateFormat = "January 2" 21 | DigestDisplayDayOfWeekFormat = "Monday" 22 | ) 23 | 24 | type DigestCommit struct { 25 | DisplaySHA string 26 | URL string 27 | Title string 28 | Message string 29 | PushDate time.Time 30 | CommitDate time.Time 31 | } 32 | 33 | func safeFormattedDate(date string) string { 34 | // Insert zero-width spaces every few characters so that Apple Data 35 | // Detectors and Gmail's calendar event detection don't pick up on these 36 | // dates. 37 | var buffer bytes.Buffer 38 | dateLength := len(date) 39 | for i := 0; i < dateLength; i += 2 { 40 | if i == dateLength-1 { 41 | buffer.WriteString(date[i : i+1]) 42 | } else { 43 | buffer.WriteString(date[i : i+2]) 44 | if date[i] != ' ' && date[i+1] != ' ' && i < dateLength-2 { 45 | buffer.WriteString("\u200b") 46 | } 47 | } 48 | } 49 | return buffer.String() 50 | } 51 | 52 | func newDigestCommit(commit *github.RepositoryCommit, repo *Repo, location *time.Location) DigestCommit { 53 | messagePieces := strings.SplitN(*commit.Commit.Message, "\n", 2) 54 | title := messagePieces[0] 55 | message := "" 56 | if len(messagePieces) == 2 { 57 | message = messagePieces[1] 58 | } 59 | return DigestCommit{ 60 | DisplaySHA: (*commit.SHA)[:7], 61 | URL: fmt.Sprintf("https://github.com/%s/commit/%s", *repo.FullName, *commit.SHA), 62 | Title: title, 63 | Message: message, 64 | PushDate: commit.Commit.Committer.Date.In(location), 65 | CommitDate: commit.Commit.Author.Date.In(location), 66 | } 67 | } 68 | 69 | func (commit DigestCommit) DisplayDate() string { 70 | // Prefer the date the commit was pushed, since that's what GitHub filters 71 | // and sorts by. 72 | return safeFormattedDate(commit.PushDate.Format(CommitDisplayDateFormat)) 73 | } 74 | 75 | func (commit DigestCommit) WeeklyDisplayDate() string { 76 | return safeFormattedDate(commit.PushDate.Format(CommitDisplayDateFullFormat)) 77 | } 78 | 79 | func (commit DigestCommit) DisplayDateTooltip() string { 80 | // But show the full details in a tooltip 81 | return fmt.Sprintf( 82 | "Pushed at %s\nCommitted at %s", 83 | commit.PushDate.Format(CommitDisplayDateFullFormat), 84 | commit.CommitDate.Format(CommitDisplayDateFullFormat)) 85 | } 86 | 87 | type RepoDigest struct { 88 | Repo *Repo 89 | Commits []DigestCommit 90 | } 91 | 92 | // sort.Interface implementation for sorting RepoDigests. 93 | type ByRepoFullName []*RepoDigest 94 | 95 | func (a ByRepoFullName) Len() int { return len(a) } 96 | func (a ByRepoFullName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 97 | func (a ByRepoFullName) Less(i, j int) bool { return *a[i].Repo.FullName < *a[j].Repo.FullName } 98 | 99 | type IntervalDigest struct { 100 | yearDelta int 101 | StartTime time.Time 102 | EndTime time.Time 103 | Weekly bool 104 | RepoDigests []*RepoDigest 105 | repos []*Repo 106 | } 107 | 108 | func (digest *IntervalDigest) Empty() bool { 109 | for i := range digest.RepoDigests { 110 | if len(digest.RepoDigests[i].Commits) > 0 { 111 | return false 112 | } 113 | } 114 | return true 115 | } 116 | 117 | func (digest *IntervalDigest) Header() string { 118 | if digest.yearDelta == -1 { 119 | return "1 Year Ago" 120 | } 121 | return fmt.Sprintf("%d Years Ago", -digest.yearDelta) 122 | } 123 | 124 | func (digest *IntervalDigest) Description() string { 125 | commitCount := 0 126 | for i := range digest.RepoDigests { 127 | commitCount += len(digest.RepoDigests[i].Commits) 128 | } 129 | var formattedCommitCount string 130 | if commitCount == 0 { 131 | formattedCommitCount = "no commits" 132 | } else if commitCount == 1 { 133 | formattedCommitCount = "1 commit" 134 | } else { 135 | formattedCommitCount = fmt.Sprintf("%d commits", commitCount) 136 | } 137 | repoCount := len(digest.RepoDigests) 138 | var formattedRepoCount string 139 | if repoCount == 1 { 140 | formattedRepoCount = "1 repository" 141 | } else { 142 | formattedRepoCount = fmt.Sprintf("%d repositories", repoCount) 143 | } 144 | 145 | if !digest.Weekly { 146 | return fmt.Sprintf("%s was a %s. You had %s in %s that day.", 147 | safeFormattedDate(digest.StartTime.Format(DigestDisplayDateFormat)), 148 | safeFormattedDate(digest.StartTime.Format(DigestDisplayDayOfWeekFormat)), 149 | formattedCommitCount, 150 | formattedRepoCount) 151 | } 152 | 153 | formattedEndTime := digest.EndTime.Format(DigestDisplayDateFormat) 154 | var formattedStartTime string 155 | if digest.StartTime.Year() == digest.EndTime.Year() { 156 | formattedStartTime = digest.StartTime.Format(DigestDisplayShortDateFormat) 157 | } else { 158 | formattedStartTime = digest.StartTime.Format(DigestDisplayDateFormat) 159 | } 160 | return fmt.Sprintf("You had %s in %s the week of %s to %s.", 161 | formattedCommitCount, 162 | formattedRepoCount, 163 | safeFormattedDate(formattedStartTime), 164 | safeFormattedDate(formattedEndTime)) 165 | } 166 | 167 | type Digest struct { 168 | User *github.User 169 | TimezoneLocation *time.Location 170 | IntervalDigests []*IntervalDigest 171 | CommitCount int 172 | RepoErrors map[string]error 173 | } 174 | 175 | func newDigest(c context.Context, githubClient *github.Client, account *Account) (*Digest, error) { 176 | user, _, err := githubClient.Users.Get(c, "") 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | repos, err := getRepos(c, githubClient, account, user) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | oldestDigestTime := repos.OldestVintage.In(account.TimezoneLocation) 187 | intervalDigests := make([]*IntervalDigest, 0) 188 | now := time.Now().In(account.TimezoneLocation) 189 | for yearDelta := -1; ; yearDelta-- { 190 | digestStartTime := time.Date(now.Year()+yearDelta, now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 191 | if digestStartTime.Before(oldestDigestTime) { 192 | break 193 | } 194 | daysInDigest := 1 195 | if account.Frequency == "weekly" { 196 | daysInDigest = 7 197 | } 198 | digestEndTime := digestStartTime.AddDate(0, 0, daysInDigest).Add(-time.Second) 199 | 200 | // Only look at repos that may have activity in the digest interval. 201 | var intervalRepos []*Repo 202 | for _, repo := range repos.AllRepos { 203 | if repo.IncludeInDigest && repo.Vintage.Before(digestEndTime) && repo.PushedAt != nil && 204 | repo.PushedAt.After(digestStartTime) { 205 | intervalRepos = append(intervalRepos, repo) 206 | } 207 | } 208 | 209 | intervalDigests = append(intervalDigests, &IntervalDigest{ 210 | yearDelta: yearDelta, 211 | repos: intervalRepos, 212 | RepoDigests: make([]*RepoDigest, 0, len(intervalRepos)), 213 | StartTime: digestStartTime, 214 | EndTime: digestEndTime, 215 | Weekly: account.Frequency == "weekly", 216 | }) 217 | } 218 | 219 | digest := &Digest{ 220 | User: user, 221 | TimezoneLocation: account.TimezoneLocation, 222 | IntervalDigests: intervalDigests, 223 | CommitCount: 0, 224 | RepoErrors: make(map[string]error), 225 | } 226 | 227 | digest.fetch(c, githubClient) 228 | for repoFullName, err := range digest.RepoErrors { 229 | log.Errorf(c, "Error fetching %s: %s", repoFullName, err.Error()) 230 | } 231 | return digest, nil 232 | } 233 | 234 | func (digest *Digest) fetch(c context.Context, githubClient *github.Client) { 235 | type RepoDigestResponse struct { 236 | intervalDigest *IntervalDigest 237 | repo *Repo 238 | repoDigest *RepoDigest 239 | err error 240 | } 241 | fetchCount := 0 242 | ch := make(chan *RepoDigestResponse) 243 | for _, intervalDigest := range digest.IntervalDigests { 244 | for _, repo := range intervalDigest.repos { 245 | go func(intervalDigest *IntervalDigest, repo *Repo) { 246 | commits := make([]*github.RepositoryCommit, 0) 247 | page := 1 248 | for { 249 | pageCommits, response, err := githubClient.Repositories.ListCommits( 250 | c, 251 | *repo.Owner.Login, 252 | *repo.Name, 253 | &github.CommitsListOptions{ 254 | ListOptions: github.ListOptions{ 255 | Page: page, 256 | PerPage: 100, 257 | }, 258 | Author: *digest.User.Login, 259 | Since: intervalDigest.StartTime.UTC(), 260 | Until: intervalDigest.EndTime.UTC(), 261 | }) 262 | if err != nil { 263 | ch <- &RepoDigestResponse{intervalDigest, repo, nil, err} 264 | return 265 | } 266 | commits = append(commits, pageCommits...) 267 | if response.NextPage == 0 { 268 | break 269 | } 270 | page = response.NextPage 271 | } 272 | digestCommits := make([]DigestCommit, len(commits)) 273 | for i := range commits { 274 | digestCommits[len(commits)-i-1] = newDigestCommit(commits[i], repo, digest.TimezoneLocation) 275 | } 276 | ch <- &RepoDigestResponse{intervalDigest, repo, &RepoDigest{repo, digestCommits}, nil} 277 | }(intervalDigest, repo) 278 | fetchCount++ 279 | } 280 | } 281 | for i := 0; i < fetchCount; i++ { 282 | select { 283 | case r := <-ch: 284 | if r.err != nil { 285 | digest.RepoErrors[*r.repo.FullName] = r.err 286 | continue 287 | } 288 | if len(r.repoDigest.Commits) > 0 { 289 | r.intervalDigest.RepoDigests = append(r.intervalDigest.RepoDigests, r.repoDigest) 290 | digest.CommitCount += len(r.repoDigest.Commits) 291 | } 292 | } 293 | } 294 | nonEmptyIntervalDigests := make([]*IntervalDigest, 0, len(digest.IntervalDigests)) 295 | for _, intervalDigest := range digest.IntervalDigests { 296 | if !intervalDigest.Empty() { 297 | nonEmptyIntervalDigests = append(nonEmptyIntervalDigests, intervalDigest) 298 | sort.Sort(ByRepoFullName(intervalDigest.RepoDigests)) 299 | } 300 | } 301 | digest.IntervalDigests = nonEmptyIntervalDigests 302 | } 303 | 304 | func (digest *Digest) Empty() bool { 305 | return len(digest.IntervalDigests) == 0 306 | } 307 | 308 | func (digest *Digest) Redact() { 309 | for _, intervalDigest := range digest.IntervalDigests { 310 | for _, repoDigest := range intervalDigest.RepoDigests { 311 | *repoDigest.Repo.HTMLURL = "https://redacted" 312 | *repoDigest.Repo.FullName = "redacted/redacted" 313 | for i := range repoDigest.Commits { 314 | commit := &repoDigest.Commits[i] 315 | commit.DisplaySHA = "0000000" 316 | commit.URL = "https://redacted" 317 | commit.Title = "Redacted" 318 | commit.Message = "Redacted redacted redacted" 319 | } 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /app/go.mod: -------------------------------------------------------------------------------- 1 | module persistent.info/retrogit 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.3 6 | 7 | require ( 8 | github.com/google/go-github/v62 v62.0.0 9 | github.com/gorilla/mux v1.8.1 10 | github.com/gorilla/sessions v1.2.2 11 | golang.org/x/oauth2 v0.20.0 12 | google.golang.org/appengine/v2 v2.0.6 13 | ) 14 | 15 | require ( 16 | github.com/golang/protobuf v1.5.0 // indirect 17 | github.com/google/go-querystring v1.1.0 // indirect 18 | github.com/gorilla/securecookie v1.1.2 // indirect 19 | google.golang.org/protobuf v1.33.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /app/go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= 2 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 3 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 4 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= 8 | github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= 9 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 10 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 11 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 12 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 13 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 14 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 15 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 16 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 17 | github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 18 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 19 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 22 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 23 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 24 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 25 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 26 | golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= 27 | golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 28 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 29 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 36 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 39 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 40 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 41 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 42 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 43 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 44 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 46 | google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= 47 | google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= 48 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 49 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 50 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 51 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 52 | -------------------------------------------------------------------------------- /app/index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | # AUTOGENERATED 3 | 4 | # This index.yaml is automatically updated whenever the Cloud Datastore 5 | # emulator detects that a new type of query is run. If you want to manage the 6 | # index.yaml file manually, remove the "# AUTOGENERATED" marker line above. 7 | # If you want to manage some indexes manually, move them above the marker line. 8 | 9 | -------------------------------------------------------------------------------- /app/queue.yaml: -------------------------------------------------------------------------------- 1 | queue: 2 | # Change the refresh rate of the default queue from 5/s to 50/s 3 | - name: default 4 | rate: 50/s 5 | retry_parameters: 6 | task_retry_limit: 5 7 | min_backoff_seconds: 300 8 | -------------------------------------------------------------------------------- /app/recovery.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // From Martini's recovery package: 4 | // https://github.com/go-martini/martini/blob/master/recovery.go 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io/ioutil" 10 | "runtime" 11 | ) 12 | 13 | var ( 14 | dunno = []byte("???") 15 | centerDot = []byte("·") 16 | dot = []byte(".") 17 | slash = []byte("/") 18 | ) 19 | 20 | // stack returns a nicely formatted stack frame, skipping skip frames 21 | func stack(skip int) []byte { 22 | buf := new(bytes.Buffer) // the returned data 23 | // As we loop, we open files and read them. These variables record the currently 24 | // loaded file. 25 | var lines [][]byte 26 | var lastFile string 27 | for i := skip; ; i++ { // Skip the expected number of frames 28 | pc, file, line, ok := runtime.Caller(i) 29 | if !ok { 30 | break 31 | } 32 | // Print this much at least. If we can't find the source, it won't show. 33 | fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) 34 | if file != lastFile { 35 | data, err := ioutil.ReadFile(file) 36 | if err != nil { 37 | continue 38 | } 39 | lines = bytes.Split(data, []byte{'\n'}) 40 | lastFile = file 41 | } 42 | fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line)) 43 | } 44 | return buf.Bytes() 45 | } 46 | 47 | // source returns a space-trimmed slice of the n'th line. 48 | func source(lines [][]byte, n int) []byte { 49 | n-- // in stack trace, lines are 1-indexed but our array is 0-indexed 50 | if n < 0 || n >= len(lines) { 51 | return dunno 52 | } 53 | return bytes.TrimSpace(lines[n]) 54 | } 55 | 56 | // function returns, if possible, the name of the function containing the PC. 57 | func function(pc uintptr) []byte { 58 | fn := runtime.FuncForPC(pc) 59 | if fn == nil { 60 | return dunno 61 | } 62 | name := []byte(fn.Name()) 63 | // The name includes the path name to the package, which is unnecessary 64 | // since the file name is already included. Plus, it has center dots. 65 | // That is, we see 66 | // runtime/debug.*T·ptrmethod 67 | // and want 68 | // *T.ptrmethod 69 | // Also the package path might contains dot (e.g. code.google.com/...), 70 | // so first eliminate the path prefix 71 | if lastslash := bytes.LastIndex(name, slash); lastslash >= 0 { 72 | name = name[lastslash+1:] 73 | } 74 | if period := bytes.Index(name, dot); period >= 0 { 75 | name = name[period+1:] 76 | } 77 | name = bytes.Replace(name, centerDot, dot, -1) 78 | return name 79 | } 80 | -------------------------------------------------------------------------------- /app/repos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "google.golang.org/appengine/v2" 10 | "google.golang.org/appengine/v2/datastore" 11 | "google.golang.org/appengine/v2/delay" 12 | "google.golang.org/appengine/v2/log" 13 | "google.golang.org/appengine/v2/taskqueue" 14 | 15 | "github.com/google/go-github/v62/github" 16 | ) 17 | 18 | const ( 19 | VintageDateFormat = "January 2, 2006" 20 | VintageChunkSize = 1000 21 | ) 22 | 23 | type RepoVintage struct { 24 | UserId int64 `datastore:",noindex"` 25 | RepoId int64 `datastore:",noindex"` 26 | Vintage time.Time `datastore:",noindex"` 27 | } 28 | 29 | func getVintageKey(c context.Context, userId int64, repoId int64) *datastore.Key { 30 | return datastore.NewKey(c, "RepoVintage", fmt.Sprintf("%d-%d", userId, repoId), 0, nil) 31 | } 32 | 33 | type RepoVintageStatsRetries struct { 34 | Count int32 `datastore:",noindex"` 35 | } 36 | 37 | func getVintageStatsRetriesKey(c context.Context, userId int64, repoId int64) *datastore.Key { 38 | return datastore.NewKey(c, "RepoVintageStatsRetries", fmt.Sprintf("%d-%d", userId, repoId), 0, nil) 39 | } 40 | 41 | var computeVintageFunc *delay.Function 42 | 43 | func computeVintage(c context.Context, userId int64, userLogin string, repoId int64, repoOwnerLogin string, repoName string) error { 44 | account, err := getAccount(c, userId) 45 | if err != nil { 46 | log.Errorf(c, "Could not load account %d: %s. Presumed deleted, aborting computing vintage for %s/%s", userId, err.Error(), repoOwnerLogin, repoName) 47 | return nil 48 | } 49 | 50 | githubClient := githubOAuthClient(c, account.OAuthToken) 51 | 52 | repo, response, err := githubClient.Repositories.Get(c, repoOwnerLogin, repoName) 53 | if response.StatusCode == 403 || response.StatusCode == 404 { 54 | log.Warningf(c, "Got a %d when trying to look up %s/%s (%d)", response.StatusCode, repoOwnerLogin, repoName, repoId) 55 | _, err = datastore.Put(c, getVintageKey(c, userId, repoId), &RepoVintage{ 56 | UserId: userId, 57 | RepoId: repoId, 58 | Vintage: time.Unix(0, 0), 59 | }) 60 | return err 61 | } else if err != nil { 62 | log.Errorf(c, "Could not load repo %s/%s (%d): %s", repoOwnerLogin, repoName, repoId, err.Error()) 63 | return err 64 | } 65 | 66 | // Cheap check to see if there are commits before the creation time. 67 | vintage := repo.CreatedAt.UTC() 68 | beforeCreationTime := repo.CreatedAt.UTC().AddDate(0, 0, -1) 69 | commits, response, err := githubClient.Repositories.ListCommits( 70 | c, 71 | repoOwnerLogin, 72 | repoName, 73 | &github.CommitsListOptions{ 74 | ListOptions: github.ListOptions{PerPage: 1}, 75 | Author: userLogin, 76 | Until: beforeCreationTime, 77 | }) 78 | if response != nil && (response.StatusCode == http.StatusConflict || response.StatusCode == http.StatusUnavailableForLegalReasons) { 79 | // GitHub returns a 409 when a repository is empty and a 451 when it's 80 | // blocked by a DMCA takedown. 81 | commits = make([]*github.RepositoryCommit, 0) 82 | } else if response != nil && response.StatusCode >= 500 { 83 | // Avoid retries if GitHub can't load commits (this happens for repos 84 | // like AOSiP-Devices/kernel_xiaomi_laurel_sprout, presumably because 85 | // they have too many commits). 86 | log.Warningf(c, "Could not load commits for repo %s (%d), not retrying: %s", *repo.FullName, repoId, err.Error()) 87 | commits = make([]*github.RepositoryCommit, 0) 88 | } else if err != nil { 89 | log.Errorf(c, "Could not load commits for repo %s (%d), will retry: %s", *repo.FullName, repoId, err.Error()) 90 | return err 91 | } 92 | 93 | // If there are, then we use the contributor stats API to figure out when 94 | // the user's first commit in the repository was. 95 | if len(commits) > 0 { 96 | stats, response, err := githubClient.Repositories.ListContributorsStats(c, repoOwnerLogin, repoName) 97 | if err != nil && (response == nil || response.StatusCode != 202) { 98 | log.Errorf(c, "Could not load stats for repo %s: %s", *repo.FullName, err.Error()) 99 | return err 100 | } 101 | // GitHub says that stats may not be immediately available, and that it 102 | // will return a 202 status code if it is still computing them. In 103 | // practice as of mid-2024 the stats are only recomputed if the user visits 104 | // the web UI, so we give up retrying after a while. 105 | if response.StatusCode == 202 { 106 | retriesKey := getVintageStatsRetriesKey(c, userId, repoId) 107 | var retries RepoVintageStatsRetries 108 | if err := datastore.Get(c, retriesKey, &retries); err != nil { 109 | if err == datastore.ErrNoSuchEntity { 110 | retries = RepoVintageStatsRetries{0} 111 | } else { 112 | log.Errorf(c, "Could not load retries for repo %s: %s", *repo.FullName, err.Error()) 113 | return err 114 | } 115 | } 116 | 117 | if retries.Count < 5 { 118 | retries.Count++ 119 | _, err = datastore.Put(c, retriesKey, &retries) 120 | if err != nil { 121 | log.Errorf(c, "Could not save retries for repo %s: %v", *repo.FullName, err) 122 | } 123 | log.Infof(c, "Stats were not available for %s, will try again later (attempt %d)", *repo.FullName, retries.Count) 124 | task, err := computeVintageFunc.Task(userId, userLogin, repoId, repoOwnerLogin, repoName) 125 | if err != nil { 126 | log.Errorf(c, "Could create delayed task for %s: %s", *repo.FullName, err.Error()) 127 | return err 128 | } 129 | task.Delay = time.Second * 10 * time.Duration(retries.Count) 130 | taskqueue.Add(c, task, "") 131 | return nil 132 | } else { 133 | log.Errorf(c, "Stats were not available for %s after %d attempts, giving up", *repo.FullName, retries.Count) 134 | } 135 | } else { 136 | for _, stat := range stats { 137 | if *stat.Author.ID == userId { 138 | for i := range stat.Weeks { 139 | weekTimestamp := stat.Weeks[i].Week.UTC() 140 | if weekTimestamp.Before(vintage) { 141 | vintage = weekTimestamp 142 | } 143 | } 144 | break 145 | } 146 | } 147 | } 148 | } 149 | 150 | _, err = datastore.Put(c, getVintageKey(c, userId, repoId), &RepoVintage{ 151 | UserId: userId, 152 | RepoId: repoId, 153 | Vintage: vintage, 154 | }) 155 | if err != nil { 156 | log.Errorf(c, "Could save vintage for repo %s: %s", *repo.FullName, err.Error()) 157 | return err 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func init() { 164 | computeVintageFunc = delay.MustRegister("computeVintage", computeVintage) 165 | } 166 | 167 | func fillVintages(c context.Context, user *github.User, repos []*Repo) error { 168 | if len(repos) > VintageChunkSize { 169 | for chunkStart := 0; chunkStart < len(repos); chunkStart += VintageChunkSize { 170 | chunkEnd := chunkStart + VintageChunkSize 171 | if chunkEnd > len(repos) { 172 | chunkEnd = len(repos) 173 | } 174 | err := fillVintages(c, user, repos[chunkStart:chunkEnd]) 175 | if err != nil { 176 | return err 177 | } 178 | } 179 | return nil 180 | } 181 | keys := make([]*datastore.Key, len(repos)) 182 | for i := range repos { 183 | keys[i] = getVintageKey(c, *user.ID, *repos[i].ID) 184 | } 185 | vintages := make([]*RepoVintage, len(repos)) 186 | for i := range vintages { 187 | vintages[i] = new(RepoVintage) 188 | } 189 | err := datastore.GetMulti(c, keys, vintages) 190 | if err != nil { 191 | if errs, ok := err.(appengine.MultiError); ok { 192 | for i, err := range errs { 193 | if err == datastore.ErrNoSuchEntity { 194 | vintages[i] = nil 195 | } else if err != nil { 196 | log.Errorf(c, "%d/%s vintage fetch error: %s", i, *repos[i].FullName, err.Error()) 197 | return err 198 | } 199 | } 200 | } else { 201 | return err 202 | } 203 | } 204 | for i := range vintages { 205 | repo := repos[i] 206 | vintage := vintages[i] 207 | if vintage != nil { 208 | if vintage.Vintage.Unix() != 0 { 209 | repo.Vintage = vintage.Vintage 210 | } 211 | continue 212 | } 213 | computeVintageFunc.Call(c, *user.ID, *user.Login, *repo.ID, *repo.Owner.Login, *repo.Name) 214 | } 215 | return nil 216 | } 217 | 218 | type Repos struct { 219 | AllRepos []*Repo 220 | UserRepos []*Repo 221 | OtherUserRepos []*UserRepos 222 | OldestVintage time.Time 223 | } 224 | 225 | func (repos *Repos) Redact() { 226 | for _, repo := range repos.UserRepos { 227 | *repo.HTMLURL = "https://redacted" 228 | *repo.FullName = "redacted/redacted" 229 | } 230 | for _, otherUserRepos := range repos.OtherUserRepos { 231 | *otherUserRepos.User.Login = "redacted" 232 | *otherUserRepos.User.AvatarURL = "https://redacted" 233 | for _, repo := range otherUserRepos.Repos { 234 | *repo.HTMLURL = "https://redacted" 235 | *repo.FullName = "redacted/redacted" 236 | } 237 | } 238 | } 239 | 240 | type Repo struct { 241 | *github.Repository 242 | Vintage time.Time 243 | IncludeInDigest bool 244 | } 245 | 246 | func newRepo(githubRepo *github.Repository, account *Account) *Repo { 247 | return &Repo{ 248 | Repository: githubRepo, 249 | Vintage: githubRepo.CreatedAt.UTC(), 250 | IncludeInDigest: !account.IsRepoIdExcluded(*githubRepo.ID), 251 | } 252 | } 253 | 254 | func (repo *Repo) TypeAsOcticonName() string { 255 | if *repo.Fork { 256 | return "repo-forked" 257 | } 258 | if *repo.Private { 259 | return "lock" 260 | } 261 | return "repo" 262 | } 263 | 264 | func (repo *Repo) TypeAsClassName() string { 265 | if *repo.Fork { 266 | return "fork" 267 | } 268 | if *repo.Private { 269 | return "private" 270 | } 271 | return "" 272 | } 273 | 274 | func (repo *Repo) DisplayVintage() string { 275 | return repo.Vintage.Format(VintageDateFormat) 276 | } 277 | 278 | type UserRepos struct { 279 | User *github.User 280 | Repos []*Repo 281 | } 282 | 283 | func getRepos(c context.Context, githubClient *github.Client, account *Account, user *github.User) (*Repos, error) { 284 | clientUserRepos := make([]*github.Repository, 0) 285 | page := 1 286 | for { 287 | pageClientUserRepos, response, err := githubClient.Repositories.List( 288 | c, 289 | // The username parameter must be left blank so that we can get all 290 | // of the repositories the user has access to, not just ones that 291 | // they own. 292 | "", 293 | &github.RepositoryListOptions{ 294 | ListOptions: github.ListOptions{ 295 | Page: page, 296 | PerPage: 100, 297 | }, 298 | }) 299 | if err != nil { 300 | return nil, err 301 | } 302 | clientUserRepos = append(clientUserRepos, pageClientUserRepos...) 303 | if response.NextPage == 0 { 304 | break 305 | } 306 | page = response.NextPage 307 | } 308 | 309 | repos := &Repos{} 310 | repos.UserRepos = make([]*Repo, 0, len(clientUserRepos)) 311 | repos.OtherUserRepos = make([]*UserRepos, 0) 312 | for i := range clientUserRepos { 313 | ownerID := *clientUserRepos[i].Owner.ID 314 | if ownerID == *user.ID { 315 | repos.UserRepos = append(repos.UserRepos, newRepo(clientUserRepos[i], account)) 316 | } else { 317 | var userRepos *UserRepos 318 | for j := range repos.OtherUserRepos { 319 | if *repos.OtherUserRepos[j].User.ID == ownerID { 320 | userRepos = repos.OtherUserRepos[j] 321 | break 322 | } 323 | } 324 | if userRepos == nil { 325 | userRepos = &UserRepos{ 326 | User: clientUserRepos[i].Owner, 327 | Repos: make([]*Repo, 0), 328 | } 329 | repos.OtherUserRepos = append(repos.OtherUserRepos, userRepos) 330 | } 331 | userRepos.Repos = append(userRepos.Repos, newRepo(clientUserRepos[i], account)) 332 | } 333 | } 334 | 335 | repos.AllRepos = make([]*Repo, 0, len(clientUserRepos)) 336 | repos.AllRepos = append(repos.AllRepos, repos.UserRepos...) 337 | for _, userRepos := range repos.OtherUserRepos { 338 | repos.AllRepos = append(repos.AllRepos, userRepos.Repos...) 339 | } 340 | 341 | err := fillVintages(c, user, repos.AllRepos) 342 | if err != nil { 343 | return nil, err 344 | } 345 | 346 | repos.OldestVintage = time.Now().UTC() 347 | for _, repo := range repos.AllRepos { 348 | repoVintage := repo.Vintage 349 | if repoVintage.Before(repos.OldestVintage) { 350 | repos.OldestVintage = repoVintage 351 | } 352 | } 353 | 354 | return repos, nil 355 | } 356 | -------------------------------------------------------------------------------- /app/retrogit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | log_ "log" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "google.golang.org/appengine/v2" 18 | "google.golang.org/appengine/v2/datastore" 19 | "google.golang.org/appengine/v2/delay" 20 | "google.golang.org/appengine/v2/log" 21 | "google.golang.org/appengine/v2/mail" 22 | "google.golang.org/appengine/v2/urlfetch" 23 | 24 | "github.com/google/go-github/v62/github" 25 | "github.com/gorilla/mux" 26 | "github.com/gorilla/sessions" 27 | "golang.org/x/oauth2" 28 | ) 29 | 30 | var router *mux.Router 31 | var githubOauthConfig oauth2.Config 32 | var githubOauthPublicConfig oauth2.Config 33 | var timezones Timezones 34 | var sessionStore *sessions.CookieStore 35 | var sessionConfig SessionConfig 36 | var templates map[string]*Template 37 | 38 | func main() { 39 | templates = loadTemplates() 40 | timezones = initTimezones() 41 | sessionStore, sessionConfig = initSession() 42 | githubOauthConfig = initGithubOAuthConfig(true) 43 | githubOauthPublicConfig = initGithubOAuthConfig(false) 44 | 45 | router = mux.NewRouter() 46 | router.Handle("/", AppHandler(indexHandler)).Name("index") 47 | router.Handle("/faq", AppHandler(faqHandler)).Name("faq") 48 | 49 | router.Handle("/session/sign-in", AppHandler(signInHandler)).Name("sign-in").Methods("POST") 50 | router.Handle("/session/sign-out", AppHandler(signOutHandler)).Name("sign-out").Methods("POST") 51 | router.Handle("/github/callback", AppHandler(githubOAuthCallbackHandler)) 52 | 53 | router.Handle("/digest/view", SignedInAppHandler(viewDigestHandler)).Name("view-digest") 54 | router.Handle("/digest/send", SignedInAppHandler(sendDigestHandler)).Name("send-digest").Methods("POST") 55 | router.Handle("/digest/cron", AppHandler(digestCronHandler)) 56 | 57 | router.Handle("/account/settings", SignedInAppHandler(settingsHandler)).Name("settings").Methods("GET") 58 | router.Handle("/account/settings", SignedInAppHandler(saveSettingsHandler)).Name("save-settings").Methods("POST") 59 | router.Handle("/account/set-initial-timezone", SignedInAppHandler(setInitialTimezoneHandler)).Name("set-initial-timezone").Methods("POST") 60 | router.Handle("/account/delete", SignedInAppHandler(deleteAccountHandler)).Name("delete-account").Methods("POST") 61 | 62 | router.Handle("/admin/users", AppHandler(usersAdminHandler)).Name("users-admin") 63 | router.Handle("/admin/digest", AppHandler(digestAdminHandler)).Name("digest-admin") 64 | router.Handle("/admin/repos", AppHandler(reposAdminHandler)).Name("repos-admin") 65 | router.Handle("/admin/delete-account", AppHandler(deleteAccountAdminHandler)).Name("delete-account-admin") 66 | http.Handle("/", router) 67 | 68 | appengine.Main() 69 | } 70 | 71 | func initGithubOAuthConfig(includePrivateRepos bool) (config oauth2.Config) { 72 | path := "config/github-oauth" 73 | if appengine.IsDevAppServer() { 74 | path += "-dev" 75 | } 76 | path += ".json" 77 | configBytes, err := ioutil.ReadFile(path) 78 | if err != nil { 79 | log_.Panicf("Could not read GitHub OAuth config from %s: %s", path, err.Error()) 80 | } 81 | err = json.Unmarshal(configBytes, &config) 82 | if err != nil { 83 | log_.Panicf("Could not parse GitHub OAuth config %s: %s", configBytes, err.Error()) 84 | } 85 | repoScopeModifier := "" 86 | if !includePrivateRepos { 87 | repoScopeModifier = "public_" 88 | } 89 | config.Scopes = []string{fmt.Sprintf("%srepo", repoScopeModifier), "user:email"} 90 | config.Endpoint = oauth2.Endpoint{ 91 | AuthURL: "https://github.com/login/oauth/authorize", 92 | TokenURL: "https://github.com/login/oauth/access_token", 93 | } 94 | return 95 | } 96 | 97 | func indexHandler(w http.ResponseWriter, r *http.Request) *AppError { 98 | session, _ := sessionStore.Get(r, sessionConfig.CookieName) 99 | userId, ok := session.Values[sessionConfig.UserIdKey].(int64) 100 | if !ok { 101 | data := map[string]interface{}{ 102 | "ContinueUrl": r.FormValue("continue_url"), 103 | } 104 | return templates["index-signed-out"].Render(w, data) 105 | } 106 | c := appengine.NewContext(r) 107 | account, err := getAccount(c, userId) 108 | if account == nil { 109 | // Can't look up the account, session cookie must be invalid, clear it. 110 | session.Options.MaxAge = -1 111 | session.Save(r, w) 112 | return RedirectToRoute("index") 113 | } 114 | if err != nil { 115 | return InternalError(err, "Could not look up account") 116 | } 117 | 118 | githubClient := githubOAuthClient(c, account.OAuthToken) 119 | 120 | var wg sync.WaitGroup 121 | wg.Add(2) 122 | var user *github.User 123 | var userErr error 124 | var emailAddress string 125 | var emailAddressErr error 126 | go func() { 127 | user, _, userErr = githubClient.Users.Get(c, "") 128 | wg.Done() 129 | }() 130 | go func() { 131 | emailAddress, emailAddressErr = account.GetDigestEmailAddress(c, githubClient) 132 | wg.Done() 133 | }() 134 | wg.Wait() 135 | if userErr != nil { 136 | return GitHubFetchError(userErr, "user") 137 | } 138 | if emailAddressErr != nil { 139 | return GitHubFetchError(userErr, "emails") 140 | } 141 | 142 | var repositoryCount string 143 | if len(account.ExcludedRepoIds) > 0 { 144 | repositoryCount = fmt.Sprintf("all but %d", len(account.ExcludedRepoIds)) 145 | } else { 146 | repositoryCount = "all" 147 | } 148 | 149 | var settingsSummary = map[string]interface{}{ 150 | "Frequency": account.Frequency, 151 | "RepositoryCount": repositoryCount, 152 | "EmailAddress": emailAddress, 153 | } 154 | var data = map[string]interface{}{ 155 | "User": user, 156 | "SettingsSummary": settingsSummary, 157 | "DetectTimezone": !account.HasTimezoneSet, 158 | } 159 | return templates["index"].Render(w, data, &AppSignedInState{ 160 | Account: account, 161 | GitHubClient: githubClient, 162 | session: session, 163 | responseWriter: w, 164 | request: r, 165 | }) 166 | } 167 | 168 | func faqHandler(w http.ResponseWriter, r *http.Request) *AppError { 169 | return templates["faq"].Render(w, nil) 170 | } 171 | 172 | func signInHandler(w http.ResponseWriter, r *http.Request) *AppError { 173 | config := &githubOauthConfig 174 | if r.FormValue("include_private") != "1" { 175 | config = &githubOauthPublicConfig 176 | } 177 | authCodeUrl := config.AuthCodeURL("") 178 | if continueUrl := r.FormValue("continue_url"); continueUrl != "" { 179 | if parsedAuthCodeUrl, err := url.Parse(authCodeUrl); err == nil { 180 | authCodeQuery := parsedAuthCodeUrl.Query() 181 | redirectUrl := authCodeQuery.Get("redirect_uri") 182 | if parsedRedirectUrl, err := url.Parse(redirectUrl); err == nil { 183 | redirectUrlQuery := parsedRedirectUrl.Query() 184 | redirectUrlQuery.Set("continue_url", continueUrl) 185 | parsedRedirectUrl.RawQuery = redirectUrlQuery.Encode() 186 | authCodeQuery.Set("redirect_uri", parsedRedirectUrl.String()) 187 | parsedAuthCodeUrl.RawQuery = authCodeQuery.Encode() 188 | authCodeUrl = parsedAuthCodeUrl.String() 189 | } 190 | } 191 | } 192 | return RedirectToUrl(authCodeUrl) 193 | } 194 | 195 | func signOutHandler(w http.ResponseWriter, r *http.Request) *AppError { 196 | session, _ := sessionStore.Get(r, sessionConfig.CookieName) 197 | session.Options.MaxAge = -1 198 | session.Save(r, w) 199 | return RedirectToRoute("index") 200 | } 201 | 202 | func viewDigestHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 203 | c := appengine.NewContext(r) 204 | digest, err := newDigest(c, state.GitHubClient, state.Account) 205 | if err != nil { 206 | return GitHubFetchError(err, "digest") 207 | } 208 | var data = map[string]interface{}{ 209 | "Digest": digest, 210 | } 211 | return templates["digest-page"].Render(w, data, state) 212 | } 213 | 214 | func sendDigestHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 215 | c := appengine.NewContext(r) 216 | sent, err := sendDigestForAccount(state.Account, c) 217 | if err != nil { 218 | return InternalError(err, "Could not send digest") 219 | } 220 | 221 | if sent { 222 | state.AddFlash("Digest emailed!") 223 | } else { 224 | state.AddFlash("No digest was sent, it was empty or disabled.") 225 | } 226 | return RedirectToRoute("index") 227 | } 228 | 229 | func digestCronHandler(w http.ResponseWriter, r *http.Request) *AppError { 230 | c := appengine.NewContext(r) 231 | accounts, err := getAllAccounts(c) 232 | if err != nil { 233 | return InternalError(err, "Could not look up accounts") 234 | } 235 | for _, account := range accounts { 236 | if account.Frequency == "weekly" { 237 | now := time.Now().In(account.TimezoneLocation) 238 | if now.Weekday() != account.WeeklyDay { 239 | log.Infof(c, "Skipping %d, since it wants weekly digests on %ss and today is a %s.", 240 | account.GitHubUserId, account.WeeklyDay, now.Weekday()) 241 | continue 242 | } 243 | } 244 | log.Infof(c, "Enqueuing task for %d...", account.GitHubUserId) 245 | sendDigestForAccountFunc.Call(c, account.GitHubUserId) 246 | } 247 | fmt.Fprint(w, "Done") 248 | return nil 249 | } 250 | 251 | var sendDigestForAccountFunc = delay.MustRegister( 252 | "sendDigestForAccount", 253 | func(c context.Context, githubUserId int64) error { 254 | log.Infof(c, "Sending digest for %d...", githubUserId) 255 | account, err := getAccount(c, githubUserId) 256 | if err != nil { 257 | log.Errorf(c, " Error looking up account: %s", err.Error()) 258 | return err 259 | } 260 | sent, err := sendDigestForAccount(account, c) 261 | if err != nil { 262 | log.Errorf(c, " Error: %s", err.Error()) 263 | if !appengine.IsDevAppServer() { 264 | sendDigestErrorMail(err, c, githubUserId) 265 | } 266 | } else if sent { 267 | log.Infof(c, " Sent!") 268 | } else { 269 | log.Infof(c, " Not sent, digest was empty") 270 | } 271 | return err 272 | }) 273 | 274 | func sendDigestErrorMail(e error, c context.Context, gitHubUserId int64) { 275 | if strings.Contains(e.Error(), ": 502") { 276 | // Ignore 502s from GitHub, there's nothing we do about them. 277 | return 278 | } 279 | if appengine.IsTimeoutError(e) || 280 | strings.Contains(e.Error(), "DEADLINE_EXCEEDED") { 281 | // Ignore deadline exceeded errors for URL fetches 282 | return 283 | } 284 | 285 | errorMessage := &mail.Message{ 286 | Sender: "RetroGit Admin ", 287 | To: []string{"mihai.parparita@gmail.com"}, 288 | Subject: fmt.Sprintf("RetroGit Digest Send Error for %d", gitHubUserId), 289 | Body: fmt.Sprintf("Error: %s", e), 290 | } 291 | err := mail.Send(c, errorMessage) 292 | if err != nil { 293 | log.Errorf(c, "Error %s sending error email.", err.Error()) 294 | } 295 | } 296 | 297 | func sendDigestForAccount(account *Account, c context.Context) (bool, error) { 298 | githubClient := githubOAuthClient(c, account.OAuthToken) 299 | 300 | emailAddress, err := account.GetDigestEmailAddress(c, githubClient) 301 | if err != nil { 302 | if gitHubError, ok := (err).(*github.ErrorResponse); ok { 303 | gitHubStatus := gitHubError.Response.StatusCode 304 | if gitHubStatus == http.StatusUnauthorized || 305 | gitHubStatus == http.StatusForbidden { 306 | log.Errorf(c, " GitHub auth error while getting email address, skipping: %s", err.Error()) 307 | return false, nil 308 | } 309 | } 310 | 311 | return false, err 312 | } 313 | if emailAddress == "disabled" { 314 | return false, nil 315 | } 316 | 317 | digest, err := newDigest(c, githubClient, account) 318 | if err != nil { 319 | if gitHubError, ok := (err).(*github.ErrorResponse); ok { 320 | gitHubStatus := gitHubError.Response.StatusCode 321 | if gitHubStatus == http.StatusUnauthorized || 322 | gitHubStatus == http.StatusForbidden { 323 | log.Errorf(c, " GitHub auth error while getting digest, sending error email: %s", err.Error()) 324 | var authErrorHtml bytes.Buffer 325 | if err := templates["github-auth-error-email"].Execute(&authErrorHtml, nil); err != nil { 326 | return false, err 327 | } 328 | 329 | digestMessage := &mail.Message{ 330 | Sender: "RetroGit ", 331 | To: []string{emailAddress}, 332 | Subject: "RetroGit Digest Error", 333 | HTMLBody: authErrorHtml.String(), 334 | } 335 | err = mail.Send(c, digestMessage) 336 | return false, err 337 | } 338 | } 339 | return false, err 340 | } 341 | if digest.Empty() { 342 | return false, nil 343 | } 344 | 345 | var data = map[string]interface{}{ 346 | "Digest": digest, 347 | } 348 | var digestHtml bytes.Buffer 349 | if err := templates["digest-email"].Execute(&digestHtml, data); err != nil { 350 | return false, err 351 | } 352 | 353 | digestMessage := &mail.Message{ 354 | Sender: "RetroGit ", 355 | To: []string{emailAddress}, 356 | Subject: "RetroGit Digest", 357 | HTMLBody: digestHtml.String(), 358 | } 359 | err = mail.Send(c, digestMessage) 360 | return true, err 361 | } 362 | 363 | func githubOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) *AppError { 364 | code := r.FormValue("code") 365 | c := appengine.NewContext(r) 366 | token, err := githubOauthConfig.Exchange(c, code) 367 | if err != nil { 368 | return InternalError(err, "Could not exchange OAuth code") 369 | } 370 | 371 | githubClient := githubOAuthClient(c, *token) 372 | user, _, err := githubClient.Users.Get(c, "") 373 | if err != nil { 374 | return GitHubFetchError(err, "user") 375 | } 376 | 377 | account, err := getAccount(c, *user.ID) 378 | if err != nil && err != datastore.ErrNoSuchEntity { 379 | return InternalError(err, "Could not look up user") 380 | } 381 | if account == nil { 382 | account = &Account{GitHubUserId: *user.ID} 383 | } 384 | account.OAuthToken = *token 385 | // Persist the default email address now, both to avoid additional lookups 386 | // later and to have a way to contact the user if they ever revoke their 387 | // OAuth token. 388 | emailAddress, err := account.GetDigestEmailAddress(c, githubClient) 389 | if err == nil && len(emailAddress) > 0 { 390 | account.DigestEmailAddress = emailAddress 391 | } 392 | err = account.Put(c) 393 | if err != nil { 394 | return InternalError(err, "Could not save user") 395 | } 396 | 397 | session, _ := sessionStore.Get(r, sessionConfig.CookieName) 398 | session.Values[sessionConfig.UserIdKey] = user.ID 399 | session.Save(r, w) 400 | continueUrl := r.FormValue("continue_url") 401 | if continueUrl != "" { 402 | continueUrlParsed, err := url.Parse(continueUrl) 403 | if err != nil || continueUrlParsed.Host != r.URL.Host { 404 | continueUrl = "" 405 | } 406 | } 407 | if continueUrl == "" { 408 | indexUrl, _ := router.Get("index").URL() 409 | continueUrl = indexUrl.String() 410 | } 411 | return RedirectToUrl(continueUrl) 412 | } 413 | 414 | func settingsHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 415 | c := appengine.NewContext(r) 416 | user, _, err := state.GitHubClient.Users.Get(c, "") 417 | if err != nil { 418 | return GitHubFetchError(err, "user") 419 | } 420 | 421 | repos, err := getRepos(c, state.GitHubClient, state.Account, user) 422 | if err != nil { 423 | return GitHubFetchError(err, "repositories") 424 | } 425 | 426 | emails, _, err := state.GitHubClient.Users.ListEmails(c, nil) 427 | if err != nil { 428 | return GitHubFetchError(err, "emails") 429 | } 430 | emailAddresses := make([]string, len(emails)) 431 | for i := range emails { 432 | emailAddresses[i] = *emails[i].Email 433 | } 434 | accountEmailAddress, err := state.Account.GetDigestEmailAddress(c, state.GitHubClient) 435 | if err != nil { 436 | return GitHubFetchError(err, "emails") 437 | } 438 | 439 | var data = map[string]interface{}{ 440 | "Account": state.Account, 441 | "User": user, 442 | "Timezones": timezones, 443 | "Repos": repos, 444 | "EmailAddresses": emailAddresses, 445 | "AccountEmailAddress": accountEmailAddress, 446 | } 447 | return templates["settings"].Render(w, data, state) 448 | } 449 | 450 | func saveSettingsHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 451 | c := appengine.NewContext(r) 452 | account := state.Account 453 | 454 | user, _, err := state.GitHubClient.Users.Get(c, "") 455 | if err != nil { 456 | return GitHubFetchError(err, "user") 457 | } 458 | 459 | repos, err := getRepos(c, state.GitHubClient, account, user) 460 | if err != nil { 461 | return GitHubFetchError(err, "repos") 462 | } 463 | 464 | account.Frequency = r.FormValue("frequency") 465 | weeklyDay, err := strconv.Atoi(r.FormValue("weekly_day")) 466 | if err != nil { 467 | return BadRequest(err, "Malformed weekly_day value") 468 | } 469 | account.WeeklyDay = time.Weekday(weeklyDay) 470 | 471 | timezoneName := r.FormValue("timezone_name") 472 | _, err = time.LoadLocation(timezoneName) 473 | if err != nil { 474 | return BadRequest(err, "Malformed timezone_name value") 475 | } 476 | account.TimezoneName = timezoneName 477 | 478 | account.ExcludedRepoIds = make([]int64, 0) 479 | for _, repo := range repos.AllRepos { 480 | repoId := *repo.ID 481 | _, included := r.Form[fmt.Sprintf("repo-%d", repoId)] 482 | if !included { 483 | account.ExcludedRepoIds = append(account.ExcludedRepoIds, repoId) 484 | } 485 | } 486 | 487 | account.DigestEmailAddress = r.FormValue("email_address") 488 | 489 | err = account.Put(c) 490 | if err != nil { 491 | return InternalError(err, "Could not save user") 492 | } 493 | 494 | state.AddFlash("Settings saved.") 495 | return RedirectToRoute("settings") 496 | } 497 | 498 | func setInitialTimezoneHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 499 | c := appengine.NewContext(r) 500 | account := state.Account 501 | 502 | timezoneName := r.FormValue("timezone_name") 503 | _, err := time.LoadLocation(timezoneName) 504 | if err != nil { 505 | return BadRequest(err, "Malformed timezone_name value") 506 | } 507 | account.TimezoneName = timezoneName 508 | 509 | err = account.Put(c) 510 | if err != nil { 511 | return InternalError(err, "Could not save user") 512 | } 513 | 514 | // Since we've now computed an initial timezone for the user, start a 515 | // background task to compute their digest. This ensures that we have most 516 | // of the relevant data already cached if they choose to view or email their 517 | // digest immediately. 518 | cacheDigestForAccountFunc.Call(c, account.GitHubUserId) 519 | 520 | return nil 521 | } 522 | 523 | var cacheDigestForAccountFunc = delay.MustRegister( 524 | "cacheDigestForAccount", 525 | func(c context.Context, githubUserId int64) error { 526 | log.Infof(c, "Caching digest for %d...", githubUserId) 527 | account, err := getAccount(c, githubUserId) 528 | if err != nil { 529 | log.Errorf(c, " Error looking up account: %s", err.Error()) 530 | // Not returning error since we don't want these tasks to be 531 | // retried. 532 | return nil 533 | } 534 | 535 | githubClient := githubOAuthClient(c, account.OAuthToken) 536 | _, err = newDigest(c, githubClient, account) 537 | if err != nil { 538 | log.Errorf(c, " Error computing digest: %s", err.Error()) 539 | } 540 | log.Infof(c, " Done!") 541 | return nil 542 | }) 543 | 544 | func deleteAccountHandler(w http.ResponseWriter, r *http.Request, state *AppSignedInState) *AppError { 545 | c := appengine.NewContext(r) 546 | state.Account.Delete(c) 547 | state.ClearSession() 548 | return RedirectToRoute("index") 549 | } 550 | 551 | func githubOAuthClient(ctx context.Context, token oauth2.Token) *github.Client { 552 | ctx_with_timeout, _ := context.WithTimeout(ctx, time.Second*60) 553 | appengineTransport := &urlfetch.Transport{Context: ctx_with_timeout} 554 | cachingTransport := &CachingTransport{ 555 | Transport: appengineTransport, 556 | Context: ctx_with_timeout, 557 | } 558 | 559 | tokenSource := githubOauthConfig.TokenSource(ctx_with_timeout, &token) 560 | httpClient := &http.Client{ 561 | Transport: &oauth2.Transport{ 562 | Base: cachingTransport, 563 | Source: oauth2.ReuseTokenSource(nil, tokenSource), 564 | }, 565 | } 566 | 567 | return github.NewClient(httpClient) 568 | } 569 | -------------------------------------------------------------------------------- /app/session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | 9 | "google.golang.org/appengine/v2" 10 | 11 | "github.com/gorilla/sessions" 12 | ) 13 | 14 | type SessionConfig struct { 15 | AuthenticationKey string 16 | EncryptionKey string 17 | CookieName string 18 | UserIdKey string 19 | } 20 | 21 | func initSession() (sessionStore *sessions.CookieStore, sessionConfig SessionConfig) { 22 | configBytes, err := ioutil.ReadFile("config/session.json") 23 | if err != nil { 24 | log.Panicf("Could not read session config: %s", err.Error()) 25 | } 26 | err = json.Unmarshal(configBytes, &sessionConfig) 27 | if err != nil { 28 | log.Panicf("Could not parse session config %s: %s", configBytes, err.Error()) 29 | } 30 | 31 | authenticationKey, err := base64.StdEncoding.DecodeString(sessionConfig.AuthenticationKey) 32 | if err != nil { 33 | log.Panicf("Could not decode session config authentication key %s: %s", sessionConfig.AuthenticationKey, err.Error()) 34 | } 35 | encryptionKey, err := base64.StdEncoding.DecodeString(sessionConfig.EncryptionKey) 36 | if err != nil { 37 | log.Panicf("Could not decode session config encryption key %s: %s", sessionConfig.EncryptionKey, err.Error()) 38 | } 39 | 40 | sessionStore = sessions.NewCookieStore(authenticationKey, encryptionKey) 41 | sessionStore.Options.Path = "/" 42 | sessionStore.Options.MaxAge = 86400 * 30 43 | sessionStore.Options.HttpOnly = true 44 | sessionStore.Options.Secure = !appengine.IsDevAppServer() 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /app/static/admin.css: -------------------------------------------------------------------------------- 1 | #users-table { 2 | border-collapse: collapse; 3 | } 4 | 5 | #users-table th, 6 | #users-table td { 7 | padding: 2px 5px; 8 | } 9 | 10 | #users-table th { 11 | text-align: left; 12 | } 13 | 14 | #users-table td { 15 | border: solid 1px #eee; 16 | font-size: 14px; 17 | } 18 | 19 | #users-table .avatar { 20 | padding-right: 1px; 21 | vertical-align: text-bottom; 22 | } 23 | -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/favicon.ico -------------------------------------------------------------------------------- /app/static/images/body-shadow@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/body-shadow@2x.png -------------------------------------------------------------------------------- /app/static/images/card-background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/card-background.jpg -------------------------------------------------------------------------------- /app/static/images/card-background@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/card-background@2x.jpg -------------------------------------------------------------------------------- /app/static/images/header.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/header.jpg -------------------------------------------------------------------------------- /app/static/images/header@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/header@2x.jpg -------------------------------------------------------------------------------- /app/static/images/screenshot-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/screenshot-thumbnail.png -------------------------------------------------------------------------------- /app/static/images/screenshot-thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/screenshot-thumbnail@2x.png -------------------------------------------------------------------------------- /app/static/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/images/screenshot.png -------------------------------------------------------------------------------- /app/static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Helvetica, Arial, sans-serif; 3 | font-size: 10pt; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | a { 9 | color: #4183c4; 10 | text-decoration: none; 11 | } 12 | 13 | a:hover { 14 | text-decoration: underline; 15 | } 16 | 17 | .header { 18 | position: relative; 19 | height: 164px; 20 | overflow: hidden; 21 | background: #eee; 22 | } 23 | 24 | .header:after { 25 | content: ""; 26 | position: absolute; 27 | left: 0; 28 | right: 0; 29 | height: 4px; 30 | bottom: 0; 31 | background: url("images/body-shadow@2x.png"); 32 | background-size: 4px 4px; 33 | } 34 | 35 | .header img { 36 | border: 0; 37 | display: block; 38 | position: absolute; 39 | top: 10px; 40 | left: 50%; 41 | margin-left: -555px; 42 | } 43 | 44 | .card-background { 45 | position: absolute; 46 | top: 0; 47 | left: 50%; 48 | margin-left: -555px; 49 | z-index: -1; 50 | } 51 | 52 | @media (max-width: 600px) { 53 | .header { 54 | height: 104px; 55 | } 56 | 57 | .header img { 58 | top: -24px; 59 | } 60 | 61 | .card-background { 62 | top: -10px; 63 | } 64 | } 65 | 66 | .body { 67 | padding: 10px; 68 | max-width: 1102px; 69 | margin: 0 auto; 70 | position: relative; 71 | } 72 | 73 | .body h1 { 74 | margin-top: 0; 75 | color: #756344; 76 | } 77 | 78 | .body h1:empty { 79 | display: none; 80 | } 81 | 82 | .blurb { 83 | font-size: 16px; 84 | line-height: 24px; 85 | margin: 1em 15px; 86 | } 87 | 88 | .blurb .avatar { 89 | vertical-align: text-bottom; 90 | padding-right: 3px; 91 | } 92 | 93 | .action-button { 94 | outline: 0; 95 | -webkit-appearance: none; 96 | border: dashed 2px #b52e26; 97 | color: #b52e26; 98 | font-family: Courier, monospace; 99 | background: transparent; 100 | border-radius: 8px; 101 | font-size: 18pt; 102 | font-weight: bold; 103 | padding: 3px 5px; 104 | cursor: pointer; 105 | background: #fefcef; 106 | } 107 | 108 | .action-button:active { 109 | background: #fff193; 110 | } 111 | 112 | form.inline { 113 | display: inline; 114 | } 115 | 116 | input[type="submit"].inline { 117 | -webkit-appearance: none; 118 | display: inline; 119 | background: transparent; 120 | padding: 0; 121 | border: 0; 122 | font-family: Helvetica, Arial, sans-serif; 123 | font-size: inherit; 124 | color: #4183c4; 125 | cursor: pointer; 126 | } 127 | 128 | input[type="submit"].inline:hover { 129 | text-decoration: underline; 130 | } 131 | 132 | input[type="submit"].inline.destructive { 133 | text-decoration: underline; 134 | color: #900; 135 | } 136 | 137 | .flash { 138 | position: absolute; 139 | top: 20px; 140 | right: 20px; 141 | background: rgba(0, 0, 0, 0.75); 142 | border-radius: 4px; 143 | color: #fff; 144 | font-size: 12pt; 145 | padding: 10px; 146 | transition: opacity ease-in-out 300ms; 147 | opacity: 0; 148 | } 149 | 150 | .flash.visible { 151 | opacity: 1; 152 | } 153 | 154 | .footer { 155 | padding: 10px; 156 | max-width: 1102px; 157 | margin: 0 auto; 158 | position: relative; 159 | color: #999; 160 | } 161 | 162 | .footer .contents { 163 | border-top: dashed 1px #ccc; 164 | padding-top: 10px; 165 | text-align: right; 166 | } 167 | 168 | .footer a { 169 | color: #779dc2; 170 | } 171 | 172 | #pitch { 173 | padding-right: 230px; 174 | position: relative; 175 | } 176 | 177 | #pitch #screenshot { 178 | display: block; 179 | position: absolute; 180 | top: 0; 181 | right: 0; 182 | width: 206px; 183 | height: 260px; 184 | border: dashed 1px #d7cab0; 185 | background: #fff; 186 | padding: 5px; 187 | } 188 | 189 | #pitch h2 { 190 | color: #444; 191 | margin: 10px 0; 192 | } 193 | 194 | #pitch p.small { 195 | font-size: 13px; 196 | } 197 | 198 | #sign-in-form { 199 | text-align: center; 200 | white-space: nowrap; 201 | padding-right: 230px; 202 | } 203 | 204 | #sign-in-form .octicon-mark-github { 205 | pointer-events: none; 206 | vertical-align: -2px; 207 | color: #76201b; 208 | z-index: 1; 209 | position: relative; 210 | left: 12px; 211 | } 212 | 213 | #sign-in-form input[type="submit"] { 214 | padding: 8px 5px 5px 43px; 215 | margin-left: -32px; 216 | } 217 | 218 | #sign-in-form input[type="submit"]:before { 219 | content: "\f00a"; 220 | } 221 | 222 | #sign-in-form label { 223 | color: #a02b24; 224 | font-family: Courier, monospace; 225 | font-size: 14px; 226 | } 227 | 228 | #sign-in-form input[type="checkbox"] { 229 | display: none; 230 | } 231 | 232 | #sign-in-form input[type="checkbox"] + label:before { 233 | color: #a02b24; 234 | content: "\2610"; 235 | font-size: 18px; 236 | } 237 | 238 | #sign-in-form input[type="checkbox"] + label:active { 239 | opacity: 0.4; 240 | } 241 | 242 | #sign-in-form input[type="checkbox"]:checked + label:before { 243 | content: "\2611"; 244 | } 245 | 246 | #sign-in-form label { 247 | display: block; 248 | } 249 | 250 | @media (max-width: 450px) { 251 | #pitch { 252 | padding-right: 0; 253 | } 254 | #pitch #screenshot { 255 | position: static; 256 | margin: 0 auto; 257 | } 258 | #sign-in-form { 259 | padding-right: 0; 260 | } 261 | #sign-in-form .octicon-mark-github { 262 | font-size: 30px; 263 | left: 10px; 264 | } 265 | #sign-in-form .action-button { 266 | font-size: 16pt; 267 | } 268 | } 269 | 270 | #primary-actions { 271 | margin: 1em 0; 272 | text-align: center; 273 | font-size: 16px; 274 | color: #999; 275 | } 276 | 277 | @media (max-width: 450px) { 278 | #primary-actions form.inline { 279 | display: block; 280 | margin: 5px 0; 281 | } 282 | } 283 | 284 | .setting { 285 | margin: 1em 0; 286 | } 287 | 288 | .setting .explanation { 289 | color: #999; 290 | margin: 0; 291 | } 292 | 293 | .repos { 294 | margin-left: 1em; 295 | } 296 | 297 | .repos h2 { 298 | font-size: 16px; 299 | font-weight: bold; 300 | position: relative; 301 | margin: 10px 0 5px; 302 | } 303 | 304 | .repos h2:after { 305 | content: ""; 306 | position: absolute; 307 | bottom: 2px; 308 | left: 0; 309 | right: 0; 310 | height: 1px; 311 | background: #ccc; 312 | } 313 | 314 | .repos h2 .avatar { 315 | height: 16px; 316 | } 317 | 318 | .repos ul { 319 | list-style-type: none; 320 | padding: 0; 321 | margin: 0; 322 | } 323 | 324 | .repos .repo { 325 | overflow: hidden; 326 | margin: 5px 0; 327 | padding: 0; 328 | } 329 | 330 | .repos .repo.private .glyph { 331 | color: #baac79; 332 | } 333 | 334 | .repos .repo .vintage { 335 | padding-left: 1em; 336 | color: #999; 337 | font-style: italic; 338 | } 339 | 340 | #delete-account-form { 341 | border-top: dashed 1px #ccc; 342 | margin-top: 1em; 343 | padding-top: 1em; 344 | } 345 | -------------------------------------------------------------------------------- /app/static/octicons/LICENSE.txt: -------------------------------------------------------------------------------- 1 | (c) 2012-2014 GitHub 2 | 3 | When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) 4 | 5 | Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) 6 | Applies to all font files 7 | 8 | Code License: MIT (http://choosealicense.com/licenses/mit/) 9 | Applies to all other files 10 | -------------------------------------------------------------------------------- /app/static/octicons/README.md: -------------------------------------------------------------------------------- 1 | If you intend to install Octicons locally, install `octicons-local.ttf`. It should appear as “github-octicons” in your font list. It is specially designed not to conflict with GitHub's web fonts. 2 | -------------------------------------------------------------------------------- /app/static/octicons/octicons-local.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/octicons/octicons-local.ttf -------------------------------------------------------------------------------- /app/static/octicons/octicons.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'octicons'; 3 | src: url('octicons.eot?#iefix') format('embedded-opentype'), 4 | url('octicons.woff') format('woff'), 5 | url('octicons.ttf') format('truetype'), 6 | url('octicons.svg#octicons') format('svg'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } 10 | 11 | /* 12 | 13 | .octicon is optimized for 16px. 14 | .mega-octicon is optimized for 32px but can be used larger. 15 | 16 | */ 17 | .octicon { 18 | font: normal normal 16px octicons; 19 | line-height: 1; 20 | display: inline-block; 21 | text-decoration: none; 22 | -webkit-font-smoothing: antialiased; 23 | -moz-osx-font-smoothing: grayscale; 24 | -webkit-user-select: none; 25 | -moz-user-select: none; 26 | -ms-user-select: none; 27 | user-select: none; 28 | } 29 | .mega-octicon { 30 | font: normal normal 32px octicons; 31 | line-height: 1; 32 | display: inline-block; 33 | text-decoration: none; 34 | -webkit-font-smoothing: antialiased; 35 | -moz-osx-font-smoothing: grayscale; 36 | -webkit-user-select: none; 37 | -moz-user-select: none; 38 | -ms-user-select: none; 39 | user-select: none; 40 | } 41 | 42 | .octicon-alert:before { content: '\f02d'} /*  */ 43 | .octicon-alignment-align:before { content: '\f08a'} /*  */ 44 | .octicon-alignment-aligned-to:before { content: '\f08e'} /*  */ 45 | .octicon-alignment-unalign:before { content: '\f08b'} /*  */ 46 | .octicon-arrow-down:before { content: '\f03f'} /*  */ 47 | .octicon-arrow-left:before { content: '\f040'} /*  */ 48 | .octicon-arrow-right:before { content: '\f03e'} /*  */ 49 | .octicon-arrow-small-down:before { content: '\f0a0'} /*  */ 50 | .octicon-arrow-small-left:before { content: '\f0a1'} /*  */ 51 | .octicon-arrow-small-right:before { content: '\f071'} /*  */ 52 | .octicon-arrow-small-up:before { content: '\f09f'} /*  */ 53 | .octicon-arrow-up:before { content: '\f03d'} /*  */ 54 | .octicon-beer:before { content: '\f069'} /*  */ 55 | .octicon-book:before { content: '\f007'} /*  */ 56 | .octicon-bookmark:before { content: '\f07b'} /*  */ 57 | .octicon-briefcase:before { content: '\f0d3'} /*  */ 58 | .octicon-broadcast:before { content: '\f048'} /*  */ 59 | .octicon-browser:before { content: '\f0c5'} /*  */ 60 | .octicon-bug:before { content: '\f091'} /*  */ 61 | .octicon-calendar:before { content: '\f068'} /*  */ 62 | .octicon-check:before { content: '\f03a'} /*  */ 63 | .octicon-checklist:before { content: '\f076'} /*  */ 64 | .octicon-chevron-down:before { content: '\f0a3'} /*  */ 65 | .octicon-chevron-left:before { content: '\f0a4'} /*  */ 66 | .octicon-chevron-right:before { content: '\f078'} /*  */ 67 | .octicon-chevron-up:before { content: '\f0a2'} /*  */ 68 | .octicon-circle-slash:before { content: '\f084'} /*  */ 69 | .octicon-circuit-board:before { content: '\f0d6'} /*  */ 70 | .octicon-clippy:before { content: '\f035'} /*  */ 71 | .octicon-clock:before { content: '\f046'} /*  */ 72 | .octicon-cloud-download:before { content: '\f00b'} /*  */ 73 | .octicon-cloud-upload:before { content: '\f00c'} /*  */ 74 | .octicon-code:before { content: '\f05f'} /*  */ 75 | .octicon-color-mode:before { content: '\f065'} /*  */ 76 | .octicon-comment-add:before, 77 | .octicon-comment:before { content: '\f02b'} /*  */ 78 | .octicon-comment-discussion:before { content: '\f04f'} /*  */ 79 | .octicon-credit-card:before { content: '\f045'} /*  */ 80 | .octicon-dash:before { content: '\f0ca'} /*  */ 81 | .octicon-dashboard:before { content: '\f07d'} /*  */ 82 | .octicon-database:before { content: '\f096'} /*  */ 83 | .octicon-device-camera:before { content: '\f056'} /*  */ 84 | .octicon-device-camera-video:before { content: '\f057'} /*  */ 85 | .octicon-device-desktop:before { content: '\f27c'} /*  */ 86 | .octicon-device-mobile:before { content: '\f038'} /*  */ 87 | .octicon-diff:before { content: '\f04d'} /*  */ 88 | .octicon-diff-added:before { content: '\f06b'} /*  */ 89 | .octicon-diff-ignored:before { content: '\f099'} /*  */ 90 | .octicon-diff-modified:before { content: '\f06d'} /*  */ 91 | .octicon-diff-removed:before { content: '\f06c'} /*  */ 92 | .octicon-diff-renamed:before { content: '\f06e'} /*  */ 93 | .octicon-ellipsis:before { content: '\f09a'} /*  */ 94 | .octicon-eye-unwatch:before, 95 | .octicon-eye-watch:before, 96 | .octicon-eye:before { content: '\f04e'} /*  */ 97 | .octicon-file-binary:before { content: '\f094'} /*  */ 98 | .octicon-file-code:before { content: '\f010'} /*  */ 99 | .octicon-file-directory:before { content: '\f016'} /*  */ 100 | .octicon-file-media:before { content: '\f012'} /*  */ 101 | .octicon-file-pdf:before { content: '\f014'} /*  */ 102 | .octicon-file-submodule:before { content: '\f017'} /*  */ 103 | .octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ 104 | .octicon-file-symlink-file:before { content: '\f0b0'} /*  */ 105 | .octicon-file-text:before { content: '\f011'} /*  */ 106 | .octicon-file-zip:before { content: '\f013'} /*  */ 107 | .octicon-flame:before { content: '\f0d2'} /*  */ 108 | .octicon-fold:before { content: '\f0cc'} /*  */ 109 | .octicon-gear:before { content: '\f02f'} /*  */ 110 | .octicon-gift:before { content: '\f042'} /*  */ 111 | .octicon-gist:before { content: '\f00e'} /*  */ 112 | .octicon-gist-secret:before { content: '\f08c'} /*  */ 113 | .octicon-git-branch-create:before, 114 | .octicon-git-branch-delete:before, 115 | .octicon-git-branch:before { content: '\f020'} /*  */ 116 | .octicon-git-commit:before { content: '\f01f'} /*  */ 117 | .octicon-git-compare:before { content: '\f0ac'} /*  */ 118 | .octicon-git-merge:before { content: '\f023'} /*  */ 119 | .octicon-git-pull-request-abandoned:before, 120 | .octicon-git-pull-request:before { content: '\f009'} /*  */ 121 | .octicon-globe:before { content: '\f0b6'} /*  */ 122 | .octicon-graph:before { content: '\f043'} /*  */ 123 | .octicon-heart:before { content: '\2665'} /* ♥ */ 124 | .octicon-history:before { content: '\f07e'} /*  */ 125 | .octicon-home:before { content: '\f08d'} /*  */ 126 | .octicon-horizontal-rule:before { content: '\f070'} /*  */ 127 | .octicon-hourglass:before { content: '\f09e'} /*  */ 128 | .octicon-hubot:before { content: '\f09d'} /*  */ 129 | .octicon-inbox:before { content: '\f0cf'} /*  */ 130 | .octicon-info:before { content: '\f059'} /*  */ 131 | .octicon-issue-closed:before { content: '\f028'} /*  */ 132 | .octicon-issue-opened:before { content: '\f026'} /*  */ 133 | .octicon-issue-reopened:before { content: '\f027'} /*  */ 134 | .octicon-jersey:before { content: '\f019'} /*  */ 135 | .octicon-jump-down:before { content: '\f072'} /*  */ 136 | .octicon-jump-left:before { content: '\f0a5'} /*  */ 137 | .octicon-jump-right:before { content: '\f0a6'} /*  */ 138 | .octicon-jump-up:before { content: '\f073'} /*  */ 139 | .octicon-key:before { content: '\f049'} /*  */ 140 | .octicon-keyboard:before { content: '\f00d'} /*  */ 141 | .octicon-law:before { content: '\f0d8'} /* */ 142 | .octicon-light-bulb:before { content: '\f000'} /*  */ 143 | .octicon-link:before { content: '\f05c'} /*  */ 144 | .octicon-link-external:before { content: '\f07f'} /*  */ 145 | .octicon-list-ordered:before { content: '\f062'} /*  */ 146 | .octicon-list-unordered:before { content: '\f061'} /*  */ 147 | .octicon-location:before { content: '\f060'} /*  */ 148 | .octicon-gist-private:before, 149 | .octicon-mirror-private:before, 150 | .octicon-git-fork-private:before, 151 | .octicon-lock:before { content: '\f06a'} /*  */ 152 | .octicon-logo-github:before { content: '\f092'} /*  */ 153 | .octicon-mail:before { content: '\f03b'} /*  */ 154 | .octicon-mail-read:before { content: '\f03c'} /*  */ 155 | .octicon-mail-reply:before { content: '\f051'} /*  */ 156 | .octicon-mark-github:before { content: '\f00a'} /*  */ 157 | .octicon-markdown:before { content: '\f0c9'} /*  */ 158 | .octicon-megaphone:before { content: '\f077'} /*  */ 159 | .octicon-mention:before { content: '\f0be'} /*  */ 160 | .octicon-microscope:before { content: '\f089'} /*  */ 161 | .octicon-milestone:before { content: '\f075'} /*  */ 162 | .octicon-mirror-public:before, 163 | .octicon-mirror:before { content: '\f024'} /*  */ 164 | .octicon-mortar-board:before { content: '\f0d7'} /* */ 165 | .octicon-move-down:before { content: '\f0a8'} /*  */ 166 | .octicon-move-left:before { content: '\f074'} /*  */ 167 | .octicon-move-right:before { content: '\f0a9'} /*  */ 168 | .octicon-move-up:before { content: '\f0a7'} /*  */ 169 | .octicon-mute:before { content: '\f080'} /*  */ 170 | .octicon-no-newline:before { content: '\f09c'} /*  */ 171 | .octicon-octoface:before { content: '\f008'} /*  */ 172 | .octicon-organization:before { content: '\f037'} /*  */ 173 | .octicon-package:before { content: '\f0c4'} /*  */ 174 | .octicon-paintcan:before { content: '\f0d1'} /*  */ 175 | .octicon-pencil:before { content: '\f058'} /*  */ 176 | .octicon-person-add:before, 177 | .octicon-person-follow:before, 178 | .octicon-person:before { content: '\f018'} /*  */ 179 | .octicon-pin:before { content: '\f041'} /*  */ 180 | .octicon-playback-fast-forward:before { content: '\f0bd'} /*  */ 181 | .octicon-playback-pause:before { content: '\f0bb'} /*  */ 182 | .octicon-playback-play:before { content: '\f0bf'} /*  */ 183 | .octicon-playback-rewind:before { content: '\f0bc'} /*  */ 184 | .octicon-plug:before { content: '\f0d4'} /*  */ 185 | .octicon-repo-create:before, 186 | .octicon-gist-new:before, 187 | .octicon-file-directory-create:before, 188 | .octicon-file-add:before, 189 | .octicon-plus:before { content: '\f05d'} /*  */ 190 | .octicon-podium:before { content: '\f0af'} /*  */ 191 | .octicon-primitive-dot:before { content: '\f052'} /*  */ 192 | .octicon-primitive-square:before { content: '\f053'} /*  */ 193 | .octicon-pulse:before { content: '\f085'} /*  */ 194 | .octicon-puzzle:before { content: '\f0c0'} /*  */ 195 | .octicon-question:before { content: '\f02c'} /*  */ 196 | .octicon-quote:before { content: '\f063'} /*  */ 197 | .octicon-radio-tower:before { content: '\f030'} /*  */ 198 | .octicon-repo-delete:before, 199 | .octicon-repo:before { content: '\f001'} /*  */ 200 | .octicon-repo-clone:before { content: '\f04c'} /*  */ 201 | .octicon-repo-force-push:before { content: '\f04a'} /*  */ 202 | .octicon-gist-fork:before, 203 | .octicon-repo-forked:before { content: '\f002'} /*  */ 204 | .octicon-repo-pull:before { content: '\f006'} /*  */ 205 | .octicon-repo-push:before { content: '\f005'} /*  */ 206 | .octicon-rocket:before { content: '\f033'} /*  */ 207 | .octicon-rss:before { content: '\f034'} /*  */ 208 | .octicon-ruby:before { content: '\f047'} /*  */ 209 | .octicon-screen-full:before { content: '\f066'} /*  */ 210 | .octicon-screen-normal:before { content: '\f067'} /*  */ 211 | .octicon-search-save:before, 212 | .octicon-search:before { content: '\f02e'} /*  */ 213 | .octicon-server:before { content: '\f097'} /*  */ 214 | .octicon-settings:before { content: '\f07c'} /*  */ 215 | .octicon-log-in:before, 216 | .octicon-sign-in:before { content: '\f036'} /*  */ 217 | .octicon-log-out:before, 218 | .octicon-sign-out:before { content: '\f032'} /*  */ 219 | .octicon-split:before { content: '\f0c6'} /*  */ 220 | .octicon-squirrel:before { content: '\f0b2'} /*  */ 221 | .octicon-star-add:before, 222 | .octicon-star-delete:before, 223 | .octicon-star:before { content: '\f02a'} /*  */ 224 | .octicon-steps:before { content: '\f0c7'} /*  */ 225 | .octicon-stop:before { content: '\f08f'} /*  */ 226 | .octicon-repo-sync:before, 227 | .octicon-sync:before { content: '\f087'} /*  */ 228 | .octicon-tag-remove:before, 229 | .octicon-tag-add:before, 230 | .octicon-tag:before { content: '\f015'} /*  */ 231 | .octicon-telescope:before { content: '\f088'} /*  */ 232 | .octicon-terminal:before { content: '\f0c8'} /*  */ 233 | .octicon-three-bars:before { content: '\f05e'} /*  */ 234 | .octicon-tools:before { content: '\f031'} /*  */ 235 | .octicon-trashcan:before { content: '\f0d0'} /*  */ 236 | .octicon-triangle-down:before { content: '\f05b'} /*  */ 237 | .octicon-triangle-left:before { content: '\f044'} /*  */ 238 | .octicon-triangle-right:before { content: '\f05a'} /*  */ 239 | .octicon-triangle-up:before { content: '\f0aa'} /*  */ 240 | .octicon-unfold:before { content: '\f039'} /*  */ 241 | .octicon-unmute:before { content: '\f0ba'} /*  */ 242 | .octicon-versions:before { content: '\f064'} /*  */ 243 | .octicon-remove-close:before, 244 | .octicon-x:before { content: '\f081'} /*  */ 245 | .octicon-zap:before { content: '\26A1'} /* ⚡ */ 246 | -------------------------------------------------------------------------------- /app/static/octicons/octicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/octicons/octicons.eot -------------------------------------------------------------------------------- /app/static/octicons/octicons.less: -------------------------------------------------------------------------------- 1 | @octicons-font-path: "."; 2 | @octicons-version: "897b19cdb9c4473f9166329e039ba8337c77d561"; 3 | 4 | @font-face { 5 | font-family: 'octicons'; 6 | src: ~"url('@{octicons-font-path}/octicons.eot?#iefix&v=@{octicons-version}') format('embedded-opentype')", 7 | ~"url('@{octicons-font-path}/octicons.woff?v=@{octicons-version}') format('woff')", 8 | ~"url('@{octicons-font-path}/octicons.ttf?v=@{octicons-version}') format('truetype')", 9 | ~"url('@{octicons-font-path}/octicons.svg?v=@{octicons-version}#octicons') format('svg')"; 10 | font-weight: normal; 11 | font-style: normal; 12 | } 13 | 14 | // .octicon is optimized for 16px. 15 | // .mega-octicon is optimized for 32px but can be used larger. 16 | .octicon { 17 | font: normal normal 16px octicons; 18 | line-height: 1; 19 | display: inline-block; 20 | text-decoration: none; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | -webkit-user-select: none; 24 | -moz-user-select: none; 25 | -ms-user-select: none; 26 | user-select: none; 27 | } 28 | .mega-octicon { 29 | font: normal normal 32px octicons; 30 | line-height: 1; 31 | display: inline-block; 32 | text-decoration: none; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | -webkit-user-select: none; 36 | -moz-user-select: none; 37 | -ms-user-select: none; 38 | user-select: none; 39 | } 40 | 41 | .octicon-alert:before { content: '\f02d'} /*  */ 42 | .octicon-alignment-align:before { content: '\f08a'} /*  */ 43 | .octicon-alignment-aligned-to:before { content: '\f08e'} /*  */ 44 | .octicon-alignment-unalign:before { content: '\f08b'} /*  */ 45 | .octicon-arrow-down:before { content: '\f03f'} /*  */ 46 | .octicon-arrow-left:before { content: '\f040'} /*  */ 47 | .octicon-arrow-right:before { content: '\f03e'} /*  */ 48 | .octicon-arrow-small-down:before { content: '\f0a0'} /*  */ 49 | .octicon-arrow-small-left:before { content: '\f0a1'} /*  */ 50 | .octicon-arrow-small-right:before { content: '\f071'} /*  */ 51 | .octicon-arrow-small-up:before { content: '\f09f'} /*  */ 52 | .octicon-arrow-up:before { content: '\f03d'} /*  */ 53 | .octicon-beer:before { content: '\f069'} /*  */ 54 | .octicon-book:before { content: '\f007'} /*  */ 55 | .octicon-bookmark:before { content: '\f07b'} /*  */ 56 | .octicon-briefcase:before { content: '\f0d3'} /*  */ 57 | .octicon-broadcast:before { content: '\f048'} /*  */ 58 | .octicon-browser:before { content: '\f0c5'} /*  */ 59 | .octicon-bug:before { content: '\f091'} /*  */ 60 | .octicon-calendar:before { content: '\f068'} /*  */ 61 | .octicon-check:before { content: '\f03a'} /*  */ 62 | .octicon-checklist:before { content: '\f076'} /*  */ 63 | .octicon-chevron-down:before { content: '\f0a3'} /*  */ 64 | .octicon-chevron-left:before { content: '\f0a4'} /*  */ 65 | .octicon-chevron-right:before { content: '\f078'} /*  */ 66 | .octicon-chevron-up:before { content: '\f0a2'} /*  */ 67 | .octicon-circle-slash:before { content: '\f084'} /*  */ 68 | .octicon-circuit-board:before { content: '\f0d6'} /*  */ 69 | .octicon-clippy:before { content: '\f035'} /*  */ 70 | .octicon-clock:before { content: '\f046'} /*  */ 71 | .octicon-cloud-download:before { content: '\f00b'} /*  */ 72 | .octicon-cloud-upload:before { content: '\f00c'} /*  */ 73 | .octicon-code:before { content: '\f05f'} /*  */ 74 | .octicon-color-mode:before { content: '\f065'} /*  */ 75 | .octicon-comment-add:before, 76 | .octicon-comment:before { content: '\f02b'} /*  */ 77 | .octicon-comment-discussion:before { content: '\f04f'} /*  */ 78 | .octicon-credit-card:before { content: '\f045'} /*  */ 79 | .octicon-dash:before { content: '\f0ca'} /*  */ 80 | .octicon-dashboard:before { content: '\f07d'} /*  */ 81 | .octicon-database:before { content: '\f096'} /*  */ 82 | .octicon-device-camera:before { content: '\f056'} /*  */ 83 | .octicon-device-camera-video:before { content: '\f057'} /*  */ 84 | .octicon-device-desktop:before { content: '\f27c'} /*  */ 85 | .octicon-device-mobile:before { content: '\f038'} /*  */ 86 | .octicon-diff:before { content: '\f04d'} /*  */ 87 | .octicon-diff-added:before { content: '\f06b'} /*  */ 88 | .octicon-diff-ignored:before { content: '\f099'} /*  */ 89 | .octicon-diff-modified:before { content: '\f06d'} /*  */ 90 | .octicon-diff-removed:before { content: '\f06c'} /*  */ 91 | .octicon-diff-renamed:before { content: '\f06e'} /*  */ 92 | .octicon-ellipsis:before { content: '\f09a'} /*  */ 93 | .octicon-eye-unwatch:before, 94 | .octicon-eye-watch:before, 95 | .octicon-eye:before { content: '\f04e'} /*  */ 96 | .octicon-file-binary:before { content: '\f094'} /*  */ 97 | .octicon-file-code:before { content: '\f010'} /*  */ 98 | .octicon-file-directory:before { content: '\f016'} /*  */ 99 | .octicon-file-media:before { content: '\f012'} /*  */ 100 | .octicon-file-pdf:before { content: '\f014'} /*  */ 101 | .octicon-file-submodule:before { content: '\f017'} /*  */ 102 | .octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ 103 | .octicon-file-symlink-file:before { content: '\f0b0'} /*  */ 104 | .octicon-file-text:before { content: '\f011'} /*  */ 105 | .octicon-file-zip:before { content: '\f013'} /*  */ 106 | .octicon-flame:before { content: '\f0d2'} /*  */ 107 | .octicon-fold:before { content: '\f0cc'} /*  */ 108 | .octicon-gear:before { content: '\f02f'} /*  */ 109 | .octicon-gift:before { content: '\f042'} /*  */ 110 | .octicon-gist:before { content: '\f00e'} /*  */ 111 | .octicon-gist-secret:before { content: '\f08c'} /*  */ 112 | .octicon-git-branch-create:before, 113 | .octicon-git-branch-delete:before, 114 | .octicon-git-branch:before { content: '\f020'} /*  */ 115 | .octicon-git-commit:before { content: '\f01f'} /*  */ 116 | .octicon-git-compare:before { content: '\f0ac'} /*  */ 117 | .octicon-git-merge:before { content: '\f023'} /*  */ 118 | .octicon-git-pull-request-abandoned:before, 119 | .octicon-git-pull-request:before { content: '\f009'} /*  */ 120 | .octicon-globe:before { content: '\f0b6'} /*  */ 121 | .octicon-graph:before { content: '\f043'} /*  */ 122 | .octicon-heart:before { content: '\2665'} /* ♥ */ 123 | .octicon-history:before { content: '\f07e'} /*  */ 124 | .octicon-home:before { content: '\f08d'} /*  */ 125 | .octicon-horizontal-rule:before { content: '\f070'} /*  */ 126 | .octicon-hourglass:before { content: '\f09e'} /*  */ 127 | .octicon-hubot:before { content: '\f09d'} /*  */ 128 | .octicon-inbox:before { content: '\f0cf'} /*  */ 129 | .octicon-info:before { content: '\f059'} /*  */ 130 | .octicon-issue-closed:before { content: '\f028'} /*  */ 131 | .octicon-issue-opened:before { content: '\f026'} /*  */ 132 | .octicon-issue-reopened:before { content: '\f027'} /*  */ 133 | .octicon-jersey:before { content: '\f019'} /*  */ 134 | .octicon-jump-down:before { content: '\f072'} /*  */ 135 | .octicon-jump-left:before { content: '\f0a5'} /*  */ 136 | .octicon-jump-right:before { content: '\f0a6'} /*  */ 137 | .octicon-jump-up:before { content: '\f073'} /*  */ 138 | .octicon-key:before { content: '\f049'} /*  */ 139 | .octicon-keyboard:before { content: '\f00d'} /*  */ 140 | .octicon-law:before { content: '\f0d8'} /* */ 141 | .octicon-light-bulb:before { content: '\f000'} /*  */ 142 | .octicon-link:before { content: '\f05c'} /*  */ 143 | .octicon-link-external:before { content: '\f07f'} /*  */ 144 | .octicon-list-ordered:before { content: '\f062'} /*  */ 145 | .octicon-list-unordered:before { content: '\f061'} /*  */ 146 | .octicon-location:before { content: '\f060'} /*  */ 147 | .octicon-gist-private:before, 148 | .octicon-mirror-private:before, 149 | .octicon-git-fork-private:before, 150 | .octicon-lock:before { content: '\f06a'} /*  */ 151 | .octicon-logo-github:before { content: '\f092'} /*  */ 152 | .octicon-mail:before { content: '\f03b'} /*  */ 153 | .octicon-mail-read:before { content: '\f03c'} /*  */ 154 | .octicon-mail-reply:before { content: '\f051'} /*  */ 155 | .octicon-mark-github:before { content: '\f00a'} /*  */ 156 | .octicon-markdown:before { content: '\f0c9'} /*  */ 157 | .octicon-megaphone:before { content: '\f077'} /*  */ 158 | .octicon-mention:before { content: '\f0be'} /*  */ 159 | .octicon-microscope:before { content: '\f089'} /*  */ 160 | .octicon-milestone:before { content: '\f075'} /*  */ 161 | .octicon-mirror-public:before, 162 | .octicon-mirror:before { content: '\f024'} /*  */ 163 | .octicon-mortar-board:before { content: '\f0d7'} /* */ 164 | .octicon-move-down:before { content: '\f0a8'} /*  */ 165 | .octicon-move-left:before { content: '\f074'} /*  */ 166 | .octicon-move-right:before { content: '\f0a9'} /*  */ 167 | .octicon-move-up:before { content: '\f0a7'} /*  */ 168 | .octicon-mute:before { content: '\f080'} /*  */ 169 | .octicon-no-newline:before { content: '\f09c'} /*  */ 170 | .octicon-octoface:before { content: '\f008'} /*  */ 171 | .octicon-organization:before { content: '\f037'} /*  */ 172 | .octicon-package:before { content: '\f0c4'} /*  */ 173 | .octicon-paintcan:before { content: '\f0d1'} /*  */ 174 | .octicon-pencil:before { content: '\f058'} /*  */ 175 | .octicon-person-add:before, 176 | .octicon-person-follow:before, 177 | .octicon-person:before { content: '\f018'} /*  */ 178 | .octicon-pin:before { content: '\f041'} /*  */ 179 | .octicon-playback-fast-forward:before { content: '\f0bd'} /*  */ 180 | .octicon-playback-pause:before { content: '\f0bb'} /*  */ 181 | .octicon-playback-play:before { content: '\f0bf'} /*  */ 182 | .octicon-playback-rewind:before { content: '\f0bc'} /*  */ 183 | .octicon-plug:before { content: '\f0d4'} /*  */ 184 | .octicon-repo-create:before, 185 | .octicon-gist-new:before, 186 | .octicon-file-directory-create:before, 187 | .octicon-file-add:before, 188 | .octicon-plus:before { content: '\f05d'} /*  */ 189 | .octicon-podium:before { content: '\f0af'} /*  */ 190 | .octicon-primitive-dot:before { content: '\f052'} /*  */ 191 | .octicon-primitive-square:before { content: '\f053'} /*  */ 192 | .octicon-pulse:before { content: '\f085'} /*  */ 193 | .octicon-puzzle:before { content: '\f0c0'} /*  */ 194 | .octicon-question:before { content: '\f02c'} /*  */ 195 | .octicon-quote:before { content: '\f063'} /*  */ 196 | .octicon-radio-tower:before { content: '\f030'} /*  */ 197 | .octicon-repo-delete:before, 198 | .octicon-repo:before { content: '\f001'} /*  */ 199 | .octicon-repo-clone:before { content: '\f04c'} /*  */ 200 | .octicon-repo-force-push:before { content: '\f04a'} /*  */ 201 | .octicon-gist-fork:before, 202 | .octicon-repo-forked:before { content: '\f002'} /*  */ 203 | .octicon-repo-pull:before { content: '\f006'} /*  */ 204 | .octicon-repo-push:before { content: '\f005'} /*  */ 205 | .octicon-rocket:before { content: '\f033'} /*  */ 206 | .octicon-rss:before { content: '\f034'} /*  */ 207 | .octicon-ruby:before { content: '\f047'} /*  */ 208 | .octicon-screen-full:before { content: '\f066'} /*  */ 209 | .octicon-screen-normal:before { content: '\f067'} /*  */ 210 | .octicon-search-save:before, 211 | .octicon-search:before { content: '\f02e'} /*  */ 212 | .octicon-server:before { content: '\f097'} /*  */ 213 | .octicon-settings:before { content: '\f07c'} /*  */ 214 | .octicon-log-in:before, 215 | .octicon-sign-in:before { content: '\f036'} /*  */ 216 | .octicon-log-out:before, 217 | .octicon-sign-out:before { content: '\f032'} /*  */ 218 | .octicon-split:before { content: '\f0c6'} /*  */ 219 | .octicon-squirrel:before { content: '\f0b2'} /*  */ 220 | .octicon-star-add:before, 221 | .octicon-star-delete:before, 222 | .octicon-star:before { content: '\f02a'} /*  */ 223 | .octicon-steps:before { content: '\f0c7'} /*  */ 224 | .octicon-stop:before { content: '\f08f'} /*  */ 225 | .octicon-repo-sync:before, 226 | .octicon-sync:before { content: '\f087'} /*  */ 227 | .octicon-tag-remove:before, 228 | .octicon-tag-add:before, 229 | .octicon-tag:before { content: '\f015'} /*  */ 230 | .octicon-telescope:before { content: '\f088'} /*  */ 231 | .octicon-terminal:before { content: '\f0c8'} /*  */ 232 | .octicon-three-bars:before { content: '\f05e'} /*  */ 233 | .octicon-tools:before { content: '\f031'} /*  */ 234 | .octicon-trashcan:before { content: '\f0d0'} /*  */ 235 | .octicon-triangle-down:before { content: '\f05b'} /*  */ 236 | .octicon-triangle-left:before { content: '\f044'} /*  */ 237 | .octicon-triangle-right:before { content: '\f05a'} /*  */ 238 | .octicon-triangle-up:before { content: '\f0aa'} /*  */ 239 | .octicon-unfold:before { content: '\f039'} /*  */ 240 | .octicon-unmute:before { content: '\f0ba'} /*  */ 241 | .octicon-versions:before { content: '\f064'} /*  */ 242 | .octicon-remove-close:before, 243 | .octicon-x:before { content: '\f081'} /*  */ 244 | .octicon-zap:before { content: '\26A1'} /* ⚡ */ 245 | -------------------------------------------------------------------------------- /app/static/octicons/octicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/octicons/octicons.ttf -------------------------------------------------------------------------------- /app/static/octicons/octicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/app/static/octicons/octicons.woff -------------------------------------------------------------------------------- /app/static/octicons/sprockets-octicons.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'octicons'; 3 | src: font-url('octicons.eot?#iefix') format('embedded-opentype'), 4 | font-url('octicons.woff') format('woff'), 5 | font-url('octicons.ttf') format('truetype'), 6 | font-url('octicons.svg#octicons') format('svg'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } 10 | 11 | // .octicon is optimized for 16px. 12 | // .mega-octicon is optimized for 32px but can be used larger. 13 | .octicon { 14 | font: normal normal 16px octicons; 15 | line-height: 1; 16 | display: inline-block; 17 | text-decoration: none; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | -webkit-user-select: none; 21 | -moz-user-select: none; 22 | -ms-user-select: none; 23 | user-select: none; 24 | } 25 | .mega-octicon { 26 | font: normal normal 32px octicons; 27 | line-height: 1; 28 | display: inline-block; 29 | text-decoration: none; 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | -webkit-user-select: none; 33 | -moz-user-select: none; 34 | -ms-user-select: none; 35 | user-select: none; 36 | } 37 | 38 | .octicon-alert:before { content: '\f02d'} /*  */ 39 | .octicon-alignment-align:before { content: '\f08a'} /*  */ 40 | .octicon-alignment-aligned-to:before { content: '\f08e'} /*  */ 41 | .octicon-alignment-unalign:before { content: '\f08b'} /*  */ 42 | .octicon-arrow-down:before { content: '\f03f'} /*  */ 43 | .octicon-arrow-left:before { content: '\f040'} /*  */ 44 | .octicon-arrow-right:before { content: '\f03e'} /*  */ 45 | .octicon-arrow-small-down:before { content: '\f0a0'} /*  */ 46 | .octicon-arrow-small-left:before { content: '\f0a1'} /*  */ 47 | .octicon-arrow-small-right:before { content: '\f071'} /*  */ 48 | .octicon-arrow-small-up:before { content: '\f09f'} /*  */ 49 | .octicon-arrow-up:before { content: '\f03d'} /*  */ 50 | .octicon-beer:before { content: '\f069'} /*  */ 51 | .octicon-book:before { content: '\f007'} /*  */ 52 | .octicon-bookmark:before { content: '\f07b'} /*  */ 53 | .octicon-briefcase:before { content: '\f0d3'} /*  */ 54 | .octicon-broadcast:before { content: '\f048'} /*  */ 55 | .octicon-browser:before { content: '\f0c5'} /*  */ 56 | .octicon-bug:before { content: '\f091'} /*  */ 57 | .octicon-calendar:before { content: '\f068'} /*  */ 58 | .octicon-check:before { content: '\f03a'} /*  */ 59 | .octicon-checklist:before { content: '\f076'} /*  */ 60 | .octicon-chevron-down:before { content: '\f0a3'} /*  */ 61 | .octicon-chevron-left:before { content: '\f0a4'} /*  */ 62 | .octicon-chevron-right:before { content: '\f078'} /*  */ 63 | .octicon-chevron-up:before { content: '\f0a2'} /*  */ 64 | .octicon-circle-slash:before { content: '\f084'} /*  */ 65 | .octicon-circuit-board:before { content: '\f0d6'} /*  */ 66 | .octicon-clippy:before { content: '\f035'} /*  */ 67 | .octicon-clock:before { content: '\f046'} /*  */ 68 | .octicon-cloud-download:before { content: '\f00b'} /*  */ 69 | .octicon-cloud-upload:before { content: '\f00c'} /*  */ 70 | .octicon-code:before { content: '\f05f'} /*  */ 71 | .octicon-color-mode:before { content: '\f065'} /*  */ 72 | .octicon-comment-add:before, 73 | .octicon-comment:before { content: '\f02b'} /*  */ 74 | .octicon-comment-discussion:before { content: '\f04f'} /*  */ 75 | .octicon-credit-card:before { content: '\f045'} /*  */ 76 | .octicon-dash:before { content: '\f0ca'} /*  */ 77 | .octicon-dashboard:before { content: '\f07d'} /*  */ 78 | .octicon-database:before { content: '\f096'} /*  */ 79 | .octicon-device-camera:before { content: '\f056'} /*  */ 80 | .octicon-device-camera-video:before { content: '\f057'} /*  */ 81 | .octicon-device-desktop:before { content: '\f27c'} /*  */ 82 | .octicon-device-mobile:before { content: '\f038'} /*  */ 83 | .octicon-diff:before { content: '\f04d'} /*  */ 84 | .octicon-diff-added:before { content: '\f06b'} /*  */ 85 | .octicon-diff-ignored:before { content: '\f099'} /*  */ 86 | .octicon-diff-modified:before { content: '\f06d'} /*  */ 87 | .octicon-diff-removed:before { content: '\f06c'} /*  */ 88 | .octicon-diff-renamed:before { content: '\f06e'} /*  */ 89 | .octicon-ellipsis:before { content: '\f09a'} /*  */ 90 | .octicon-eye-unwatch:before, 91 | .octicon-eye-watch:before, 92 | .octicon-eye:before { content: '\f04e'} /*  */ 93 | .octicon-file-binary:before { content: '\f094'} /*  */ 94 | .octicon-file-code:before { content: '\f010'} /*  */ 95 | .octicon-file-directory:before { content: '\f016'} /*  */ 96 | .octicon-file-media:before { content: '\f012'} /*  */ 97 | .octicon-file-pdf:before { content: '\f014'} /*  */ 98 | .octicon-file-submodule:before { content: '\f017'} /*  */ 99 | .octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ 100 | .octicon-file-symlink-file:before { content: '\f0b0'} /*  */ 101 | .octicon-file-text:before { content: '\f011'} /*  */ 102 | .octicon-file-zip:before { content: '\f013'} /*  */ 103 | .octicon-flame:before { content: '\f0d2'} /*  */ 104 | .octicon-fold:before { content: '\f0cc'} /*  */ 105 | .octicon-gear:before { content: '\f02f'} /*  */ 106 | .octicon-gift:before { content: '\f042'} /*  */ 107 | .octicon-gist:before { content: '\f00e'} /*  */ 108 | .octicon-gist-secret:before { content: '\f08c'} /*  */ 109 | .octicon-git-branch-create:before, 110 | .octicon-git-branch-delete:before, 111 | .octicon-git-branch:before { content: '\f020'} /*  */ 112 | .octicon-git-commit:before { content: '\f01f'} /*  */ 113 | .octicon-git-compare:before { content: '\f0ac'} /*  */ 114 | .octicon-git-merge:before { content: '\f023'} /*  */ 115 | .octicon-git-pull-request-abandoned:before, 116 | .octicon-git-pull-request:before { content: '\f009'} /*  */ 117 | .octicon-globe:before { content: '\f0b6'} /*  */ 118 | .octicon-graph:before { content: '\f043'} /*  */ 119 | .octicon-heart:before { content: '\2665'} /* ♥ */ 120 | .octicon-history:before { content: '\f07e'} /*  */ 121 | .octicon-home:before { content: '\f08d'} /*  */ 122 | .octicon-horizontal-rule:before { content: '\f070'} /*  */ 123 | .octicon-hourglass:before { content: '\f09e'} /*  */ 124 | .octicon-hubot:before { content: '\f09d'} /*  */ 125 | .octicon-inbox:before { content: '\f0cf'} /*  */ 126 | .octicon-info:before { content: '\f059'} /*  */ 127 | .octicon-issue-closed:before { content: '\f028'} /*  */ 128 | .octicon-issue-opened:before { content: '\f026'} /*  */ 129 | .octicon-issue-reopened:before { content: '\f027'} /*  */ 130 | .octicon-jersey:before { content: '\f019'} /*  */ 131 | .octicon-jump-down:before { content: '\f072'} /*  */ 132 | .octicon-jump-left:before { content: '\f0a5'} /*  */ 133 | .octicon-jump-right:before { content: '\f0a6'} /*  */ 134 | .octicon-jump-up:before { content: '\f073'} /*  */ 135 | .octicon-key:before { content: '\f049'} /*  */ 136 | .octicon-keyboard:before { content: '\f00d'} /*  */ 137 | .octicon-law:before { content: '\f0d8'} /* */ 138 | .octicon-light-bulb:before { content: '\f000'} /*  */ 139 | .octicon-link:before { content: '\f05c'} /*  */ 140 | .octicon-link-external:before { content: '\f07f'} /*  */ 141 | .octicon-list-ordered:before { content: '\f062'} /*  */ 142 | .octicon-list-unordered:before { content: '\f061'} /*  */ 143 | .octicon-location:before { content: '\f060'} /*  */ 144 | .octicon-gist-private:before, 145 | .octicon-mirror-private:before, 146 | .octicon-git-fork-private:before, 147 | .octicon-lock:before { content: '\f06a'} /*  */ 148 | .octicon-logo-github:before { content: '\f092'} /*  */ 149 | .octicon-mail:before { content: '\f03b'} /*  */ 150 | .octicon-mail-read:before { content: '\f03c'} /*  */ 151 | .octicon-mail-reply:before { content: '\f051'} /*  */ 152 | .octicon-mark-github:before { content: '\f00a'} /*  */ 153 | .octicon-markdown:before { content: '\f0c9'} /*  */ 154 | .octicon-megaphone:before { content: '\f077'} /*  */ 155 | .octicon-mention:before { content: '\f0be'} /*  */ 156 | .octicon-microscope:before { content: '\f089'} /*  */ 157 | .octicon-milestone:before { content: '\f075'} /*  */ 158 | .octicon-mirror-public:before, 159 | .octicon-mirror:before { content: '\f024'} /*  */ 160 | .octicon-mortar-board:before { content: '\f0d7'} /* */ 161 | .octicon-move-down:before { content: '\f0a8'} /*  */ 162 | .octicon-move-left:before { content: '\f074'} /*  */ 163 | .octicon-move-right:before { content: '\f0a9'} /*  */ 164 | .octicon-move-up:before { content: '\f0a7'} /*  */ 165 | .octicon-mute:before { content: '\f080'} /*  */ 166 | .octicon-no-newline:before { content: '\f09c'} /*  */ 167 | .octicon-octoface:before { content: '\f008'} /*  */ 168 | .octicon-organization:before { content: '\f037'} /*  */ 169 | .octicon-package:before { content: '\f0c4'} /*  */ 170 | .octicon-paintcan:before { content: '\f0d1'} /*  */ 171 | .octicon-pencil:before { content: '\f058'} /*  */ 172 | .octicon-person-add:before, 173 | .octicon-person-follow:before, 174 | .octicon-person:before { content: '\f018'} /*  */ 175 | .octicon-pin:before { content: '\f041'} /*  */ 176 | .octicon-playback-fast-forward:before { content: '\f0bd'} /*  */ 177 | .octicon-playback-pause:before { content: '\f0bb'} /*  */ 178 | .octicon-playback-play:before { content: '\f0bf'} /*  */ 179 | .octicon-playback-rewind:before { content: '\f0bc'} /*  */ 180 | .octicon-plug:before { content: '\f0d4'} /*  */ 181 | .octicon-repo-create:before, 182 | .octicon-gist-new:before, 183 | .octicon-file-directory-create:before, 184 | .octicon-file-add:before, 185 | .octicon-plus:before { content: '\f05d'} /*  */ 186 | .octicon-podium:before { content: '\f0af'} /*  */ 187 | .octicon-primitive-dot:before { content: '\f052'} /*  */ 188 | .octicon-primitive-square:before { content: '\f053'} /*  */ 189 | .octicon-pulse:before { content: '\f085'} /*  */ 190 | .octicon-puzzle:before { content: '\f0c0'} /*  */ 191 | .octicon-question:before { content: '\f02c'} /*  */ 192 | .octicon-quote:before { content: '\f063'} /*  */ 193 | .octicon-radio-tower:before { content: '\f030'} /*  */ 194 | .octicon-repo-delete:before, 195 | .octicon-repo:before { content: '\f001'} /*  */ 196 | .octicon-repo-clone:before { content: '\f04c'} /*  */ 197 | .octicon-repo-force-push:before { content: '\f04a'} /*  */ 198 | .octicon-gist-fork:before, 199 | .octicon-repo-forked:before { content: '\f002'} /*  */ 200 | .octicon-repo-pull:before { content: '\f006'} /*  */ 201 | .octicon-repo-push:before { content: '\f005'} /*  */ 202 | .octicon-rocket:before { content: '\f033'} /*  */ 203 | .octicon-rss:before { content: '\f034'} /*  */ 204 | .octicon-ruby:before { content: '\f047'} /*  */ 205 | .octicon-screen-full:before { content: '\f066'} /*  */ 206 | .octicon-screen-normal:before { content: '\f067'} /*  */ 207 | .octicon-search-save:before, 208 | .octicon-search:before { content: '\f02e'} /*  */ 209 | .octicon-server:before { content: '\f097'} /*  */ 210 | .octicon-settings:before { content: '\f07c'} /*  */ 211 | .octicon-log-in:before, 212 | .octicon-sign-in:before { content: '\f036'} /*  */ 213 | .octicon-log-out:before, 214 | .octicon-sign-out:before { content: '\f032'} /*  */ 215 | .octicon-split:before { content: '\f0c6'} /*  */ 216 | .octicon-squirrel:before { content: '\f0b2'} /*  */ 217 | .octicon-star-add:before, 218 | .octicon-star-delete:before, 219 | .octicon-star:before { content: '\f02a'} /*  */ 220 | .octicon-steps:before { content: '\f0c7'} /*  */ 221 | .octicon-stop:before { content: '\f08f'} /*  */ 222 | .octicon-repo-sync:before, 223 | .octicon-sync:before { content: '\f087'} /*  */ 224 | .octicon-tag-remove:before, 225 | .octicon-tag-add:before, 226 | .octicon-tag:before { content: '\f015'} /*  */ 227 | .octicon-telescope:before { content: '\f088'} /*  */ 228 | .octicon-terminal:before { content: '\f0c8'} /*  */ 229 | .octicon-three-bars:before { content: '\f05e'} /*  */ 230 | .octicon-tools:before { content: '\f031'} /*  */ 231 | .octicon-trashcan:before { content: '\f0d0'} /*  */ 232 | .octicon-triangle-down:before { content: '\f05b'} /*  */ 233 | .octicon-triangle-left:before { content: '\f044'} /*  */ 234 | .octicon-triangle-right:before { content: '\f05a'} /*  */ 235 | .octicon-triangle-up:before { content: '\f0aa'} /*  */ 236 | .octicon-unfold:before { content: '\f039'} /*  */ 237 | .octicon-unmute:before { content: '\f0ba'} /*  */ 238 | .octicon-versions:before { content: '\f064'} /*  */ 239 | .octicon-remove-close:before, 240 | .octicon-x:before { content: '\f081'} /*  */ 241 | .octicon-zap:before { content: '\26A1'} /* ⚡ */ 242 | -------------------------------------------------------------------------------- /app/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: /account 3 | Disallow: /admin 4 | Disallow: /digest 5 | Disallow: /github 6 | Disallow: /session 7 | -------------------------------------------------------------------------------- /app/static/settings.js: -------------------------------------------------------------------------------- 1 | function updateWeeklyDayContainer() { 2 | var frequencyNode = document.getElementById("frequency"); 3 | var weeklyDayContainerNode = document.getElementById("weekly-day-container"); 4 | if (frequencyNode.value == "weekly") { 5 | weeklyDayContainerNode.style.display = "inline"; 6 | } else { 7 | weeklyDayContainerNode.style.display = "none"; 8 | } 9 | } 10 | 11 | function updateReposContainer() { 12 | var includedReposNode = document.getElementById("included-repos"); 13 | var reposContainerNode = document.getElementById("repos-container"); 14 | if (includedReposNode.value == "some") { 15 | reposContainerNode.style.display = "block"; 16 | } else { 17 | var repoCheckboxes = document.querySelectorAll(".repo input[type=checkbox]"); 18 | for (var i = 0; i < repoCheckboxes.length; i++) { 19 | repoCheckboxes[i].checked = true; 20 | } 21 | reposContainerNode.style.display = "none"; 22 | } 23 | } 24 | 25 | function confirmDeleteAccount() { 26 | return confirm("Are you sure you want to delete your account?"); 27 | } 28 | -------------------------------------------------------------------------------- /app/templates/base/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RetroGit {{template "title" .}} 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | RetroGit 15 | 16 |
17 | 18 |
19 |

{{template "title" .}}

20 | 21 | {{range .Flashes}} 22 | {{template "flash" .}} 23 | {{end}} 24 | 25 | {{template "body" .}} 26 |
27 | 28 | 37 | 38 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/templates/digest-admin.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Digest Admin{{end}} 2 | 3 | {{define "body"}} 4 | 5 | {{template "digest" .Digest}} 6 | 7 | {{end}} 8 | -------------------------------------------------------------------------------- /app/templates/digest-email.html: -------------------------------------------------------------------------------- 1 | {{template "digest" .Digest}} 2 | 3 | {{template "email-footer"}} 4 | -------------------------------------------------------------------------------- /app/templates/digest-page.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Digest for {{.Digest.User.Login}}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | {{template "digest" .Digest}} 6 | 7 | {{end}} 8 | -------------------------------------------------------------------------------- /app/templates/faq.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} FAQ {{end}} 2 | 3 | {{define "body"}} 4 | 5 |

Why do you need such broad access to my GitHub account?

6 | 7 |
8 | RetroGit requests access for a couple of kinds of data from your GitHub account: 9 |
    10 |
  • Personal user data: Needed in order to determine which email address to send your digest to (I did not want to build my own email address validation infrastructure).
  • 11 |
  • Repositories: Needed to get at historical commits used to generate your digest. The authentication scopes that GitHub's API offers are quite coarse-grained, so there is no narrower option. This means that RetroGit also has access to the read-write contents of your source files, even though it does not need (or use) it. The one mitigating option is to only request this level of access for public repositories — this can be done in RetroGit by unchecking the "Include private repositories" checkbox when signing in.
  • 12 |
13 | 14 |

How much data can you see about my account?

15 | 16 |
17 | RetroGit has access to the following data about your GitHub account and repositories: 18 | 19 |
    20 |
  • Email addresses
  • 21 |
  • Commit history
  • 22 |
  • Source code
  • 23 |
  • Issues
  • 24 |
  • Pull requests
  • 25 |
  • Wikis
  • 26 |
  • Settings
  • 27 |
  • Webhooks
  • 28 |
  • Deploy keys
  • 29 |
30 | 31 | However it only uses the data in bold, everything else is provided as a side effect of the scope that it uses with the GitHub API. 32 |
33 | 34 |

What is is stored in your servers?

35 | 36 |
37 | RetroGit does not persist any commit messages or source code from your repositories on its servers (GitHub API responses may be cached in memory for a short period). Digests are generated dynamically when they need to be sent out. What ends up being stored is (see the Account struct for details): 38 | 39 |
    40 |
  • OAuth token enabling RetroGit to query data for your account.
  • 41 |
  • Which email address to receive your digests at.
  • 42 |
  • Timezone, digest frequency and other settings.
  • 43 |
44 | 45 | There is also a per-user map of the timestamp of the oldest commit for each repository, since this is expensive to compute. 46 |
47 | 48 |

Can I run my own instance?

49 | 50 |
51 | RetroGit's source is available and it runs on the App Engine Go Runtime, so you can easily start your own instance. It is not very resource intensive -- single user accounts should definitely fit within the free daily quota. 52 |
53 | 54 |

Can I delete my account?

55 | 56 |
57 | Yes, this can be done via the settings page. You can also revoke RetroGit's access to your account via the GitHub authorized applications page. 58 |
59 | 60 | {{end}} 61 | -------------------------------------------------------------------------------- /app/templates/github-auth-error-email.html: -------------------------------------------------------------------------------- 1 |

2 | A RetroGit digest could not be generated for your account due to a GitHub 3 | authentication error. You may have revoked RetroGit's access (you can see this 4 | on your GitHub settings 5 | page). If you wish to grant it access again, use the button below: 6 |

7 | 8 |
9 | 10 | 14 |
15 | 16 | {{template "email-footer"}} 17 | -------------------------------------------------------------------------------- /app/templates/github-auth-error.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} GitHub Access Unauthorized {{end}} 2 | 3 | {{define "body"}} 4 | 5 |
6 | It looks like you have a RetroGit account, but we can't access your GitHub 7 | account. You may have revoked RetroGit's access (you can see this on your 8 | GitHub settings page). 9 | If you wish to grant it access again, use the button below: 10 |
11 | 12 |
13 | 14 | 15 | 16 | 17 | 20 |
21 | 22 | {{end}} 23 | -------------------------------------------------------------------------------- /app/templates/index-signed-out.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 | 7 |
8 |

See your GitHub activity on this exact day in history.

9 | 10 |

RetroGit emails you a daily or weekly digest with your GitHub commits from all the previous years during which you checked in code.

11 | 12 | 13 | RetroGit Screenshot 14 | 15 | 16 |

Use it as a nostalgia trip, or to remind you of TODOs that you never quite got around to cleaning up. Think of it as Timehop for your codebase.

17 | 18 |

If you have any concerns about how RetroGit uses GitHub data, please see the FAQ.

19 |
20 | 21 |
22 | 23 | 24 | 25 | 26 | 29 |
30 | 31 | {{end}} 32 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 | 7 |
8 | You're signed in as 9 | {{template "user" .User}} 10 | (
). 11 | {{if eq .SettingsSummary.EmailAddress "disabled"}} 12 | You've disabled emails, but you can still view your digest for your 13 | GitHub activity in {{.SettingsSummary.RepositoryCount}} repositories below 14 | {{else}} 15 | You'll be getting a {{.SettingsSummary.Frequency}} digest of your past 16 | GitHub activity in {{.SettingsSummary.RepositoryCount}} repositories sent to 17 | {{.SettingsSummary.EmailAddress}} 18 | {{end}} 19 | (change settings). 20 |
21 | 22 | {{if ne .SettingsSummary.EmailAddress "disabled"}} 23 |
24 | If you just can't wait, you can get your digest now: 25 |
26 | {{end}} 27 | 28 |
29 |
30 | 31 |
32 | {{if ne .SettingsSummary.EmailAddress "disabled"}} 33 | or 34 |
35 | 36 |
37 | {{end}} 38 |
39 | 40 | {{if .DetectTimezone }} 41 | 42 | 43 | 57 | 58 | {{end}} 59 | 60 | {{end}} 61 | -------------------------------------------------------------------------------- /app/templates/internal-error.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} Internal Error {{end}} 2 | 3 | {{define "body"}} 4 | 5 | {{if .ShowDetails}} 6 | 7 |

8 | HTTP status code: {{.Error.Code}}
9 | Error type: {{.Error.Type}}
10 |

11 | 12 |

13 | Message: {{.Error.Message}}
14 | Error:

{{.Error.Error}}
15 |

16 | 17 | {{else}} 18 | An internal error occured. The developer has been notified. Hopefully it'll 19 | be fixed soon. You can also try checking the 20 | current issues list. 21 | {{end}} 22 | 23 | {{end}} 24 | -------------------------------------------------------------------------------- /app/templates/repos-admin.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Repos Admin{{end}} 2 | 3 | {{define "repo"}} 4 |
  • 5 | 6 | {{.FullName}} 7 | {{.DisplayVintage}} 8 |
  • 9 | {{end}} 10 | 11 | {{define "body"}} 12 | 13 | {{if .ReposError}} 14 | {{.ReposError}} 15 | {{else}} 16 | {{len .Repos.AllRepos}} from {{len .Repos.OtherUserRepos}} other users 17 | 18 |
    19 |

    20 | 21 | {{.User.Login}} 22 |

    23 |
      24 | {{range .Repos.UserRepos}} 25 | {{template "repo" .}} 26 | {{end}} 27 |
    28 |
    29 | 30 | {{range .Repos.OtherUserRepos}} 31 |
    32 |

    33 | {{.User.Login}} 34 |

    35 |
      36 | {{range .Repos}} 37 | {{template "repo" .}} 38 | {{end}} 39 |
    40 |
    41 | {{end}} 42 | 43 | {{end}} 44 | 45 | {{end}} 46 | -------------------------------------------------------------------------------- /app/templates/settings.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Settings{{end}} 2 | 3 | {{define "repo"}} 4 |
  • 5 | 11 |
  • 12 | {{end}} 13 | 14 | {{define "body"}} 15 | 16 | 17 | 18 |
    19 | 20 |
    21 | 43 |
    44 | 45 |
    46 | 62 |
    63 | 64 |
    65 | 76 |
    77 | Where your digest will be sent to. Set of addresses is controlled by your GitHub settings. 78 |
    79 |
    80 | 81 |
    82 | 119 |
    120 | 121 | 122 | 123 |
    124 | 125 |
    126 | If you'd like all data that's stored about your GitHub account removed, you can 127 | . 128 |
    129 | 130 | 134 | 135 | {{end}} 136 | 137 | -------------------------------------------------------------------------------- /app/templates/shared/digest.html: -------------------------------------------------------------------------------- 1 | {{define "digest"}} 2 | 3 |
    4 | 5 |

    6 | Here {{if eq .CommitCount 1}}is{{else}}are{{end}} your 7 | ({{.User.Login}}'s) 14 | {{.CommitCount}} {{if eq .CommitCount 1}}commit{{else}}commits{{end}} from 15 | years past. 16 |

    17 | 18 | {{range .IntervalDigests }} 19 | {{$interval := .}} 20 |

    {{.Header}}

    21 | 22 |

    {{.Description}}

    23 | 24 | {{range .RepoDigests}} 25 |

    26 | {{.Repo.FullName}} 27 |

    28 | 29 |
    30 | {{range .Commits }} 31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 |

    {{.Title}}

    40 | {{if .Message}} 41 |
    {{.Message}}
    42 | {{end}} 43 |
    44 | {{.DisplaySHA}} 46 | {{if $interval.Weekly}}{{.WeeklyDisplayDate}}{{else}}{{.DisplayDate}}{{end}} 48 |
    49 |
    50 |
    51 | {{end}} 52 |
    53 | {{end}} 54 | 55 | {{end}} 56 | 57 | {{if .RepoErrors}} 58 |
    59 | Errors were encountered for the following repositories: 60 | {{range $repoFullName, $error := .RepoErrors}} 61 | {{$repoFullName}} 62 | {{end}} 63 |
    64 | {{end}} 65 | 66 |
    67 | 68 | {{end}} 69 | -------------------------------------------------------------------------------- /app/templates/shared/email-footer.html: -------------------------------------------------------------------------------- 1 | {{define "email-footer"}} 2 | 3 |
    4 | 5 |
    6 | 7 |

    8 | You are receiving this email because you set up a 9 | RetroGit account. 10 |

    11 | 12 |

    13 | Update your email preferences 14 | | View digest in browser 15 |

    16 | 17 |

    18 | RetroGit is a project by 19 | Mihai Parparita. 20 |

    21 | 22 |
    23 | 24 | {{end}} 25 | -------------------------------------------------------------------------------- /app/templates/shared/flash.html: -------------------------------------------------------------------------------- 1 | {{define "flash"}} 2 | 3 |
    4 | {{.}} 5 |
    6 | 21 | 22 | 23 | {{end}} 24 | -------------------------------------------------------------------------------- /app/templates/shared/user.html: -------------------------------------------------------------------------------- 1 | {{define "user"}} 2 | 3 | 5 | {{.Login}} 10 | 11 | 12 | {{end}} 13 | -------------------------------------------------------------------------------- /app/templates/users-admin.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Users Admin{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 | 7 |
    8 | {{len .Users}} users. 9 |
    10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{range .Users}} 25 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 44 | 45 | {{end}} 46 | 47 |
    User IDUsernameEmailFrequencyDigestReposAccount
    {{.Account.GitHubUserId}} 28 | {{if .User}} 29 | {{template "user" .User}} 30 | {{else}} 31 | User could not be looked up, credentials have most likely been revoked. 32 | {{end}} 33 | {{.EmailAddress}}{{.Account.Frequency}}ViewView 39 |
    40 | 41 | 42 |
    43 |
    48 | 49 | {{end}} 50 | -------------------------------------------------------------------------------- /app/timezones.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "time" 9 | ) 10 | 11 | type TimezoneConfig struct { 12 | LocationNames []string 13 | } 14 | 15 | type Timezone struct { 16 | LocationName string 17 | DisplayUTCOffset string 18 | } 19 | 20 | type Timezones []Timezone 21 | 22 | func initTimezones() Timezones { 23 | configBytes, err := ioutil.ReadFile("config/timezones.json") 24 | if err != nil { 25 | log.Panicf("Could not read timezone config: %s", err.Error()) 26 | } 27 | var config TimezoneConfig 28 | err = json.Unmarshal(configBytes, &config) 29 | if err != nil { 30 | log.Panicf("Could not parse timezones config %s: %s", configBytes, err.Error()) 31 | } 32 | timezones = make(Timezones, 0, len(config.LocationNames)) 33 | // Use a January date so that UTC offsets are not affected by DST in the northern hemisphere 34 | now := time.Unix(380937600, 0) 35 | for _, locationName := range config.LocationNames { 36 | if locationName == "" { 37 | timezones = append(timezones, Timezone{ 38 | LocationName: locationName, 39 | }) 40 | continue 41 | } 42 | location, err := time.LoadLocation(locationName) 43 | if err != nil { 44 | log.Printf("Location %s could not be loaded: %s", locationName, err.Error()) 45 | continue 46 | } 47 | utcDelta := now.Sub(time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), location)) 48 | utcDeltaHours := int(utcDelta.Hours()) 49 | utcDeltaMinutes := int(utcDelta.Minutes()) - 60*utcDeltaHours 50 | timezones = append(timezones, Timezone{ 51 | LocationName: locationName, 52 | DisplayUTCOffset: fmt.Sprintf("%d:%02d", utcDeltaHours, utcDeltaMinutes), 53 | }) 54 | } 55 | return timezones 56 | } 57 | -------------------------------------------------------------------------------- /assets/favicon16.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/favicon16.psd -------------------------------------------------------------------------------- /assets/favicon32.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/favicon32.psd -------------------------------------------------------------------------------- /assets/github app logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/github app logo.psd -------------------------------------------------------------------------------- /assets/perforated paper.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/perforated paper.psd -------------------------------------------------------------------------------- /assets/punch card commit corner.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/punch card commit corner.psd -------------------------------------------------------------------------------- /assets/punch card.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/punch card.psd -------------------------------------------------------------------------------- /assets/punch card@2x.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/punch card@2x.psd -------------------------------------------------------------------------------- /assets/screenshot.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mihaip/retrogit/8817f50e697b186f77deceea5a7534c93f63eb22/assets/screenshot.psd -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd app 4 | gcloud app deploy --project retro-git app.yaml 5 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python3 `which dev_appserver.py` --enable_sendmail=yes app 4 | --------------------------------------------------------------------------------