├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
32 | {{if ne .SettingsSummary.EmailAddress "disabled"}}
33 | or
34 |
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 |
23 |
24 | {{range .Repos.UserRepos}}
25 | {{template "repo" .}}
26 | {{end}}
27 |
28 |
29 |
30 | {{range .Repos.OtherUserRepos}}
31 |
32 |
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 |
6 |
7 |
8 | {{.FullName}}
9 | {{.DisplayVintage}}
10 |
11 |
12 | {{end}}
13 |
14 | {{define "body"}}
15 |
16 |
17 |
18 |
124 |
125 |
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 |
28 |
29 |
30 | {{range .Commits }}
31 |
32 |
35 |
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 |
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 | User ID
15 | Username
16 | Email
17 | Frequency
18 | Digest
19 | Repos
20 | Account
21 |
22 |
23 |
24 | {{range .Users}}
25 |
26 | {{.Account.GitHubUserId}}
27 |
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 |
34 | {{.EmailAddress}}
35 | {{.Account.Frequency}}
36 | View
37 | View
38 |
39 |
43 |
44 |
45 | {{end}}
46 |
47 |
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 |
--------------------------------------------------------------------------------