├── .github └── workflows │ └── mirror-pull-requests.yaml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── dashboard ├── dashboard.go └── dashboard_test.go ├── go.mod ├── main.go ├── repo ├── git.go ├── model.go └── repotest │ └── mock_repo.go ├── ui ├── file_contents.html ├── list_branches.html ├── list_repos.html ├── list_todos.html ├── list_todos_paths.html ├── todo_details.html ├── todo_tracker.css └── todo_tracker.js └── utils └── resource-constants.go /.github/workflows/mirror-pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Mirror pull requests into git-notes 2 | on: [pull_request, issue_comment, pull_request_review, pull_request_review_comment, status] 3 | jobs: 4 | build: 5 | name: Mirror 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Check out the repo 9 | uses: actions/checkout@v2 10 | 11 | - name: Set up Go 1.14 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: 1.14 15 | 16 | - name: Setup git-appraise and the pr mirror 17 | run: | 18 | go get github.com/google/git-appraise/git-appraise 19 | go get github.com/google/git-pull-request-mirror/batch 20 | 21 | - name: Configure git for the PR mirror 22 | run: | 23 | git config --global user.email "${{ github.repository }}@github.com" 24 | git config --global user.name "Pull Request Mirror" 25 | 26 | - name: Fetch upstream refs 27 | run: | 28 | git fetch origin '+refs/heads/*:refs/heads/*' 29 | git fetch origin '+refs/tags/*:refs/tags/*' 30 | git fetch origin '+refs/pull/*:refs/pull/*' 31 | 32 | - name: Pull existing reviews 33 | run: go run github.com/google/git-appraise/git-appraise pull 34 | 35 | - name: Mirror pull requests into local reviews 36 | run: go run github.com/google/git-pull-request-mirror/batch --target '${{ github.repository }}' --local ./ --auth-token '${{ secrets.PR_MIRROR_TOKEN }}' 37 | 38 | - name: Merge any upstream review changes 39 | run: go run github.com/google/git-appraise/git-appraise pull 40 | 41 | - name: Push updated reviews back upstream 42 | run: go run github.com/google/git-appraise/git-appraise push 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | *~ 3 | resources/ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: go 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Want to contribute? Great! First, read this page (including the small print at the end). 2 | 3 | ### Before you contribute 4 | Before we can use your code, you must sign the 5 | [Google Individual Contributor License Agreement](https://developers.google.com/open-source/cla/individual?csw=1) 6 | (CLA), which you can do online. The CLA is necessary mainly because you own the 7 | copyright to your changes, even after your contribution becomes part of our 8 | codebase, so we need your permission to use and distribute your code. We also 9 | need to be sure of various other things—for instance that you'll tell us if you 10 | know that your code infringes on other people's patents. You don't have to sign 11 | the CLA until after you've submitted your code for review and a member has 12 | approved it, but you must do it before we can put your code into our codebase. 13 | Before you start working on a larger contribution, you should get in touch with 14 | us first through the issue tracker with your idea so that we can help out and 15 | possibly guide you. Coordinating up front makes it much easier to avoid 16 | frustration later on. 17 | 18 | ### Code reviews 19 | All submissions, including submissions by project members, require review. We 20 | use Github pull requests for this purpose. 21 | 22 | ### The small print 23 | Contributions made by corporations are covered by a different agreement than 24 | the one above, the Software Grant and Corporate Contributor License Agreement. 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: test 2 | go build -o bin/todos main.go 3 | 4 | test: resource-constants 5 | go test ./... 6 | 7 | resource-constants: fmt 8 | mkdir -p bin 9 | go build -o bin/resource-constants utils/resource-constants.go 10 | if [ ! -e "resources" ]; then mkdir resources; fi 11 | bin/resource-constants --base_dir $(shell pwd)/ui/ > resources/constants.go 12 | 13 | fmt: 14 | gofmt -w `find ./ -name '*.go'` 15 | 16 | clean: 17 | rm -r bin || true 18 | rm -r resources || true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TODO Tracks 2 | 3 | TODO Tracks is a tool to let users get a handle on the various TODOs they or 4 | their teammate have added over time. This allows people to track progress by 5 | examining the TODOs remaining. 6 | 7 | The tool examines all the branches in a git repo (local and remote), finds the TODOs 8 | in the different revisions, and presents them to the user. 9 | 10 | Use cases: 11 | 12 | * List the TODOs in a branch. 13 | * Examine when a TODO was added, removed, and who added it. 14 | * Show which branches a TODO is in. 15 | 16 | ## Disclaimer 17 | 18 | This is not an official Google product. 19 | 20 | ## Prerequisites 21 | 22 | Building requires the Go tools and GNU Make. Running the built binary requires the git command line tool. 23 | 24 | ## Building the source code 25 | 26 | First checkout the code from the git repo: 27 | 28 | git clone git@github.com:google/todo-tracks.git 29 | 30 | Build the binary: 31 | 32 | make 33 | 34 | And then launch it: 35 | 36 | bin/todos 37 | 38 | The tracker requires that it be started in a directory that contains at least one git repo, and it shows the TODOs from every git repo under that directory. 39 | 40 | The UI for the tracker is a webserver which defaults to listening on port 8080. To use a different port, pass it as an argument to the "--port" flag: 41 | 42 | bin/todos --port=12345 43 | 44 | For more details about the supported command line flags, pass in the "--help" flag. 45 | 46 | bin/todos --help 47 | -------------------------------------------------------------------------------- /dashboard/dashboard.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package dashboard 18 | 19 | import ( 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "html/template" 24 | "net/http" 25 | "net/url" 26 | "sort" 27 | "strconv" 28 | 29 | "github.com/google/todo-tracks/repo" 30 | "github.com/google/todo-tracks/resources" 31 | ) 32 | 33 | const ( 34 | fileContentsResource = "file_contents.html" 35 | ) 36 | 37 | type Dashboard struct { 38 | Repositories map[string]*repo.Repository 39 | TodoRegex string 40 | ExcludePaths string 41 | } 42 | 43 | func (db Dashboard) readRepoParam(r *http.Request) (*repo.Repository, error) { 44 | repoParam := r.URL.Query().Get("repo") 45 | if repoParam == "" { 46 | if len(db.Repositories) != 1 { 47 | return nil, errors.New("Missing the repo parameter") 48 | } 49 | for key := range db.Repositories { 50 | repoParam = key 51 | break 52 | } 53 | } 54 | repository := db.Repositories[repoParam] 55 | if repository == nil { 56 | return nil, errors.New(fmt.Sprintf("Unknown repo '%s'", repoParam)) 57 | } 58 | return repository, nil 59 | } 60 | 61 | func (db Dashboard) readRepoAndRevisionParams(r *http.Request) (*repo.Repository, repo.Revision, error) { 62 | repository, err := db.readRepoParam(r) 63 | if err != nil { 64 | return nil, repo.Revision(""), err 65 | } 66 | revisionParam := r.URL.Query().Get("revision") 67 | if revisionParam == "" { 68 | return nil, repo.Revision(""), errors.New("Missing the revision parameter") 69 | } 70 | revision, err := (*repository).ValidateRevision(revisionParam) 71 | if err != nil { 72 | return nil, repo.Revision(""), err 73 | } 74 | return repository, revision, nil 75 | } 76 | 77 | func (db Dashboard) readRepoRevisionAndPathParams(r *http.Request) (*repo.Repository, repo.Revision, string, error) { 78 | repository, revision, err := db.readRepoAndRevisionParams(r) 79 | if err != nil { 80 | return nil, repo.Revision(""), "", err 81 | } 82 | fileName, err := url.QueryUnescape(r.URL.Query().Get("fileName")) 83 | if err != nil || fileName == "" { 84 | return nil, repo.Revision(""), "", errors.New("Missing the fileName parameter") 85 | } 86 | err = (*repository).ValidatePathAtRevision(revision, fileName) 87 | return repository, revision, fileName, err 88 | } 89 | 90 | func (db Dashboard) readRepoRevisionPathAndLineNumberParams(r *http.Request) (*repo.Repository, repo.Revision, string, int, error) { 91 | repository, revision, fileName, err := db.readRepoRevisionAndPathParams(r) 92 | if err != nil { 93 | return nil, repo.Revision(""), "", 0, err 94 | } 95 | lineNumberParam := r.URL.Query().Get("lineNumber") 96 | if lineNumberParam == "" { 97 | return nil, repo.Revision(""), "", 0, errors.New("Missing the lineNumber param") 98 | } 99 | lineNumber, err := strconv.Atoi(lineNumberParam) 100 | if err != nil { 101 | return nil, repo.Revision(""), "", 0, fmt.Errorf("Invalid format for the lineNumber parameter: %v", err) 102 | } 103 | err = (*repository).ValidateLineNumberInPathAtRevision(revision, fileName, lineNumber) 104 | return repository, revision, fileName, lineNumber, err 105 | } 106 | 107 | // Serve the main page. 108 | func (db Dashboard) ServeMainPage(w http.ResponseWriter, r *http.Request) { 109 | if len(db.Repositories) == 1 { 110 | for repoId := range db.Repositories { 111 | http.Redirect(w, r, 112 | "/ui/list_branches.html#?repo="+repoId, 113 | http.StatusMovedPermanently) 114 | return 115 | } 116 | } else { 117 | http.Redirect(w, r, "/ui/list_repos.html", http.StatusMovedPermanently) 118 | } 119 | } 120 | 121 | // Serve the aliases JSON for a repo. 122 | func (db Dashboard) ServeAliasesJson(w http.ResponseWriter, r *http.Request) { 123 | repositoryPtr, err := db.readRepoParam(r) 124 | if err != nil { 125 | w.WriteHeader(http.StatusBadRequest) 126 | fmt.Fprintf(w, "Error loading repo: \"%s\"", err) 127 | return 128 | } 129 | repository := *repositoryPtr 130 | err = repo.WriteJson(w, repository) 131 | if err != nil { 132 | w.WriteHeader(http.StatusInternalServerError) 133 | fmt.Fprintf(w, "Server error \"%s\"", err) 134 | } 135 | } 136 | 137 | // Serve the JSON for a single revision. 138 | // The ID of the revision is taken from the URL parameters of the request. 139 | func (db Dashboard) ServeRevisionJson(w http.ResponseWriter, r *http.Request) { 140 | repositoryPtr, revision, err := db.readRepoAndRevisionParams(r) 141 | if err != nil { 142 | w.WriteHeader(http.StatusBadRequest) 143 | fmt.Fprintf(w, err.Error()) 144 | return 145 | } 146 | repository := *repositoryPtr 147 | err = repo.WriteTodosJson( 148 | w, repository, revision, db.TodoRegex, db.ExcludePaths) 149 | if err != nil { 150 | w.WriteHeader(http.StatusInternalServerError) 151 | fmt.Fprintf(w, "Server error \"%s\"", err) 152 | } 153 | } 154 | 155 | // Serve the details JSON for a single TODO. 156 | // The revision, path, and line number are all taken from the URL parameters of the request. 157 | func (db Dashboard) ServeTodoJson(w http.ResponseWriter, r *http.Request) { 158 | repositoryPtr, revision, fileName, lineNumber, err := db.readRepoRevisionPathAndLineNumberParams(r) 159 | if err != nil { 160 | w.WriteHeader(http.StatusBadRequest) 161 | fmt.Fprint(w, err) 162 | return 163 | } 164 | repository := *repositoryPtr 165 | todoId := repo.TodoId{ 166 | Revision: revision, 167 | FileName: fileName, 168 | LineNumber: lineNumber, 169 | } 170 | repo.WriteTodoDetailsJson(w, repository, todoId) 171 | } 172 | 173 | // Serve the status details JSON for a single TODO. 174 | // The revision, path, and line number are all taken from the URL parameters of the request. 175 | func (db Dashboard) ServeTodoStatusJson(w http.ResponseWriter, r *http.Request) { 176 | repositoryPtr, revision, fileName, lineNumber, err := db.readRepoRevisionPathAndLineNumberParams(r) 177 | if err != nil { 178 | w.WriteHeader(http.StatusBadRequest) 179 | fmt.Fprint(w, err) 180 | return 181 | } 182 | repository := *repositoryPtr 183 | todoId := repo.TodoId{ 184 | Revision: revision, 185 | FileName: fileName, 186 | LineNumber: lineNumber, 187 | } 188 | repo.WriteTodoStatusDetailsJson(w, repository, todoId) 189 | } 190 | 191 | // Serve the redirect for browsing a file. 192 | // The revision, path, and line number are all taken from the URL parameters of the request. 193 | func (db Dashboard) ServeBrowseRedirect(w http.ResponseWriter, r *http.Request) { 194 | repositoryPtr, revision, fileName, err := db.readRepoRevisionAndPathParams(r) 195 | if err != nil { 196 | w.WriteHeader(http.StatusBadRequest) 197 | fmt.Fprintf(w, err.Error()) 198 | return 199 | } 200 | repository := *repositoryPtr 201 | lineNumberParam := r.URL.Query().Get("lineNumber") 202 | if lineNumberParam == "" { 203 | lineNumberParam = "1" 204 | } 205 | lineNumber, err := strconv.Atoi(lineNumberParam) 206 | if err != nil { 207 | w.WriteHeader(http.StatusBadRequest) 208 | fmt.Fprintf(w, "Invalid format for the lineNumber parameter: %s", err) 209 | return 210 | } 211 | err = repository.ValidateLineNumberInPathAtRevision(revision, fileName, lineNumber) 212 | if err != nil { 213 | w.WriteHeader(http.StatusBadRequest) 214 | fmt.Fprintf(w, err.Error()) 215 | return 216 | } 217 | http.Redirect(w, r, repository.GetBrowseUrl( 218 | revision, fileName, lineNumber), http.StatusMovedPermanently) 219 | } 220 | 221 | type fileContents struct { 222 | LineNumber int 223 | Contents string 224 | } 225 | 226 | // Serve the contents for a single file. 227 | // The revision, path, and line number are all taken from the URL parameters of the request. 228 | func (db Dashboard) ServeFileContents(w http.ResponseWriter, r *http.Request) { 229 | htmlTemplate, err := template.New("fileContentsTemplate").Parse( 230 | string(resources.Constants[fileContentsResource])) 231 | if err != nil { 232 | w.WriteHeader(http.StatusInternalServerError) 233 | fmt.Fprintf(w, "Server error \"%s\"", err) 234 | } 235 | repositoryPtr, revision, fileName, lineNumber, err := db.readRepoRevisionPathAndLineNumberParams(r) 236 | if err != nil { 237 | w.WriteHeader(http.StatusBadRequest) 238 | fmt.Fprintf(w, err.Error()) 239 | return 240 | } 241 | repository := *repositoryPtr 242 | contents := repository.ReadFileSnippetAtRevision(revision, fileName, 1, -1) 243 | err = htmlTemplate.Execute(w, fileContents{ 244 | LineNumber: lineNumber, 245 | Contents: contents}) 246 | if err != nil { 247 | w.WriteHeader(http.StatusInternalServerError) 248 | fmt.Fprintf(w, "Server error \"%s\"", err) 249 | } 250 | } 251 | 252 | type repoPath struct { 253 | Path string 254 | RepoId string 255 | } 256 | type sortByPath []repoPath 257 | 258 | func (rs sortByPath) Len() int { return len(rs) } 259 | func (rs sortByPath) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } 260 | func (rs sortByPath) Less(i, j int) bool { return rs[i].Path < rs[j].Path } 261 | 262 | func (db Dashboard) ServeReposJson(w http.ResponseWriter, r *http.Request) { 263 | repoPaths := make([]repoPath, 0) 264 | for repoId, repository := range db.Repositories { 265 | repoPaths = append(repoPaths, repoPath{(*repository).GetRepoPath(), repoId}) 266 | } 267 | sort.Sort(sortByPath(repoPaths)) 268 | 269 | reposJson, err := json.Marshal(repoPaths) 270 | if err != nil { 271 | w.WriteHeader(http.StatusInternalServerError) 272 | fmt.Fprintf(w, "Server error \"%s\"", err) 273 | } 274 | w.Write(reposJson) 275 | } 276 | -------------------------------------------------------------------------------- /dashboard/dashboard_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package dashboard_test 18 | 19 | import ( 20 | "encoding/json" 21 | "net/http" 22 | "net/http/httptest" 23 | "net/url" 24 | "strconv" 25 | "strings" 26 | "testing" 27 | 28 | "github.com/google/todo-tracks/dashboard" 29 | "github.com/google/todo-tracks/repo" 30 | "github.com/google/todo-tracks/repo/repotest" 31 | ) 32 | 33 | const ( 34 | TestRevision = "testRevision" 35 | TestFileName = "testFile" 36 | TestLineNumber = 42 37 | TestTodoContents = "TODO: test this" 38 | ) 39 | 40 | var mockAlias repo.Alias 41 | var mockTodo repo.Line 42 | var mockRepo repo.Repository 43 | var mockRepos map[string]*repo.Repository 44 | 45 | func init() { 46 | mockAlias = repo.Alias{"branch", repo.Revision("revision")} 47 | mockTodo = repo.Line{ 48 | Revision: repo.Revision(TestRevision), 49 | FileName: TestFileName, 50 | LineNumber: TestLineNumber, 51 | Contents: TestTodoContents, 52 | } 53 | 54 | aliases := make([]repo.Alias, 0) 55 | aliases = append(aliases, mockAlias) 56 | revisionTodos := make(map[string][]repo.Line) 57 | revisionTodos[TestRevision] = 58 | append(revisionTodos[TestRevision], mockTodo) 59 | mockRepo = repotest.MockRepository{ 60 | Aliases: aliases, 61 | RevisionTodos: revisionTodos, 62 | } 63 | mockRepos = make(map[string]*repo.Repository) 64 | mockRepos[mockRepo.GetRepoId()] = &mockRepo 65 | } 66 | 67 | func TestServeAliasesJsonNoRepo(t *testing.T) { 68 | request, err := http.NewRequest("GET", "/", strings.NewReader("")) 69 | if err != nil { 70 | t.Error(err) 71 | } 72 | rw := httptest.NewRecorder() 73 | db := dashboard.Dashboard{mockRepos, "", ""} 74 | db.ServeAliasesJson(rw, request) 75 | if rw.Code != http.StatusOK { 76 | t.Errorf("Expected a response code of %d, but saw %d, with a body of '%s'", 77 | http.StatusOK, rw.Code, rw.Body.String()) 78 | return 79 | } 80 | var returnedAliases []repo.Alias 81 | err = json.Unmarshal(rw.Body.Bytes(), &returnedAliases) 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | if len(returnedAliases) != 1 || returnedAliases[0] != mockAlias { 86 | t.Errorf("Expected a singleton slice of %s, but saw %s", mockAlias, returnedAliases) 87 | } 88 | } 89 | 90 | func TestServeAliasesJson(t *testing.T) { 91 | request, err := http.NewRequest("GET", "/?repo="+mockRepo.GetRepoId(), strings.NewReader("")) 92 | if err != nil { 93 | t.Error(err) 94 | } 95 | rw := httptest.NewRecorder() 96 | db := dashboard.Dashboard{mockRepos, "", ""} 97 | db.ServeAliasesJson(rw, request) 98 | if rw.Code != http.StatusOK { 99 | t.Errorf("Expected a response code of %d, but saw %d, with a body of '%s'", 100 | http.StatusOK, rw.Code, rw.Body.String()) 101 | return 102 | } 103 | var returnedAliases []repo.Alias 104 | err = json.Unmarshal(rw.Body.Bytes(), &returnedAliases) 105 | if err != nil { 106 | t.Error(err) 107 | } 108 | if len(returnedAliases) != 1 || returnedAliases[0] != mockAlias { 109 | t.Errorf("Expected a singleton slice of %s, but saw %s", mockAlias, returnedAliases) 110 | } 111 | } 112 | 113 | func TestServeRevisionJsonNoRepo(t *testing.T) { 114 | request, err := http.NewRequest("GET", "/revision", strings.NewReader("")) 115 | if err != nil { 116 | t.Error(err) 117 | } 118 | rw := httptest.NewRecorder() 119 | db := dashboard.Dashboard{mockRepos, "", ""} 120 | db.ServeRevisionJson(rw, request) 121 | if rw.Code != http.StatusBadRequest { 122 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 123 | } 124 | } 125 | 126 | func TestServeRevisionJsonNoId(t *testing.T) { 127 | params := url.Values{} 128 | params.Add("repo", mockRepo.GetRepoId()) 129 | request, err := http.NewRequest("GET", "/revision?"+params.Encode(), strings.NewReader("")) 130 | if err != nil { 131 | t.Error(err) 132 | } 133 | rw := httptest.NewRecorder() 134 | db := dashboard.Dashboard{mockRepos, "", ""} 135 | db.ServeRevisionJson(rw, request) 136 | if rw.Code != http.StatusBadRequest { 137 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 138 | } 139 | } 140 | 141 | func TestServeRevisionJson(t *testing.T) { 142 | params := url.Values{} 143 | params.Add("repo", mockRepo.GetRepoId()) 144 | params.Add("revision", TestRevision) 145 | request, err := http.NewRequest("GET", "/revision?"+params.Encode(), strings.NewReader("")) 146 | if err != nil { 147 | t.Error(err) 148 | } 149 | rw := httptest.NewRecorder() 150 | db := dashboard.Dashboard{mockRepos, "", ""} 151 | db.ServeRevisionJson(rw, request) 152 | if rw.Code != http.StatusOK { 153 | t.Errorf("Expected a response code of %d, but saw %d, with a body of '%s'", 154 | http.StatusOK, rw.Code, rw.Body.String()) 155 | return 156 | } 157 | var returnedTodos []repo.Line 158 | err = json.Unmarshal(rw.Body.Bytes(), &returnedTodos) 159 | if err != nil { 160 | t.Error(err) 161 | } 162 | if len(returnedTodos) != 1 || returnedTodos[0] != mockTodo { 163 | t.Errorf("Expected a singleton slice of %v, but saw %v", mockTodo, returnedTodos) 164 | } 165 | } 166 | 167 | func TestServeTodoJsonNoRepo(t *testing.T) { 168 | request, err := http.NewRequest("GET", "/todo", strings.NewReader("")) 169 | if err != nil { 170 | t.Error(err) 171 | } 172 | rw := httptest.NewRecorder() 173 | db := dashboard.Dashboard{mockRepos, "", ""} 174 | db.ServeTodoJson(rw, request) 175 | if rw.Code != http.StatusBadRequest { 176 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 177 | } 178 | } 179 | 180 | func TestServeTodoJsonNoRevision(t *testing.T) { 181 | params := url.Values{} 182 | params.Add("repo", mockRepo.GetRepoId()) 183 | request, err := http.NewRequest("GET", "/todo?"+params.Encode(), strings.NewReader("")) 184 | if err != nil { 185 | t.Error(err) 186 | } 187 | rw := httptest.NewRecorder() 188 | db := dashboard.Dashboard{mockRepos, "", ""} 189 | db.ServeTodoJson(rw, request) 190 | if rw.Code != http.StatusBadRequest { 191 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 192 | } 193 | } 194 | 195 | func TestServeTodoJsonNoFileName(t *testing.T) { 196 | params := url.Values{} 197 | params.Add("repo", mockRepo.GetRepoId()) 198 | params.Add("revision", TestRevision) 199 | request, err := http.NewRequest("GET", "/todo?"+params.Encode(), strings.NewReader("")) 200 | if err != nil { 201 | t.Error(err) 202 | } 203 | rw := httptest.NewRecorder() 204 | db := dashboard.Dashboard{mockRepos, "", ""} 205 | db.ServeTodoJson(rw, request) 206 | if rw.Code != http.StatusBadRequest { 207 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 208 | } 209 | } 210 | 211 | func TestServeTodoJsonNoLineNumber(t *testing.T) { 212 | params := url.Values{} 213 | params.Add("repo", mockRepo.GetRepoId()) 214 | params.Add("revision", TestRevision) 215 | params.Add("fileName", TestFileName) 216 | request, err := http.NewRequest("GET", "/todo?"+params.Encode(), strings.NewReader("")) 217 | if err != nil { 218 | t.Error(err) 219 | } 220 | rw := httptest.NewRecorder() 221 | db := dashboard.Dashboard{mockRepos, "", ""} 222 | db.ServeTodoJson(rw, request) 223 | if rw.Code != http.StatusBadRequest { 224 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 225 | } 226 | } 227 | 228 | func TestServeTodoJsonInvalidLineNumber(t *testing.T) { 229 | params := url.Values{} 230 | params.Add("repo", mockRepo.GetRepoId()) 231 | params.Add("revision", TestRevision) 232 | params.Add("fileName", TestFileName) 233 | params.Add("lineNumber", "fortyTwo") 234 | request, err := http.NewRequest("GET", "/todo?"+params.Encode(), strings.NewReader("")) 235 | if err != nil { 236 | t.Error(err) 237 | } 238 | rw := httptest.NewRecorder() 239 | db := dashboard.Dashboard{mockRepos, "", ""} 240 | db.ServeTodoJson(rw, request) 241 | if rw.Code != http.StatusBadRequest { 242 | t.Errorf("Expected a response code of %d, but saw %d", http.StatusBadRequest, rw.Code) 243 | } 244 | } 245 | 246 | func TestServeTodoJson(t *testing.T) { 247 | params := url.Values{} 248 | params.Add("repo", mockRepo.GetRepoId()) 249 | params.Add("revision", TestRevision) 250 | params.Add("fileName", TestFileName) 251 | params.Add("lineNumber", strconv.Itoa(TestLineNumber)) 252 | request, err := http.NewRequest("GET", "/todo?"+params.Encode(), strings.NewReader("")) 253 | if err != nil { 254 | t.Error(err) 255 | } 256 | rw := httptest.NewRecorder() 257 | db := dashboard.Dashboard{mockRepos, "", ""} 258 | db.ServeTodoJson(rw, request) 259 | if rw.Code != http.StatusOK { 260 | t.Errorf("Expected a response code of %d, but saw %d, with a body of '%s'", 261 | http.StatusOK, rw.Code, rw.Body.String()) 262 | return 263 | } 264 | var returnedTodo repo.TodoDetails 265 | err = json.Unmarshal(rw.Body.Bytes(), &returnedTodo) 266 | if err != nil { 267 | t.Error(err) 268 | } 269 | if returnedTodo.Id.Revision != mockTodo.Revision || 270 | returnedTodo.Id.FileName != mockTodo.FileName || 271 | returnedTodo.Id.LineNumber != mockTodo.LineNumber { 272 | t.Errorf("Expected %v, but saw %v", mockTodo, returnedTodo) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/todo-tracks 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "flag" 21 | "fmt" 22 | "log" 23 | "net/http" 24 | "os" 25 | "path/filepath" 26 | "strings" 27 | 28 | "github.com/google/todo-tracks/dashboard" 29 | "github.com/google/todo-tracks/repo" 30 | "github.com/google/todo-tracks/resources" 31 | ) 32 | 33 | const ( 34 | fileContentsResource = "file_contents.html" 35 | ) 36 | 37 | var port int 38 | var todoRegex string 39 | var excludePaths string 40 | 41 | func init() { 42 | flag.IntVar(&port, "port", 8080, "Port on which to start the server.") 43 | flag.StringVar( 44 | &todoRegex, 45 | "todo_regex", 46 | "(^|[^[:alpha:]])(t|T)(o|O)(d|D)(o|O)[^[:alpha:]]", 47 | "Regular expression (using the re2 syntax) to use when matching TODOs.") 48 | flag.StringVar( 49 | &excludePaths, 50 | "exclude_paths", 51 | "", 52 | "Comma-separated list of file paths to exclude when matching TODOs. Each path is specified as a regular expression using the re2 syntax.") 53 | } 54 | 55 | func serveStaticContent(w http.ResponseWriter, resourceName string) { 56 | resourceContents := resources.Constants[resourceName] 57 | var contentType string 58 | if strings.HasSuffix(resourceName, ".css") { 59 | contentType = "text/css" 60 | } else if strings.HasSuffix(resourceName, ".html") { 61 | contentType = "text/html" 62 | } else if strings.HasSuffix(resourceName, ".js") { 63 | contentType = "text/javascript" 64 | } else { 65 | contentType = http.DetectContentType(resourceContents) 66 | } 67 | w.Header().Set("Content-Type", contentType) 68 | w.Write(resourceContents) 69 | } 70 | 71 | func serveDashboard(dashboard dashboard.Dashboard) { 72 | http.HandleFunc("/ui/", func(w http.ResponseWriter, r *http.Request) { 73 | resourceName := r.URL.Path[4:] 74 | serveStaticContent(w, resourceName) 75 | }) 76 | http.HandleFunc("/repos", dashboard.ServeReposJson) 77 | http.HandleFunc("/aliases", dashboard.ServeAliasesJson) 78 | http.HandleFunc("/revision", dashboard.ServeRevisionJson) 79 | http.HandleFunc("/todo", dashboard.ServeTodoJson) 80 | http.HandleFunc("/todoStatus", dashboard.ServeTodoStatusJson) 81 | http.HandleFunc("/browse", dashboard.ServeBrowseRedirect) 82 | http.HandleFunc("/raw", dashboard.ServeFileContents) 83 | http.HandleFunc("/_ah/health", 84 | func(w http.ResponseWriter, r *http.Request) { 85 | fmt.Fprintf(w, "ok") 86 | }) 87 | http.HandleFunc("/", dashboard.ServeMainPage) 88 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil)) 89 | } 90 | 91 | // Find all local repositories under the current working directory. 92 | func getLocalRepos() (map[string]*repo.Repository, error) { 93 | cwd, err := os.Getwd() 94 | if err != nil { 95 | return nil, err 96 | } 97 | repos := make(map[string]*repo.Repository) 98 | filepath.Walk(cwd, func(path string, info os.FileInfo, err error) error { 99 | if err != nil { 100 | return err 101 | } 102 | if info.IsDir() { 103 | dir, err := os.Open(path) 104 | if err != nil { 105 | return err 106 | } 107 | children, err := dir.Readdir(-1) 108 | if err != nil { 109 | return err 110 | } 111 | for _, child := range children { 112 | if child.IsDir() && child.Name() == ".git" { 113 | gitRepo := repo.NewGitRepository(path, todoRegex, excludePaths) 114 | repos[gitRepo.GetRepoId()] = &gitRepo 115 | return filepath.SkipDir 116 | } 117 | } 118 | } 119 | return nil 120 | }) 121 | return repos, nil 122 | } 123 | 124 | func main() { 125 | flag.Parse() 126 | repos, err := getLocalRepos() 127 | if err != nil { 128 | log.Fatal(err.Error()) 129 | } 130 | if repos == nil { 131 | log.Fatal("Unable to find any local repositories under the current directory") 132 | } 133 | serveDashboard(dashboard.Dashboard{repos, todoRegex, excludePaths}) 134 | } 135 | -------------------------------------------------------------------------------- /repo/git.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repo 18 | 19 | import ( 20 | "bytes" 21 | "crypto/sha1" 22 | "errors" 23 | "fmt" 24 | "log" 25 | "net/url" 26 | "os/exec" 27 | "regexp" 28 | "strconv" 29 | "strings" 30 | "sync" 31 | ) 32 | 33 | const ( 34 | hashFormat = "^([[:xdigit:]]){40}$" 35 | maxCacheEntries = 1000 36 | ) 37 | 38 | var hashRegexp *regexp.Regexp 39 | 40 | func init() { 41 | var err error 42 | hashRegexp, err = regexp.Compile(hashFormat) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | } 47 | 48 | type gitRepository struct { 49 | DirPath string 50 | BlobTodosCache *sync.Map 51 | RevisionTodosCache *sync.Map 52 | } 53 | 54 | func NewGitRepository(dirPath, todoRegex, excludePaths string) Repository { 55 | repository := &gitRepository{ 56 | DirPath: dirPath, 57 | BlobTodosCache: &sync.Map{}, 58 | RevisionTodosCache: &sync.Map{}, 59 | } 60 | go func() { 61 | // Pre-load all of the TODOs for the current branches 62 | for _, alias := range repository.ListBranches() { 63 | repository.LoadRevisionTodos(alias.Revision, todoRegex, excludePaths) 64 | } 65 | }() 66 | return repository 67 | } 68 | 69 | func (repository *gitRepository) GetRepoId() string { 70 | return fmt.Sprintf("%x", sha1.Sum([]byte(repository.DirPath))) 71 | } 72 | 73 | func (repository *gitRepository) GetRepoPath() string { 74 | return repository.DirPath 75 | } 76 | 77 | func (repository *gitRepository) runGitCommand(cmd *exec.Cmd) (string, error) { 78 | cmd.Dir = repository.DirPath 79 | out, err := cmd.Output() 80 | if err != nil { 81 | return "", err 82 | } 83 | return strings.Trim(string(out), " \n"), nil 84 | } 85 | 86 | func (repository *gitRepository) runGitCommandWithoutTrim(cmd *exec.Cmd) (string, error) { 87 | cmd.Dir = repository.DirPath 88 | out, err := cmd.Output() 89 | if err != nil { 90 | return "", err 91 | } 92 | return string(out), nil 93 | } 94 | 95 | func (repository *gitRepository) runGitCommandOrDie(cmd *exec.Cmd) string { 96 | out, err := repository.runGitCommand(cmd) 97 | if err != nil { 98 | log.Print(cmd.Args) 99 | log.Print(out) 100 | log.Fatal(err) 101 | } 102 | return out 103 | } 104 | 105 | func (repository *gitRepository) runGitCommandWithoutTrimOrDie(cmd *exec.Cmd) string { 106 | out, err := repository.runGitCommandWithoutTrim(cmd) 107 | if err != nil { 108 | log.Print(cmd.Args) 109 | log.Print(out) 110 | log.Fatal(err) 111 | } 112 | return out 113 | } 114 | 115 | func splitCommandOutputLine(line string) []string { 116 | lineParts := make([]string, 0) 117 | for _, part := range strings.Split(line, " ") { 118 | if part != "" { 119 | lineParts = append(lineParts, part) 120 | } 121 | } 122 | return lineParts 123 | } 124 | 125 | func (repository *gitRepository) ListBranches() []Alias { 126 | out := repository.runGitCommandOrDie( 127 | exec.Command("git", "branch", "-av", "--list", "--abbrev=40", "--no-color")) 128 | lines := strings.Split(out, "\n") 129 | aliases := make([]Alias, 0) 130 | for _, line := range lines { 131 | line = strings.Trim(line, "* ") 132 | lineParts := splitCommandOutputLine(line) 133 | if len(lineParts) >= 2 && len(lineParts[1]) == 40 { 134 | branch := lineParts[0] 135 | revision := Revision(lineParts[1]) 136 | aliases = append(aliases, Alias{branch, revision}) 137 | } 138 | } 139 | return aliases 140 | } 141 | 142 | func (repository *gitRepository) IsAncestor(ancestor, descendant Revision) bool { 143 | _, err := repository.runGitCommand( 144 | exec.Command("git", "merge-base", "--is-ancestor", 145 | string(ancestor), string(descendant))) 146 | return err == nil 147 | } 148 | 149 | func (repository *gitRepository) ReadRevisionContents(revision Revision) *RevisionContents { 150 | out := repository.runGitCommandOrDie(exec.Command("git", "ls-tree", "-r", string(revision))) 151 | lines := strings.Split(out, "\n") 152 | paths := make([]string, 0) 153 | for _, line := range lines { 154 | line = strings.Replace(line, "\t", " ", -1) 155 | lineParts := strings.SplitN(line, " ", 4) 156 | paths = append(paths, lineParts[len(lineParts)-1]) 157 | } 158 | return &RevisionContents{revision, paths} 159 | } 160 | 161 | func (repository *gitRepository) getSubject(revision Revision) string { 162 | return repository.runGitCommandOrDie(exec.Command( 163 | "git", "show", string(revision), "--format=%s", "-s")) 164 | } 165 | 166 | func (repository *gitRepository) getAuthorName(revision Revision) string { 167 | return repository.runGitCommandOrDie(exec.Command( 168 | "git", "show", string(revision), "--format=%an", "-s")) 169 | } 170 | 171 | func (repository *gitRepository) getAuthorEmail(revision Revision) string { 172 | return repository.runGitCommandOrDie(exec.Command( 173 | "git", "show", string(revision), "--format=%ae", "-s")) 174 | } 175 | 176 | func (repository *gitRepository) getTimestamp(revision Revision) int64 { 177 | out := repository.runGitCommandOrDie(exec.Command( 178 | "git", "show", string(revision), "--format=%ct", "-s")) 179 | timestamp, err := strconv.ParseInt(out, 10, 64) 180 | if err != nil { 181 | log.Fatal(err) 182 | } 183 | return timestamp 184 | } 185 | 186 | func (repository *gitRepository) ReadRevisionMetadata(revision Revision) RevisionMetadata { 187 | return RevisionMetadata{ 188 | Revision: revision, 189 | Timestamp: repository.getTimestamp(revision), 190 | Subject: repository.getSubject(revision), 191 | AuthorName: repository.getAuthorName(revision), 192 | AuthorEmail: repository.getAuthorEmail(revision), 193 | } 194 | } 195 | 196 | func (repository *gitRepository) getFileBlob(revision Revision, path string) (string, error) { 197 | out, err := repository.runGitCommand(exec.Command("git", "ls-tree", "-r", string(revision))) 198 | if err != nil { 199 | return "", err 200 | } 201 | lines := strings.Split(out, "\n") 202 | for _, line := range lines { 203 | if strings.Contains(line, path) { 204 | lineParts := strings.Split(strings.Replace(line, "\t", " ", -1), " ") 205 | return lineParts[2], nil 206 | } 207 | } 208 | return "", errors.New("Failed to lookup blob hash for " + path) 209 | } 210 | 211 | func (repository *gitRepository) getFileBlobOrDie(revision Revision, path string) string { 212 | blob, err := repository.getFileBlob(revision, path) 213 | if err != nil { 214 | log.Fatal(err.Error()) 215 | } 216 | return blob 217 | } 218 | 219 | func parseBlameOutputOrDie(fileName string, out string) []Line { 220 | result := make([]Line, 0) 221 | for out != "" { 222 | // First split off the next blame section 223 | split := strings.SplitN(out, "\n\t", 2) 224 | blame := split[0] 225 | // Then split off the source line that goes with that blame section 226 | split = strings.SplitN(split[1], "\n", 2) 227 | contents := strings.TrimPrefix(split[0], "\t") 228 | // And update the out variable to be what is left. 229 | if len(split) == 2 { 230 | out = split[1] 231 | } else { 232 | out = "" 233 | } 234 | 235 | // Finally, parse the blame section and add to the result. 236 | blameParts := strings.Split(blame, "\n") 237 | firstLineParts := strings.Split(blameParts[0], " ") 238 | revision := Revision(firstLineParts[0]) 239 | lineNumber, err := strconv.Atoi(firstLineParts[1]) 240 | if err != nil { 241 | log.Fatal(err) 242 | } 243 | for _, blamePart := range blameParts[1:] { 244 | if strings.HasPrefix(blamePart, "filename ") { 245 | fileName = strings.SplitN(blamePart, " ", 2)[1] 246 | } 247 | } 248 | result = append(result, Line{revision, fileName, lineNumber, contents}) 249 | } 250 | return result 251 | } 252 | 253 | func compileRegexsOrDie(commaSeparatedString string) []*regexp.Regexp { 254 | regexs := make([]*regexp.Regexp, 0) 255 | for _, regexString := range strings.Split(commaSeparatedString, ",") { 256 | if regexString != "" { 257 | regex, err := regexp.Compile(regexString) 258 | if err != nil { 259 | log.Fatal(err) 260 | } 261 | regexs = append(regexs, regex) 262 | } 263 | } 264 | return regexs 265 | } 266 | 267 | func (repository *gitRepository) LoadRevisionTodos( 268 | revision Revision, todoRegex, excludePaths string) []Line { 269 | todosChannel := make(chan []Line, 1) 270 | go repository.asyncLoadRevisionTodos(revision, todoRegex, excludePaths, todosChannel) 271 | return <-todosChannel 272 | } 273 | 274 | func (repository *gitRepository) loadRevisionPaths(revision Revision, excludePaths string) []string { 275 | // Since this is specified by the user who started the server, we treat erros as fatal. 276 | excludeRegexs := compileRegexsOrDie(excludePaths) 277 | includePath := func(path string) bool { 278 | for _, regex := range excludeRegexs { 279 | if regex.MatchString(path) { 280 | return false 281 | } 282 | } 283 | return true 284 | } 285 | revisionPaths := make([]string, 0) 286 | for _, path := range repository.ReadRevisionContents(revision).Paths { 287 | if includePath(path) { 288 | revisionPaths = append(revisionPaths, path) 289 | } 290 | } 291 | return revisionPaths 292 | } 293 | 294 | func (repository *gitRepository) asyncLoadRevisionTodos( 295 | revision Revision, todoRegex, excludePaths string, todosChannel chan []Line) { 296 | var todos []Line 297 | cachedTodos, ok := repository.RevisionTodosCache.Load(revision) 298 | if ok { 299 | todos, ok = cachedTodos.([]Line) 300 | } 301 | if !ok { 302 | revisionPaths := repository.loadRevisionPaths(revision, excludePaths) 303 | todoChannels := make([]chan []Line, 0) 304 | for _, path := range revisionPaths { 305 | blob := repository.getFileBlobOrDie(revision, path) 306 | channel := make(chan []Line, 1) 307 | todoChannels = append(todoChannels, channel) 308 | go repository.asyncLoadFileTodos(revision, path, blob, todoRegex, channel) 309 | } 310 | for _, channel := range todoChannels { 311 | pathTodos := <-channel 312 | todos = append(todos, pathTodos...) 313 | } 314 | // TODO: Consider grouping the TODOs based on the containing file. 315 | repository.RevisionTodosCache.Store(revision, todos) 316 | } 317 | todosChannel <- todos 318 | } 319 | 320 | func (repository *gitRepository) LoadFileTodos( 321 | revision Revision, path string, todoRegex string) []Line { 322 | blob := repository.getFileBlobOrDie(revision, path) 323 | todosChannel := make(chan []Line, 1) 324 | go repository.asyncLoadFileTodos(revision, path, blob, todoRegex, todosChannel) 325 | return <-todosChannel 326 | } 327 | 328 | func (repository *gitRepository) asyncLoadFileTodos( 329 | revision Revision, path, blob, todoRegex string, todosChannel chan []Line) { 330 | var blobTodos []Line 331 | cachedTodos, ok := repository.BlobTodosCache.Load(blob) 332 | if ok { 333 | blobTodos, ok = cachedTodos.([]Line) 334 | } 335 | if !ok { 336 | raw := repository.runGitCommandWithoutTrimOrDie(exec.Command("git", "show", blob)) 337 | rawLines := strings.Split(raw, "\n") 338 | for lineNumber, lineContents := range rawLines { 339 | matched, err := regexp.MatchString(todoRegex, lineContents) 340 | if err == nil && matched { 341 | // git-blame numbers lines starting from 1 rather than 0 342 | gitLineNumber := lineNumber + 1 343 | out := repository.runGitCommandOrDie(exec.Command( 344 | "git", "blame", "--root", "--line-porcelain", 345 | "-L", fmt.Sprintf("%d,+1", gitLineNumber), 346 | string(revision), "--", path)) 347 | blobTodos = append(blobTodos, parseBlameOutputOrDie(path, out)...) 348 | } 349 | } 350 | repository.BlobTodosCache.Store(blob, blobTodos) 351 | } 352 | todosChannel <- blobTodos 353 | } 354 | 355 | func (repository *gitRepository) ReadFileSnippetAtRevision(revision Revision, path string, startLine, endLine int) string { 356 | blob := repository.getFileBlobOrDie(revision, path) 357 | out := repository.runGitCommandOrDie(exec.Command("git", "show", blob)) 358 | lines := strings.Split(out, "\n") 359 | if startLine < 1 { 360 | startLine = 1 361 | } 362 | if endLine > len(lines) || endLine < 0 { 363 | endLine = len(lines) + 1 364 | } 365 | // Git treats lines as starting from 1, so we have to move our indices before slicing 366 | startIndex := startLine - 1 367 | endIndex := endLine - 1 368 | lines = lines[startIndex:endIndex] 369 | var buffer bytes.Buffer 370 | for _, line := range lines { 371 | buffer.WriteString(line) 372 | buffer.WriteString("\n") 373 | } 374 | return buffer.String() 375 | } 376 | 377 | func (repository *gitRepository) readTodoContents(todoId TodoId) string { 378 | blob := repository.getFileBlobOrDie(todoId.Revision, todoId.FileName) 379 | out := repository.runGitCommandOrDie(exec.Command("git", "show", blob)) 380 | lines := strings.Split(out, "\n") 381 | return lines[todoId.LineNumber-1] 382 | } 383 | 384 | func (repository *gitRepository) FindClosingRevisions(todoId TodoId) []Revision { 385 | results := make([]Revision, 0) 386 | contents := repository.readTodoContents(todoId) 387 | args := []string{"log", "--pretty=oneline", "--no-abbrev-commit", "--no-color", fmt.Sprintf("-S%s", contents), "^" + string(todoId.Revision)} 388 | for _, alias := range repository.ListBranches() { 389 | if alias.Revision != todoId.Revision { 390 | args = append(args, string(alias.Revision)) 391 | } 392 | } 393 | out := repository.runGitCommandOrDie(exec.Command("git", args...)) 394 | lines := strings.Split(out, "\n") 395 | for _, entry := range lines { 396 | if len(entry) > 40 { 397 | revision := Revision(strings.Split(entry, " ")[0]) 398 | raw := repository.runGitCommandOrDie(exec.Command( 399 | "git", "show", "--no-color", string(revision))) 400 | // TODO(ojarjur): Exclude revisions that are later rolled back. 401 | if strings.Contains(raw, "-"+contents) && !strings.Contains(raw, "+"+contents) { 402 | results = append(results, revision) 403 | } 404 | } 405 | } 406 | return results 407 | } 408 | 409 | func isGitHubHttpsUrl(remoteUrl string) bool { 410 | return strings.HasPrefix(remoteUrl, "https://github.com/") && 411 | strings.HasSuffix(remoteUrl, ".git") 412 | } 413 | 414 | func isGitHubSshUrl(remoteUrl string) bool { 415 | return strings.HasPrefix(remoteUrl, "git@github.com:") && 416 | strings.HasSuffix(remoteUrl, ".git") 417 | } 418 | 419 | func gitHubBrowseSuffix(revision Revision, path string, lineNumber int) string { 420 | return fmt.Sprintf("/blob/%s/%s#L%d", string(revision), path, lineNumber) 421 | } 422 | 423 | func (repository *gitRepository) GetBrowseUrl(revision Revision, path string, lineNumber int) string { 424 | rawUrl := fmt.Sprintf("/raw?repo=%s&revision=%s&fileName=%s&lineNumber=%d", 425 | repository.GetRepoId(), string(revision), url.QueryEscape(path), lineNumber) 426 | out, err := repository.runGitCommand(exec.Command("git", "remote", "-v")) 427 | if err != nil { 428 | return rawUrl 429 | } 430 | remotes := strings.Split(strings.Trim(string(out), "\n"), "\n") 431 | for _, remote := range remotes { 432 | remoteParts := strings.SplitN(remote, "\t", 2) 433 | if len(remoteParts) == 2 { 434 | remoteUrl := strings.Split(remoteParts[1], " ")[0] 435 | if isGitHubHttpsUrl(remoteUrl) { 436 | browseSuffix := gitHubBrowseSuffix(revision, path, lineNumber) 437 | return strings.TrimSuffix(remoteUrl, ".git") + browseSuffix 438 | } 439 | if isGitHubSshUrl(remoteUrl) { 440 | browseSuffix := gitHubBrowseSuffix(revision, path, lineNumber) 441 | repoName := strings.SplitN( 442 | strings.TrimSuffix(remoteUrl, ".git"), 443 | ":", 2)[1] 444 | return "https://github.com/" + repoName + browseSuffix 445 | } 446 | } 447 | } 448 | return rawUrl 449 | } 450 | 451 | func (repository *gitRepository) ValidateRevision(revisionString string) (Revision, error) { 452 | if !hashRegexp.MatchString(revisionString) { 453 | return Revision(""), errors.New(fmt.Sprintf("Invalid hash format: %s", revisionString)) 454 | } 455 | _, err := repository.runGitCommand( 456 | exec.Command("git", "ls-tree", "--name-only", revisionString)) 457 | if err != nil { 458 | return Revision(""), err 459 | } 460 | return Revision(revisionString), nil 461 | } 462 | 463 | func (repository *gitRepository) ValidatePathAtRevision(revision Revision, path string) error { 464 | out, err := repository.runGitCommand( 465 | exec.Command("git", "ls-tree", "-r", "--name-only", string(revision))) 466 | if err != nil { 467 | return err 468 | } 469 | revisionPaths := strings.Split(out, "\n") 470 | for _, revisionPath := range revisionPaths { 471 | if path == revisionPath { 472 | return nil 473 | } 474 | } 475 | return errors.New(fmt.Sprintf("Path '%s' not found at revision %s", path, string(revision))) 476 | } 477 | 478 | func (repository *gitRepository) ValidateLineNumberInPathAtRevision( 479 | revision Revision, path string, lineNumber int) error { 480 | blob := repository.getFileBlobOrDie(revision, path) 481 | out := repository.runGitCommandOrDie(exec.Command("git", "show", blob)) 482 | lines := strings.Split(out, "\n") 483 | if len(lines) < lineNumber { 484 | return errors.New(fmt.Sprintf( 485 | "Line #%d, not found at path %s in revision %s", 486 | lineNumber, path, string(revision))) 487 | } 488 | return nil 489 | } 490 | -------------------------------------------------------------------------------- /repo/model.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repo 18 | 19 | import ( 20 | "encoding/json" 21 | "io" 22 | ) 23 | 24 | type Revision string 25 | type RevisionContents struct { 26 | Revision Revision 27 | Paths []string 28 | } 29 | 30 | type RevisionMetadata struct { 31 | Revision Revision 32 | Timestamp int64 33 | Subject string 34 | AuthorName string 35 | AuthorEmail string 36 | } 37 | 38 | type Alias struct { 39 | Branch string 40 | Revision Revision 41 | // TODO: Add LastModified and LastModifiedBy fields based on the RevisionMetadata 42 | } 43 | 44 | type Line struct { 45 | Revision Revision 46 | FileName string 47 | LineNumber int 48 | Contents string 49 | } 50 | 51 | // Key that uniquely identifies a TODO. 52 | type TodoId struct { 53 | Revision Revision 54 | FileName string 55 | LineNumber int 56 | } 57 | 58 | type TodoDetails struct { 59 | Id TodoId 60 | RevisionMetadata RevisionMetadata 61 | Context string 62 | } 63 | 64 | type TodoStatus struct { 65 | BranchesMissing []Alias 66 | BranchesPresent []Alias 67 | BranchesRemoved []Alias 68 | } 69 | 70 | type Repository interface { 71 | // Get an opaque ID that uniquely identifies this repo on this machine. 72 | GetRepoId() string 73 | // Get the path to this repo on this machine. 74 | GetRepoPath() string 75 | 76 | ListBranches() []Alias 77 | IsAncestor(ancestor, descendant Revision) bool 78 | ReadRevisionContents(revision Revision) *RevisionContents 79 | ReadRevisionMetadata(revision Revision) RevisionMetadata 80 | ReadFileSnippetAtRevision(revision Revision, path string, startLine, endLine int) string 81 | LoadRevisionTodos(revision Revision, todoRegex, excludePaths string) []Line 82 | LoadFileTodos(revision Revision, path string, todoRegex string) []Line 83 | FindClosingRevisions(todoId TodoId) []Revision 84 | 85 | GetBrowseUrl(revision Revision, path string, lineNumber int) string 86 | 87 | // Check that the given string is a valid revision. 88 | // This is intended for user input validation. 89 | ValidateRevision(revisionString string) (Revision, error) 90 | 91 | // Check that the given path is in the given revision. 92 | // This is intended for user input validation, and assumes that ValidateRevision 93 | // has already been called. 94 | ValidatePathAtRevision(revision Revision, path string) error 95 | 96 | // Check that the given line number exists for the given path in the given revision. 97 | // This is intended for user input validation, and assumes that ValidatePathAtRevision 98 | // has already been called. 99 | ValidateLineNumberInPathAtRevision(revision Revision, path string, lineNumber int) error 100 | } 101 | 102 | func WriteJson(w io.Writer, repository Repository) error { 103 | bytes, err := json.Marshal(repository.ListBranches()) 104 | if err != nil { 105 | return err 106 | } 107 | w.Write(bytes) 108 | return nil 109 | } 110 | 111 | func LoadTodoDetails(repository Repository, todoId TodoId, linesBefore int, linesAfter int) *TodoDetails { 112 | startLine := todoId.LineNumber - linesBefore 113 | endLine := todoId.LineNumber + linesAfter + 1 114 | context := repository.ReadFileSnippetAtRevision( 115 | todoId.Revision, todoId.FileName, startLine, endLine) 116 | return &TodoDetails{ 117 | Id: todoId, 118 | RevisionMetadata: repository.ReadRevisionMetadata(todoId.Revision), 119 | Context: context, 120 | } 121 | } 122 | 123 | func LoadTodoStatus(repository Repository, todoId TodoId) *TodoStatus { 124 | closingRevs := repository.FindClosingRevisions(todoId) 125 | missing := make([]Alias, 0) 126 | present := make([]Alias, 0) 127 | removed := make([]Alias, 0) 128 | Branches: 129 | for _, alias := range repository.ListBranches() { 130 | if alias.Revision == todoId.Revision { 131 | present = append(present, alias) 132 | } else if repository.IsAncestor(todoId.Revision, alias.Revision) { 133 | for _, closingRev := range closingRevs { 134 | if repository.IsAncestor(closingRev, alias.Revision) { 135 | removed = append(removed, alias) 136 | continue Branches 137 | } 138 | } 139 | present = append(present, alias) 140 | } else { 141 | missing = append(missing, alias) 142 | } 143 | } 144 | return &TodoStatus{ 145 | BranchesMissing: missing, 146 | BranchesPresent: present, 147 | BranchesRemoved: removed, 148 | } 149 | } 150 | 151 | func WriteTodosJson(w io.Writer, repository Repository, revision Revision, todoRegex, excludePaths string) error { 152 | bytes, err := json.Marshal(repository.LoadRevisionTodos(revision, todoRegex, excludePaths)) 153 | if err != nil { 154 | return err 155 | } 156 | w.Write(bytes) 157 | return nil 158 | } 159 | 160 | func WriteTodoDetailsJson(w io.Writer, repository Repository, todoId TodoId) error { 161 | // TODO: Make the lines before and after a parameter. 162 | bytes, err := json.Marshal(LoadTodoDetails(repository, todoId, 5, 5)) 163 | if err != nil { 164 | return err 165 | } 166 | w.Write(bytes) 167 | return nil 168 | } 169 | 170 | func WriteTodoStatusDetailsJson(w io.Writer, repository Repository, todoId TodoId) error { 171 | bytes, err := json.Marshal(LoadTodoStatus(repository, todoId)) 172 | if err != nil { 173 | return err 174 | } 175 | w.Write(bytes) 176 | return nil 177 | } 178 | -------------------------------------------------------------------------------- /repo/repotest/mock_repo.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repotest 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | 23 | "github.com/google/todo-tracks/repo" 24 | ) 25 | 26 | type MockRepository struct { 27 | Aliases []repo.Alias 28 | RevisionTodos map[string][]repo.Line 29 | } 30 | 31 | func (repository MockRepository) GetRepoId() string { 32 | return "repoID" 33 | } 34 | 35 | func (repository MockRepository) GetRepoPath() string { 36 | return "~/repo/path" 37 | } 38 | 39 | func (repository MockRepository) ListBranches() []repo.Alias { 40 | return repository.Aliases 41 | } 42 | 43 | func (repository MockRepository) IsAncestor(ancestor, descendant repo.Revision) bool { 44 | return false 45 | } 46 | 47 | func (repository MockRepository) ReadRevisionContents(revision repo.Revision) *repo.RevisionContents { 48 | return &repo.RevisionContents{ 49 | Revision: revision, 50 | Paths: make([]string, 0), 51 | } 52 | } 53 | 54 | func (repository MockRepository) ReadRevisionMetadata(revision repo.Revision) repo.RevisionMetadata { 55 | return repo.RevisionMetadata{ 56 | Revision: revision, 57 | } 58 | } 59 | 60 | func (repository MockRepository) ReadFileSnippetAtRevision(revision repo.Revision, path string, startLine, endLine int) string { 61 | return "" 62 | } 63 | 64 | func (repository MockRepository) LoadRevisionTodos(revision repo.Revision, todoRegex, excludePaths string) []repo.Line { 65 | return repository.RevisionTodos[string(revision)] 66 | } 67 | 68 | func (repository MockRepository) LoadFileTodos(revision repo.Revision, path string, todoRegex string) []repo.Line { 69 | return make([]repo.Line, 0) 70 | } 71 | 72 | func (repository MockRepository) FindClosingRevisions(todoId repo.TodoId) []repo.Revision { 73 | return nil 74 | } 75 | 76 | func (repository MockRepository) GetBrowseUrl(revision repo.Revision, path string, lineNumber int) string { 77 | return "" 78 | } 79 | 80 | func (repository MockRepository) ValidateRevision(revisionString string) (repo.Revision, error) { 81 | if _, ok := repository.RevisionTodos[revisionString]; ok { 82 | return repo.Revision(revisionString), nil 83 | } 84 | return repo.Revision(""), errors.New(fmt.Sprintf("Not a valid revision: %s", revisionString)) 85 | } 86 | 87 | func (repository MockRepository) ValidatePathAtRevision(revision repo.Revision, path string) error { 88 | return nil 89 | } 90 | 91 | func (repository MockRepository) ValidateLineNumberInPathAtRevision( 92 | revision repo.Revision, path string, lineNumber int) error { 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /ui/file_contents.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 33 | 34 | 35 |
{{.Contents}}
36 | 37 | 38 | -------------------------------------------------------------------------------- /ui/list_branches.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | TODO Tracker -- Branch List 25 | 26 | 27 | 28 |
29 |
30 |
31 |
32 |

