├── .github └── workflows │ ├── go.yml │ └── release.yaml ├── .gitignore ├── LICENSE ├── Procfile ├── core.go ├── core_test.go ├── flake.lock ├── flake.nix ├── fly.toml ├── go.mod ├── go.sum ├── index.html ├── main.go ├── main_test.go ├── notes ├── readme └── types.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | 19 | - name: Build 20 | run: go build -v ./... 21 | 22 | - name: Test 23 | run: go test -v ./... 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release Go project 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" # triggers only if push new tag version, like `0.8.4` or else 7 | 8 | jobs: 9 | build: 10 | name: GoReleaser build 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 # See: https://goreleaser.com/ci/actions/ 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test -v ./... 27 | 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@master 30 | with: 31 | version: latest 32 | args: release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /gh-issues-to-rss 2 | /issues.json 3 | result 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gh-issues-to-rss --server -------------------------------------------------------------------------------- /core.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "io" 13 | 14 | "io/fs" 15 | 16 | "github.com/gorilla/feeds" 17 | ) 18 | 19 | func makeRequest(repo string) ([]byte, error) { 20 | // do an http get request to the github api. Add auth header if token is present 21 | req, err := http.NewRequest("GET", baseUrl+repo+"/issues?state=all", nil) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | token := os.Getenv("GH_ISSUES_TO_RSS_GITHUB_TOKEN") 27 | if token != "" { 28 | req.Header.Add("Authorization", "Bearer"+token) 29 | } 30 | 31 | response, err := http.DefaultClient.Do(req) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | if response.StatusCode != 200 { 37 | return nil, errors.New("unable to fetch data, make sure you have a valid repo") 38 | } 39 | 40 | defer response.Body.Close() 41 | 42 | body, err := io.ReadAll(io.Reader(response.Body)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return body, nil 48 | } 49 | 50 | func saveBackup(repo string, content []byte) error { 51 | path := cacheLocation + "/" + repo 52 | if _, err := os.Stat(path); os.IsNotExist(err) { 53 | err = os.MkdirAll(path, 0755) 54 | if err != nil { 55 | fmt.Println("Unable to create directory for caching:", err) 56 | } 57 | } 58 | 59 | err := os.WriteFile(path+"/issues.json", content, fs.FileMode(0644)) 60 | if err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | 66 | func loadBackup(repo string, timeout time.Duration) ([]byte, error) { 67 | path := cacheLocation + "/" + repo + "/issues.json" 68 | 69 | fi, err := os.Stat(path) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | if time.Now().Sub(fi.ModTime()) > timeout { 75 | return nil, nil 76 | } 77 | 78 | b, err := os.ReadFile(path) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return b, nil 84 | } 85 | 86 | func isIn(item string, items []string) bool { 87 | for _, i := range items { 88 | if i == item { 89 | return true 90 | } 91 | } 92 | return false 93 | } 94 | 95 | func generateRss(data []GithubIssue, rc RunConfig) (string, error) { 96 | now := time.Now() 97 | feed := &feeds.Feed{ 98 | Title: rc.Repo, 99 | Link: &feeds.Link{Href: "https://github.com/" + rc.Repo}, 100 | Created: now, 101 | } 102 | 103 | var items []*feeds.Item 104 | fdata := filterIssues(data, rc) 105 | 106 | for _, entry := range fdata { 107 | entryType := "issue" 108 | if entry.PullRequest.URL != "" { 109 | entryType = "pr" 110 | } 111 | createTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", entry.CreatedAt) 112 | closeTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", entry.ClosedAt) 113 | 114 | if entry.State == "closed" { 115 | if !((entryType == "pr" && !rc.Modes.PRClosed) || (entryType == "issue" && !rc.Modes.IssuesClosed)) { 116 | items = append(items, &feeds.Item{ 117 | Title: "[" + entryType + "-" + "closed" + "]: " + entry.Title, 118 | Link: &feeds.Link{Href: entry.HTMLURL}, 119 | Description: strings.ReplaceAll(entry.Body, "\n", "
"), 120 | Content: strings.ReplaceAll(entry.Body, "\n", "
"), 121 | Author: &feeds.Author{Name: entry.User.Login}, 122 | Created: closeTime, 123 | }) 124 | } 125 | } 126 | if (entryType == "pr" && !rc.Modes.PROpen) || (entryType == "issue" && !rc.Modes.IssueOpen) { 127 | continue 128 | } 129 | items = append(items, &feeds.Item{ 130 | Title: "[" + entryType + "-" + "open" + "]: " + entry.Title, 131 | Link: &feeds.Link{Href: entry.HTMLURL}, 132 | Description: strings.ReplaceAll(entry.Body, "\n", "
"), 133 | Content: strings.ReplaceAll(entry.Body, "\n", "
"), 134 | Author: &feeds.Author{Name: entry.User.Login}, 135 | Created: createTime, 136 | }) 137 | 138 | } 139 | feed.Items = items 140 | 141 | rss, err := feed.ToRss() 142 | if err != nil { 143 | return "", err 144 | } 145 | return rss, nil 146 | } 147 | 148 | func getData(repo string, cacheTimeout time.Duration) ([]byte, error) { 149 | content, err := loadBackup(repo, cacheTimeout) 150 | if err != nil || content == nil { 151 | fmt.Println("No cache found for " + repo + ", fetching from Github") 152 | resp, err := makeRequest(repo) 153 | if err != nil { 154 | return nil, err 155 | } 156 | err = saveBackup(repo, resp) 157 | if err != nil { 158 | fmt.Println("Unable to save backup:", err) 159 | } 160 | return resp, nil 161 | } 162 | return content, nil 163 | } 164 | 165 | func getIssueFeed(rc RunConfig, cacheTimeout time.Duration) (string, error) { 166 | content, err := getData(rc.Repo, cacheTimeout) 167 | if err != nil { 168 | return "", err 169 | } 170 | 171 | data := []GithubIssue{} 172 | if err := json.Unmarshal(content, &data); err != nil { 173 | return "", err 174 | } 175 | 176 | rss, err := generateRss(data, rc) 177 | if err != nil { 178 | return "", err 179 | } 180 | return rss, nil 181 | } 182 | 183 | func filterIssues(issues []GithubIssue, rc RunConfig) []GithubIssue { 184 | var fi []GithubIssue 185 | 186 | for _, issue := range issues { 187 | var issueLabels []string 188 | for _, i := range issue.Labels { 189 | issueLabels = append(issueLabels, i.Name) 190 | } 191 | 192 | bk := false 193 | for _, label := range rc.NotLabels { 194 | if isIn(label, issueLabels) { 195 | bk = true 196 | break 197 | } 198 | } 199 | 200 | if bk { 201 | continue 202 | } 203 | 204 | for _, user := range rc.NotUsers { 205 | if issue.User.Login == user { 206 | bk = true 207 | break 208 | } 209 | } 210 | 211 | if bk { 212 | continue 213 | } 214 | 215 | for _, label := range rc.Labels { 216 | if !isIn(label, issueLabels) { 217 | bk = true 218 | break 219 | } 220 | } 221 | 222 | if bk { 223 | continue 224 | } 225 | 226 | bk = len(rc.Users) != 0 227 | 228 | for _, user := range rc.Users { 229 | if issue.User.Login == user { 230 | bk = false 231 | break 232 | } 233 | } 234 | 235 | if bk { 236 | continue 237 | } 238 | 239 | fi = append(fi, issue) 240 | } 241 | 242 | return fi 243 | } 244 | -------------------------------------------------------------------------------- /core_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "os" 10 | 11 | "gopkg.in/h2non/gock.v1" 12 | ) 13 | 14 | // Not sure if testing this has a point 15 | func TestMakeRequest(t *testing.T) { 16 | defer gock.Off() 17 | gock.New("https://api.github.com"). 18 | Get("/issues"). 19 | Reply(200).BodyString("mango") 20 | 21 | content, err := makeRequest("meain/dotfiles") 22 | if err != nil { 23 | t.Fatalf("Unable to fetch star count") 24 | } 25 | if string(content) != "mango" { 26 | t.Fatalf("makeReqest seems to have some problems. expected %v, got %v", "mango", string(content)) 27 | } 28 | } 29 | 30 | func TestBackup(t *testing.T) { 31 | cacheLocationBackup := cacheLocation 32 | defer func() { cacheLocation = cacheLocationBackup }() 33 | dir, err := os.MkdirTemp("", "gh-issues-to-rss") 34 | if err != nil { 35 | log.Fatal("Unable to create temp directory:", err) 36 | } 37 | cacheLocation = dir 38 | 39 | err = saveBackup("meain/dotfiles", []byte("dummy")) 40 | if err != nil { 41 | t.Fatalf("Unable to save backup file") 42 | } 43 | content, err := loadBackup("meain/dotfiles", time.Hour) 44 | if err != nil { 45 | t.Fatalf("Unable to load backup file") 46 | } 47 | if string(content) != "dummy" { 48 | t.Fatalf("Invalid backup content") 49 | } 50 | } 51 | 52 | func TestRssGenerationSimple(t *testing.T) { 53 | rssContent := ` 54 | [issue-open]: Sample Entry 55 | https://example.com 56 | Some body 57 | 58 | Wed, 08 Sep 2021 12:44:47 +0000 59 | 60 | 61 | ` 62 | data := []GithubIssue{ 63 | GithubIssue{ 64 | CreatedAt: "2021-09-08T12:44:47Z", 65 | Title: "Sample Entry", 66 | HTMLURL: "https://example.com", 67 | Body: "Some body", 68 | }, 69 | } 70 | 71 | content, err := generateRss(data, RunConfig{Repo: "meain/dotfiles", Modes: Modes{true, true, true, true}}) 72 | if err != nil { 73 | t.Fatalf("Unable to save backup file") 74 | } 75 | 76 | if !strings.Contains(content, rssContent) { 77 | t.Fatalf("Rss feed content does not match up") 78 | } 79 | } 80 | 81 | func TestRssGenerationWithClosed(t *testing.T) { 82 | rssContent := ` 83 | [issue-open]: Sample Entry 84 | https://example.com 85 | Some body 86 | 87 | Wed, 08 Sep 2021 12:44:47 +0000 88 | 89 | 90 | [issue-closed]: Another Entry 91 | https://example.com 92 | Another body 93 | 94 | Fri, 08 Oct 2021 12:44:47 +0000 95 | 96 | 97 | [issue-open]: Another Entry 98 | https://example.com 99 | Another body 100 | 101 | Wed, 08 Sep 2021 12:44:47 +0000 102 | 103 | 104 | ` 105 | data := []GithubIssue{ 106 | GithubIssue{ 107 | CreatedAt: "2021-09-08T12:44:47Z", 108 | Title: "Sample Entry", 109 | HTMLURL: "https://example.com", 110 | Body: "Some body", 111 | }, 112 | GithubIssue{ 113 | CreatedAt: "2021-09-08T12:44:47Z", 114 | ClosedAt: "2021-10-08T12:44:47Z", 115 | State: "closed", 116 | Title: "Another Entry", 117 | HTMLURL: "https://example.com", 118 | Body: "Another body", 119 | }, 120 | } 121 | content, err := generateRss(data, RunConfig{Repo: "meain/dotfiles", Modes: Modes{true, true, true, true}}) 122 | if err != nil { 123 | t.Fatalf("Unable to save backup file") 124 | } 125 | if !strings.Contains(content, rssContent) { 126 | t.Fatalf("Rss feed content does not match up") 127 | } 128 | } 129 | 130 | func TestRssGenerationWithClosedButOnlyOpen(t *testing.T) { 131 | rssContent := ` 132 | [issue-open]: Sample Entry 133 | https://example.com 134 | Some body 135 | 136 | Wed, 08 Sep 2021 12:44:47 +0000 137 | 138 | 139 | [issue-open]: Another Entry 140 | https://example.com 141 | Another body 142 | 143 | Wed, 08 Sep 2021 12:44:47 +0000 144 | 145 | 146 | ` 147 | data := []GithubIssue{ 148 | GithubIssue{ 149 | CreatedAt: "2021-09-08T12:44:47Z", 150 | Title: "Sample Entry", 151 | HTMLURL: "https://example.com", 152 | Body: "Some body", 153 | }, 154 | GithubIssue{ 155 | CreatedAt: "2021-09-08T12:44:47Z", 156 | ClosedAt: "2021-10-08T12:44:47Z", 157 | State: "closed", 158 | Title: "Another Entry", 159 | HTMLURL: "https://example.com", 160 | Body: "Another body", 161 | }, 162 | } 163 | content, err := generateRss(data, RunConfig{Repo: "meain/dotfiles", Modes: Modes{true, false, true, false}}) 164 | if err != nil { 165 | t.Fatalf("Unable to save backup file") 166 | } 167 | if !strings.Contains(content, rssContent) { 168 | t.Fatalf("Rss feed content does not match up") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1717893485, 24 | "narHash": "sha256-WMU6ZRZrBgEUDIF0siu2aIyVAXcxfElSwzZtS/mSpN4=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "3bcedce9f4de37570242faf16e1e143583407eab", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "id": "nixpkgs", 32 | "type": "indirect" 33 | } 34 | }, 35 | "root": { 36 | "inputs": { 37 | "flake-utils": "flake-utils", 38 | "nixpkgs": "nixpkgs" 39 | } 40 | }, 41 | "systems": { 42 | "locked": { 43 | "lastModified": 1681028828, 44 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 45 | "owner": "nix-systems", 46 | "repo": "default", 47 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "nix-systems", 52 | "repo": "default", 53 | "type": "github" 54 | } 55 | } 56 | }, 57 | "root": "root", 58 | "version": 7 59 | } 60 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Generate RSS feed for GH issues and PRs"; 3 | 4 | inputs.flake-utils.url = "github:numtide/flake-utils"; 5 | 6 | outputs = { self, nixpkgs, flake-utils }: 7 | flake-utils.lib.eachDefaultSystem (system: 8 | let 9 | pkgs = nixpkgs.legacyPackages.${system}; 10 | deploy = pkgs.writeShellScriptBin "deploy" '' 11 | set -euo pipefail 12 | ${pkgs.flyctl}/bin/fly deploy 13 | ''; 14 | in 15 | { 16 | packages = rec { 17 | noah = pkgs.buildGoModule { 18 | pname = "gh-issues-to-rss"; 19 | version = "dev"; 20 | src = ./.; 21 | vendorHash = "sha256-30tnt3fYXDDm+YQgY65dUcytDokrUdCChOU5LMgnGYE="; 22 | doCheck = false; 23 | }; 24 | 25 | default = noah; 26 | }; 27 | 28 | devShells.default = pkgs.mkShell { 29 | packages = with pkgs; [ 30 | go 31 | 32 | # for deployment 33 | flyctl 34 | deploy 35 | ]; 36 | }; 37 | } 38 | ); 39 | } -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for gh-issues-to-rss on 2022-07-29T10:06:43+05:30 2 | 3 | app = "gh-issues-to-rss" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [build] 9 | builder = "paketobuildpacks/builder:base" 10 | buildpacks = ["gcr.io/paketo-buildpacks/go"] 11 | 12 | [env] 13 | PORT = "8080" 14 | 15 | [experimental] 16 | allowed_public_ports = [] 17 | auto_rollback = true 18 | 19 | [[services]] 20 | http_checks = [] 21 | internal_port = 8080 22 | processes = ["app"] 23 | protocol = "tcp" 24 | script_checks = [] 25 | [services.concurrency] 26 | hard_limit = 25 27 | soft_limit = 20 28 | type = "connections" 29 | 30 | [[services.ports]] 31 | force_https = true 32 | handlers = ["http"] 33 | port = 80 34 | 35 | [[services.ports]] 36 | handlers = ["tls", "http"] 37 | port = 443 38 | 39 | [[services.tcp_checks]] 40 | grace_period = "1s" 41 | interval = "15s" 42 | restart_limit = 0 43 | timeout = "2s" 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/meain/gh-issues-to-rss 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.6 7 | github.com/gorilla/feeds v1.1.1 8 | github.com/kr/pretty v0.3.0 // indirect 9 | gopkg.in/h2non/gock.v1 v1.1.2 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 3 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 4 | github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= 5 | github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= 6 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 7 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 8 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 9 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 10 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 11 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 12 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 13 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 14 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 15 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 16 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 17 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 18 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 19 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 20 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 21 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 23 | gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= 24 | gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | gh-issues-to-rss 7 | 8 | 9 | 23 | 24 | 25 |
26 |
27 |
28 |

