├── .github ├── FUNDING.yml ├── workflows │ ├── build.yml │ ├── nightly.yml │ ├── goreleaser.yml │ ├── coverage.yml │ ├── lint.yml │ └── lint-soft.yml └── dependabot.yml ├── Dockerfile ├── literal.go ├── .gitignore ├── .goreleaser.yml ├── goodreads.go ├── template.go ├── .golangci.yml ├── .golangci-soft.yml ├── rss.go ├── LICENSE ├── gists.go ├── go.mod ├── stars.go ├── users.go ├── templates └── github-profile.tpl ├── literal └── client.go ├── sponsors.go ├── main.go ├── types.go ├── go.sum ├── README.md └── repos.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: muesli 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | COPY markscribe /usr/local/bin/markscribe 3 | ENTRYPOINT [ "/usr/local/bin/markscribe" ] 4 | -------------------------------------------------------------------------------- /literal.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/markscribe/literal" 4 | 5 | func literalClubCurrentlyReading(count int) []literal.Book { 6 | books, err := literal.CurrentlyReading() 7 | if err != nil { 8 | panic(err) 9 | } 10 | if len(books) > count { 11 | return books[:count] 12 | } 13 | return books 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | markscribe 18 | dist/ 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | uses: charmbracelet/meta/.github/workflows/build.yml@main 12 | 13 | snapshot: 14 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 15 | secrets: 16 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 17 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | version: 2 3 | 4 | includes: 5 | - from_url: 6 | url: charmbracelet/meta/main/goreleaser.yaml 7 | 8 | variables: 9 | binary_name: markscribe 10 | description: "Your personal markdown scribe with template-engine and Git(Hub) & RSS powers" 11 | github_url: "https://github.com/charmbracelet/markscribe" 12 | maintainer: "Christian Muehlhaeuser " 13 | brew_commit_author_name: "Christian Muehlhaeuser" 14 | brew_commit_author_email: "muesli@charm.sh" 15 | -------------------------------------------------------------------------------- /goodreads.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/KyleBanks/goodreads/responses" 5 | ) 6 | 7 | func goodReadsReviews(count int) []responses.Review { 8 | reviews, err := goodReadsClient.ReviewList(goodReadsID, "read", "date_read", "", "d", 1, count) 9 | if err != nil { 10 | panic(err) 11 | } 12 | return reviews 13 | } 14 | 15 | func goodReadsCurrentlyReading(count int) []responses.Review { 16 | reviews, err := goodReadsClient.ReviewList(goodReadsID, "currently-reading", "date_updated", "", "d", 1, count) 17 | if err != nil { 18 | panic(err) 19 | } 20 | return reviews 21 | } 22 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dustin/go-humanize" 8 | ) 9 | 10 | func humanized(t interface{}) string { 11 | switch v := t.(type) { 12 | case time.Time: 13 | // flatten time to prevent updating README too often: 14 | v = time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, v.Location()) 15 | 16 | if time.Since(v) <= time.Hour*24 { 17 | return "today" 18 | } 19 | 20 | return humanize.Time(v) 21 | case int64: 22 | return humanize.Comma(v) 23 | case int: 24 | return humanize.Comma(int64(v)) 25 | default: 26 | return fmt.Sprintf("%v", t) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | timeout: 5m 4 | 5 | issues: 6 | include: 7 | - EXC0001 8 | - EXC0005 9 | - EXC0011 10 | - EXC0012 11 | - EXC0013 12 | 13 | max-issues-per-linter: 0 14 | max-same-issues: 0 15 | 16 | linters: 17 | enable: 18 | - bodyclose 19 | - exportloopref 20 | - gofumpt 21 | - goimports 22 | - gosec 23 | - nilerr 24 | - predeclared 25 | - revive 26 | - rowserrcheck 27 | - sqlclosecheck 28 | - tparallel 29 | - unconvert 30 | - unparam 31 | - whitespace 32 | 33 | severity: 34 | default-severity: error 35 | rules: 36 | - linters: 37 | - revive 38 | severity: info 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: 8 | - "dependencies" 9 | commit-message: 10 | prefix: "chore" 11 | include: "scope" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | labels: 17 | - "dependencies" 18 | commit-message: 19 | prefix: "chore" 20 | include: "scope" 21 | - package-ecosystem: "docker" 22 | directory: "/" 23 | schedule: 24 | interval: "daily" 25 | labels: 26 | - "dependencies" 27 | commit-message: 28 | prefix: "chore" 29 | include: "scope" 30 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | concurrency: 9 | group: goreleaser 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | goreleaser: 14 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 15 | secrets: 16 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 18 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 19 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 20 | fury_token: ${{ secrets.FURY_TOKEN }} 21 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 22 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 23 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 24 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coverage: 6 | strategy: 7 | matrix: 8 | go-version: [^1] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | 22 | - name: Coverage 23 | env: 24 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: | 26 | go install github.com/mattn/goveralls@latest 27 | go test -race -covermode atomic -coverprofile=covprofile ./... 28 | goveralls -coverprofile=covprofile -service=github 29 | -------------------------------------------------------------------------------- /.golangci-soft.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | timeout: 5m 4 | 5 | issues: 6 | include: 7 | - EXC0001 8 | - EXC0005 9 | - EXC0011 10 | - EXC0012 11 | - EXC0013 12 | 13 | max-issues-per-linter: 0 14 | max-same-issues: 0 15 | 16 | linters: 17 | enable: 18 | # - dupl 19 | - exhaustive 20 | # - exhaustivestruct 21 | - goconst 22 | - godot 23 | - godox 24 | - gomoddirectives 25 | - goprintffuncname 26 | # - lll 27 | - misspell 28 | - mnd 29 | - nakedret 30 | - nestif 31 | - noctx 32 | - nolintlint 33 | - prealloc 34 | - wrapcheck 35 | 36 | # disable default linters, they are already enabled in .golangci.yml 37 | disable: 38 | - errcheck 39 | - gosimple 40 | - govet 41 | - ineffassign 42 | - staticcheck 43 | - typecheck 44 | - unused 45 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ^1 23 | 24 | - uses: actions/checkout@v4 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v4 27 | with: 28 | # Optional: golangci-lint command line arguments. 29 | #args: 30 | # Optional: show only new issues if it's a pull request. The default value is `false`. 31 | only-new-issues: true 32 | -------------------------------------------------------------------------------- /rss.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mmcdole/gofeed" 7 | ) 8 | 9 | // RSSEntry represents a single RSS entry. 10 | type RSSEntry struct { 11 | Title string 12 | Author string 13 | Description string 14 | URL string 15 | PublishedAt time.Time 16 | } 17 | 18 | func rssFeed(url string, count int) []RSSEntry { 19 | var r []RSSEntry 20 | 21 | fp := gofeed.NewParser() 22 | feed, err := fp.ParseURL(url) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | for _, v := range feed.Items { 28 | // fmt.Printf("%+v\n", v) 29 | 30 | r = append(r, RSSEntry{ 31 | Title: v.Title, 32 | Author: v.Author.Name, 33 | Description: v.Description, 34 | URL: v.Link, 35 | PublishedAt: *v.PublishedParsed, 36 | }) 37 | if len(r) == count { 38 | break 39 | } 40 | } 41 | 42 | return r 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/lint-soft.yml: -------------------------------------------------------------------------------- 1 | name: lint-soft 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint-soft 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ^1 23 | 24 | - uses: actions/checkout@v4 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v4 27 | with: 28 | # Optional: golangci-lint command line arguments. 29 | args: --config .golangci-soft.yml --issues-exit-code=0 30 | # Optional: show only new issues if it's a pull request. The default value is `false`. 31 | only-new-issues: true 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Muehlhaeuser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gists.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shurcooL/githubv4" 7 | ) 8 | 9 | var gistsQuery struct { 10 | User struct { 11 | Login githubv4.String 12 | Gists struct { 13 | TotalCount githubv4.Int 14 | Edges []struct { 15 | Cursor githubv4.String 16 | Node qlGist 17 | } 18 | } `graphql:"gists(first: $count, orderBy: {field: CREATED_AT, direction: DESC})"` 19 | } `graphql:"user(login:$username)"` 20 | } 21 | 22 | func gists(count int) []Gist { 23 | // fmt.Printf("Finding gists...\n") 24 | 25 | var gists []Gist 26 | variables := map[string]interface{}{ 27 | "username": githubv4.String(username), 28 | "count": githubv4.Int(count), 29 | } 30 | err := gitHubClient.Query(context.Background(), &gistsQuery, variables) 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | // fmt.Printf("%+v\n", query) 36 | for _, v := range gistsQuery.User.Gists.Edges { 37 | gists = append(gists, gistFromQL(v.Node)) 38 | } 39 | 40 | // fmt.Printf("Found %d gists!\n", len(gists)) 41 | return gists 42 | } 43 | 44 | /* 45 | { 46 | user(login: "muesli") { 47 | login 48 | gists(first: 100) { 49 | totalCount 50 | edges { 51 | cursor 52 | node { 53 | name 54 | description 55 | url 56 | createdAt 57 | } 58 | } 59 | } 60 | } 61 | } 62 | */ 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/markscribe 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.3 6 | 7 | require ( 8 | github.com/KyleBanks/goodreads v0.0.0-20200527082926-28539417959b 9 | github.com/caarlos0/env/v6 v6.10.1 10 | github.com/dustin/go-humanize v1.0.1 11 | github.com/go-sprout/sprout v0.4.1 12 | github.com/mmcdole/gofeed v1.2.1 13 | github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 14 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f 15 | golang.org/x/oauth2 v0.7.0 16 | ) 17 | 18 | require ( 19 | dario.cat/mergo v1.0.0 // indirect 20 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 21 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 22 | github.com/andybalholm/cascadia v1.3.1 // indirect 23 | github.com/golang/protobuf v1.5.2 // indirect 24 | github.com/google/uuid v1.6.0 // indirect 25 | github.com/json-iterator/go v1.1.12 // indirect 26 | github.com/mitchellh/copystructure v1.2.0 // indirect 27 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 28 | github.com/mmcdole/goxpp v1.1.0 // indirect 29 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 30 | github.com/modern-go/reflect2 v1.0.2 // indirect 31 | github.com/spf13/cast v1.6.0 // indirect 32 | golang.org/x/crypto v0.21.0 // indirect 33 | golang.org/x/net v0.21.0 // indirect 34 | golang.org/x/text v0.14.0 // indirect 35 | google.golang.org/appengine v1.6.7 // indirect 36 | google.golang.org/protobuf v1.28.0 // indirect 37 | gopkg.in/yaml.v3 v3.0.1 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /stars.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shurcooL/githubv4" 7 | ) 8 | 9 | var recentStarsQuery struct { 10 | User struct { 11 | Login githubv4.String 12 | Stars struct { 13 | TotalCount githubv4.Int 14 | Edges []struct { 15 | Cursor githubv4.String 16 | StarredAt githubv4.DateTime 17 | Node qlRepository 18 | } 19 | } `graphql:"starredRepositories(first: $count, after:$after, orderBy: {field: STARRED_AT, direction: DESC})"` 20 | } `graphql:"user(login:$username)"` 21 | } 22 | 23 | func recentStars(count int) []Star { 24 | var starredRepos []Star 25 | var after *githubv4.String 26 | 27 | outer: 28 | for { 29 | variables := map[string]interface{}{ 30 | "username": githubv4.String(username), 31 | "count": githubv4.Int(count), 32 | "after": after, 33 | } 34 | err := gitHubClient.Query(context.Background(), &recentStarsQuery, variables) 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | for _, v := range recentStarsQuery.User.Stars.Edges { 40 | if v.Node.IsPrivate { 41 | continue 42 | } 43 | starredRepos = append(starredRepos, Star{ 44 | StarredAt: v.StarredAt.Time, 45 | Repo: repoFromQL(v.Node), 46 | }) 47 | if len(starredRepos) >= count { 48 | break outer 49 | } 50 | after = githubv4.NewString(v.Cursor) 51 | } 52 | } 53 | 54 | return starredRepos 55 | } 56 | 57 | /* 58 | { 59 | viewer { 60 | login 61 | starredRepositories(first: 3, orderBy: {field: STARRED_AT, direction: DESC}) { 62 | totalCount 63 | edges { 64 | cursor 65 | starredAt 66 | node { 67 | nameWithOwner 68 | url 69 | description 70 | } 71 | } 72 | } 73 | } 74 | } 75 | */ 76 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shurcooL/githubv4" 7 | ) 8 | 9 | var viewerQuery struct { 10 | Viewer struct { 11 | Login githubv4.String 12 | } 13 | } 14 | 15 | var recentFollowersQuery struct { 16 | User struct { 17 | Login githubv4.String 18 | Followers struct { 19 | TotalCount githubv4.Int 20 | Edges []struct { 21 | Cursor githubv4.String 22 | Node qlUser 23 | } 24 | } `graphql:"followers(first: $count)"` 25 | } `graphql:"user(login:$username)"` 26 | } 27 | 28 | func getUsername() (string, error) { 29 | err := gitHubClient.Query(context.Background(), &viewerQuery, nil) 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | return string(viewerQuery.Viewer.Login), nil 35 | } 36 | 37 | func recentFollowers(count int) []User { 38 | // fmt.Printf("Finding recent followers...\n") 39 | 40 | var users []User 41 | variables := map[string]interface{}{ 42 | "username": githubv4.String(username), 43 | "count": githubv4.Int(count), 44 | } 45 | err := gitHubClient.Query(context.Background(), &recentFollowersQuery, variables) 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | // fmt.Printf("%+v\n", query) 51 | for _, v := range recentFollowersQuery.User.Followers.Edges { 52 | users = append(users, userFromQL(v.Node)) 53 | } 54 | 55 | // fmt.Printf("Found %d recent followers!\n", len(users)) 56 | return users 57 | } 58 | 59 | /* 60 | { 61 | user(login: "muesli") { 62 | login 63 | followers(first: 10) { 64 | totalCount 65 | edges { 66 | cursor 67 | node { 68 | id 69 | avatarUrl 70 | login 71 | name 72 | url 73 | } 74 | } 75 | } 76 | } 77 | } 78 | */ 79 | -------------------------------------------------------------------------------- /templates/github-profile.tpl: -------------------------------------------------------------------------------- 1 | ### Hi there 👋 2 | 3 | #### 👷 Check out what I'm currently working on 4 | {{range recentContributions 10}} 5 | - [{{.Repo.Name}}]({{.Repo.URL}}) - {{.Repo.Description}} ({{humanize .OccurredAt}}) 6 | {{- end}} 7 | 8 | #### 🌱 My latest projects 9 | {{range recentCreatedRepos "charmbracelet" 10}} 10 | - [{{.Name}}]({{.URL}}) - {{.Description}} 11 | {{- end}} 12 | 13 | #### 🍴 My recent forks 14 | {{range recentForkedRepos "charmbracelet" 10}} 15 | - [{{.Name}}]({{.URL}}) - {{.Description}} 16 | {{- end}} 17 | 18 | #### 🔭 Latest releases I've contributed to 19 | {{range recentReleases 10}} 20 | - [{{.Name}}]({{.URL}}) ([{{.LastRelease.TagName}}]({{.LastRelease.URL}}), {{humanize .LastRelease.PublishedAt}}) - {{.Description}} 21 | {{- end}} 22 | 23 | #### 🔨 My recent Pull Requests 24 | {{range recentPullRequests 10}} 25 | - [{{.Title}}]({{.URL}}) on [{{.Repo.Name}}]({{.Repo.URL}}) ({{humanize .CreatedAt}}) 26 | {{- end}} 27 | 28 | #### 📜 My recent blog posts 29 | {{range rss "https://.../posts/index.xml" 5}} 30 | - [{{.Title}}]({{.URL}}) ({{humanize .PublishedAt}}) 31 | {{- end}} 32 | 33 | #### 📓 Gists I wrote 34 | {{range gists 5}} 35 | - [{{.Description}}]({{.URL}}) ({{humanize .CreatedAt}}) 36 | {{- end}} 37 | 38 | #### ⭐ Recent Stars 39 | {{range recentStars 10}} 40 | - [{{.Repo.Name}}]({{.Repo.URL}}) - {{.Repo.Description}} ({{humanize .StarredAt}}) 41 | {{- end}} 42 | 43 | #### ❤️ These awesome people sponsor me (thank you!) 44 | {{range sponsors 5}} 45 | - [{{.User.Login}}]({{.User.URL}}) ({{humanize .CreatedAt}}) 46 | {{- end}} 47 | 48 | #### 👯 Check out some of my recent followers 49 | {{range followers 5}} 50 | - [{{.Login}}]({{.URL}}) 51 | {{- end}} 52 | 53 | #### 💬 Feedback 54 | 55 | Say Hello, I don't bite! 56 | 57 | #### 📫 How to reach me 58 | 59 | - Twitter: https://twitter.com/... 60 | - Fediverse: https://mastodon.social/@... 61 | - Blog: https://... 62 | 63 | Want your own self-generating profile page? Check out [readme-scribe](https://github.com/charmbracelet/readme-scribe)! 64 | 65 | 66 | -------------------------------------------------------------------------------- /literal/client.go: -------------------------------------------------------------------------------- 1 | package literal 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/caarlos0/env/v6" 8 | "github.com/shurcooL/graphql" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // Auth is the authentication information for the literal.club API. 13 | type Auth struct { 14 | Email string `env:"LITERAL_EMAIL"` 15 | Password string `env:"LITERAL_PASSWORD"` 16 | } 17 | 18 | const literalURL = "https://literal.club/graphql/" 19 | 20 | func login() (*graphql.Client, error) { 21 | var auth Auth 22 | if err := env.Parse(&auth); err != nil { 23 | return nil, err 24 | } 25 | 26 | client := graphql.NewClient(literalURL, http.DefaultClient) 27 | m := loginM{} 28 | if err := client.Mutate(context.Background(), &m, map[string]interface{}{ 29 | "email": graphql.String(auth.Email), 30 | "password": graphql.String(auth.Password), 31 | }); err != nil { 32 | return nil, err 33 | } 34 | 35 | src := oauth2.StaticTokenSource( 36 | &oauth2.Token{AccessToken: string(m.Login.Token)}, 37 | ) 38 | cli := oauth2.NewClient(context.Background(), src) 39 | return graphql.NewClient(literalURL, cli), nil 40 | } 41 | 42 | // CurrentlyReading retrieves the currently reading list. 43 | func CurrentlyReading() ([]Book, error) { 44 | client, err := login() 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | q := readingQ{} 50 | if err := client.Query(context.Background(), &q, nil); err != nil { 51 | return nil, err 52 | } 53 | 54 | var books []Book 55 | for _, rs := range q.MyReadingStates { 56 | if rs.ReadingState.Status != "IS_READING" { 57 | continue 58 | } 59 | book := rs.ReadingState.Book 60 | books = append(books, book) 61 | } 62 | return books, nil 63 | } 64 | 65 | // Book is a book. 66 | type Book struct { 67 | Slug graphql.String 68 | Title graphql.String 69 | Subtitle graphql.String 70 | Description graphql.String 71 | Authors []Author 72 | } 73 | 74 | // Author is an author. 75 | type Author struct { 76 | Name graphql.String 77 | } 78 | 79 | type loginM struct { 80 | Login struct { 81 | Token graphql.String 82 | } `graphql:"login(email: $email, password: $password)"` 83 | } 84 | 85 | type readingQ struct { 86 | MyReadingStates []struct { 87 | ReadingState struct { 88 | Status graphql.String 89 | Book Book 90 | } `graphql:"... on ReadingState"` 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /sponsors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shurcooL/githubv4" 7 | ) 8 | 9 | var sponsorsQuery struct { 10 | User struct { 11 | Login githubv4.String 12 | SponsorshipsAsMaintainer struct { 13 | TotalCount githubv4.Int 14 | Edges []struct { 15 | Cursor githubv4.String 16 | Node struct { 17 | CreatedAt githubv4.DateTime 18 | SponsorEntity struct { 19 | Typename githubv4.String `graphql:"__typename"` 20 | User qlUser `graphql:"... on User"` 21 | Organization qlUser `graphql:"... on Organization"` 22 | } 23 | } 24 | } 25 | } `graphql:"sponsorshipsAsMaintainer(first: $count, orderBy: {field: CREATED_AT, direction: DESC})"` 26 | } `graphql:"user(login:$username)"` 27 | } 28 | 29 | func sponsors(count int) []Sponsor { 30 | // fmt.Printf("Finding sponsors...\n") 31 | 32 | var sponsors []Sponsor 33 | variables := map[string]interface{}{ 34 | "username": githubv4.String(username), 35 | "count": githubv4.Int(count), 36 | } 37 | err := gitHubClient.Query(context.Background(), &sponsorsQuery, variables) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | // fmt.Printf("%+v\n", query) 43 | 44 | for _, v := range sponsorsQuery.User.SponsorshipsAsMaintainer.Edges { 45 | switch v.Node.SponsorEntity.Typename { 46 | case "User": 47 | sponsors = append(sponsors, Sponsor{ 48 | User: userFromQL(v.Node.SponsorEntity.User), 49 | CreatedAt: v.Node.CreatedAt.Time, 50 | }) 51 | case "Organization": 52 | sponsors = append(sponsors, Sponsor{ 53 | User: userFromQL(v.Node.SponsorEntity.Organization), 54 | CreatedAt: v.Node.CreatedAt.Time, 55 | }) 56 | } 57 | } 58 | 59 | // fmt.Printf("Found %d sponsors!\n", len(users)) 60 | return sponsors 61 | } 62 | 63 | /* 64 | { 65 | user(login: "muesli") { 66 | login 67 | sponsorshipsAsMaintainer(first: 100) { 68 | totalCount 69 | edges { 70 | cursor 71 | node { 72 | createdAt 73 | sponsorEntity { 74 | __typename 75 | ... on User { 76 | login 77 | name 78 | avatarUrl 79 | url 80 | } 81 | ... on Organization { 82 | login 83 | name 84 | avatarUrl 85 | url 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | */ 94 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "text/template" 10 | 11 | "github.com/KyleBanks/goodreads" 12 | "github.com/go-sprout/sprout" 13 | "github.com/shurcooL/githubv4" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var ( 18 | gitHubClient *githubv4.Client 19 | goodReadsClient *goodreads.Client 20 | goodReadsID string 21 | username string 22 | 23 | write = flag.String("write", "", "write output to") 24 | ) 25 | 26 | func main() { 27 | flag.Parse() 28 | 29 | if len(flag.Args()) == 0 { 30 | fmt.Println("Usage: markscribe [template]") 31 | os.Exit(1) 32 | } 33 | 34 | tplIn, err := os.ReadFile(flag.Args()[0]) 35 | if err != nil { 36 | fmt.Println("Can't read file:", err) 37 | os.Exit(1) 38 | } 39 | 40 | funcMap := sprout.FuncMap(sprout.WithAlias("lower", "toLower")) 41 | /* Github */ 42 | funcMap["recentContributions"] = recentContributions 43 | funcMap["recentPullRequests"] = recentPullRequests 44 | funcMap["popularRepos"] = popularRepos 45 | funcMap["recentCreatedRepos"] = recentCreatedRepos 46 | funcMap["recentPushedRepos"] = recentPushedRepos 47 | funcMap["recentForkedRepos"] = recentForkedRepos 48 | funcMap["latestReleasedRepos"] = latestReleasedRepos 49 | funcMap["recentReleases"] = recentReleases 50 | funcMap["followers"] = recentFollowers 51 | funcMap["recentStars"] = recentStars 52 | funcMap["gists"] = gists 53 | funcMap["sponsors"] = sponsors 54 | funcMap["repo"] = repo 55 | funcMap["repoRecentReleases"] = repoRecentReleases 56 | /* RSS */ 57 | funcMap["rss"] = rssFeed 58 | /* GoodReads */ 59 | funcMap["goodReadsReviews"] = goodReadsReviews 60 | funcMap["goodReadsCurrentlyReading"] = goodReadsCurrentlyReading 61 | /* Literal.club */ 62 | funcMap["literalClubCurrentlyReading"] = literalClubCurrentlyReading 63 | /* Utils */ 64 | funcMap["humanize"] = humanized 65 | 66 | tpl, err := template.New("tpl").Funcs(funcMap).Parse(string(tplIn)) 67 | if err != nil { 68 | fmt.Println("Can't parse template:", err) 69 | os.Exit(1) 70 | } 71 | 72 | var httpClient *http.Client 73 | gitHubToken := os.Getenv("GITHUB_TOKEN") 74 | goodReadsToken := os.Getenv("GOODREADS_TOKEN") 75 | goodReadsID = os.Getenv("GOODREADS_USER_ID") 76 | if len(gitHubToken) > 0 { 77 | httpClient = oauth2.NewClient(context.Background(), oauth2.StaticTokenSource( 78 | &oauth2.Token{AccessToken: gitHubToken}, 79 | )) 80 | } 81 | 82 | gitHubClient = githubv4.NewClient(httpClient) 83 | goodReadsClient = goodreads.NewClient(goodReadsToken) 84 | 85 | if len(gitHubToken) > 0 { 86 | username, err = getUsername() 87 | if err != nil { 88 | fmt.Println("Can't retrieve GitHub profile:", err) 89 | os.Exit(1) 90 | } 91 | } 92 | 93 | w := os.Stdout 94 | if len(*write) > 0 { 95 | f, err := os.Create(*write) 96 | if err != nil { 97 | fmt.Println("Can't create:", err) 98 | os.Exit(1) 99 | } 100 | defer f.Close() //nolint: errcheck 101 | w = f 102 | } 103 | 104 | err = tpl.Execute(w, nil) 105 | if err != nil { 106 | fmt.Println("Can't render template:", err) 107 | os.Exit(1) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/shurcooL/githubv4" 7 | ) 8 | 9 | // Contribution represents a contribution to a repo. 10 | type Contribution struct { 11 | OccurredAt time.Time 12 | Repo Repo 13 | } 14 | 15 | // Gist represents a gist. 16 | type Gist struct { 17 | Name string 18 | Description string 19 | URL string 20 | CreatedAt time.Time 21 | } 22 | 23 | // Star represents a star/favorite event. 24 | type Star struct { 25 | StarredAt time.Time 26 | Repo Repo 27 | } 28 | 29 | // PullRequest represents a pull request. 30 | type PullRequest struct { 31 | Title string 32 | URL string 33 | State string 34 | CreatedAt time.Time 35 | Repo Repo 36 | } 37 | 38 | // Release represents a release. 39 | type Release struct { 40 | Name string 41 | TagName string 42 | PublishedAt time.Time 43 | CreatedAt time.Time 44 | URL string 45 | IsLatest bool 46 | IsPreRelease bool 47 | IsDraft bool 48 | } 49 | 50 | // Repo represents a git repo. 51 | type Repo struct { 52 | Owner string 53 | Name string 54 | NameWithOwner string 55 | URL string 56 | Description string 57 | IsPrivate bool 58 | Stargazers int 59 | LastRelease Release 60 | } 61 | 62 | // Sponsor represents a sponsor. 63 | type Sponsor struct { 64 | User User 65 | CreatedAt time.Time 66 | } 67 | 68 | // User represents a SCM user. 69 | type User struct { 70 | Login string 71 | Name string 72 | AvatarURL string 73 | URL string 74 | } 75 | 76 | type qlGist struct { 77 | Name githubv4.String 78 | Description githubv4.String 79 | URL githubv4.String 80 | CreatedAt githubv4.DateTime 81 | } 82 | 83 | type qlPullRequest struct { 84 | URL githubv4.String 85 | Title githubv4.String 86 | State githubv4.PullRequestState 87 | CreatedAt githubv4.DateTime 88 | Repository qlRepository 89 | } 90 | 91 | type qlRelease struct { 92 | Name githubv4.String 93 | TagName githubv4.String 94 | PublishedAt githubv4.DateTime 95 | CreatedAt githubv4.DateTime 96 | URL githubv4.String 97 | IsPrerelease githubv4.Boolean 98 | IsLatest githubv4.Boolean 99 | IsDraft githubv4.Boolean 100 | } 101 | 102 | type qlReleases struct { 103 | Nodes []struct { 104 | Name githubv4.String 105 | TagName githubv4.String 106 | PublishedAt githubv4.DateTime 107 | CreatedAt githubv4.DateTime 108 | URL githubv4.String 109 | IsPrerelease githubv4.Boolean 110 | IsLatest githubv4.Boolean 111 | IsDraft githubv4.Boolean 112 | } 113 | } 114 | 115 | type qlRepository struct { 116 | Owner struct { 117 | Login githubv4.String 118 | } 119 | Name githubv4.String 120 | NameWithOwner githubv4.String 121 | URL githubv4.String 122 | Description githubv4.String 123 | IsPrivate githubv4.Boolean 124 | Stargazers struct { 125 | TotalCount githubv4.Int 126 | } 127 | } 128 | 129 | type qlUser struct { 130 | Login githubv4.String 131 | Name githubv4.String 132 | AvatarURL githubv4.String 133 | URL githubv4.String 134 | } 135 | 136 | func gistFromQL(gist qlGist) Gist { 137 | return Gist{ 138 | Name: string(gist.Name), 139 | Description: string(gist.Description), 140 | URL: string(gist.URL), 141 | CreatedAt: gist.CreatedAt.Time, 142 | } 143 | } 144 | 145 | func pullRequestFromQL(pullRequest qlPullRequest) PullRequest { 146 | return PullRequest{ 147 | Title: string(pullRequest.Title), 148 | URL: string(pullRequest.URL), 149 | State: string(pullRequest.State), 150 | CreatedAt: pullRequest.CreatedAt.Time, 151 | Repo: repoFromQL(pullRequest.Repository), 152 | } 153 | } 154 | 155 | func releaseFromQL(release qlRelease) Release { 156 | return Release{ 157 | Name: string(release.Name), 158 | TagName: string(release.TagName), 159 | PublishedAt: release.PublishedAt.Time, 160 | URL: string(release.URL), 161 | } 162 | } 163 | 164 | func releasesFromQL(release qlReleases) Release { 165 | if len(release.Nodes) != 0 { 166 | return Release{ 167 | Name: string(release.Nodes[0].Name), 168 | TagName: string(release.Nodes[0].TagName), 169 | PublishedAt: release.Nodes[0].PublishedAt.Time, 170 | URL: string(release.Nodes[0].URL), 171 | } 172 | } 173 | return Release{} 174 | } 175 | 176 | func repoFromQL(repo qlRepository) Repo { 177 | return Repo{ 178 | Owner: string(repo.Owner.Login), 179 | Name: string(repo.Name), 180 | NameWithOwner: string(repo.NameWithOwner), 181 | URL: string(repo.URL), 182 | Description: string(repo.Description), 183 | Stargazers: int(repo.Stargazers.TotalCount), 184 | IsPrivate: bool(repo.IsPrivate), 185 | } 186 | } 187 | 188 | func userFromQL(user qlUser) User { 189 | return User{ 190 | Login: string(user.Login), 191 | Name: string(user.Name), 192 | AvatarURL: string(user.AvatarURL), 193 | URL: string(user.URL), 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/KyleBanks/goodreads v0.0.0-20200527082926-28539417959b h1:PH3E8P/BzsV5duxi1yFgxWokNrn9KcwWJ2MI83qHBME= 4 | github.com/KyleBanks/goodreads v0.0.0-20200527082926-28539417959b/go.mod h1:7+z+03v0GI8UMBav4Iaqajq/ARtCx9jcX5RONXP3iRM= 5 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 6 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 7 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 8 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 9 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 10 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 11 | github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= 12 | github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 17 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 18 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 19 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 20 | github.com/go-sprout/sprout v0.4.1 h1:grvsR21YepGs64EFoIXg4g+5OzIZFwmsw5Y88Wod9sI= 21 | github.com/go-sprout/sprout v0.4.1/go.mod h1:jRgO0n+24zLgiPAg/6rMaeq2oEnBSGlZiHUoK3hnQc4= 22 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 23 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 24 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 25 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 26 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 27 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 28 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 29 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 30 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 31 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 32 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 33 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 34 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 35 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 36 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 37 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 38 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 39 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 40 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 41 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 42 | github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s= 43 | github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4= 44 | github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI= 45 | github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= 46 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 49 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 50 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 51 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 53 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 54 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 55 | github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 h1:T9uus1QvcPgeLShS30YOnnzk3r9Vvygp45muhlrufgY= 56 | github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= 57 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= 58 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= 59 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 60 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 63 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 64 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 65 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 66 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 67 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 68 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 69 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 70 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 71 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 72 | golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= 73 | golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= 74 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 75 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 78 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 79 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 80 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 81 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 82 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 83 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 84 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 86 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 87 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 88 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 89 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 90 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 92 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 94 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markscribe 2 | 3 | [![Latest Release](https://img.shields.io/github/release/charmbracelet/markscribe.svg)](https://github.com/charmbracelet/markscribe/releases) 4 | [![Build Status](https://github.com/charmbracelet/markscribe/workflows/build/badge.svg)](https://github.com/charmbracelet/markscribe/actions) 5 | [![Go ReportCard](https://goreportcard.com/badge/charmbracelet/markscribe)](https://goreportcard.com/report/charmbracelet/markscribe) 6 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/markscribe) 7 | 8 | Your personal markdown scribe with template-engine and Git(Hub) & RSS powers 📜 9 | 10 | You can run markscribe as a GitHub Action: [readme-scribe](https://github.com/charmbracelet/readme-scribe/) 11 | 12 | ## Usage 13 | 14 | Render a template to stdout: 15 | 16 | markscribe template.tpl 17 | 18 | Render to a file: 19 | 20 | markscribe -write /tmp/output.md template.tpl 21 | 22 | ## Installation 23 | 24 | ### Packages & Binaries 25 | 26 | If you use Brew, you can simply install the package: 27 | 28 | brew install charmbracelet/tap/markscribe 29 | 30 | Or download a binary from the [releases](https://github.com/charmbracelet/markscribe/releases) 31 | page. Linux (including ARM) binaries are available, as well as Debian and RPM 32 | packages. 33 | 34 | ### Build From Source 35 | 36 | Alternatively you can also build `markscribe` from source. Make sure you have a 37 | working Go environment (Go 1.16 or higher is required). See the 38 | [install instructions](https://golang.org/doc/install.html). 39 | 40 | To install markscribe, simply run: 41 | 42 | go get github.com/charmbracelet/markscribe 43 | 44 | ## Templates 45 | 46 | You can find an example template to generate a GitHub profile README under 47 | [`templates/github-profile.tpl`](templates/github-profile.tpl). Make sure to fill in (or remove) placeholders, 48 | like the RSS-feed or social media URLs. 49 | 50 | Rendered it looks a little like my own profile page: https://github.com/charmbracelet 51 | 52 | ## Functions 53 | 54 | ### RSS feed 55 | 56 | ``` 57 | {{range rss "https://domain.tld/feed.xml" 5}} 58 | Title: {{.Title}} 59 | URL: {{.URL}} 60 | Published: {{humanize .PublishedAt}} 61 | {{end}} 62 | ``` 63 | 64 | ### Your recent contributions 65 | 66 | ``` 67 | {{range recentContributions 10}} 68 | Name: {{.Repo.Name}} 69 | Description: {{.Repo.Description}} 70 | URL: {{.Repo.URL}}) 71 | Occurred: {{humanize .OccurredAt}} 72 | {{end}} 73 | ``` 74 | 75 | This function requires GitHub authentication with the following API scopes: 76 | `repo:status`, `public_repo`, `read:user`. 77 | 78 | ### Your recent pull requests 79 | 80 | ``` 81 | {{range recentPullRequests 10}} 82 | Title: {{.Title}} 83 | URL: {{.URL}} 84 | State: {{.State}} 85 | CreatedAt: {{humanize .CreatedAt}} 86 | Repository name: {{.Repo.Name}} 87 | Repository description: {{.Repo.Description}} 88 | Repository URL: {{.Repo.URL}} 89 | {{end}} 90 | ``` 91 | 92 | This function requires GitHub authentication with the following API scopes: 93 | `repo:status`, `public_repo`, `read:user`. 94 | 95 | ### Repositories you recently starred 96 | 97 | ``` 98 | {{range recentStars 10}} 99 | Name: {{.Repo.Name}} 100 | Description: {{.Repo.Description}} 101 | URL: {{.Repo.URL}}) 102 | Stars: {{.Repo.Stargazers}} 103 | {{end}} 104 | ``` 105 | 106 | This function requires GitHub authentication with the following API scopes: 107 | `repo:status`, `public_repo`, `read:user`. 108 | 109 | ### Repositories you recently created 110 | 111 | ``` 112 | {{range recentCreatedRepos "charmbracelet" 10}} 113 | Name: {{.Name}} 114 | Description: {{.Description}} 115 | URL: {{.URL}}) 116 | Stars: {{.Stargazers}} 117 | {{end}} 118 | ``` 119 | 120 | This function requires GitHub authentication with the following API scopes: 121 | `repo:status`, `public_repo`, `read:user` or `read:org` if you provide an organization name. 122 | 123 | ### Repositories with the most stars 124 | 125 | ``` 126 | {{range popularRepos "charmbracelet" 10}} 127 | Name: {{.Name}} 128 | NameWithOwner: {{.NameWithOwner}} 129 | Description: {{.Description}} 130 | URL: {{.URL}}) 131 | Stars: {{.Stargazers}} 132 | {{end}} 133 | ``` 134 | 135 | This function requires GitHub authentication with the following API scopes: 136 | `read:org`, `public_repo`, `read:user` 137 | 138 | > [!TIP] 139 | > Use `{{with repo "charmbracelet .Name"}}` to create a pipeline that grabs additional information about the repo including releases. 140 | 141 | ### Custom GitHub repository 142 | 143 | ``` 144 | {{with repo "charmbracelet" "markscribe"}} 145 | Name: {{.Name}} 146 | Description: {{.Description}} 147 | URL: {{.URL}} 148 | Stars: {{.Stargazers}} 149 | Is Private: {{.IsPrivate}} 150 | Last Git Tag: {{.LastRelease.TagName}} 151 | Last Release: {{humanize .LastRelease.PublishedAt}} 152 | {{end}} 153 | ``` 154 | 155 | ### Recent releases to a given repository 156 | 157 | ``` 158 | {{range recentRepoReleases "charmbracelet" "markscribe" 10}} 159 | Name: {{.Name}} 160 | Git Tag: {{.TagName}} 161 | URL: {{.URL}} 162 | Published: {{humanize .PublishedAt}} 163 | CreatedAt: {{humanize .CreatedAt}} 164 | IsPreRelease: {{.IsPreRelease}} 165 | IsDraft: {{.IsDraft}} 166 | IsLatest: {{.IsLatest}} 167 | {{end}} 168 | ``` 169 | 170 | This function requires GitHub authentication with the following API scopes: 171 | `repo:status`, `public_repo`, `read:user`. 172 | 173 | ### Forks you recently created 174 | 175 | ``` 176 | {{range recentForkedRepos "charmbracelet" 10}} 177 | Name: {{.Name}} 178 | Description: {{.Description}} 179 | URL: {{.URL}}) 180 | Stars: {{.Stargazers}} 181 | {{end}} 182 | ``` 183 | 184 | This function requires GitHub authentication with the following API scopes: 185 | `repo:status`, `public_repo`, `read:user` or `read:org` if you provide an organization name. 186 | 187 | ### Latest released projects 188 | 189 | ``` 190 | {{range latestReleasedRepos "charmbracelet" 10}} 191 | Name: {{.Name}} 192 | Description: {{.Description}} 193 | URL: {{.URL}}) 194 | Stars: {{.Stargazers}} 195 | Last Release Name: {{.LastRelease.TagName}} 196 | Last Release URL: {{.LastRelease.URL}} 197 | Last Release Date: {{humanize .LastRelease.PublishedAt}} 198 | {{end}} 199 | ``` 200 | 201 | This function requires GitHub authentication with the following API scopes: 202 | `repo:status`, `public_repo`, `read:user`, `read:org`. 203 | 204 | ### Recent releases you contributed to 205 | 206 | ``` 207 | {{range recentReleases 10}} 208 | Name: {{.Name}} 209 | Git Tag: {{.LastRelease.TagName}} 210 | URL: {{.LastRelease.URL}} 211 | Published: {{humanize .LastRelease.PublishedAt}} 212 | {{end}} 213 | ``` 214 | 215 | This function requires GitHub authentication with the following API scopes: 216 | `repo:status`, `public_repo`, `read:user`. 217 | 218 | ### Recent pushes 219 | 220 | ``` 221 | {{range recentPushedRepos "charmbracelet" 10}} 222 | Name: {{.Name}} 223 | URL: {{.URL}} 224 | Description: {{.Description}} 225 | Stars: {{.Stargazers}} 226 | {{end}} 227 | ``` 228 | 229 | This function requires GitHub authentication with the following API scopes: 230 | `public_repo`, `read:org`. 231 | 232 | > [!TIP] 233 | > Use `{{with repo "charmbracelet .Name"}}` to create a pipeline that grabs additional information about the repo including releases. 234 | 235 | ### Your published gists 236 | 237 | ``` 238 | {{range gists 10}} 239 | Name: {{.Name}} 240 | Description: {{.Description}} 241 | URL: {{.URL}} 242 | Created: {{humanize .CreatedAt}} 243 | {{end}} 244 | ``` 245 | 246 | This function requires GitHub authentication with the following API scopes: 247 | `repo:status`, `public_repo`, `read:user`. 248 | 249 | ### Your latest followers 250 | 251 | ``` 252 | {{range followers 5}} 253 | Username: {{.Login}} 254 | Name: {{.Name}} 255 | Avatar: {{.AvatarURL}} 256 | URL: {{.URL}} 257 | {{end}} 258 | ``` 259 | 260 | This function requires GitHub authentication with the following API scopes: 261 | `read:user`. 262 | 263 | ### Your sponsors 264 | 265 | ``` 266 | {{range sponsors 5}} 267 | Username: {{.User.Login}} 268 | Name: {{.User.Name}} 269 | Avatar: {{.User.AvatarURL}} 270 | URL: {{.User.URL}} 271 | Created: {{humanize .CreatedAt}} 272 | {{end}} 273 | ``` 274 | 275 | This function requires GitHub authentication with the following API scopes: 276 | `repo:status`, `public_repo`, `read:user`, `read:org`. 277 | 278 | ### Your GoodReads reviews 279 | 280 | ``` 281 | {{range goodReadsReviews 5}} 282 | - {{.Book.Title}} - {{.Book.Link}} - {{.Rating}} - {{humanize .DateUpdated}} 283 | {{- end}} 284 | ``` 285 | 286 | This function requires GoodReads API key! 287 | 288 | ### Your GoodReads currently reading books 289 | 290 | ``` 291 | {{range goodReadsCurrentlyReading 5}} 292 | - {{.Book.Title}} - {{.Book.Link}} - {{humanize .DateUpdated}} 293 | {{- end}} 294 | ``` 295 | 296 | This function requires GoodReads API key! 297 | 298 | ### Your Literal.club currently reading books 299 | 300 | ``` 301 | {{range literalClubCurrentlyReading 5}} 302 | - {{.Title}} - {{.Subtitle}} - {{.Description}} - https://literal.club/_YOUR_USERNAME_/book/{{.Slug}} 303 | {{- range .Authors }}{{ .Name }}{{ end }} 304 | {{- end}} 305 | ``` 306 | 307 | This function requires a `LITERAL_EMAIL` and `LITERAL_PASSWORD`. 308 | 309 | ## Template Engine 310 | 311 | markscribe uses Go's powerful template engine. You can find its documentation 312 | here: https://golang.org/pkg/text/template/ 313 | 314 | ## Template Helpers 315 | 316 | markscribe comes with [sprout](https://docs.atom.codes/sprout) and a few more template helpers: 317 | 318 | To format timestamps, call `humanize`: 319 | 320 | ``` 321 | {{humanize .Timestamp}} 322 | ``` 323 | 324 | ## GitHub Authentication 325 | 326 | In order to access some of GitHub's API, markscribe requires you to provide a 327 | valid GitHub token in an environment variable called `GITHUB_TOKEN`. You can 328 | create a new token by going to your profile settings: 329 | 330 | `Developer settings` > `Personal access tokens` > `Generate new token` 331 | 332 | ## GoodReads API key 333 | 334 | In order to access some of GoodReads' API, markscribe requires you to provide a 335 | valid GoodReads key in an environment variable called `GOODREADS_TOKEN`. You can 336 | create a new token by going [here](https://www.goodreads.com/api/keys). 337 | Then you need to go to your repository and add it, `Settings -> Secrets -> New secret`. 338 | You also need to set your GoodReads user ID in your secrets as `GOODREADS_USER_ID`. 339 | 340 | ## FAQ 341 | 342 | Q: That's awesome, but can you expose more APIs and data? 343 | A: Of course, just open a new issue and let me know what you'd like to do with markscribe! 344 | 345 | Q: That's awesome, but I don't have my own server to run this on. Can you help? 346 | A: Check out [readme-scribe](https://github.com/charmbracelet/readme-scribe/), a GitHub Action that runs markscribe for you! 347 | -------------------------------------------------------------------------------- /repos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | "sort" 8 | "time" 9 | 10 | "github.com/shurcooL/githubv4" 11 | ) 12 | 13 | var recentContributionsQuery struct { 14 | User struct { 15 | Login githubv4.String 16 | ContributionsCollection struct { 17 | CommitContributionsByRepository []struct { 18 | Contributions struct { 19 | Edges []struct { 20 | Cursor githubv4.String 21 | Node struct { 22 | OccurredAt githubv4.DateTime 23 | } 24 | } 25 | } `graphql:"contributions(first: 1)"` 26 | Repository qlRepository 27 | } `graphql:"commitContributionsByRepository(maxRepositories: 100)"` 28 | } 29 | } `graphql:"user(login:$username)"` 30 | } 31 | 32 | var recentPullRequestsQuery struct { 33 | User struct { 34 | Login githubv4.String 35 | PullRequests struct { 36 | TotalCount githubv4.Int 37 | Edges []struct { 38 | Cursor githubv4.String 39 | Node qlPullRequest 40 | } 41 | } `graphql:"pullRequests(first: $count, orderBy: {field: CREATED_AT, direction: DESC})"` 42 | } `graphql:"user(login:$username)"` 43 | } 44 | 45 | var recentReposQuery struct { 46 | User struct { 47 | Login githubv4.String 48 | Repositories struct { 49 | TotalCount githubv4.Int 50 | Edges []struct { 51 | Cursor githubv4.String 52 | Node qlRepository 53 | } 54 | } `graphql:"repositories(first: $count, privacy: PUBLIC, isFork: $isFork, ownerAffiliations: OWNER, orderBy: {field: CREATED_AT, direction: DESC})"` 55 | } `graphql:"repositoryOwner(login: $owner)"` 56 | } 57 | 58 | var recentReleasesQuery struct { 59 | User struct { 60 | Login githubv4.String 61 | RepositoriesContributedTo struct { 62 | TotalCount githubv4.Int 63 | Edges []struct { 64 | Cursor githubv4.String 65 | Node struct { 66 | qlRepository 67 | Releases qlReleases `graphql:"releases(first: 10, orderBy: {field: CREATED_AT, direction: DESC})"` 68 | } 69 | } 70 | } `graphql:"repositoriesContributedTo(first: 100, after:$after includeUserRepositories: true, contributionTypes: COMMIT, privacy: PUBLIC)"` 71 | } `graphql:"user(login:$username)"` 72 | } 73 | 74 | /* 75 | Order by stars 76 | 77 | { 78 | repositoryOwner(login: "charmbracelet") { 79 | id 80 | login 81 | repositories( 82 | first: 5 83 | privacy: PUBLIC 84 | orderBy: {field: STARGAZERS, direction: DESC} 85 | ) { 86 | edges { 87 | node { 88 | name 89 | description 90 | url 91 | } 92 | } 93 | } 94 | } 95 | } 96 | */ 97 | func popularRepos(owner string, count int) []Repo { 98 | var query struct { 99 | Owner struct { 100 | Repositories struct { 101 | Edges []struct { 102 | Node qlRepository 103 | } 104 | } `graphql:"repositories(first: $count, privacy: PUBLIC, orderBy: {field: STARGAZERS, direction: DESC})"` 105 | } `graphql:"repositoryOwner(login: $owner)"` 106 | } 107 | 108 | fmt.Println("Finding popular repos...") 109 | 110 | var repos []Repo 111 | variables := map[string]interface{}{ 112 | "owner": githubv4.String(owner), 113 | "count": githubv4.Int(count + 1), // +1 in case we encounter the meta-repo itself 114 | } 115 | err := gitHubClient.Query(context.Background(), &query, variables) 116 | if err != nil { 117 | panic(err) 118 | } 119 | 120 | for _, v := range query.Owner.Repositories.Edges { 121 | // ignore meta-repo 122 | if string(v.Node.NameWithOwner) == fmt.Sprintf("%s/%s", owner, username) { 123 | continue 124 | } 125 | if len(repos) == count { 126 | break 127 | } 128 | 129 | repos = append(repos, repoFromQL(v.Node)) 130 | } 131 | 132 | fmt.Printf("Found %d repos!\n", len(repos)) 133 | return repos 134 | } 135 | 136 | var repoQuery struct { 137 | Repository struct { 138 | Description githubv4.String 139 | Owner struct { 140 | Login githubv4.String 141 | } 142 | Name githubv4.String 143 | NameWithOwner githubv4.String 144 | IsPrivate githubv4.Boolean 145 | URL githubv4.String 146 | Stargazers struct { 147 | TotalCount githubv4.Int 148 | } 149 | Releases qlReleases `graphql:"releases(first: 1)"` 150 | } `graphql:"repository(owner:$owner, name:$name)"` 151 | } 152 | 153 | var repoRecentReleasesQuery struct { 154 | Repository struct { 155 | Releases qlReleases `graphql:"releases(first: $count, orderBy: {field: CREATED_AT, direction: DESC})"` 156 | } `graphql:"repository(name: $name, owner: $owner)"` 157 | } 158 | 159 | func recentContributions(count int) []Contribution { 160 | var contributions []Contribution 161 | variables := map[string]interface{}{ 162 | "username": githubv4.String(username), 163 | } 164 | err := gitHubClient.Query(context.Background(), &recentContributionsQuery, variables) 165 | if err != nil { 166 | panic(err) 167 | } 168 | 169 | for _, v := range recentContributionsQuery.User.ContributionsCollection.CommitContributionsByRepository { 170 | // ignore meta-repo 171 | if string(v.Repository.NameWithOwner) == fmt.Sprintf("%s/%s", username, username) { 172 | continue 173 | } 174 | if v.Repository.IsPrivate { 175 | continue 176 | } 177 | 178 | c := Contribution{ 179 | Repo: repoFromQL(v.Repository), 180 | OccurredAt: v.Contributions.Edges[0].Node.OccurredAt.Time, 181 | } 182 | 183 | contributions = append(contributions, c) 184 | } 185 | 186 | sort.Slice(contributions, func(i, j int) bool { 187 | return contributions[i].OccurredAt.After(contributions[j].OccurredAt) 188 | }) 189 | 190 | if len(contributions) > count { 191 | return contributions[:count] 192 | } 193 | return contributions 194 | } 195 | 196 | func recentPullRequests(count int) []PullRequest { 197 | var pullRequests []PullRequest 198 | variables := map[string]interface{}{ 199 | "username": githubv4.String(username), 200 | "count": githubv4.Int(count + 1), // +1 in case we encounter the meta-repo itself 201 | } 202 | err := gitHubClient.Query(context.Background(), &recentPullRequestsQuery, variables) 203 | if err != nil { 204 | panic(err) 205 | } 206 | 207 | for _, v := range recentPullRequestsQuery.User.PullRequests.Edges { 208 | // ignore meta-repo 209 | if string(v.Node.Repository.NameWithOwner) == fmt.Sprintf("%s/%s", username, username) { 210 | continue 211 | } 212 | if v.Node.Repository.IsPrivate { 213 | continue 214 | } 215 | 216 | pullRequests = append(pullRequests, pullRequestFromQL(v.Node)) 217 | if len(pullRequests) == count { 218 | break 219 | } 220 | } 221 | 222 | return pullRequests 223 | } 224 | 225 | func recentCreatedRepos(owner string, count int) []Repo { 226 | var repos []Repo 227 | variables := map[string]interface{}{ 228 | "owner": githubv4.String(owner), 229 | "count": githubv4.Int(count + 1), // +1 in case we encounter the meta-repo itself 230 | "isFork": githubv4.Boolean(false), 231 | } 232 | err := gitHubClient.Query(context.Background(), &recentReposQuery, variables) 233 | if err != nil { 234 | panic(err) 235 | } 236 | 237 | for _, v := range recentReposQuery.User.Repositories.Edges { 238 | // ignore meta-repo 239 | if string(v.Node.NameWithOwner) == fmt.Sprintf("%s/%s", owner, owner) { 240 | continue 241 | } 242 | 243 | repos = append(repos, repoFromQL(v.Node)) 244 | if len(repos) == count { 245 | break 246 | } 247 | } 248 | 249 | return repos 250 | } 251 | 252 | func recentForkedRepos(owner string, count int) []Repo { 253 | var repos []Repo 254 | variables := map[string]interface{}{ 255 | "owner": githubv4.String(owner), 256 | "count": githubv4.Int(count + 1), // +1 in case we encounter the meta-repo itself 257 | "isFork": githubv4.Boolean(true), 258 | } 259 | err := gitHubClient.Query(context.Background(), &recentReposQuery, variables) 260 | if err != nil { 261 | panic(err) 262 | } 263 | 264 | for _, v := range recentReposQuery.User.Repositories.Edges { 265 | // ignore meta-repo 266 | if string(v.Node.NameWithOwner) == fmt.Sprintf("%s/%s", owner, owner) { 267 | continue 268 | } 269 | 270 | repos = append(repos, repoFromQL(v.Node)) 271 | if len(repos) == count { 272 | break 273 | } 274 | } 275 | return repos 276 | } 277 | 278 | func latestReleasedRepos(owner string, count int) []Repo { 279 | var query struct { 280 | Owner struct { 281 | Repositories struct { 282 | Edges []struct { 283 | Cursor githubv4.String 284 | Node struct { 285 | qlRepository 286 | Release qlRelease `graphql:"latestRelease"` 287 | } 288 | } 289 | } `graphql:"repositories(first: 100, privacy: PUBLIC, orderBy: {field: UPDATED_AT, direction: DESC})"` 290 | } `graphql:"repositoryOwner(login: $owner)"` 291 | } 292 | 293 | var repos []Repo 294 | variables := map[string]interface{}{ 295 | "owner": githubv4.String(owner), 296 | } 297 | err := gitHubClient.Query(context.Background(), &query, variables) 298 | if err != nil { 299 | panic(err) 300 | } 301 | 302 | for _, v := range query.Owner.Repositories.Edges { 303 | repo := repoFromQL(v.Node.qlRepository) 304 | release := releaseFromQL(v.Node.Release) 305 | repo.LastRelease = release 306 | if repo.LastRelease.Name != "" { 307 | repos = append(repos, repo) 308 | } 309 | } 310 | 311 | slices.SortFunc(repos, func(a, b Repo) int { 312 | return a.LastRelease.PublishedAt.Compare(b.LastRelease.PublishedAt) 313 | }) 314 | slices.Reverse(repos) 315 | return repos[:count] 316 | } 317 | 318 | func recentReleases(count int) []Repo { 319 | var after *githubv4.String 320 | var repos []Repo 321 | 322 | for { 323 | variables := map[string]interface{}{ 324 | "username": githubv4.String(username), 325 | "after": after, 326 | } 327 | err := gitHubClient.Query(context.Background(), &recentReleasesQuery, variables) 328 | if err != nil { 329 | panic(err) 330 | } 331 | 332 | if len(recentReleasesQuery.User.RepositoriesContributedTo.Edges) == 0 { 333 | break 334 | } 335 | 336 | for _, v := range recentReleasesQuery.User.RepositoriesContributedTo.Edges { 337 | r := repoFromQL(v.Node.qlRepository) 338 | 339 | for _, rel := range v.Node.Releases.Nodes { 340 | if rel.IsPrerelease || rel.IsDraft { 341 | continue 342 | } 343 | if v.Node.Releases.Nodes[0].TagName == "" || 344 | v.Node.Releases.Nodes[0].PublishedAt.Time.IsZero() { 345 | continue 346 | } 347 | r.LastRelease = releasesFromQL(v.Node.Releases) 348 | break 349 | } 350 | 351 | if !r.LastRelease.PublishedAt.IsZero() { 352 | repos = append(repos, r) 353 | } 354 | 355 | after = githubv4.NewString(v.Cursor) 356 | } 357 | } 358 | 359 | sort.Slice(repos, func(i, j int) bool { 360 | if repos[i].LastRelease.PublishedAt.Equal(repos[j].LastRelease.PublishedAt) { 361 | return repos[i].Stargazers > repos[j].Stargazers 362 | } 363 | return repos[i].LastRelease.PublishedAt.After(repos[j].LastRelease.PublishedAt) 364 | }) 365 | 366 | if len(repos) > count { 367 | return repos[:count] 368 | } 369 | return repos 370 | } 371 | 372 | /* 373 | { 374 | repositoryOwner(login: "charmbracelet") { 375 | id 376 | login 377 | repositories( 378 | first: 5 379 | privacy: PUBLIC 380 | orderBy: {field: PUSHED_AT, direction: DESC} 381 | ) { 382 | edges { 383 | node { 384 | name 385 | description 386 | url 387 | } 388 | } 389 | } 390 | } 391 | } 392 | */ 393 | 394 | type RepoWithPushedAt struct { 395 | Repo 396 | PushedAt time.Time 397 | } 398 | 399 | func recentPushedRepos(owner string, count int) []RepoWithPushedAt { 400 | type qlRepoWithPushedAt struct { 401 | qlRepository 402 | PushedAt githubv4.DateTime 403 | } 404 | 405 | var query struct { 406 | Owner struct { 407 | Repositories struct { 408 | Edges []struct { 409 | Node qlRepoWithPushedAt 410 | } 411 | } `graphql:"repositories(first: $count, privacy: PUBLIC, orderBy: {field: PUSHED_AT, direction: DESC})"` 412 | } `graphql:"repositoryOwner(login: $owner)"` 413 | } 414 | var repos []RepoWithPushedAt 415 | variables := map[string]interface{}{ 416 | "count": githubv4.Int(count), 417 | "owner": githubv4.String(owner), 418 | } 419 | err := gitHubClient.Query(context.Background(), &query, variables) 420 | if err != nil { 421 | panic(err) 422 | } 423 | 424 | for _, v := range query.Owner.Repositories.Edges { 425 | repo := RepoWithPushedAt{ 426 | PushedAt: v.Node.PushedAt.Time, 427 | } 428 | repo.Owner = string(v.Node.Owner.Login) 429 | repo.Name = string(v.Node.Name) 430 | repo.NameWithOwner = string(v.Node.NameWithOwner) 431 | repo.URL = string(v.Node.URL) 432 | repo.Description = string(v.Node.Description) 433 | repo.Stargazers = int(v.Node.Stargazers.TotalCount) 434 | repo.IsPrivate = bool(v.Node.IsPrivate) 435 | 436 | repos = append(repos, repo) 437 | if len(repos) == count { 438 | break 439 | } 440 | } 441 | return repos 442 | } 443 | 444 | func repo(owner, name string) Repo { 445 | variables := map[string]interface{}{ 446 | "owner": githubv4.String(owner), 447 | "name": githubv4.String(name), 448 | } 449 | err := gitHubClient.Query(context.Background(), &repoQuery, variables) 450 | if err != nil { 451 | panic(err) 452 | } 453 | repo := repoQuery.Repository 454 | return Repo{ 455 | Owner: string(repo.Owner.Login), 456 | Name: string(repo.Name), 457 | NameWithOwner: string(repo.NameWithOwner), 458 | URL: string(repo.URL), 459 | Description: string(repo.Description), 460 | Stargazers: int(repo.Stargazers.TotalCount), 461 | IsPrivate: bool(repo.IsPrivate), 462 | LastRelease: releasesFromQL(repo.Releases), 463 | } 464 | } 465 | 466 | func repoRecentReleases(owner, name string, count int) []Release { 467 | var releases []Release 468 | 469 | variables := map[string]interface{}{ 470 | "owner": githubv4.String(owner), 471 | "name": githubv4.String(name), 472 | "count": githubv4.Int(count), 473 | } 474 | err := gitHubClient.Query(context.Background(), &repoRecentReleasesQuery, variables) 475 | if err != nil { 476 | panic(err) 477 | } 478 | 479 | for _, rel := range repoRecentReleasesQuery.Repository.Releases.Nodes { 480 | if bool(rel.IsPrerelease) { 481 | continue 482 | } 483 | releases = append(releases, Release{ 484 | Name: string(rel.Name), 485 | TagName: string(rel.TagName), 486 | PublishedAt: rel.PublishedAt.Time, 487 | CreatedAt: rel.CreatedAt.Time, 488 | URL: string(rel.URL), 489 | IsLatest: bool(rel.IsLatest), 490 | IsPreRelease: bool(rel.IsPrerelease), 491 | IsDraft: bool(rel.IsDraft), 492 | }) 493 | } 494 | 495 | return releases 496 | } 497 | 498 | /* 499 | { 500 | user(login: "muesli") { 501 | login 502 | repositoriesContributedTo(first: 100, includeUserRepositories: true, contributionTypes: COMMIT) { 503 | totalCount 504 | edges { 505 | cursor 506 | node { 507 | id 508 | nameWithOwner 509 | } 510 | } 511 | } 512 | } 513 | } 514 | 515 | { 516 | user(login: "muesli") { 517 | login 518 | repositoriesContributedTo(first: 100, includeUserRepositories: true, contributionTypes: COMMIT) { 519 | totalCount 520 | edges { 521 | cursor 522 | node { 523 | id 524 | nameWithOwner 525 | releases(first: 3, orderBy: {field: CREATED_AT, direction: DESC}) { 526 | nodes { 527 | name 528 | PublishedAt 529 | url 530 | isPrerelease 531 | isDraft 532 | } 533 | } 534 | } 535 | } 536 | } 537 | } 538 | } 539 | 540 | { 541 | user(login: "muesli") { 542 | login 543 | repositories(first: 10, privacy: PUBLIC, isFork: false, ownerAffiliations: OWNER, orderBy: {field: CREATED_AT, direction: DESC}) { 544 | totalCount 545 | edges { 546 | cursor 547 | node { 548 | id 549 | nameWithOwner 550 | } 551 | } 552 | } 553 | } 554 | } 555 | 556 | { 557 | user(login: "muesli") { 558 | login 559 | contributionsCollection { 560 | commitContributionsByRepository { 561 | contributions(first: 1) { 562 | edges { 563 | cursor 564 | node { 565 | occurredAt 566 | } 567 | } 568 | } 569 | repository { 570 | id 571 | nameWithOwner 572 | url 573 | description 574 | } 575 | } 576 | } 577 | } 578 | } 579 | */ 580 | --------------------------------------------------------------------------------