TODO Tracker

33 |
34 |
35 |
36 |
37 |

Branch List

38 |
39 |
40 |
41 |
42 | 43 |
44 |
45 | Remote: 46 |
47 |
48 | {{remote.name}} 49 |
50 |
51 |
52 |
53 | Local: 54 |
55 |
56 | 57 | 58 |
59 |
60 |
61 |
62 |
63 |
64 | Branch 65 |
66 |
67 | Revision 68 |
69 |
70 | Last Modified 71 |
72 |
73 | 74 |
75 |
76 | {{branch.branch}} 77 |
78 |
79 | {{branch.revision}}
80 | [list by file] 81 | [list by revision] 82 |
83 |
84 |
85 | {{branch.lastModified}} by {{branch.lastModifiedBy}} 86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /ui/list_repos.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | TODO Tracker -- Repo List 25 | 26 | 27 |
28 |
29 |
30 |
31 |

TODO Tracker

32 |
33 |
34 |
35 |
36 |

Repo List

37 |
38 |
39 |
40 |
41 |
42 |
43 | Path: 44 |
45 |
46 |
47 |
48 | {{repo.path}} 49 |
50 |
51 |
52 |
53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /ui/list_todos.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | TODO Tracker -- TODO List 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 |

TODO Tracker

37 |
38 |
39 |
40 |
41 |