gh-issues-to-rss

29 |
30 |
Edit settings to get url
31 | 32 |
33 |
34 | View on GitHub 35 |
36 | 37 |
38 |
39 |
40 |

Github URL

41 |

Github URL for the project

42 | 43 |
44 | 45 |
46 |

Type

47 |

Type of notifications

48 |
49 | 53 | 57 | 61 | 65 |
66 |
67 | 68 |
69 |

Filters (Optional)

70 |

Filter down the results based on certain conditions

71 | 72 |
73 |
74 | 75 |
76 | 77 | 78 |
79 |
80 | 81 |
82 | 83 |
84 | 85 | 86 |
87 |
88 | 89 |
90 | 91 |
92 | 93 | 94 |
95 |
96 | 97 |
98 | 99 |
100 | 101 | 102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | 110 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | var baseUrl = "https://api.github.com/repos/" 19 | var cacheLocation = "/tmp/gh-issues-to-rss-cache" 20 | 21 | //go:embed index.html 22 | var index string 23 | 24 | func getModesFromList(m []string) Modes { 25 | modes := Modes{false, false, false, false} 26 | for _, entry := range m { 27 | switch entry { 28 | case "io": 29 | modes.IssueOpen = true 30 | case "ic": 31 | modes.IssuesClosed = true 32 | case "po": 33 | modes.PROpen = true 34 | case "pc": 35 | modes.PRClosed = true 36 | } 37 | } 38 | return modes 39 | } 40 | 41 | func setupResponse(w *http.ResponseWriter, req *http.Request) { 42 | (*w).Header().Set("Access-Control-Allow-Origin", "*") 43 | (*w).Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") 44 | (*w).Header().Set("Access-Control-Allow-Headers", "*") 45 | } 46 | 47 | func getHandler(cacheTimeout time.Duration) func(http.ResponseWriter, *http.Request) { 48 | handler := func(w http.ResponseWriter, r *http.Request) { 49 | setupResponse(&w, r) 50 | if (*r).Method == "OPTIONS" { 51 | return 52 | } 53 | if r.Method != "GET" { 54 | http.Error(w, "Method is not supported", http.StatusNotFound) 55 | return 56 | } 57 | url := r.URL.Path 58 | if url == "/" { 59 | http.ServeContent(w, r, "index.html", time.Now(), strings.NewReader(index)) 60 | return 61 | } 62 | if url == "/_ping" { 63 | io.WriteString(w, "PONG") 64 | return 65 | } 66 | params := r.URL.Query() 67 | m, ok := params["m"] 68 | modes := Modes{true, true, true, true} 69 | if ok { 70 | modes = getModesFromList(m) 71 | } 72 | 73 | // We don't want the url to be invalid just because people 74 | // forgot to remove a trailing / when copy pasting the url 75 | if strings.HasSuffix(url, "/") { 76 | url = url[:len(url)-1] 77 | } 78 | 79 | splits := strings.Split(url, "/") 80 | if len(splits) != 3 { // url starts with / 81 | http.Error(w, "Invalid request: call `/org/repo`", http.StatusBadRequest) 82 | return 83 | } 84 | repo := splits[1] + "/" + splits[2] 85 | 86 | labels := params["l"] 87 | notlabels := params["nl"] 88 | users := params["u"] 89 | notusers := params["nu"] 90 | 91 | rc := RunConfig{ 92 | Modes: modes, 93 | Labels: labels, 94 | NotLabels: notlabels, 95 | Users: users, 96 | NotUsers: notusers, 97 | Repo: repo, 98 | } 99 | 100 | rss, err := getIssueFeed(rc, cacheTimeout) 101 | if err != nil { 102 | http.Error(w, "Unable to fetch atom feed", http.StatusNotFound) 103 | return 104 | } 105 | fmt.Println(time.Now().Format("2006-01-02 15:04:05"), "[OK]", repo) 106 | io.WriteString(w, rss) 107 | } 108 | 109 | return handler 110 | } 111 | 112 | func getCliArgs() (config, error) { 113 | var ( 114 | modes string 115 | labels string 116 | notlabels string 117 | users string 118 | notusers string 119 | server bool 120 | port int 121 | cacheTimeout int64 122 | ) 123 | 124 | flag.StringVar(&modes, "m", "", "Comma separated list of modes [io,ic,po,pc]") 125 | flag.StringVar(&labels, "l", "", "Comma separated list of labels to include") 126 | flag.StringVar(¬labels, "nl", "", "Comma separated list of labels to exclude") 127 | flag.StringVar(&users, "u", "", "Comma separated list of users to include") 128 | flag.StringVar(¬users, "nu", "", "Comma separated list of users to exclude") 129 | flag.BoolVar(&server, "server", false, "run as server instead of cli mode") 130 | flag.IntVar(&port, "port", 0, "port to use for server") 131 | flag.Int64Var(&cacheTimeout, "cache-timeout", 60*12, "cache timeout in minutes, 0 to disable") 132 | 133 | flag.Parse() // after declaring flags we need to call it 134 | 135 | if server { 136 | return config{ServerConfig: &ServerConfig{port, cacheTimeout}}, nil 137 | } 138 | 139 | if len(flag.Args()) != 1 { 140 | return config{}, errors.New("need repo when not running in server mode") 141 | } 142 | 143 | cfg := config{RunConfig: &RunConfig{ 144 | Modes: Modes{true, true, true, true}, // default should be all true 145 | }} 146 | 147 | if modes != "" { 148 | cfg.RunConfig.Modes = getModesFromList(strings.Split(modes, ",")) 149 | } 150 | 151 | if labels != "" { // prevents empty "" item 152 | cfg.RunConfig.Labels = strings.Split(labels, ",") 153 | } 154 | 155 | if notlabels != "" { 156 | cfg.RunConfig.NotLabels = strings.Split(notlabels, ",") 157 | } 158 | 159 | if users != "" { 160 | cfg.RunConfig.Users = strings.Split(users, ",") 161 | } 162 | 163 | if notusers != "" { 164 | cfg.RunConfig.NotUsers = strings.Split(notusers, ",") 165 | } 166 | 167 | cfg.RunConfig.Repo = flag.Args()[0] 168 | 169 | return cfg, nil 170 | } 171 | 172 | // A better version of flag.Usage 173 | func printHelp() { 174 | fmt.Println(path.Base(os.Args[0]) + ` [FLAGS] [repo] [--server] 175 | 176 | Server mode (use -server to switch to server mode): 177 | -port int 178 | port to use for server (default 8080) 179 | -cache-timeout float 180 | cache timeout in minutes, 0 to disable (default: 12 hours) 181 | Example: ` + path.Base(os.Args[0]) + ` -server -port 8080 -cache-timeout 720 182 | 183 | Single repo mode: 184 | -m string 185 | Comma separated list of modes [io,ic,po,pc] 186 | -l string 187 | Comma separated list of labels to include 188 | -nl string 189 | Comma separated list of labels to exclude 190 | -u string 191 | Comma separated list of users to include 192 | -nu string 193 | Comma separated list of users to exclude 194 | Example: ` + path.Base(os.Args[0]) + ` -m io,ic,po,pc -l bug,enhancement -nl invalid -u user1,user2 -nu user3,user4 org/repo`) 195 | } 196 | 197 | func main() { 198 | flag.Usage = printHelp 199 | 200 | cfg, err := getCliArgs() 201 | if err != nil { 202 | flag.Usage() 203 | fmt.Println("\nError:", err) 204 | os.Exit(1) 205 | } 206 | 207 | if cfg.RunConfig != nil { 208 | atom, err := getIssueFeed(*cfg.RunConfig, 0) 209 | if err != nil { 210 | log.Fatal("Unable to create feed for repo", cfg.RunConfig.Repo, ":", err) 211 | } 212 | fmt.Println(atom) 213 | } else { 214 | http.HandleFunc("/", getHandler(time.Duration(cfg.ServerConfig.CacheTimeout)*time.Minute)) 215 | 216 | port := ":" + strconv.Itoa(cfg.ServerConfig.Port) 217 | if cfg.ServerConfig.Port == 0 { 218 | port = os.Getenv("PORT") 219 | if port == "" { 220 | port = ":8080" 221 | } else { 222 | port = ":" + port 223 | } 224 | } 225 | 226 | fmt.Println("Starting server on", port) 227 | err := http.ListenAndServe(port, nil) 228 | if err != nil { 229 | log.Fatal(err) 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "gopkg.in/h2non/gock.v1" 13 | ) 14 | 15 | func TestWebserverPing(t *testing.T) { 16 | request, _ := http.NewRequest(http.MethodGet, "/_ping", nil) 17 | response := httptest.NewRecorder() 18 | handler := getHandler(0) 19 | handler(response, request) 20 | got := response.Body.String() 21 | 22 | if !strings.Contains(got, "PONG") { 23 | t.Fatalf("Did not get PONG back from server") 24 | } 25 | 26 | } 27 | 28 | func TestFetchRssAll(t *testing.T) { 29 | data := []GithubIssue{ 30 | GithubIssue{ 31 | CreatedAt: "2021-09-08T12:44:47Z", 32 | Title: "Sample Entry", 33 | HTMLURL: "https://example.com", 34 | Body: "Some body", 35 | }, 36 | GithubIssue{ 37 | CreatedAt: "2021-09-08T12:44:47Z", 38 | ClosedAt: "2021-10-08T12:44:47Z", 39 | State: "closed", 40 | Title: "Another Entry", 41 | HTMLURL: "https://example.com", 42 | Body: "Another body", 43 | }, 44 | } 45 | defer gock.Off() 46 | gock.New("https://api.github.com"). 47 | Get("/repos"). 48 | Reply(200). 49 | JSON(data) 50 | 51 | // delete any cashed file 52 | path := cacheLocation + "/meain/dotfiles/issues.json" 53 | os.Remove(path) 54 | 55 | request, _ := http.NewRequest(http.MethodGet, "/meain/dotfiles", nil) 56 | response := httptest.NewRecorder() 57 | handler := getHandler(0) 58 | handler(response, request) 59 | 60 | got := response.Body.String() 61 | rssContent := ` 62 | [issue-open]: Sample Entry 63 | https://example.com 64 | Some body 65 | 66 | Wed, 08 Sep 2021 12:44:47 +0000 67 | 68 | 69 | [issue-closed]: Another Entry 70 | https://example.com 71 | Another body 72 | 73 | Fri, 08 Oct 2021 12:44:47 +0000 74 | 75 | 76 | [issue-open]: Another Entry 77 | https://example.com 78 | Another body 79 | 80 | Wed, 08 Sep 2021 12:44:47 +0000 81 | 82 | 83 | ` 84 | 85 | if !strings.Contains(got, rssContent) { 86 | t.Fatalf("Rss feed content does not match up") 87 | } 88 | 89 | } 90 | 91 | func TestFetchRssOpenOnly(t *testing.T) { 92 | data := []GithubIssue{ 93 | GithubIssue{ 94 | CreatedAt: "2021-09-08T12:44:47Z", 95 | Title: "Sample Entry", 96 | HTMLURL: "https://example.com", 97 | Body: "Some body", 98 | }, 99 | GithubIssue{ 100 | CreatedAt: "2021-09-08T12:44:47Z", 101 | ClosedAt: "2021-10-08T12:44:47Z", 102 | State: "closed", 103 | Title: "Another Entry", 104 | HTMLURL: "https://example.com", 105 | Body: "Another body", 106 | }, 107 | } 108 | defer gock.Off() 109 | gock.New("https://api.github.com"). 110 | Get("/repos"). 111 | Reply(200). 112 | JSON(data) 113 | 114 | // delete any cashed file 115 | path := cacheLocation + "/meain/dotfiles/issues.json" 116 | os.Remove(path) 117 | 118 | request, _ := http.NewRequest(http.MethodGet, "/meain/dotfiles?m=io&m=po", nil) 119 | response := httptest.NewRecorder() 120 | handler := getHandler(0) 121 | handler(response, request) 122 | 123 | got := response.Body.String() 124 | rssContent := ` 125 | [issue-open]: Sample Entry 126 | https://example.com 127 | Some body 128 | 129 | Wed, 08 Sep 2021 12:44:47 +0000 130 | 131 | 132 | [issue-open]: Another Entry 133 | https://example.com 134 | Another body 135 | 136 | Wed, 08 Sep 2021 12:44:47 +0000 137 | 138 | 139 | ` 140 | 141 | if !strings.Contains(got, rssContent) { 142 | t.Fatalf("Rss feed content does not match up") 143 | } 144 | 145 | } 146 | 147 | func TestFetchRssWithGoodFirstLabel(t *testing.T) { 148 | data := []GithubIssue{ 149 | GithubIssue{ 150 | CreatedAt: "2021-09-08T12:44:47Z", 151 | Title: "Sample Entry", 152 | HTMLURL: "https://example.com", 153 | Body: "Some body", 154 | Labels: []GithubIssueLabel{GithubIssueLabel{Name: "good-first-issue"}}, 155 | }, 156 | GithubIssue{ 157 | CreatedAt: "2021-09-08T12:44:47Z", 158 | ClosedAt: "2021-10-08T12:44:47Z", 159 | State: "closed", 160 | Title: "Another Entry", 161 | HTMLURL: "https://example.com", 162 | Body: "Another body", 163 | }, 164 | } 165 | defer gock.Off() 166 | gock.New("https://api.github.com"). 167 | Get("/repos"). 168 | Reply(200). 169 | JSON(data) 170 | 171 | // delete any cashed file 172 | path := cacheLocation + "/meain/dotfiles/issues.json" 173 | os.Remove(path) 174 | 175 | request, _ := http.NewRequest(http.MethodGet, "/meain/dotfiles?l=good-first-issue", nil) 176 | response := httptest.NewRecorder() 177 | handler := getHandler(0) 178 | handler(response, request) 179 | 180 | got := response.Body.String() 181 | shouldContent := ` 182 | [issue-open]: Sample Entry 183 | https://example.com 184 | Some body 185 | 186 | Wed, 08 Sep 2021 12:44:47 +0000 187 | ` 188 | 189 | shouldntContent := ` 190 | [issue-open]: Another Entry 191 | https://example.com 192 | Another body 193 | 194 | Wed, 08 Sep 2021 12:44:47 +0000 195 | ` 196 | 197 | if !strings.Contains(got, shouldContent) { 198 | t.Fatalf("Rss feed content does not match up") 199 | } 200 | if strings.Contains(got, shouldntContent) { 201 | t.Fatalf("Rss feed content unnecessary stuff") 202 | } 203 | } 204 | 205 | // func TestCliFlagParsing(t *testing.T) { 206 | // oldArgs := os.Args 207 | // defer func() { os.Args = oldArgs }() 208 | 209 | // tests := []struct { 210 | // name string 211 | // input []string 212 | // repo string 213 | // mode Modes 214 | // labels []string 215 | // notlabels []string 216 | // users []string 217 | // notusers []string 218 | // server bool 219 | // }{ 220 | // {"simple", []string{"meain/dotfiles"}, "meain/dotfiles", Modes{true, true, true, true}, nil, nil, nil, nil, false}, 221 | // {"with-labels", []string{"-l", "good-first-issue", "meain/dotfiles"}, "meain/dotfiles", Modes{true, true, true, true}, []string{"good-first-issue"}, nil, nil, nil, false}, 222 | // {"with-modes", []string{"-m", "ic,po", "meain/dotfiles"}, "meain/dotfiles", Modes{false, true, true, false}, nil, nil, nil, nil, false}, 223 | // {"with-modes-and-labels", []string{"-m", "ic,po", "-l", "good-first-issue", "meain/dotfiles"}, "meain/dotfiles", Modes{false, true, true, false}, []string{"good-first-issue"}, nil, nil, nil, false}, 224 | // {"with-not-labels", []string{"-nl", "good-first-issue", "meain/dotfiles"}, "meain/dotfiles", Modes{true, true, true, true}, nil, []string{"good-first-issue"}, nil, nil, false}, 225 | // {"with-users-and-not-users", []string{"-u", "meain", "-nu", "niaem", "meain/dotfiles"}, "meain/dotfiles", Modes{true, true, true, true}, nil, nil, []string{"meain"}, []string{"niaem"}, false}, 226 | 227 | // // server 228 | // {"server", []string{"--server"}, "", Modes{true, true, true, true}, nil, nil, nil, nil, true}, 229 | // } 230 | // for _, tc := range tests { 231 | // t.Run(tc.name, func(t *testing.T) { 232 | // flag.CommandLine = flag.NewFlagSet("gh-issues-to-rss", flag.ExitOnError) 233 | // // we need a value to set Args[0] to, cause flag begins parsing at Args[1] 234 | // items := []string{"gh-issues-to-rss"} 235 | // items = append(items, tc.input...) 236 | 237 | // os.Args = items 238 | // cfg, err := getCliArgs() 239 | // if err != nil { 240 | // t.Fatalf("Unable to parse cli arg") 241 | // } 242 | 243 | // if cfg.serverConfig 244 | 245 | // if !cmp.Equal(tc.repo, cfg.runConfig.repo) { 246 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.repo, cfg.runConfig.repo)) 247 | // } 248 | // if !cmp.Equal(tc.mode, cfg.runConfig.modes) { 249 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.mode, cfg.runConfig.modes)) 250 | // } 251 | // if !cmp.Equal(tc.labels, cfg.runConfig.labels) { 252 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.labels, cfg.runConfig.labels)) 253 | // } 254 | // if !cmp.Equal(tc.notlabels, cfg.runConfig.notLabels) { 255 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.notlabels, cfg.runConfig.labels)) 256 | // } 257 | // if !cmp.Equal(tc.users, cfg.runConfig.users) { 258 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.users, cfg.runConfig.users)) 259 | // } 260 | // if !cmp.Equal(tc.notusers, cfg.runConfig.notUsers) { 261 | // t.Fatalf("values are not the same %s", cmp.Diff(tc.notusers, cfg.runConfig.notUsers)) 262 | // } 263 | // }) 264 | // } 265 | // } 266 | 267 | func TestCliFlagParsing(t *testing.T) { 268 | oldArgs := os.Args 269 | defer func() { os.Args = oldArgs }() 270 | 271 | table := []struct { 272 | name string 273 | input string 274 | cfg config 275 | }{ 276 | { 277 | name: "simple repo", 278 | input: "meain/dotfiles", 279 | cfg: config{ 280 | RunConfig: &RunConfig{ 281 | Repo: "meain/dotfiles", 282 | Modes: Modes{true, true, true, true}, 283 | }, 284 | }, 285 | }, 286 | { 287 | name: "with everything", 288 | input: "-m ic,po -l good-first-issue -nl bug -u meain -nu niaem meain/dotfiles", 289 | cfg: config{ 290 | RunConfig: &RunConfig{ 291 | Repo: "meain/dotfiles", 292 | Modes: Modes{false, true, true, false}, 293 | Labels: []string{"good-first-issue"}, 294 | NotLabels: []string{"bug"}, 295 | Users: []string{"meain"}, 296 | NotUsers: []string{"niaem"}, 297 | }, 298 | }, 299 | }, 300 | { 301 | name: "multiple items", 302 | input: "-m ic,po -l good-first-issue,p0 -nl bug,documentation -u meain,ain -nu niaem,nia meain/dotfiles", 303 | cfg: config{ 304 | RunConfig: &RunConfig{ 305 | Repo: "meain/dotfiles", 306 | Modes: Modes{false, true, true, false}, 307 | Labels: []string{"good-first-issue", "p0"}, 308 | NotLabels: []string{"bug", "documentation"}, 309 | Users: []string{"meain", "ain"}, 310 | NotUsers: []string{"niaem", "nia"}, 311 | }, 312 | }, 313 | }, 314 | { 315 | name: "server", 316 | input: "--server", 317 | cfg: config{ 318 | ServerConfig: &ServerConfig{ 319 | Port: 0, 320 | CacheTimeout: 60 * 12, 321 | }, 322 | }, 323 | }, 324 | { 325 | name: "server with args", 326 | input: "--server -port 8081 -cache-timeout 120", 327 | cfg: config{ 328 | ServerConfig: &ServerConfig{ 329 | Port: 8081, 330 | CacheTimeout: 120, 331 | }, 332 | }, 333 | }, 334 | } 335 | 336 | for _, tc := range table { 337 | t.Run(tc.name, func(t *testing.T) { 338 | flag.CommandLine = flag.NewFlagSet("gh-issues-to-rss", flag.ExitOnError) 339 | // we need a value to set Args[0] to, cause flag begins parsing at Args[1] 340 | items := []string{"gh-issues-to-rss"} 341 | input := strings.Split(tc.input, " ") 342 | items = append(items, input...) 343 | 344 | os.Args = items 345 | cfg, err := getCliArgs() 346 | if err != nil { 347 | t.Fatalf("unable to parse cli arg: %s", err) 348 | } 349 | 350 | if !cmp.Equal(tc.cfg, cfg) { 351 | t.Fatalf("values are not the same %s", cmp.Diff(tc.cfg, cfg)) 352 | } 353 | }) 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /notes: -------------------------------------------------------------------------------- 1 | curl \ 2 | -H "Accept: application/vnd.github.v3+json" \ 3 | 'https://api.github.com/repos/meain/dotfiles/issues?state=all' 4 | 5 | Pull requests have a "pull_request" key 6 | https://docs.github.com/en/rest/reference/issues#list-repository-issues 7 | -------------------------------------------------------------------------------- /readme: -------------------------------------------------------------------------------- 1 | Ever wanted to passively watch prs/issues in a repo without subscribing to every event? 2 | 3 | Yup, me too. 4 | 5 | This gives you an rss feed. One entry when it opens and one when it closes. 6 | That too, filterable. I know! 7 | 8 | -------------------------------------------- 9 | 10 | > Demo server: https://gh-issues-to-rss.fly.dev 11 | > Example: https://gh-issues-to-rss.fly.dev/meain/evil-textobj-tree-sitter 12 | 13 | Usage 14 | gh-issues-to-rss --server # in your server 15 | http://// # use this in your feed reader 16 | 17 | Example 18 | nvim-treesitter/nvim-tree [pr-open]: Adds fish shell textobjects 19 | nvim-treesitter/nvim-tree [pr-close]: Add Elixir textobjects 20 | meain/dotfiles [issue-close]: Just a thought 21 | meain/dotfiles [issue-close]: Screenshots 22 | nvim-treesitter/nvim-tree [issue-open]: Question: is it expected that inner function objects include braces? 23 | 24 | You can pass in extra arg in the url to filter things down: 25 | 26 | - `m`: specify modes 27 | - ic: issue-closed 28 | - io: issue-open 29 | - pc: pr-closed 30 | - po: pr-open 31 | > Eg: http:////?m=io&m=po # just open issues and prs 32 | - `l`: speify label 33 | > Eg: http:////?l=good-first-issue # just issus/prs labeled good-first-issue 34 | - `u`: specify user 35 | > Eg: http:////?u=meain # just issus/prs opened by meain 36 | - `nu`: specify user to exclude 37 | > Eg: http:////?nu=meain # just issus/prs not opened by meain 38 | 39 | All filters can be used multiple times. Positive filters are ANDed 40 | together, negative filters are ORed together. 41 | 42 | Notes 43 | - Github rate limits to 60 requests per hour (set GH_ISSUES_TO_RSS_GITHUB_TOKEN to PAT to increase this limit) 44 | - We invalidate internal cache only every 12 hours (use --cache-timeout to change this) 45 | 46 | -------------------------------------------- 47 | 48 | CLI help: 49 | 50 | gh-issues-to-rss [FLAGS] [repo] [--server] 51 | 52 | Server mode (use -server to switch to server mode): 53 | -port int 54 | port to use for server (default 8080) 55 | -cache-timeout float 56 | cache timeout in minutes, 0 to disable (default: 12 hours) 57 | Example: gh-issues-to-rss -server -port 8080 -cache-timeout 720 58 | 59 | Single repo mode: 60 | -m string 61 | Comma separated list of modes [io,ic,po,pc] 62 | -l string 63 | Comma separated list of labels to include 64 | -nl string 65 | Comma separated list of labels to exclude 66 | -u string 67 | Comma separated list of users to include 68 | -nu string 69 | Comma separated list of users to exclude 70 | Example: gh-issues-to-rss -m io,ic,po,pc -l bug,enhancement -nl invalid -u user1,user2 -nu user3,user4 org/repo 71 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Modes struct { 4 | IssueOpen bool 5 | IssuesClosed bool 6 | PROpen bool 7 | PRClosed bool 8 | } 9 | 10 | // If running as a server 11 | type ServerConfig struct { 12 | Port int 13 | CacheTimeout int64 14 | } 15 | 16 | // If running on individual repo 17 | type RunConfig struct { 18 | Modes Modes 19 | Repo string 20 | Labels []string 21 | NotLabels []string 22 | Users []string 23 | NotUsers []string 24 | } 25 | 26 | type config struct { 27 | RunConfig *RunConfig 28 | ServerConfig *ServerConfig 29 | } 30 | 31 | type GithubIssueLabel struct { 32 | Name string `json:"name"` 33 | } 34 | 35 | type GithubIssue struct { 36 | ActiveLockReason interface{} `json:"active_lock_reason"` 37 | Assignee interface{} `json:"assignee"` 38 | Assignees []interface{} `json:"assignees"` 39 | AuthorAssociation string `json:"author_association"` 40 | Body string `json:"body"` 41 | ClosedAt string `json:"closed_at"` 42 | Comments int64 `json:"comments"` 43 | CommentsURL string `json:"comments_url"` 44 | CreatedAt string `json:"created_at"` 45 | EventsURL string `json:"events_url"` 46 | HTMLURL string `json:"html_url"` 47 | ID int64 `json:"id"` 48 | Labels []GithubIssueLabel `json:"labels"` 49 | LabelsURL string `json:"labels_url"` 50 | Locked bool `json:"locked"` 51 | Milestone interface{} `json:"milestone"` 52 | NodeID string `json:"node_id"` 53 | Number int64 `json:"number"` 54 | PerformedViaGithubApp interface{} `json:"performed_via_github_app"` 55 | PullRequest struct { 56 | DiffURL string `json:"diff_url"` 57 | HTMLURL string `json:"html_url"` 58 | PatchURL string `json:"patch_url"` 59 | URL string `json:"url"` 60 | } `json:"pull_request"` 61 | RepositoryURL string `json:"repository_url"` 62 | State string `json:"state"` 63 | Title string `json:"title"` 64 | UpdatedAt string `json:"updated_at"` 65 | URL string `json:"url"` 66 | User struct { 67 | AvatarURL string `json:"avatar_url"` 68 | EventsURL string `json:"events_url"` 69 | FollowersURL string `json:"followers_url"` 70 | FollowingURL string `json:"following_url"` 71 | GistsURL string `json:"gists_url"` 72 | GravatarID string `json:"gravatar_id"` 73 | HTMLURL string `json:"html_url"` 74 | ID int64 `json:"id"` 75 | Login string `json:"login"` 76 | NodeID string `json:"node_id"` 77 | OrganizationsURL string `json:"organizations_url"` 78 | ReceivedEventsURL string `json:"received_events_url"` 79 | ReposURL string `json:"repos_url"` 80 | SiteAdmin bool `json:"site_admin"` 81 | StarredURL string `json:"starred_url"` 82 | SubscriptionsURL string `json:"subscriptions_url"` 83 | Type string `json:"type"` 84 | URL string `json:"url"` 85 | } `json:"user"` 86 | } 87 | --------------------------------------------------------------------------------