TODO List

42 |
43 |
44 |
45 | 46 |
47 | 48 |
49 |
50 | Revision: 51 |
52 |
53 | {{revision.revision}} 54 |
55 |
56 | 57 | 58 | 59 |
60 |
61 |
62 |
63 | File Name 64 |
65 |
66 | Line 67 |
68 |
69 | Content 70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | 79 |
80 | 83 |
84 | {{oneTodo.fileName}} 85 |
86 |
87 |
88 | 92 |
93 |
94 |
95 |
96 | 99 |
100 | 101 | 102 | 103 |
104 |
105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /ui/list_todos_paths.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | TODO Tracker -- TODO List 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 |

TODO Tracker

37 |
38 |
39 |
40 |
41 |

TODO List

42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 | File Name: 50 |
51 |
52 | {{filename.fileName}} 53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 |
61 | Revision 62 |
63 |
64 | Line 65 |
66 |
67 | Content 68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 |
78 | 79 |
80 | {{oneTodo.revision}} 81 |
82 |
83 |
84 | 88 |
89 |
90 |
91 |
92 | 95 |
96 | 97 | 98 | 99 |
100 |
101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /ui/todo_details.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | TODO Tracker -- TODO Details 25 | 26 | 27 |
28 |
29 |
30 |

TODO Tracker

31 |
32 |
33 |
34 |
35 |

TODO Details

36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 |
49 | {{detail.key}} 50 |
51 |
52 | 53 |
{{detail.value}}
54 | {{detail.value}} 55 |
56 | 57 |
{{detail.value}}
58 | {{detail.value}} 59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Present In: 67 |
68 |
69 |
70 |
71 |
72 | 77 |
78 |
79 |
80 | Removed In: 81 |
82 |
83 |
84 |
85 |
86 | 91 |
92 |
93 |
94 | Missing In: 95 |
96 |
97 |
98 |
99 |
100 | 105 |
106 |
107 |
108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /ui/todo_tracker.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | .alternate_row:nth-child(even){ 17 | background-color: #f4f4f4; 18 | } 19 | .alternate_row:nth-child(odd){ 20 | background-color: #ffffff; 21 | } 22 | 23 | table tr:nth-child(odd) { 24 | background-color: #f1f1f1; 25 | } 26 | table tr:nth-child(even) { 27 | background-color: #ffffff; 28 | } 29 | 30 | .csblue { 31 | color:#4787ed; 32 | } 33 | 34 | .header-bar { 35 | background-color:#bcf; 36 | /* margin-bottom: 8px; */ 37 | } 38 | 39 | .header-bar-lighter { 40 | background-color:#e3e9ff; 41 | margin-top: 18px; 42 | margin-bottom: 12px; 43 | } 44 | 45 | .header-bar-text { 46 | color:#888; 47 | } 48 | 49 | pre.nobg-noborder { 50 | border: 0px; 51 | background-color: inherit; 52 | padding: inherit; 53 | } 54 | 55 | a:link { 56 | color:black; 57 | } 58 | -------------------------------------------------------------------------------- /ui/todo_tracker.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview Angularjs controllers for TODO Tracker HTML files. 19 | */ 20 | var todoTrackerApp=angular.module("todoTrackerApp", []); 21 | todoTrackerApp.controller("listRepos", function($scope,$http) { 22 | $http.get(window.location.protocol + "//" + window.location.host + "/repos") 23 | .success(function(response) {$scope.repositories = processRepoListResponse(response);}); 24 | 25 | function processRepoListResponse(response) { 26 | var repos = [] 27 | for (var i in response) { 28 | var repoRaw = response[i] 29 | repos.push(new Repo(repoRaw.Path, repoRaw.RepoId)) 30 | } 31 | return repos; 32 | } 33 | 34 | function Repo(path, id) { 35 | this.path = path; 36 | this.id = id; 37 | } 38 | }); 39 | 40 | todoTrackerApp.controller("listBranches", function($scope,$http,$location) { 41 | var repo = $location.search()['repo']; 42 | $http.get(window.location.protocol + "//" + window.location.host + "/aliases?repo=" + repo) 43 | .success(function(response) {$scope.remotes = processBranchListResponse(response);}); 44 | 45 | function processBranchListResponse(response) { 46 | var remotesRaw = {}; 47 | 48 | for (var i in response) { 49 | var oneBranchRaw = response[i]; 50 | console.log("branch = " + oneBranchRaw.Branch); 51 | var result = parseBranchName(oneBranchRaw.Branch); 52 | // TODO: add lastModified and lastModifiedBy fields 53 | var branch = new Branch(result[1], oneBranchRaw.Revision, "", ""); 54 | if (!(result[0] in remotesRaw)) { 55 | remotesRaw[result[0]] = []; 56 | } 57 | remotesRaw[result[0]].push(branch); 58 | } 59 | 60 | var remotes = []; 61 | for (var r in remotesRaw) { 62 | var remote = new Remote(r); 63 | remote.branches = remotesRaw[r]; 64 | remotes.push(remote); 65 | } 66 | 67 | function Remote(name) { 68 | this.name = name; 69 | this.branches = []; 70 | } 71 | 72 | function Branch(branch, revision, lastModified, lastModifiedBy) { 73 | this.repo = repo; 74 | this.branch = branch; 75 | this.revision = revision; 76 | this.lastModified = lastModified; 77 | this.lastModifiedBy = lastModifiedBy; 78 | } 79 | 80 | function parseBranchName(branchName) { 81 | var result = branchName.split("/"); 82 | if (result.length >= 3 && result[0] == "remotes") { 83 | return [result[1], result.slice(2).join('/')]; 84 | } else { 85 | return ["", result.join('/')]; 86 | } 87 | } 88 | 89 | console.log("final remotes = " + JSON.stringify(remotes)); 90 | return remotes; 91 | } 92 | }); 93 | 94 | todoTrackerApp.controller("listTodos", function($scope,$http,$location) { 95 | var repo = $location.search()['repo']; 96 | var revision = $location.search()['revision']; 97 | $http.get(window.location.protocol + "//" + window.location.host + 98 | "/revision?repo="+ repo + "&revision=" + revision) 99 | .success(function(response) {$scope.revisions= processTodoListResponse(response);}); 100 | 101 | function processTodoListResponse(response) { 102 | var todosObj = response; 103 | var todosMap = {}; 104 | 105 | for (var i = 0; i < todosObj.length; i++) { 106 | var oneTodoRaw = todosObj[i]; 107 | if (!(oneTodoRaw.Revision in todosMap)) { 108 | todosMap[oneTodoRaw.Revision] = []; 109 | } 110 | var todo = new Todo(oneTodoRaw.Revision, oneTodoRaw.FileName, 111 | oneTodoRaw.LineNumber, oneTodoRaw.Contents); 112 | todosMap[oneTodoRaw.Revision].push(todo); 113 | } 114 | 115 | var revisionAndTodos = []; 116 | for (var revisionId in todosMap) { 117 | var revision = new Revision(revisionId); 118 | revision.todos = todosMap[revisionId]; 119 | revisionAndTodos.push(revision); 120 | } 121 | 122 | 123 | function Revision(revision) { 124 | this.revision = revision; 125 | this.todos = []; 126 | } 127 | 128 | function Todo(revision, fileName, lineNumber, content) { 129 | this.repo = repo; 130 | this.revision = revision; 131 | this.fileName = fileName; 132 | this.lineNumber = lineNumber; 133 | this.content = content; 134 | } 135 | 136 | return revisionAndTodos; 137 | } 138 | }); 139 | 140 | todoTrackerApp.controller("listTodosPaths", function($scope,$http,$location) { 141 | var repo = $location.search()['repo']; 142 | var revision = $location.search()['revision']; 143 | $http.get(window.location.protocol + "//" + window.location.host + 144 | "/revision?repo="+ repo + "&revision=" + revision) 145 | .success(function(response) {$scope.filenames = processTodoListPathsResponse(response);}); 146 | 147 | function processTodoListPathsResponse(response) { 148 | var todosObj = response; 149 | var todosMap = {}; 150 | 151 | for (var i = 0; i < todosObj.length; i++) { 152 | var oneTodoRaw = todosObj[i]; 153 | var fileNameKey = oneTodoRaw.FileName; 154 | if (!(fileNameKey in todosMap)) { 155 | todosMap[fileNameKey] = []; 156 | } 157 | var todo = new Todo(oneTodoRaw.Revision, oneTodoRaw.FileName, 158 | oneTodoRaw.LineNumber, oneTodoRaw.Contents); 159 | todosMap[fileNameKey].push(todo); 160 | } 161 | 162 | var filenamesAndTodos = []; 163 | for (var filename in todosMap) { 164 | var filenameObj = new FileName(filename); 165 | filenameObj.todos = todosMap[filename]; 166 | filenamesAndTodos.push(filenameObj); 167 | } 168 | 169 | 170 | function FileName(fileName) { 171 | this.fileName = fileName; 172 | this.todos = []; 173 | } 174 | 175 | 176 | function Todo(revision, fileName, lineNumber, content) { 177 | this.repo = repo; 178 | this.revision = revision; 179 | this.fileName = fileName; 180 | this.lineNumber = lineNumber; 181 | this.content = content; 182 | } 183 | 184 | return filenamesAndTodos; 185 | } 186 | }); 187 | 188 | function getRevisionLink(repo, revision) { 189 | // the # sign in the URL is to make Angularjs to recoginize QS params in 190 | // $location.search(). It is a workaround for a bug in Angularjs. 191 | return window.location.protocol + "//" + window.location.host + 192 | "/ui/list_todos_paths.html#?repo=" + repo + "&revision=" + revision; 193 | } 194 | 195 | todoTrackerApp.controller("todoDetails", function($scope,$http,$location) { 196 | var repo = $location.search()['repo']; 197 | var revision = $location.search()['revision']; 198 | var fileName = $location.search()['fn']; 199 | var lineNumber = $location.search()['ln']; 200 | // TODO: Pass in the number of lines above and below the TODO to display 201 | // This needs the JSON file to provide the informaiton. 202 | $http.get(window.location.protocol + "//" + window.location.host + 203 | "/todo?repo=" + repo + "&revision=" + revision + 204 | "&fileName=" + fileName + "&lineNumber=" + lineNumber) 205 | .success(function(response) {$scope.todoDetails = processTodoDetailsResponse(response);}); 206 | 207 | function processTodoDetailsResponse(response) { 208 | var detailsObj = response; 209 | var todoDetails = []; 210 | 211 | todoDetails.push(new TodoDetail("Revision", detailsObj.Id.Revision, true, 212 | getRevisionLink(repo, detailsObj.Id.Revision))); 213 | todoDetails.push(new TodoDetail("File Name", detailsObj.Id.FileName, true, 214 | getFileInRepoLink(detailsObj.Id.Revision, detailsObj.Id.FileName))); 215 | todoDetails.push(new TodoDetail("Line Number", detailsObj.Id.LineNumber, true, 216 | getCodeLineInRepoLink(detailsObj.Id.Revision, detailsObj.Id.FileName, detailsObj.Id.LineNumber))); 217 | todoDetails.push(new TodoDetail("Author", 218 | detailsObj.RevisionMetadata.AuthorName + " (" + 219 | detailsObj.RevisionMetadata.AuthorEmail + ")", 220 | false, "")); 221 | todoDetails.push(new TodoDetail("Timestamp", 222 | timestampPretty(detailsObj.RevisionMetadata.Timestamp) + " (" + 223 | detailsObj.RevisionMetadata.Timestamp + ")", 224 | false, "")); 225 | todoDetails.push(new TodoDetail("Subject", detailsObj.RevisionMetadata.Subject, false, "")); 226 | // TODO: Display this with syntax highlighting and the TODO line highlighted. 227 | todoDetails.push(new TodoDetail("Context", detailsObj.Context, false, "", true)); 228 | 229 | function TodoDetail(key, value, hasLink, link, htmlPre) { 230 | this.key = key; 231 | this.value = value; 232 | this.hasLink = hasLink; 233 | this.link = link; 234 | // Whether to use
 on this detail field.
235 |       this.htmlPre = htmlPre == null ? false : htmlPre;
236 |     }
237 | 
238 |     function getFileInRepoLink(revision, fileName) {
239 |       return window.location.protocol + "//" + window.location.host +
240 | 	  "/browse?repo=" + repo + "&revision=" + revision + "&fileName=" + fileName;
241 |     }
242 | 
243 |     function getCodeLineInRepoLink(revision, fileName, lineNumber) {
244 |       return getFileInRepoLink(revision, fileName) + "&lineNumber=" + lineNumber;
245 |     }
246 | 
247 |     function timestampPretty(timestamp) {
248 |       var date = new Date(timestamp * 1000);
249 |       return date.toString();
250 |     }
251 | 
252 |     return todoDetails;
253 |   }
254 | });
255 | 
256 | todoTrackerApp.controller("todoStatus", function($scope,$http,$location) {
257 |   var repo = $location.search()['repo'];
258 |   var revision = $location.search()['revision'];
259 |   var fileName = $location.search()['fn'];
260 |   var lineNumber = $location.search()['ln'];
261 |   $http.get(window.location.protocol + "//" + window.location.host +
262 |       "/todoStatus?repo=" + repo + "&revision=" + revision +
263 |       "&fileName=" + fileName + "&lineNumber=" + lineNumber)
264 |     .success(function(response) {$scope.todoStatus = processTodoStatusResponse(response);});
265 | 
266 |   function processTodoStatusResponse(response) {
267 |     var statusObj = response;
268 |     var todoStatus = {present: [], removed: [], missing: []};
269 | 
270 |     for (var i in statusObj.BranchesPresent) {
271 |       var oneBranchRaw = statusObj.BranchesPresent[i];
272 |       var branch = oneBranchRaw.Branch;
273 |       var revision = oneBranchRaw.Revision;
274 |       var link = getRevisionLink(repo, revision);
275 |       todoStatus.present.push(new BranchDetail(branch, link));
276 |     }
277 | 
278 |     for (var i in statusObj.BranchesRemoved) {
279 |       var oneBranchRaw = statusObj.BranchesRemoved[i];
280 |       var branch = oneBranchRaw.Branch;
281 |       var revision = oneBranchRaw.Revision;
282 |       var link = getRevisionLink(repo, revision);
283 |       todoStatus.removed.push(new BranchDetail(branch, link));
284 |     }
285 | 
286 |     for (var i in statusObj.BranchesMissing) {
287 |       var oneBranchRaw = statusObj.BranchesMissing[i];
288 |       var branch = oneBranchRaw.Branch;
289 |       var revision = oneBranchRaw.Revision;
290 |       var link = getRevisionLink(repo, revision);
291 |       todoStatus.missing.push(new BranchDetail(branch, link));
292 |     }
293 | 
294 |     function BranchDetail(name, link) {
295 |       this.name = name;
296 |       this.link = link;
297 |     }
298 | 
299 |     return todoStatus;
300 |   }
301 | });
302 | 


--------------------------------------------------------------------------------
/utils/resource-constants.go:
--------------------------------------------------------------------------------
 1 | /*
 2 | Copyright 2014 Google Inc. All rights reserved.
 3 | 
 4 | 	Licensed under the Apache License, Version 2.0 (the "License");
 5 | you may not use this file except in compliance with the License.
 6 | You may obtain a copy of the License at
 7 | 
 8 | http://www.apache.org/licenses/LICENSE-2.0
 9 | 
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | 
17 | // Program resource-constants generates Go source files that embed resource files read at compile time.
18 | //
19 | // Usage:
20 | //
21 | //	bin/resource-constants --base_dir / > src/resources/constants.go
22 | //
23 | // Using the generated code:
24 | //
25 | //	import "resources"
26 | //
27 | //	var fileContents = resources.Constants["fileName"]
28 | package main
29 | 
30 | import (
31 | 	"bytes"
32 | 	"flag"
33 | 	"fmt"
34 | 	"go/format"
35 | 	"io/ioutil"
36 | 	"log"
37 | 	"os"
38 | 	"path/filepath"
39 | 	"strings"
40 | )
41 | 
42 | var baseDir = flag.String("base_dir", "./", "Directory under which to look for resource files")
43 | var supportedExtensions = flag.String("supported_extensions", "html,js,css", "Comma-separated list of supported file extensions")
44 | 
45 | func isSupported(fileName string) bool {
46 | 	for _, extension := range strings.Split(*supportedExtensions, ",") {
47 | 		if strings.HasSuffix(fileName, "."+extension) {
48 | 			return true
49 | 		}
50 | 	}
51 | 	return false
52 | }
53 | 
54 | func main() {
55 | 	flag.Parse()
56 | 	var buf bytes.Buffer
57 | 	fmt.Fprintln(&buf, "package resources")
58 | 	fmt.Fprintln(&buf, "var Constants = map[string][]byte{")
59 | 	filepath.Walk(*baseDir, func(path string, info os.FileInfo, err error) error {
60 | 		if err != nil {
61 | 			return err
62 | 		}
63 | 		if !info.IsDir() && isSupported(path) {
64 | 			fileName := path[len(*baseDir):]
65 | 			bytes, err := ioutil.ReadFile(path)
66 | 			if err != nil {
67 | 				return err
68 | 			}
69 | 			fmt.Fprintf(&buf, "%q: {", fileName)
70 | 			for _, b := range bytes {
71 | 				fmt.Fprintf(&buf, " %d,", b)
72 | 			}
73 | 			fmt.Fprintln(&buf, "},")
74 | 		}
75 | 		return nil
76 | 	})
77 | 	fmt.Fprintln(&buf, "}")
78 | 	src, err := format.Source(buf.Bytes())
79 | 	if err != nil {
80 | 		log.Fatalf("Invalid generated source: %v", err)
81 | 	}
82 | 	fmt.Print(string(src))
83 | }
84 | 


--------------------------------------------------------------------------------