├── .go-version ├── .sample_files ├── params.yml ├── Gemfile.sample └── go.mod.sample ├── tools.go ├── main.go ├── .claude └── settings.local.json ├── presenter ├── csv_presenter.go ├── tsv_presenter.go ├── markdown_presenter.go ├── make_analyzed_list_test.go ├── select_presenter_test.go ├── headers_test.go ├── presenters_missing_test.go ├── analyzed_libinfo_test.go ├── presenter.go └── presenter_test.go ├── .air.toml ├── Makefile ├── .gitignore ├── parser ├── go_parser_internal_test.go ├── parser_core_test.go ├── parser.go ├── ruby_parser_edge_test.go ├── ruby_parser_test.go ├── ruby_parser.go ├── go_parser.go └── go_parser_test.go ├── LICENSE ├── .github └── workflows │ ├── pr.yml │ └── release.yml ├── utils └── debug.go ├── .golangci.yml ├── analyzer ├── parameter_weights.go ├── parameter_weights_test.go ├── github_repo_analyzer_internal_test.go ├── github_repo_analyzer_test.go └── github_repo_analyzer.go ├── AGENTS.md ├── TEST_CHECKLIST.md ├── Readme.md ├── LINT_MIGRATION_TODO.md ├── cmd ├── run_unit_test.go ├── root_test.go └── root.go ├── go.mod └── coverage.html /.go-version: -------------------------------------------------------------------------------- 1 | 1.25.1 2 | -------------------------------------------------------------------------------- /.sample_files/params.yml: -------------------------------------------------------------------------------- 1 | watchers: 1 2 | stars: 2 3 | forks: 3 4 | open_pull_requests: 4 5 | open_issues: 5 6 | last_commit_date: -6000 7 | archived: -99999 -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/air-verse/air" 8 | _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" 9 | ) 10 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 @konyu 3 | */ 4 | package main 5 | 6 | import ( 7 | "github.com/joho/godotenv" 8 | 9 | "github.com/uzumaki-inc/stay_or_go/cmd" 10 | ) 11 | 12 | func main() { 13 | // .envファイルを読み込む 14 | _ = godotenv.Load() 15 | 16 | cmd.Execute() 17 | } 18 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(golangci-lint run:*)", 5 | "Bash(awk:*)", 6 | "Bash(go test:*)", 7 | "Bash(gofumpt:*)", 8 | "Bash(go install:*)", 9 | "Bash(~/go/bin/gofumpt -w analyzer/parameter_weights_test.go)", 10 | "Bash(gci write:*)", 11 | "Bash(cat:*)", 12 | "Bash(go run:*)" 13 | ], 14 | "deny": [], 15 | "ask": [] 16 | } 17 | } -------------------------------------------------------------------------------- /.sample_files/Gemfile.sample: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '3.2.2' 4 | 5 | gem 'rails', '~> 7.0.0' 6 | 7 | gem "self_hosting", git: "https://github.com/uzumaki-inc/self_hosting_gem.git", tag: "v1.0.0" 8 | 9 | gem 'webmock', '~> 7.0.0', :require => false 10 | 11 | gem 'nokogiri', git: 'https://self_hosting_git.com/sparklemotion/nokogiri.git' 12 | 13 | source 'https://gems.com/inside' do 14 | gem 'internal-gem' 15 | end 16 | 17 | group :test do 18 | gem 'rspec' 19 | gem 'rubocop' 20 | end 21 | 22 | platforms :jruby do 23 | gem 'jdbc-sqlite3' 24 | end -------------------------------------------------------------------------------- /presenter/csv_presenter.go: -------------------------------------------------------------------------------- 1 | package presenter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type CsvPresenter struct { 8 | analyzedLibInfos []AnalyzedLibInfo 9 | } 10 | 11 | func NewCsvPresenter(infos []AnalyzedLibInfo) CsvPresenter { 12 | return CsvPresenter{analyzedLibInfos: infos} 13 | } 14 | 15 | func (p CsvPresenter) Display() { 16 | Display(p) 17 | } 18 | 19 | func (p CsvPresenter) makeHeader() []string { 20 | headerRow := strings.Join(headerString, ", ") 21 | 22 | return []string{headerRow} 23 | } 24 | 25 | func (p CsvPresenter) makeBody() []string { 26 | return makeBody(p.analyzedLibInfos, ", ") 27 | } 28 | -------------------------------------------------------------------------------- /presenter/tsv_presenter.go: -------------------------------------------------------------------------------- 1 | package presenter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type TsvPresenter struct { 8 | analyzedLibInfos []AnalyzedLibInfo 9 | } 10 | 11 | func NewTsvPresenter(infos []AnalyzedLibInfo) TsvPresenter { 12 | return TsvPresenter{analyzedLibInfos: infos} 13 | } 14 | 15 | func (p TsvPresenter) Display() { 16 | Display(p) 17 | } 18 | 19 | func (p TsvPresenter) makeHeader() []string { 20 | headerRow := strings.Join(headerString, "\t") 21 | 22 | return []string{headerRow} 23 | } 24 | 25 | func (p TsvPresenter) makeBody() []string { 26 | return makeBody(p.analyzedLibInfos, "\t") 27 | } 28 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | # Command to build the Go application 3 | cmd = "go build -o ./tmp/main ." 4 | 5 | # Pre-build command: 6 | # this command finds changed Go files, groups them by directory, 7 | # and runs golangci-lint once per directory for the changed files. 8 | pre_cmd = [ 9 | "make lintFix", 10 | "go test ./... " 11 | ] 12 | 13 | # Directory for the build output 14 | bin = "tmp/main" 15 | # full_bin = "./tmp/main go" 16 | 17 | # File extensions to watch for changes and trigger a build 18 | include_ext = ["go", "tmpl", "html"] 19 | 20 | # Directories and files to exclude from the build process 21 | exclude_dir = ["tmp", "vendor", "node_modules"] 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: air lint lintFix cover 2 | 3 | # airを実行 4 | air: 5 | go run github.com/air-verse/air 6 | 7 | # golangci-lintを実行 8 | lint: 9 | go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint run 10 | lintFix: 11 | go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint run --fix 12 | 13 | # Run tests with coverage and open HTML report 14 | cover: 15 | mkdir -p .gocache 16 | GOCACHE=$(PWD)/.gocache go test ./... -coverprofile=coverage.out -covermode=atomic 17 | go tool cover -html=coverage.out -o coverage.html 18 | @if command -v open >/dev/null 2>&1; then \ 19 | open coverage.html; \ 20 | elif command -v xdg-open >/dev/null 2>&1; then \ 21 | xdg-open coverage.html; \ 22 | else \ 23 | echo "Coverage report saved to $(PWD)/coverage.html"; \ 24 | fi 25 | -------------------------------------------------------------------------------- /presenter/markdown_presenter.go: -------------------------------------------------------------------------------- 1 | package presenter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type MarkdownPresenter struct { 8 | analyzedLibInfos []AnalyzedLibInfo 9 | } 10 | 11 | func NewMarkdownPresenter(infos []AnalyzedLibInfo) MarkdownPresenter { 12 | return MarkdownPresenter{analyzedLibInfos: infos} 13 | } 14 | 15 | func (p MarkdownPresenter) Display() { 16 | Display(p) 17 | } 18 | 19 | func (p MarkdownPresenter) makeHeader() []string { 20 | headerRow := "| " + strings.Join(headerString, " | ") + " |" 21 | 22 | separatorRow := "|" 23 | for _, header := range headerString { 24 | separatorRow += " " + strings.Repeat("-", len(header)) + " |" 25 | } 26 | 27 | return []string{headerRow, separatorRow} 28 | } 29 | 30 | func (p MarkdownPresenter) makeBody() []string { 31 | return makeBody(p.analyzedLibInfos, "|") 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ビルドされた実行ファイル 2 | /app 3 | /app.exe 4 | /app.dmg 5 | /app.pkg 6 | /app.app 7 | /app.so 8 | /app.dylib 9 | /app.dll 10 | 11 | # 一般的なバイナリファイル 12 | *.exe 13 | *.dll 14 | *.so 15 | *.dylib 16 | .gocache/ 17 | # テストバイナリ 18 | *.test 19 | 20 | # 出力ファイル 21 | *.out 22 | 23 | # プロファイルデータ 24 | *.prof 25 | 26 | # ビルドディレクトリ 27 | /bin/ 28 | build/ 29 | 30 | # カバレッジプロファイル 31 | coverage.out 32 | 33 | # 編集者やIDEの設定ファイル 34 | .idea/ 35 | .vscode/ 36 | *.sublime-project 37 | *.sublime-workspace 38 | 39 | # OS固有のファイル 40 | .DS_Store 41 | Thumbs.db 42 | 43 | # ログファイル 44 | *.log 45 | 46 | # 一時ファイル 47 | *.tmp 48 | *.temp 49 | 50 | tmp/* 51 | .env 52 | 53 | # GolangCI-Lintのキャッシュ 54 | .golangci.* 55 | !.golangci.yml 56 | 57 | # Goのワークスペースファイル 58 | go.work 59 | go.work.sum 60 | 61 | # ベンダーディレクトリ(必要に応じて) 62 | # /vendor/ 63 | 64 | # depパッケージマネージャーのファイル 65 | Gopkg.lock 66 | Gopkg.toml 67 | -------------------------------------------------------------------------------- /parser/go_parser_internal_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestExtractRepoURL_Errors(t *testing.T) { 9 | t.Parallel() 10 | 11 | // invalid JSON 12 | _, err := extractRepoURL([]byte("not-json"), "github.com/user/lib") 13 | if err == nil || !errors.Is(err, ErrFailedToUnmarshalJSON) { 14 | t.Fatalf("expected ErrFailedToUnmarshalJSON, got %v", err) 15 | } 16 | 17 | // no github in name and empty origin.url 18 | body := []byte(`{"origin":{"url":""}}`) 19 | 20 | _, err = extractRepoURL(body, "code.gitea.io/sdk") 21 | if err == nil || !errors.Is(err, ErrNotAGitHubRepository) { 22 | t.Fatalf("expected ErrNotAGitHubRepository, got %v", err) 23 | } 24 | 25 | // non-github URL in origin 26 | body2 := []byte(`{"origin":{"url":"https://example.com/foo"}}`) 27 | 28 | _, err = extractRepoURL(body2, "example.com/foo") 29 | if err == nil || !errors.Is(err, ErrNotAGitHubRepository) { 30 | t.Fatalf("expected ErrNotAGitHubRepository, got %v", err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /presenter/make_analyzed_list_test.go: -------------------------------------------------------------------------------- 1 | package presenter_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uzumaki-inc/stay_or_go/analyzer" 7 | "github.com/uzumaki-inc/stay_or_go/parser" 8 | "github.com/uzumaki-inc/stay_or_go/presenter" 9 | ) 10 | 11 | func TestMakeAnalyzedLibInfoList_Mapping(t *testing.T) { 12 | t.Parallel() 13 | 14 | li1 := parser.LibInfo{Name: "a", RepositoryURL: "https://github.com/u/a"} 15 | li2 := parser.LibInfo{Name: "b", RepositoryURL: "https://github.com/u/b"} 16 | 17 | gi1 := analyzer.GitHubRepoInfo{GithubRepoURL: "https://github.com/u/a", Stars: 10} 18 | infos := presenter.MakeAnalyzedLibInfoList([]parser.LibInfo{li1, li2}, []analyzer.GitHubRepoInfo{gi1}) 19 | 20 | if len(infos) != 2 { 21 | t.Fatalf("want 2") 22 | } 23 | 24 | if infos[0].GitHubRepoInfo == nil || infos[0].GitHubRepoInfo.Stars != 10 { 25 | t.Fatalf("first should be mapped with stars 10") 26 | } 27 | 28 | if infos[1].GitHubRepoInfo != nil { 29 | t.Fatalf("second should be nil GitHubRepoInfo") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /presenter/select_presenter_test.go: -------------------------------------------------------------------------------- 1 | package presenter_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uzumaki-inc/stay_or_go/presenter" 7 | ) 8 | 9 | func TestSelectPresenter_Formats(t *testing.T) { 10 | t.Parallel() 11 | 12 | infos := []presenter.AnalyzedLibInfo{} 13 | 14 | pres := presenter.SelectPresenter("csv", infos) 15 | if _, ok := pres.(presenter.CsvPresenter); !ok { 16 | t.Fatalf("expected CsvPresenter, got %T", pres) 17 | } 18 | 19 | pres = presenter.SelectPresenter("tsv", infos) 20 | if _, ok := pres.(presenter.TsvPresenter); !ok { 21 | t.Fatalf("expected TsvPresenter, got %T", pres) 22 | } 23 | 24 | // default → markdown 25 | pres = presenter.SelectPresenter("unknown", infos) 26 | if _, ok := pres.(presenter.MarkdownPresenter); !ok { 27 | t.Fatalf("expected MarkdownPresenter, got %T", pres) 28 | } 29 | 30 | pres = presenter.SelectPresenter("markdown", infos) 31 | if _, ok := pres.(presenter.MarkdownPresenter); !ok { 32 | t.Fatalf("expected MarkdownPresenter, got %T", pres) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 UZUMAKI 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 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | lint-and-test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: .go-version 19 | cache: true 20 | - name: Install dependencies 21 | run: go mod download 22 | - name: Run lint 23 | run: make lint 24 | - name: Run unit tests 25 | run: go test ./... 26 | check_stay_or_go: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v5 31 | - name: Run stay_or_go 32 | uses: konyu/action_stay_or_go@v1 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | mode: go 36 | workdir: . 37 | min_score: "100" # ← 100 以下ならジョブ失敗 38 | - name: Upload report artifact 39 | if: always() 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: stay_or_go_report_tsv_go 43 | path: stay_or_go_report.tsv 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against tags 7 | tags: 8 | - "v*" 9 | 10 | permissions: 11 | contents: write 12 | # packages: write 13 | # issues: write 14 | # id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | # More assembly might be required: Docker logins, GPG, etc. 29 | # It all depends on your needs. 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | # either 'goreleaser' (default) or 'goreleaser-pro' 34 | distribution: goreleaser 35 | # 'latest', 'nightly', or a semver 36 | version: "~> v2" 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 41 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} -------------------------------------------------------------------------------- /utils/debug.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | ) 8 | 9 | const ( 10 | ansiReset = "\033[0m" 11 | ansiRed = "\033[31m" 12 | ) 13 | 14 | var Verbose bool 15 | 16 | func DebugPrintln(message string) { 17 | if Verbose { 18 | fmt.Fprintf(os.Stderr, "%s%s%s\n", ansiRed, message, ansiReset) 19 | } 20 | } 21 | 22 | func StdErrorPrintln(message string, a ...interface{}) { 23 | fmt.Fprintf(os.Stderr, message+"\n", a...) 24 | } 25 | 26 | func PrintStructFields(structObj interface{}) { 27 | if structObj == nil { 28 | fmt.Println("nil value provided") 29 | 30 | return 31 | } 32 | 33 | val := reflect.ValueOf(structObj) 34 | typ := reflect.TypeOf(structObj) 35 | 36 | if val.Kind() == reflect.Ptr { 37 | if val.IsNil() { 38 | fmt.Println("nil pointer provided") 39 | 40 | return 41 | } 42 | 43 | val = val.Elem() 44 | typ = typ.Elem() 45 | } 46 | 47 | if val.Kind() != reflect.Struct { 48 | fmt.Println("provided value is not a struct") 49 | 50 | return 51 | } 52 | 53 | for i := range make([]struct{}, val.NumField()) { 54 | fieldName := typ.Field(i).Name 55 | fieldValue := val.Field(i) 56 | 57 | if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { 58 | fmt.Printf("%s: nil\n", fieldName) 59 | } else { 60 | fmt.Printf("%s: %v\n", fieldName, fieldValue.Interface()) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /presenter/headers_test.go: -------------------------------------------------------------------------------- 1 | //nolint:testpackage // Tests unexported methods 2 | package presenter 3 | 4 | import ( 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMarkdownPresenter_makeHeader(t *testing.T) { 10 | t.Parallel() 11 | 12 | p := NewMarkdownPresenter(nil) 13 | header := p.makeHeader() 14 | 15 | if len(header) != 2 { 16 | t.Fatalf("expected 2 header lines, got %d", len(header)) 17 | } 18 | 19 | if !strings.HasPrefix(header[0], "| ") || !strings.HasSuffix(header[0], " |") { 20 | t.Fatalf("unexpected header row: %q", header[0]) 21 | } 22 | 23 | if !strings.HasPrefix(header[1], "|") || !strings.HasSuffix(header[1], "|") { 24 | t.Fatalf("unexpected separator row: %q", header[1]) 25 | } 26 | } 27 | 28 | func TestCsvPresenter_makeHeader(t *testing.T) { 29 | t.Parallel() 30 | 31 | p := NewCsvPresenter(nil) 32 | header := p.makeHeader() 33 | 34 | if len(header) != 1 { 35 | t.Fatalf("expected single header row") 36 | } 37 | 38 | if !strings.Contains(header[0], ", ") { 39 | t.Fatalf("expected commas in header: %q", header[0]) 40 | } 41 | } 42 | 43 | func TestTsvPresenter_makeHeader(t *testing.T) { 44 | t.Parallel() 45 | 46 | p := NewTsvPresenter(nil) 47 | header := p.makeHeader() 48 | 49 | if len(header) != 1 { 50 | t.Fatalf("expected single header row") 51 | } 52 | 53 | if !strings.Contains(header[0], "\t") { 54 | t.Fatalf("expected tabs in header: %q", header[0]) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - exhaustruct 7 | - forbidigo 8 | - gochecknoglobals 9 | - gochecknoinits 10 | - godot 11 | - ireturn 12 | - mnd 13 | - wsl # Deprecated - replaced by wsl_v5 14 | - tagliatelle # Allow snake_case in YAML for backward compatibility 15 | enable: 16 | - wsl_v5 # New version of wsl 17 | settings: 18 | tagliatelle: 19 | case: 20 | rules: 21 | json: snake 22 | use-field-name: true 23 | varnamelen: 24 | ignore-names: 25 | - err 26 | - ok 27 | - idx 28 | revive: 29 | rules: 30 | - name: var-naming 31 | disabled: true 32 | wsl_v5: 33 | allow-first-in-block: true 34 | allow-whole-block: false 35 | branch-max-lines: 2 36 | exclusions: 37 | generated: lax 38 | presets: 39 | - comments 40 | - common-false-positives 41 | - legacy 42 | - std-error-handling 43 | paths: 44 | - third_party$ 45 | - builtin$ 46 | - examples$ 47 | formatters: 48 | enable: 49 | - gci 50 | - gofmt 51 | - gofumpt 52 | - goimports 53 | settings: 54 | gci: 55 | sections: 56 | - standard 57 | - prefix(github.com/uzumaki-inc/stay_or_go) 58 | - default 59 | exclusions: 60 | generated: lax 61 | paths: 62 | - third_party$ 63 | - builtin$ 64 | - examples$ 65 | -------------------------------------------------------------------------------- /parser/parser_core_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/uzumaki-inc/stay_or_go/parser" 10 | ) 11 | 12 | func TestSelectParser_ReturnsCorrectTypes(t *testing.T) { 13 | t.Parallel() 14 | 15 | goParser, err := parser.SelectParser("go") 16 | require.NoError(t, err) 17 | 18 | if _, ok := goParser.(parser.GoParser); !ok { 19 | t.Fatalf("expected GoParser, got %T", goParser) 20 | } 21 | 22 | rubyParser, err := parser.SelectParser("ruby") 23 | require.NoError(t, err) 24 | 25 | if _, ok := rubyParser.(parser.RubyParser); !ok { 26 | t.Fatalf("expected RubyParser, got %T", rubyParser) 27 | } 28 | } 29 | 30 | func TestSelectParser_UnsupportedLanguage(t *testing.T) { 31 | t.Parallel() 32 | 33 | pythonParser, err := parser.SelectParser("python") 34 | assert.Nil(t, pythonParser) 35 | require.Error(t, err) 36 | assert.ErrorIs(t, err, parser.ErrUnsupportedLanguage) 37 | } 38 | 39 | func TestNewLibInfo_DefaultsAndOptions(t *testing.T) { 40 | t.Parallel() 41 | 42 | // Defaults 43 | li := parser.NewLibInfo("libX") 44 | assert.Equal(t, "libX", li.Name) 45 | assert.False(t, li.Skip) 46 | assert.Empty(t, li.SkipReason) 47 | assert.Nil(t, li.Others) 48 | assert.Empty(t, li.RepositoryURL) 49 | 50 | // With options 51 | li2 := parser.NewLibInfo( 52 | "libY", 53 | parser.WithSkip(true), 54 | parser.WithSkipReason("reason"), 55 | parser.WithOthers([]string{"a", "b"}), 56 | ) 57 | assert.Equal(t, "libY", li2.Name) 58 | assert.True(t, li2.Skip) 59 | assert.Equal(t, "reason", li2.SkipReason) 60 | assert.Equal(t, []string{"a", "b"}, li2.Others) 61 | } 62 | -------------------------------------------------------------------------------- /analyzer/parameter_weights.go: -------------------------------------------------------------------------------- 1 | package analyzer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | defaultWatcherWeight = 0.1 13 | defaultStarWeight = 0.1 14 | defaultForkWeight = 0.1 15 | defaultOpenPullRequestWeight = 0.01 16 | defaultOpenIssueWeight = 0.01 17 | defaultLastCommitDateWeight = -0.05 18 | defaultArchivedWeight = -1000000 19 | ) 20 | 21 | type ParameterWeights struct { 22 | Watchers float64 `yaml:"watchers"` 23 | Stars float64 `yaml:"stars"` 24 | Forks float64 `yaml:"forks"` 25 | OpenIssues float64 `yaml:"open_issues"` 26 | LastCommitDate float64 `yaml:"last_commit_date"` 27 | Archived float64 `yaml:"archived"` 28 | } 29 | 30 | func NewParameterWeights() ParameterWeights { 31 | return ParameterWeights{ 32 | Watchers: defaultWatcherWeight, 33 | Stars: defaultStarWeight, 34 | Forks: defaultForkWeight, 35 | OpenIssues: defaultOpenIssueWeight, 36 | LastCommitDate: defaultLastCommitDateWeight, 37 | Archived: defaultArchivedWeight, 38 | } 39 | } 40 | 41 | // NewParameterWeightsFromReader creates ParameterWeights from an io.Reader 42 | // This function replaces Viper with direct YAML parsing and proper error handling 43 | func NewParameterWeightsFromReader(reader io.Reader) (ParameterWeights, error) { 44 | data, err := io.ReadAll(reader) 45 | if err != nil { 46 | return ParameterWeights{}, fmt.Errorf("failed to read config data: %w", err) 47 | } 48 | 49 | var weights ParameterWeights 50 | 51 | err = yaml.Unmarshal(data, &weights) 52 | if err != nil { 53 | return ParameterWeights{}, fmt.Errorf("failed to unmarshal YAML: %w", err) 54 | } 55 | 56 | return weights, nil 57 | } 58 | 59 | // NewParameterWeightsFromFile creates ParameterWeights from a file path 60 | // This function provides proper error handling without os.Exit 61 | func NewParameterWeightsFromFile(configFilePath string) (ParameterWeights, error) { 62 | file, err := os.Open(configFilePath) 63 | if err != nil { 64 | return ParameterWeights{}, fmt.Errorf("failed to read config file: %w", err) 65 | } 66 | defer file.Close() 67 | 68 | return NewParameterWeightsFromReader(file) 69 | } 70 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | ErrMethodNotFound = errors.New("method not found in struct") 10 | ErrFiledToOpenFile = errors.New("error opening file") 11 | ErrFailedToReadFile = errors.New("failed to read file") 12 | ErrFailedToResetFilePointer = errors.New("failed to reset file pointer") 13 | ErrFailedToScanFile = errors.New("failed to scan file") 14 | ErrFailedToGetRepository = errors.New("can't get the gem repository, skipping") 15 | ErrNotAGitHubRepository = errors.New("not a GitHub repository, skipping") 16 | ErrFailedToReadResponseBody = errors.New("failed to read response body") 17 | ErrFailedToUnmarshalJSON = errors.New("failed to unmarshal JSON response") 18 | ErrInvalidLineFormat = errors.New("invalid line format") 19 | ErrMissingGemName = errors.New("missing gem name") 20 | ErrUnsupportedLanguage = errors.New("unsupported language") 21 | ) 22 | 23 | const timeOutSec = 30 24 | 25 | type LibInfo struct { 26 | Skip bool // スキップするかどうかのフラグ 27 | SkipReason string // スキップ理由 28 | Name string // ライブラリの名前 29 | Others []string // その他のライブラリの設定値 30 | RepositoryURL string // githubのりポトリのURL 31 | } 32 | 33 | type LibInfoOption func(*LibInfo) 34 | 35 | func WithSkip(skip bool) LibInfoOption { 36 | return func(l *LibInfo) { 37 | l.Skip = skip 38 | } 39 | } 40 | 41 | func WithSkipReason(reason string) LibInfoOption { 42 | return func(l *LibInfo) { 43 | l.SkipReason = reason 44 | } 45 | } 46 | 47 | func WithOthers(others []string) LibInfoOption { 48 | return func(l *LibInfo) { 49 | l.Others = others 50 | } 51 | } 52 | 53 | func NewLibInfo(name string, options ...LibInfoOption) LibInfo { 54 | libInfo := LibInfo{ 55 | Name: name, 56 | Skip: false, 57 | SkipReason: "", 58 | Others: nil, 59 | RepositoryURL: "", 60 | } 61 | 62 | for _, option := range options { 63 | option(&libInfo) 64 | } 65 | 66 | return libInfo 67 | } 68 | 69 | type Parser interface { 70 | Parse(file string) ([]LibInfo, error) 71 | GetRepositoryURL(AnalyzedLibInfoList []LibInfo) []LibInfo 72 | } 73 | 74 | func SelectParser(language string) (Parser, error) { 75 | switch language { 76 | case "ruby": 77 | return RubyParser{}, nil 78 | case "go": 79 | return GoParser{}, nil 80 | default: 81 | return nil, fmt.Errorf("%w: %s", ErrUnsupportedLanguage, language) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | - `cmd/`: Cobra CLI entry (`root.go`), flag parsing and command execution. 5 | - `parser/`: Reads `go.mod`/`Gemfile`, resolves repo URLs. 6 | - `analyzer/`: Fetches GitHub repo stats and computes scores. 7 | - `presenter/`: Renders results (`markdown`, `csv`, `tsv`). 8 | - `utils/`: Shared helpers (logging/verbosity). 9 | - Top level: `main.go` (loads `.env`, runs CLI), `.golangci.yml`, `.air.toml`, `Makefile`, `.sample_files/`. 10 | 11 | ## Build, Test, and Development Commands 12 | - `go run . go -g $GITHUB_TOKEN`: Analyze Go deps in current repo. 13 | - `go run . ruby -i ./Gemfile -g $GITHUB_TOKEN`: Analyze Ruby deps. 14 | - `GITHUB_TOKEN=... go run . go`: Use env var instead of `-g`. 15 | - `go test ./...`: Run unit tests across packages. 16 | - `make lint` / `make lintFix`: Run `golangci-lint` (and autofix where possible). 17 | - `make air`: Live dev with Air; runs lint + tests before rebuild. 18 | - `make cover`: Run tests with coverage and open `coverage.html`. 19 | 20 | ## Coding Style & Naming Conventions 21 | - Go standards: `gofmt` formatting, idiomatic naming (`CamelCase` for exported, `camelCase` for unexported, packages lowercase). 22 | - Errors: prefer sentinel vars `Err...` and `fmt.Errorf("...: %w", err)` wrapping. 23 | - Linting: follow `.golangci.yml` rules; some linters are disabled by design (e.g., gofumpt, exhaustruct). 24 | - Output: use `utils.StdErrorPrintln`/`utils.DebugPrintln` for consistent stderr and verbose logs. 25 | 26 | ## Testing Guidelines 27 | - Frameworks: std `testing` with `testify/assert`; HTTP calls mocked via `jarcoal/httpmock`. 28 | - Placement: co-locate tests as `*_test.go` within each package (see `parser/`, `presenter/`, `analyzer/`). 29 | - Run: `go test ./...` (Air also runs it). Add focused tests for new logic and edge cases. 30 | 31 | ## Commit & Pull Request Guidelines 32 | - Commits: short, imperative subject with a type prefix seen in history (e.g., `Fix: ...`, `Add: ...`, `Update: ...`, `Rename: ...`, `Upgrade: ...`). 33 | - Example: `Fix: include watchers in scoring`. 34 | - PRs: include clear description, rationale, and linked issues; add tests for new behavior; include sample output when changing formats. 35 | - Before opening: run `make lint` and `go test ./...` and ensure CLI help/flags remain accurate. 36 | 37 | ## Security & Configuration Tips 38 | - Auth: set `GITHUB_TOKEN` (env or `-g`). Do not commit `.env` or tokens. 39 | - Rate limits: prefer env var with a personal access token to avoid API throttling during analysis. 40 | -------------------------------------------------------------------------------- /parser/ruby_parser_edge_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jarcoal/httpmock" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/uzumaki-inc/stay_or_go/parser" 11 | ) 12 | 13 | //nolint:paralleltest // Uses file I/O which may conflict in parallel 14 | func TestRubyParser_Parse_SkipsGemsInBlocks(t *testing.T) { 15 | content := `source "https://rubygems.org" do 16 | gem 'rails' 17 | end 18 | 19 | platforms :jruby do 20 | gem 'jruby-openssl' 21 | end 22 | 23 | install_if -> { true } do 24 | gem 'pg' 25 | end 26 | 27 | gem 'puma' 28 | ` 29 | 30 | tmpFile, err := os.CreateTemp(t.TempDir(), "Gemfile-*.tmp") 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | defer os.Remove(tmpFile.Name()) 36 | 37 | _, err = tmpFile.WriteString(content) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | _ = tmpFile.Close() 43 | 44 | p := parser.RubyParser{} 45 | 46 | libs, err := p.Parse(tmpFile.Name()) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | // Expect 4 gems listed in order encountered 52 | assert.Len(t, libs, 4) 53 | 54 | // First three are inside blocks → skipped 55 | assert.Equal(t, "rails", libs[0].Name) 56 | assert.True(t, libs[0].Skip) 57 | assert.Equal(t, "Not hosted on Github", libs[0].SkipReason) 58 | 59 | assert.Equal(t, "jruby-openssl", libs[1].Name) 60 | assert.True(t, libs[1].Skip) 61 | assert.Equal(t, "Not hosted on Github", libs[1].SkipReason) 62 | 63 | assert.Equal(t, "pg", libs[2].Name) 64 | assert.True(t, libs[2].Skip) 65 | assert.Equal(t, "Not hosted on Github", libs[2].SkipReason) 66 | 67 | // Outside blocks → not skipped 68 | assert.Equal(t, "puma", libs[3].Name) 69 | assert.False(t, libs[3].Skip) 70 | } 71 | 72 | //nolint:paralleltest // Uses httpmock which doesn't support parallel tests 73 | func TestRubyParser_GetRepositoryURL_NonGitHubHomepageSkips(t *testing.T) { 74 | httpmock.Activate() 75 | 76 | defer httpmock.DeactivateAndReset() 77 | 78 | // homepage_uri points to non-GitHub → should skip 79 | httpmock.RegisterResponder( 80 | "GET", 81 | "https://rubygems.org/api/v1/gems/foo.json", 82 | httpmock.NewStringResponder(200, `{"homepage_uri": "https://example.com/foo", "source_code_uri": ""}`), 83 | ) 84 | 85 | libs := []parser.LibInfo{{Name: "foo"}} 86 | 87 | p := parser.RubyParser{} 88 | updated := p.GetRepositoryURL(libs) 89 | 90 | assert.True(t, updated[0].Skip) 91 | assert.Equal(t, "Does not support libraries hosted outside of Github", updated[0].SkipReason) 92 | assert.Empty(t, updated[0].RepositoryURL) 93 | } 94 | -------------------------------------------------------------------------------- /parser/ruby_parser_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jarcoal/httpmock" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/uzumaki-inc/stay_or_go/parser" 11 | ) 12 | 13 | func TestRubyParser_Parse(t *testing.T) { 14 | t.Parallel() 15 | // Create a temporary file for testing 16 | content := `gem 'rails', '~> 6.0' 17 | gem 'nokogiri', git: 'https://self_hosting_git.com/sparklemotion/nokogiri.git' 18 | gem 'puma'` 19 | 20 | tempFile, err := os.CreateTemp(t.TempDir(), "testfile-*.txt") 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | defer tempFile.Close() 26 | defer os.Remove(tempFile.Name()) 27 | 28 | // Write content to the file 29 | _, err = tempFile.WriteString(content) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | // Parse the file using RubyParser 35 | p := parser.RubyParser{} 36 | 37 | libInfoList, err := p.Parse(tempFile.Name()) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | // Assertions 42 | assert.Len(t, libInfoList, 3) 43 | assert.Equal(t, "rails", libInfoList[0].Name) 44 | assert.False(t, libInfoList[0].Skip) 45 | assert.Equal(t, "nokogiri", libInfoList[1].Name) 46 | assert.True(t, libInfoList[1].Skip) 47 | assert.Equal(t, "Not hosted on Github", libInfoList[1].SkipReason) 48 | assert.Equal(t, "puma", libInfoList[2].Name) 49 | assert.False(t, libInfoList[2].Skip) 50 | } 51 | 52 | //nolint:paralleltest // Uses httpmock which doesn't support parallel tests 53 | func TestRubyParser_GetRepositoryURL(t *testing.T) { 54 | // Mock HTTP requests for rubygems API 55 | httpmock.Activate() 56 | defer httpmock.DeactivateAndReset() 57 | 58 | // Setup mock responses 59 | httpmock.RegisterResponder("GET", "https://rubygems.org/api/v1/gems/rails.json", 60 | httpmock.NewStringResponder(200, `{"source_code_uri": "https://github.com/rails/rails"}`)) 61 | 62 | httpmock.RegisterResponder("GET", "https://rubygems.org/api/v1/gems/nokogiri.json", 63 | httpmock.NewStringResponder(200, `{"homepage_uri": "https://github.com/sparklemotion/nokogiri"}`)) 64 | 65 | httpmock.RegisterResponder("GET", "https://rubygems.org/api/v1/gems/puma.json", 66 | httpmock.NewStringResponder(200, `{"source_code_uri": ""}`)) 67 | 68 | // Create initial LibInfo list 69 | libInfoList := []parser.LibInfo{ 70 | {Name: "rails"}, 71 | {Name: "nokogiri"}, 72 | {Name: "puma"}, 73 | } 74 | 75 | // Run GetRepositoryURL method 76 | p := parser.RubyParser{} 77 | updatedLibInfoList := p.GetRepositoryURL(libInfoList) 78 | 79 | // Assertions 80 | assert.Equal(t, "https://github.com/rails/rails", updatedLibInfoList[0].RepositoryURL) 81 | assert.Equal(t, "https://github.com/sparklemotion/nokogiri", updatedLibInfoList[1].RepositoryURL) 82 | assert.Empty(t, updatedLibInfoList[2].RepositoryURL) 83 | assert.True(t, updatedLibInfoList[2].Skip) 84 | assert.Equal(t, "Does not support libraries hosted outside of Github", updatedLibInfoList[2].SkipReason) 85 | } 86 | -------------------------------------------------------------------------------- /TEST_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | # Go テスト作成チェックリスト 2 | 3 | 対象: テスト未作成の `.go` ファイルに対する、実装前のチェック項目一覧。 4 | 5 | ## 対象ファイル 6 | 7 | - analyzer/parameter_weights.go 8 | - cmd/root.go 9 | - main.go 10 | - parser/go_parser.go 11 | - parser/parser.go 12 | - presenter/csv_presenter.go 13 | - presenter/markdown_presenter.go 14 | - presenter/tsv_presenter.go 15 | 16 | ## 共通タスク 17 | 18 | - [ ] テーブル駆動テストで正常系/境界/エラー系を網羅 19 | - [ ] 外部呼び出しは `jarcoal/httpmock` でモック 20 | - [ ] 一時ファイル/ディレクトリを使う場合は `t.Cleanup` で片付け 21 | - [ ] 出力は `bytes.Buffer` または `os.Pipe` で捕捉しアサート 22 | - [ ] `go test ./...` がローカルで成功することを確認 23 | 24 | ## analyzer/parameter_weights.go 25 | 26 | - [x] `NewParameterWeights` が定数どおりのデフォルト値を返す(`analyzer/parameter_weights_test.go`) 27 | - [x] `NewParameterWeightsFromConfiFile` が一時 YAML の値を正しく読み込む 28 | - [x] 異常系(存在しないパス)は `os.Exit(1)` となることをサブプロセスで検証 29 | - 例: `exec.Command(os.Args[0], "-test.run=TestHelperProcess_WeightsExit")` 30 | - 実行メモ: サンドボックス環境では Go モジュール取得/ビルドキャッシュ制約により `go test` 実行が不可。ローカルでは `GOCACHE` を任意ディレクトリに設定して実行: 31 | - 例: `GOCACHE=.gocache go test ./analyzer -v` 32 | 33 | ## cmd/root.go 34 | 35 | - [x] 未サポート言語指定でエラーメッセージと終了コード 1 を出す(サブプロセス) 36 | - [x] `-f` 未対応フォーマットでエラーを出す(サブプロセス) 37 | - [x] `-g` 未指定時に `GITHUB_TOKEN` 未設定ならエラー(サブプロセス) 38 | - [x] 言語ごとのデフォルト入力パスが設定(`go`→`go.mod` / `ruby`→`Gemfile`) 39 | - [x] `--verbose` でデバッグ出力(`Selected Language: go` 等)が stderr に出る 40 | - 追加テスト: `cmd/root_test.go` 41 | - 実行例: `GOCACHE=.gocache go test ./cmd -v` 42 | 43 | ## main.go 44 | 45 | - [ ] スモークテスト(実行してパニックせず起動する) 46 | - 必要に応じて `cmd.Execute` をスタブ/サブプロセスで検証 47 | 48 | ## parser/go_parser.go 49 | 50 | - [x] `require (...)` ブロックから `// indirect` を除外して抽出できる 51 | - [x] `replace (...)` の対象は `Skip` と `SkipReason` が設定される 52 | - [x] `GetRepositoryURL` が `proxy.golang.org` のレスポンスから GitHub URL を設定する 53 | - [x] GitHub 以外の場合は `Skip` と理由が設定される(HTTP モックで検証) 54 | - 追加テスト: `parser/go_parser_test.go` 55 | - 実行例: `GOCACHE=.gocache go test ./parser -v` 56 | 57 | ## parser/parser.go 58 | 59 | - [x] `SelectParser("go")`/`("ruby")` が対応型を返す(`parser/parser_core_test.go`) 60 | - [x] 未対応言語でエラーを返す(`errors.Is(err, parser.ErrUnsupportedLanguage)`) 61 | - [x] `NewLibInfo` と各 `With...` オプションの反映を確認 62 | - 実行例: `GOCACHE=.gocache go test ./parser -run TestSelectParser -v` 63 | 64 | ## presenter/\*\_presenter.go 65 | 66 | - [x] CSV/TSV/Markdown の `Display` が想定ヘッダ・ボディを出力する 67 | - 既存: `presenter/presenter_test.go`(通常ケース) 68 | - 追加: `presenter/presenters_missing_test.go`(Repo情報欠損 + Skip理由) 69 | - [x] `makeBody` が区切り文字ごとに正しい整形を行い、欠損値を `N/A` とする(上記テストで検証) 70 | - [x] `SelectPresenter` がフォーマットに応じて型を返す(`presenter/select_presenter_test.go`) 71 | - 実行メモ: サンドボックスでは `go test` 実行が制限される場合があります。ローカルでは次を推奨: 72 | - `GOCACHE=.gocache go test ./presenter -v` 73 | 74 | ## parser/ruby_parser.go 75 | 76 | - [x] `source`/`platforms`/`install_if` ブロック内の gem は Skip される 77 | - [x] `GetRepositoryURL` で `homepage_uri`/`source_code_uri` が GitHub でない場合は Skip 78 | - 追加テスト: `parser/ruby_parser_edge_test.go` 79 | - 実行例: `GOCACHE=.gocache go test ./parser -v` 80 | 81 | ## 実行コマンド例 82 | 83 | - `go test ./...` 84 | - リント: `make lint`(自動修正は `make lintFix`) 85 | -------------------------------------------------------------------------------- /analyzer/parameter_weights_test.go: -------------------------------------------------------------------------------- 1 | package analyzer_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/uzumaki-inc/stay_or_go/analyzer" 13 | ) 14 | 15 | func TestNewParameterWeightsDefaults(t *testing.T) { 16 | t.Parallel() 17 | 18 | weights := analyzer.NewParameterWeights() 19 | 20 | // Default weights should match implementation defaults 21 | assert.InDelta(t, 0.1, weights.Watchers, 0.0001) 22 | assert.InDelta(t, 0.1, weights.Stars, 0.0001) 23 | assert.InDelta(t, 0.1, weights.Forks, 0.0001) 24 | assert.InDelta(t, 0.01, weights.OpenIssues, 0.0001) 25 | assert.InDelta(t, -0.05, weights.LastCommitDate, 0.0001) 26 | assert.InDelta(t, -1000000.0, weights.Archived, 0.1) 27 | } 28 | 29 | func TestNewParameterWeightsFromReader_LoadsValues(t *testing.T) { 30 | t.Parallel() 31 | 32 | // Create YAML content 33 | yamlContent := ` 34 | watchers: 0.2 35 | stars: 0.3 36 | forks: 0.15 37 | open_issues: 0.02 38 | last_commit_date: -0.1 39 | archived: -2000000 40 | ` 41 | 42 | reader := strings.NewReader(yamlContent) 43 | 44 | weights, err := analyzer.NewParameterWeightsFromReader(reader) 45 | 46 | require.NoError(t, err) 47 | assert.InDelta(t, 0.2, weights.Watchers, 0.0001) 48 | assert.InDelta(t, 0.3, weights.Stars, 0.0001) 49 | assert.InDelta(t, 0.15, weights.Forks, 0.0001) 50 | assert.InDelta(t, 0.02, weights.OpenIssues, 0.0001) 51 | assert.InDelta(t, -0.1, weights.LastCommitDate, 0.0001) 52 | assert.InDelta(t, -2000000.0, weights.Archived, 0.1) 53 | } 54 | 55 | func TestNewParameterWeightsFromReader_ErrorOnInvalidYAML(t *testing.T) { 56 | t.Parallel() 57 | 58 | invalidYAML := "invalid: yaml: content: [" 59 | reader := strings.NewReader(invalidYAML) 60 | 61 | _, err := analyzer.NewParameterWeightsFromReader(reader) 62 | 63 | require.Error(t, err) 64 | assert.Contains(t, err.Error(), "failed to unmarshal") 65 | } 66 | 67 | func TestNewParameterWeightsFromFile_LoadsValues(t *testing.T) { 68 | t.Parallel() 69 | 70 | dir := t.TempDir() 71 | path := filepath.Join(dir, "weights.yml") 72 | content := []byte( 73 | "watchers: 1.5\n" + 74 | "stars: 2.5\n" + 75 | "forks: 3.5\n" + 76 | "open_issues: 4.5\n" + 77 | "last_commit_date: -6.5\n" + 78 | "archived: -99999\n", 79 | ) 80 | 81 | err := os.WriteFile(path, content, 0o600) 82 | require.NoError(t, err) 83 | 84 | weights, err := analyzer.NewParameterWeightsFromFile(path) 85 | 86 | require.NoError(t, err) 87 | assert.InDelta(t, 1.5, weights.Watchers, 0.0001) 88 | assert.InDelta(t, 2.5, weights.Stars, 0.0001) 89 | assert.InDelta(t, 3.5, weights.Forks, 0.0001) 90 | assert.InDelta(t, 4.5, weights.OpenIssues, 0.0001) 91 | assert.InDelta(t, -6.5, weights.LastCommitDate, 0.0001) 92 | assert.InDelta(t, -99999.0, weights.Archived, 0.0001) 93 | } 94 | 95 | func TestNewParameterWeightsFromFile_ErrorOnMissingFile(t *testing.T) { 96 | t.Parallel() 97 | 98 | _, err := analyzer.NewParameterWeightsFromFile("/path/does/not/exist.yml") 99 | 100 | require.Error(t, err) 101 | assert.Contains(t, err.Error(), "failed to read config file") 102 | } 103 | -------------------------------------------------------------------------------- /presenter/presenters_missing_test.go: -------------------------------------------------------------------------------- 1 | package presenter_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/uzumaki-inc/stay_or_go/parser" 11 | "github.com/uzumaki-inc/stay_or_go/presenter" 12 | ) 13 | 14 | // Verify presenters when GitHubRepoInfo is missing and LibInfo is skipped. 15 | func getTestCases() []struct { 16 | name string 17 | presenterFunc func([]presenter.AnalyzedLibInfo) presenter.Presenter 18 | expectedOutput string 19 | } { 20 | return []struct { 21 | name string 22 | presenterFunc func([]presenter.AnalyzedLibInfo) presenter.Presenter 23 | expectedOutput string 24 | }{ 25 | { 26 | name: "Markdown", 27 | presenterFunc: func(infos []presenter.AnalyzedLibInfo) presenter.Presenter { 28 | return presenter.NewMarkdownPresenter(infos) 29 | }, 30 | expectedOutput: `| Name | RepositoryURL | Watchers | Stars | Forks | OpenIssues | ` + 31 | `LastCommitDate | Archived | Score | Skip | SkipReason | 32 | | ---- | ------------- | -------- | ----- | ----- | ---------- | ` + 33 | `-------------- | -------- | ----- | ---- | ---------- | 34 | |libX|N/A|N/A|N/A|N/A|N/A|N/A|N/A|N/A|true|Not hosted on Github| 35 | `, 36 | }, 37 | { 38 | name: "CSV", 39 | presenterFunc: func(infos []presenter.AnalyzedLibInfo) presenter.Presenter { 40 | return presenter.NewCsvPresenter(infos) 41 | }, 42 | //nolint:dupword // N/A repetition is expected output format 43 | expectedOutput: "Name, RepositoryURL, Watchers, Stars, Forks, OpenIssues, " + 44 | "LastCommitDate, Archived, Score, Skip, SkipReason\n" + 45 | "libX, N/A, N/A, N/A, N/A, N/A, N/A, N/A, N/A, true, Not hosted on Github\n", 46 | }, 47 | { 48 | name: "TSV", 49 | presenterFunc: func(infos []presenter.AnalyzedLibInfo) presenter.Presenter { 50 | return presenter.NewTsvPresenter(infos) 51 | }, 52 | //nolint:dupword // N/A repetition is expected output format 53 | expectedOutput: "Name\tRepositoryURL\tWatchers\tStars\tForks\tOpenIssues\t" + 54 | "LastCommitDate\tArchived\tScore\tSkip\tSkipReason\n" + 55 | "libX\tN/A\tN/A\tN/A\tN/A\tN/A\tN/A\tN/A\tN/A\ttrue\tNot hosted on Github\n", 56 | }, 57 | } 58 | } 59 | 60 | func capturePresenterOutput(pres presenter.Presenter) string { 61 | rPipe, wPipe, _ := os.Pipe() 62 | old := os.Stdout 63 | 64 | os.Stdout = wPipe 65 | 66 | defer func() { os.Stdout = old }() 67 | 68 | pres.Display() 69 | wPipe.Close() 70 | 71 | var buf bytes.Buffer 72 | 73 | _, _ = buf.ReadFrom(rPipe) 74 | 75 | return buf.String() 76 | } 77 | 78 | //nolint:paralleltest // Test manipulates os.Stdout 79 | func TestPresenters_WithSkippedLibInfoAndMissingRepoInfo(t *testing.T) { 80 | lib := parser.LibInfo{ 81 | Name: "libX", 82 | RepositoryURL: "", 83 | Skip: true, 84 | SkipReason: "Not hosted on Github", 85 | } 86 | 87 | analyzed := []presenter.AnalyzedLibInfo{{LibInfo: &lib, GitHubRepoInfo: nil}} 88 | cases := getTestCases() 89 | 90 | for _, testCase := range cases { 91 | t.Run(testCase.name, func(t *testing.T) { 92 | p := testCase.presenterFunc(analyzed) 93 | output := capturePresenterOutput(p) 94 | assert.Equal(t, testCase.expectedOutput, output) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # stay_or_go 2 | 3 | stay_or_go is a CLI tool that analyzes Go and Ruby dependencies to evaluate their popularity and maintenance status. This tool generates scores to help you decide whether to "Stay" with or "Go" from your dependencies. Results can be output in Markdown, CSV, or TSV formats. 4 | 5 | ![Demo](https://github.com/user-attachments/assets/cbb4c138-fee0-47bc-ae61-afb21897a577) 6 | 7 | [Demo on Youtube](https://www.youtube.com/watch?v=qxMEraHYnmM) 8 | 9 | ## Features 10 | 11 | - Scans Go (`go.mod`) and Ruby (`Gemfile`) dependency files 12 | - Evaluates each library's popularity and maintenance status 13 | - Outputs results in Markdown, CSV, or TSV formats 14 | 15 | ## Installation 16 | 17 | To install this tool, you need a Go environment. Use the following command to install: 18 | 19 | ```bash 20 | go install github.com/uzumaki-inc/stay_or_go@latest 21 | ``` 22 | 23 | ## Usage 24 | 25 | To use stay_or_go, run the following command: 26 | 27 | ```bash 28 | stay_or_go [flags] 29 | ``` 30 | 31 | 32 | ### Flags 33 | 34 | - `-i, --input`: Specify the file to read. 35 | - `-f, --format`: Specify the output format (`csv`, `tsv`, `markdown`). 36 | - `-g, --github-token`: Specify the GitHub token for authentication. 37 | - `-v, --verbose`: Enable verbose output. 38 | - `-c, --config`: Specify a configuration file to modify evaluation parameters. 39 | 40 | ## Examples 41 | 42 | Example of evaluating Go dependencies in Markdown format: 43 | 44 | ```bash 45 | stay_or_go go -g YOUR_GITHUB_TOKEN 46 | ``` 47 | 48 | Example of evaluating Ruby dependencies in CSV format: 49 | 50 | ```bash 51 | stay_or_go ruby -i ./path/to/your/Gemfile -f csv --github-token YOUR_GITHUB_TOKEN 52 | ``` 53 | 54 | ### Using GITHUB_TOKEN Environment Variable 55 | 56 | If the `GITHUB_TOKEN` is set as an environment variable, the `-g` option is not required. You can run the command as follows: 57 | 58 | ```bash 59 | export GITHUB_TOKEN=your_github_token 60 | stay_or_go go 61 | ``` 62 | 63 | ### Custom Parameter File with -c Option 64 | 65 | You can specify a custom parameter file using the `-c` option. The configuration file should be in YAML format. Here is an example configuration: 66 | 67 | ```yaml 68 | watchers: 1 69 | stars: 2 70 | forks: 3 71 | open_pull_requests: 4 72 | open_issues: 5 73 | last_commit_date: -6 74 | archived: -99999 75 | ``` 76 | 77 | To use this configuration file, run the command as follows: 78 | 79 | ```bash 80 | your_command_here -c path/to/your/params.yml 81 | ``` 82 | Adding these examples will help users understand how to use environment variables and custom configuration files effectively. 83 | 84 | 85 | 86 | ## Development 87 | 88 | If you want to contribute to this project, please follow these steps: 89 | 90 | 1. Fork the repository. 91 | 2. Create a new branch. 92 | 3. Commit your changes. 93 | 4. Submit a pull request. 94 | 95 | ## License 96 | 97 | This project is licensed under the MIT License. 98 | 99 | ## Contributors 100 | 101 | - [uzumaki-inc](https://github.com/uzumaki-inc) 102 | 103 | ## Reporting Issues 104 | 105 | If you encounter any issues, please report them on [GitHub Issues](https://github.com/uzumaki-inc/stay_or_go/issues). 106 | -------------------------------------------------------------------------------- /LINT_MIGRATION_TODO.md: -------------------------------------------------------------------------------- 1 | # Option B Lint Migration Plan 2 | 3 | This document outlines concrete, reversible steps to move from Option A (lenient tests) to Option B (strict linting across all code, including tests). 4 | 5 | ## 1) Re-enable gofmt for tests 6 | 7 | - [x] Update `.golangci.yml`: remove `gofmt` from the `_test.go` exclusion list. 8 | - [x] Format code: `gofmt -s -w .` 9 | - [x] Verify: `go run github.com/golangci/golangci-lint/cmd/golangci-lint run` passes. 10 | 11 | ## 2) Reduce complex tests (cyclomatic/cognitive) 12 | 13 | - [x] Split large tests into smaller, focused tests (`presenter/analyzed_libinfo_test.go`). 14 | - [x] Avoid long, chained condition checks; simplified per-assert checks. 15 | - [x] Rename ultra-short variables in tests to self-descriptive names. 16 | - [x] Verify: lints pass, `go test ./...` remains green. 17 | 18 | ## 3) Fix long lines (lll) in tests 19 | 20 | - [x] Break long literals (expected outputs/JSON) into parts (done in `presenter/presenter_test.go`, `parser/go_parser_test.go`). 21 | - [x] Prefer multi-line or split strings for long JSON and tables. 22 | - [x] Remove temporary `//nolint:lll` where feasible. 23 | - [x] Verify: lints pass, behavior unchanged (`golangci-lint run`, `go test ./...`). 24 | 25 | ## 4) Re-enable goimports/gci for tests 26 | 27 | - [x] Update `.golangci.yml`: remove `goimports` and `gci` from the `_test.go` exclusion list. 28 | - [x] Normalize imports in tests (fixed grouping/indent in `presenter/presenter_test.go`, `parser/go_parser_test.go`). 29 | - [x] Verify: `golangci-lint run` passes import checks; `go test ./...` remains green. 30 | 31 | ## 5) Tighten parallel/testpackage rules 32 | 33 | - [ ] Revisit `t.Parallel()` usage: avoid when mutating global state (stdout, env, cwd). 34 | - [ ] For stdout-capturing tests, keep serial; otherwise add `t.Parallel()`. 35 | - [ ] Where possible, move test packages to `*_test` (e.g., `presenter_test`) without breaking access patterns. 36 | - [ ] Replace global env changes with per-test subprocess where safer. 37 | - [ ] Verify: race-safe, lints pass. 38 | 39 | ## 6) Remove temporary suppressions for `cmd/root.go` 40 | 41 | - [x] Update `.golangci.yml`: delete the `cmd/root.go`-specific exclusions (`gci`, `goimports`, `gofmt`, `gofumpt`, `whitespace`, `wsl`, `nlreturn`, `varnamelen`). 42 | - [x] Adjust code style (whitespace) and add targeted function-level nolint for `wsl` to keep readability. 43 | - [x] Keep error wrapping and sentinel errors (err113, wrapcheck) intact. 44 | - [x] Verify: project-wide `golangci-lint run` passes. 45 | 46 | ## 7) Acceptance criteria (for each step) 47 | 48 | - [x] `golangci-lint run` passes locally. 49 | - [x] `go test ./...` passes. 50 | - [x] Coverage stays ≥ 90% for core packages (`analyzer` 91.8%, `parser` 90.4%, `presenter` 97.1%, `cmd` 90.5%). 51 | - [x] No behavior changes: CLI and test outputs remain consistent. 52 | 53 | ## 8) Rollback plan 54 | 55 | - If a step causes friction, temporarily re-add that linter to the `_test.go` exclusion in `.golangci.yml` and open a follow-up task to address root causes. 56 | 57 | ## 9) Suggested command snippets 58 | 59 | - Lint: `go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v` 60 | - Format: `gofmt -s -w .` 61 | - Imports: `goimports -w .` (or configure in your IDE) 62 | - Tests (with local cache): `GOCACHE="$(pwd)/.gocache" go test ./... -v` 63 | -------------------------------------------------------------------------------- /analyzer/github_repo_analyzer_internal_test.go: -------------------------------------------------------------------------------- 1 | package analyzer 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestParseRepoURL_Variants(t *testing.T) { 12 | t.Parallel() 13 | 14 | cases := []struct { 15 | in string 16 | owner string 17 | repo string 18 | }{ 19 | {"https://github.com/user/repo", "user", "repo"}, 20 | {"https://github.com/user/repo/", "user", "repo"}, 21 | {"https://github.com/user/repo.git", "user", "repo"}, 22 | {"https://github.com/user/repo/tree/main", "user", "repo"}, 23 | {"http://github.com/user/repo/tree/main/subdir", "user", "repo"}, 24 | } 25 | 26 | for _, tc := range cases { 27 | o, r := parseRepoURL(tc.in) 28 | if o != tc.owner || r != tc.repo { 29 | t.Fatalf("parseRepoURL(%q) => %s/%s, want %s/%s", tc.in, o, r, tc.owner, tc.repo) 30 | } 31 | } 32 | } 33 | 34 | func TestCalcScore_InvalidDate_SetsSkip(t *testing.T) { 35 | t.Parallel() 36 | 37 | info := &GitHubRepoInfo{LastCommitDate: "invalid-date"} 38 | w := &ParameterWeights{} 39 | 40 | calcScore(info, w) 41 | 42 | if !info.Skip { 43 | t.Fatalf("expected Skip=true when date invalid") 44 | } 45 | 46 | if info.SkipReason == "" { 47 | t.Fatalf("expected SkipReason to be set") 48 | } 49 | } 50 | 51 | func TestCreateRepoInfo_MapsFields(t *testing.T) { 52 | t.Parallel() 53 | 54 | rd := &RepoData{Name: "r", SubscribersCount: 1, StargazersCount: 2, ForksCount: 3, OpenIssuesCount: 4, Archived: true} 55 | gi := createRepoInfo(rd, "2024-01-01T00:00:00Z") 56 | 57 | if gi.RepositoryName != "r" || gi.Watchers != 1 || gi.Stars != 2 || gi.Forks != 3 || gi.OpenIssues != 4 || 58 | gi.Archived != true || gi.LastCommitDate != "2024-01-01T00:00:00Z" { 59 | t.Fatalf("unexpected mapping: %+v", gi) 60 | } 61 | } 62 | 63 | func TestIndexOf(t *testing.T) { 64 | t.Parallel() 65 | 66 | s := []string{"a", "b", "c"} 67 | if indexOf(s, "b") != 1 { 68 | t.Fatalf("want 1") 69 | } 70 | 71 | if indexOf(s, "x") != -1 { 72 | t.Fatalf("want -1") 73 | } 74 | } 75 | 76 | type rtFunc func(*http.Request) (*http.Response, error) 77 | 78 | func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } 79 | 80 | func TestFetchJSONData_Non200AndDecodeError(t *testing.T) { 81 | t.Parallel() 82 | 83 | var out any 84 | 85 | // Non-200 client 86 | client1 := &http.Client{Transport: rtFunc(func(_ *http.Request) (*http.Response, error) { 87 | body := io.NopCloser(strings.NewReader("teapot")) 88 | hdr := make(http.Header) 89 | 90 | return &http.Response{StatusCode: http.StatusTeapot, Body: body, Header: hdr}, nil 91 | })} 92 | err := fetchJSONData(client1, "http://example", nil, &out) 93 | 94 | if !errors.Is(err, ErrUnexpectedStatusCode) { 95 | t.Fatalf("expected ErrUnexpectedStatusCode, got %v", err) 96 | } 97 | 98 | // 200 but invalid JSON 99 | client2 := &http.Client{Transport: rtFunc(func(_ *http.Request) (*http.Response, error) { 100 | body := io.NopCloser(strings.NewReader("not-json")) 101 | hdr := make(http.Header) 102 | 103 | return &http.Response{StatusCode: http.StatusOK, Body: body, Header: hdr}, nil 104 | })} 105 | 106 | err = fetchJSONData(client2, "http://example", nil, &out) 107 | if err == nil { 108 | t.Fatalf("expected decode error") 109 | } 110 | } 111 | 112 | func TestFetchGithubInfo_NoToken_SetsSkip(t *testing.T) { 113 | t.Parallel() 114 | 115 | a := NewGitHubRepoAnalyzer("", NewParameterWeights()) 116 | infos := a.FetchGithubInfo([]string{"https://github.com/user/repo"}) 117 | 118 | if len(infos) != 1 { 119 | t.Fatalf("want 1 info") 120 | } 121 | 122 | if !infos[0].Skip { 123 | t.Fatalf("expected Skip true when token missing") 124 | } 125 | 126 | if infos[0].SkipReason == "" { 127 | t.Fatalf("expected SkipReason set") 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /presenter/analyzed_libinfo_test.go: -------------------------------------------------------------------------------- 1 | package presenter_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/uzumaki-inc/stay_or_go/analyzer" 9 | "github.com/uzumaki-inc/stay_or_go/parser" 10 | "github.com/uzumaki-inc/stay_or_go/presenter" 11 | ) 12 | 13 | func TestAnalyzedLibInfo_EmptyLibInfo_NoRepoInfo(t *testing.T) { 14 | t.Parallel() 15 | 16 | info := presenter.AnalyzedLibInfo{LibInfo: &parser.LibInfo{Skip: true, SkipReason: "li"}, GitHubRepoInfo: nil} 17 | 18 | assert.Nil(t, info.Name()) 19 | assert.Nil(t, info.RepositoryURL()) 20 | assert.Nil(t, info.Watchers()) 21 | assert.Nil(t, info.Stars()) 22 | assert.Nil(t, info.Forks()) 23 | assert.Nil(t, info.OpenIssues()) 24 | assert.Nil(t, info.LastCommitDate()) 25 | assert.Nil(t, info.GithubRepoURL()) 26 | assert.Nil(t, info.Archived()) 27 | assert.Nil(t, info.Score()) 28 | 29 | if info.Skip() == nil { 30 | t.Fatalf("skip pointer is nil") 31 | } 32 | 33 | assert.True(t, *info.Skip()) 34 | 35 | if v := info.SkipReason(); v == nil { 36 | t.Fatalf("skip reason nil") 37 | } else { 38 | assert.Equal(t, "li", *v) 39 | } 40 | } 41 | 42 | func TestAnalyzedLibInfo_WithValues_AllGetters(t *testing.T) { 43 | t.Parallel() 44 | 45 | lib := parser.LibInfo{Name: "lib", RepositoryURL: "https://github.com/x/y"} 46 | repo := analyzer.GitHubRepoInfo{ 47 | Watchers: 1, 48 | Stars: 2, 49 | Forks: 3, 50 | OpenIssues: 4, 51 | LastCommitDate: "2024-01-01T00:00:00Z", 52 | GithubRepoURL: "https://github.com/x/y", 53 | Archived: true, 54 | Score: 42, 55 | } 56 | info := presenter.AnalyzedLibInfo{LibInfo: &lib, GitHubRepoInfo: &repo} 57 | 58 | assert.NotNil(t, info.Name()) 59 | assert.Equal(t, "lib", *info.Name()) 60 | assert.NotNil(t, info.RepositoryURL()) 61 | assert.Equal(t, "https://github.com/x/y", *info.RepositoryURL()) 62 | assert.NotNil(t, info.Watchers()) 63 | assert.Equal(t, 1, *info.Watchers()) 64 | assert.NotNil(t, info.Stars()) 65 | assert.Equal(t, 2, *info.Stars()) 66 | assert.NotNil(t, info.Forks()) 67 | assert.Equal(t, 3, *info.Forks()) 68 | assert.NotNil(t, info.OpenIssues()) 69 | assert.Equal(t, 4, *info.OpenIssues()) 70 | assert.NotNil(t, info.LastCommitDate()) 71 | assert.Equal(t, "2024-01-01T00:00:00Z", *info.LastCommitDate()) 72 | assert.NotNil(t, info.GithubRepoURL()) 73 | assert.Equal(t, "https://github.com/x/y", *info.GithubRepoURL()) 74 | assert.NotNil(t, info.Archived()) 75 | assert.True(t, *info.Archived()) 76 | assert.NotNil(t, info.Score()) 77 | assert.Equal(t, 42, *info.Score()) 78 | assert.NotNil(t, info.Skip()) 79 | assert.False(t, *info.Skip()) 80 | assert.Nil(t, info.SkipReason()) 81 | } 82 | 83 | func TestAnalyzedLibInfo_SkipReason_FromLibInfo(t *testing.T) { 84 | t.Parallel() 85 | 86 | lib := parser.LibInfo{Name: "lib3", Skip: true, SkipReason: "reason"} 87 | repo := analyzer.GitHubRepoInfo{Score: 1} 88 | info := presenter.AnalyzedLibInfo{LibInfo: &lib, GitHubRepoInfo: &repo} 89 | 90 | if v := info.Skip(); v == nil { 91 | t.Fatalf("skip nil") 92 | } else { 93 | assert.True(t, *v) 94 | } 95 | 96 | if v := info.SkipReason(); v == nil { 97 | t.Fatalf("reason nil") 98 | } else { 99 | assert.Equal(t, "reason", *v) 100 | } 101 | } 102 | 103 | func TestAnalyzedLibInfo_SkipReason_FromRepoInfo(t *testing.T) { 104 | t.Parallel() 105 | 106 | lib := parser.LibInfo{Name: "lib"} 107 | repo := analyzer.GitHubRepoInfo{Skip: true, SkipReason: "repo-reason"} 108 | info := presenter.AnalyzedLibInfo{LibInfo: &lib, GitHubRepoInfo: &repo} 109 | 110 | if v := info.Skip(); v == nil { 111 | t.Fatalf("skip nil") 112 | } else { 113 | assert.True(t, *v) 114 | } 115 | 116 | if v := info.SkipReason(); v == nil { 117 | t.Fatalf("reason nil") 118 | } else { 119 | assert.Equal(t, "repo-reason", *v) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /presenter/presenter.go: -------------------------------------------------------------------------------- 1 | package presenter 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/uzumaki-inc/stay_or_go/analyzer" 8 | "github.com/uzumaki-inc/stay_or_go/parser" 9 | "github.com/uzumaki-inc/stay_or_go/utils" 10 | ) 11 | 12 | type AnalyzedLibInfo struct { 13 | LibInfo *parser.LibInfo 14 | GitHubRepoInfo *analyzer.GitHubRepoInfo 15 | } 16 | 17 | func (ainfo AnalyzedLibInfo) Name() *string { 18 | if ainfo.LibInfo.Name != "" { 19 | return &ainfo.LibInfo.Name 20 | } 21 | 22 | return nil 23 | } 24 | 25 | func (ainfo AnalyzedLibInfo) RepositoryURL() *string { 26 | if ainfo.LibInfo.RepositoryURL != "" { 27 | return &ainfo.LibInfo.RepositoryURL 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (ainfo AnalyzedLibInfo) Watchers() *int { 34 | if ainfo.GitHubRepoInfo != nil { 35 | return &ainfo.GitHubRepoInfo.Watchers 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func (ainfo AnalyzedLibInfo) Stars() *int { 42 | if ainfo.GitHubRepoInfo != nil { 43 | return &ainfo.GitHubRepoInfo.Stars 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func (ainfo AnalyzedLibInfo) Forks() *int { 50 | if ainfo.GitHubRepoInfo != nil { 51 | return &ainfo.GitHubRepoInfo.Forks 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (ainfo AnalyzedLibInfo) OpenIssues() *int { 58 | if ainfo.GitHubRepoInfo != nil { 59 | return &ainfo.GitHubRepoInfo.OpenIssues 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (ainfo AnalyzedLibInfo) LastCommitDate() *string { 66 | if ainfo.GitHubRepoInfo != nil { 67 | return &ainfo.GitHubRepoInfo.LastCommitDate 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (ainfo AnalyzedLibInfo) GithubRepoURL() *string { 74 | if ainfo.GitHubRepoInfo != nil { 75 | return &ainfo.GitHubRepoInfo.GithubRepoURL 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (ainfo AnalyzedLibInfo) Archived() *bool { 82 | if ainfo.GitHubRepoInfo != nil { 83 | return &ainfo.GitHubRepoInfo.Archived 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (ainfo AnalyzedLibInfo) Score() *int { 90 | if ainfo.GitHubRepoInfo != nil { 91 | return &ainfo.GitHubRepoInfo.Score 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func (ainfo AnalyzedLibInfo) Skip() *bool { 98 | trueValue := true 99 | falseValue := false 100 | 101 | if ainfo.LibInfo.Skip { 102 | return &trueValue 103 | } else if ainfo.GitHubRepoInfo.Skip { 104 | return &trueValue 105 | } 106 | 107 | return &falseValue 108 | } 109 | 110 | func (ainfo AnalyzedLibInfo) SkipReason() *string { 111 | if ainfo.LibInfo.Skip { 112 | return &ainfo.LibInfo.SkipReason 113 | } else if ainfo.GitHubRepoInfo.Skip { 114 | return &ainfo.GitHubRepoInfo.SkipReason 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func MakeAnalyzedLibInfoList( 121 | libInfoList []parser.LibInfo, 122 | gitHubRepoInfos []analyzer.GitHubRepoInfo, 123 | ) []AnalyzedLibInfo { 124 | analyzedLibInfos := make([]AnalyzedLibInfo, 0, len(libInfoList)) 125 | 126 | repoIndex := 0 127 | 128 | for idx := range libInfoList { 129 | analyzedLibInfo := AnalyzedLibInfo{ 130 | LibInfo: &libInfoList[idx], 131 | GitHubRepoInfo: nil, 132 | } 133 | 134 | if repoIndex < len(gitHubRepoInfos) && libInfoList[idx].RepositoryURL == gitHubRepoInfos[repoIndex].GithubRepoURL { 135 | analyzedLibInfo.GitHubRepoInfo = &gitHubRepoInfos[repoIndex] 136 | repoIndex++ 137 | } 138 | 139 | analyzedLibInfos = append(analyzedLibInfos, analyzedLibInfo) 140 | } 141 | 142 | return analyzedLibInfos 143 | } 144 | 145 | type Presenter interface { 146 | Display() 147 | makeHeader() []string 148 | makeBody() []string 149 | } 150 | 151 | func Display(p Presenter) { 152 | header := p.makeHeader() 153 | body := p.makeBody() 154 | 155 | for _, line := range header { 156 | fmt.Println(line) 157 | } 158 | 159 | for _, line := range body { 160 | fmt.Println(line) 161 | } 162 | } 163 | 164 | func makeBody(analyzedLibInfos []AnalyzedLibInfo, separator string) []string { 165 | rows := []string{} 166 | 167 | for _, info := range analyzedLibInfos { 168 | row := makeRow(info, separator) 169 | rows = append(rows, row) 170 | } 171 | 172 | return rows 173 | } 174 | 175 | func makeRow(info AnalyzedLibInfo, separator string) string { 176 | row := "" 177 | val := reflect.ValueOf(info) 178 | 179 | if val.Kind() == reflect.Ptr { 180 | val = val.Elem() 181 | } 182 | 183 | for index, header := range headerString { 184 | cellValue := getCellValue(val, header, info) 185 | row += cellValue 186 | 187 | // 最後の要素でない場合にのみseparatorを追加 188 | if index < len(headerString)-1 { 189 | row += separator 190 | } 191 | } 192 | 193 | if separator == "|" { 194 | row = "|" + row + "|" 195 | } 196 | 197 | return row 198 | } 199 | 200 | func getCellValue(val reflect.Value, header string, info AnalyzedLibInfo) string { 201 | method := val.MethodByName(header) 202 | 203 | if !method.IsValid() { 204 | utils.StdErrorPrintln("method %s not found in %v", header, info) 205 | 206 | return "N/A" 207 | } 208 | 209 | result := method.Call(nil) 210 | 211 | if len(result) == 0 || !result[0].IsValid() || result[0].IsNil() { 212 | return "N/A" 213 | } 214 | 215 | return fmt.Sprintf("%v", result[0].Elem().Interface()) 216 | } 217 | 218 | var headerString = []string{ 219 | "Name", 220 | "RepositoryURL", 221 | "Watchers", 222 | "Stars", 223 | "Forks", 224 | "OpenIssues", 225 | "LastCommitDate", 226 | "Archived", 227 | "Score", 228 | "Skip", 229 | "SkipReason", 230 | } 231 | 232 | func SelectPresenter(format string, analyzedLibInfos []AnalyzedLibInfo) Presenter { 233 | switch format { 234 | case "tsv": 235 | return TsvPresenter{analyzedLibInfos} 236 | case "csv": 237 | return CsvPresenter{analyzedLibInfos} 238 | default: 239 | return MarkdownPresenter{analyzedLibInfos} 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /parser/ruby_parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "regexp" 13 | "strings" 14 | "time" 15 | 16 | "github.com/uzumaki-inc/stay_or_go/utils" 17 | ) 18 | 19 | type RubyParser struct{} 20 | 21 | type RubyRepository struct { 22 | SourceCodeURI string `json:"source_code_uri"` 23 | HomepageURI string `json:"homepage_uri"` 24 | } 25 | 26 | // Parse メソッド 27 | func (p RubyParser) Parse(filePath string) ([]LibInfo, error) { 28 | lines, err := p.readLines(filePath) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | var libs []LibInfo 34 | 35 | inOtherBlock := false 36 | 37 | for _, line := range lines { 38 | if p.isOtherBlockStart(line) { 39 | inOtherBlock = true 40 | 41 | continue 42 | } 43 | 44 | if p.isBlockEnd(line) { 45 | inOtherBlock = false 46 | 47 | continue 48 | } 49 | 50 | // gem を解析 51 | if gemName := p.extractGemName(line); gemName != "" { 52 | isNgGem := p.containsInvalidKeywords(line) 53 | lib := p.createLibInfo(gemName, isNgGem, inOtherBlock) 54 | libs = append(libs, lib) 55 | } 56 | } 57 | 58 | return libs, nil 59 | } 60 | 61 | func (p RubyParser) GetRepositoryURL(libInfoList []LibInfo) []LibInfo { 62 | client := &http.Client{} 63 | 64 | for i := range libInfoList { 65 | // ポインタを取得 66 | libInfo := &libInfoList[i] 67 | name := libInfo.Name 68 | 69 | if libInfo.Skip { 70 | continue 71 | } 72 | 73 | repoURL, err := p.getGitHubRepositoryURL(client, name) 74 | if err != nil { 75 | libInfo.Skip = true 76 | libInfo.SkipReason = "Does not support libraries hosted outside of Github" 77 | 78 | utils.StdErrorPrintln("%s does not support libraries hosted outside of Github: %s", name, err) 79 | 80 | continue 81 | } 82 | 83 | libInfo.RepositoryURL = repoURL 84 | } 85 | 86 | return libInfoList 87 | } 88 | 89 | // ファイルの内容を行ごとに読み取る 90 | func (p RubyParser) readLines(filePath string) ([]string, error) { 91 | file, err := os.Open(filePath) 92 | if err != nil { 93 | return nil, fmt.Errorf("%w: %w", ErrFiledToOpenFile, err) 94 | } 95 | defer file.Close() 96 | 97 | var lines []string 98 | 99 | scanner := bufio.NewScanner(file) 100 | for scanner.Scan() { 101 | lines = append(lines, strings.TrimSpace(scanner.Text())) 102 | } 103 | 104 | err = scanner.Err() 105 | if err != nil { 106 | return nil, fmt.Errorf("%w: %w", ErrFailedToReadFile, err) 107 | } 108 | 109 | return lines, nil 110 | } 111 | 112 | // その他のブロックの開始か判定 113 | func (p RubyParser) isOtherBlockStart(line string) bool { 114 | sourceStartRegex := regexp.MustCompile(`source\s+['"].+['"]\s+do`) 115 | platformsStartRegex := regexp.MustCompile(`platforms\s+[:\w,]+\s+do`) 116 | installIfStartRegex := regexp.MustCompile(`install_if\s+->\s+\{.*\}\s+do`) 117 | 118 | return sourceStartRegex.MatchString(line) || 119 | platformsStartRegex.MatchString(line) || 120 | installIfStartRegex.MatchString(line) 121 | } 122 | 123 | // ブロックの終了か判定 124 | func (p RubyParser) isBlockEnd(line string) bool { 125 | endRegex := regexp.MustCompile(`^end$`) 126 | 127 | return endRegex.MatchString(line) 128 | } 129 | 130 | // gem 名を抽出 131 | func (p RubyParser) extractGemName(line string) string { 132 | gemRegex := regexp.MustCompile(`gem ['"]([^'"]+)['"]`) 133 | 134 | if matches := gemRegex.FindStringSubmatch(line); matches != nil { 135 | return matches[1] 136 | } 137 | 138 | return "" 139 | } 140 | 141 | func (p RubyParser) containsInvalidKeywords(line string) bool { 142 | // カンマ区切りで分割 143 | parts := strings.Split(line, ",") 144 | 145 | // 判定するキーワード 146 | ngKeywords := []string{"source", "git", "github"} 147 | 148 | // 2番目以降をチェック 149 | for _, part := range parts[1:] { 150 | trimmedPart := strings.TrimSpace(part) 151 | for _, keyword := range ngKeywords { 152 | if strings.Contains(trimmedPart, keyword) { 153 | return true 154 | } 155 | } 156 | } 157 | 158 | return false 159 | } 160 | 161 | func (p RubyParser) createLibInfo(gemName string, isNgGem bool, inOtherBlock bool) LibInfo { 162 | lib := LibInfo{Name: gemName} 163 | if isNgGem { 164 | lib.Skip = true 165 | lib.SkipReason = "Not hosted on Github" 166 | } else if inOtherBlock { 167 | lib.Skip = true 168 | lib.SkipReason = "Not hosted on Github" 169 | } 170 | 171 | return lib 172 | } 173 | 174 | func (p RubyParser) getGitHubRepositoryURL(client *http.Client, name string) (string, error) { 175 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second) 176 | defer cancel() 177 | 178 | baseURL := "https://rubygems.org/api/v1/gems/" 179 | repoURL := baseURL + name + ".json" 180 | utils.DebugPrintln("Fetching: " + repoURL) 181 | 182 | parsedURL, err := url.Parse(repoURL) 183 | if err != nil { 184 | return "", ErrFailedToGetRepository 185 | } 186 | 187 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) 188 | if err != nil { 189 | return "", ErrFailedToGetRepository 190 | } 191 | 192 | response, err := client.Do(req) 193 | if err != nil { 194 | return "", ErrFailedToGetRepository 195 | } 196 | defer response.Body.Close() 197 | 198 | if response.StatusCode != http.StatusOK { 199 | return "", ErrNotAGitHubRepository 200 | } 201 | 202 | bodyBytes, err := io.ReadAll(response.Body) 203 | if err != nil { 204 | return "", ErrFailedToReadResponseBody 205 | } 206 | 207 | var repo RubyRepository 208 | 209 | err = json.Unmarshal(bodyBytes, &repo) 210 | if err != nil { 211 | return "", ErrFailedToUnmarshalJSON 212 | } 213 | 214 | repoURLfromRubyGems := repo.SourceCodeURI 215 | 216 | if repoURLfromRubyGems == "" { 217 | repoURLfromRubyGems = repo.HomepageURI 218 | } 219 | 220 | if repoURLfromRubyGems == "" || !strings.Contains(repoURLfromRubyGems, "github.com") { 221 | return "", ErrNotAGitHubRepository 222 | } 223 | 224 | return repoURLfromRubyGems, nil 225 | } 226 | -------------------------------------------------------------------------------- /cmd/run_unit_test.go: -------------------------------------------------------------------------------- 1 | //nolint:testpackage // Tests unexported functions 2 | package cmd 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/uzumaki-inc/stay_or_go/analyzer" 10 | "github.com/uzumaki-inc/stay_or_go/parser" 11 | "github.com/uzumaki-inc/stay_or_go/presenter" 12 | ) 13 | 14 | // Stubs 15 | type stubAnalyzer struct{ called bool } 16 | 17 | func (s *stubAnalyzer) FetchGithubInfo(_ []string) []analyzer.GitHubRepoInfo { 18 | s.called = true 19 | 20 | return []analyzer.GitHubRepoInfo{{GithubRepoURL: "https://github.com/u/a"}} 21 | } 22 | 23 | type recorderParser struct { 24 | lastFile string 25 | list []parser.LibInfo 26 | } 27 | 28 | func (r *recorderParser) Parse(file string) ([]parser.LibInfo, error) { 29 | r.lastFile = file 30 | 31 | return r.list, nil 32 | } 33 | func (r *recorderParser) GetRepositoryURL(list []parser.LibInfo) []parser.LibInfo { return list } 34 | 35 | type recorderPresenter struct{ displayed bool } 36 | 37 | func (r *recorderPresenter) Display() { r.displayed = true } 38 | 39 | func TestRun_Success_Go_DefaultFile(t *testing.T) { 40 | t.Parallel() 41 | // Prepare deps 42 | recParser := &recorderParser{list: []parser.LibInfo{{Name: "a", RepositoryURL: "https://github.com/u/a"}}} 43 | stubAnal := &stubAnalyzer{} 44 | recPresenter := &recorderPresenter{} 45 | 46 | deps := Deps{ 47 | NewAnalyzer: func(_ string, _ analyzer.ParameterWeights) AnalyzerPort { return stubAnal }, 48 | SelectParser: func(_ string) (parser.Parser, error) { return recParser, nil }, 49 | SelectPresenter: func(_ string, _ []presenter.AnalyzedLibInfo) PresenterPort { return recPresenter }, 50 | } 51 | 52 | // Unset env to ensure token from argument is used 53 | _ = os.Unsetenv("GITHUB_TOKEN") 54 | 55 | err := run("go", "", "markdown", "tok", "", false, deps) 56 | if err != nil { 57 | t.Fatalf("unexpected error: %v", err) 58 | } 59 | 60 | if recParser.lastFile != "go.mod" { 61 | t.Fatalf("expected default go.mod, got %q", recParser.lastFile) 62 | } 63 | 64 | if !stubAnal.called { 65 | t.Fatalf("expected analyzer called") 66 | } 67 | 68 | if !recPresenter.displayed { 69 | t.Fatalf("expected presenter.Display called") 70 | } 71 | } 72 | 73 | func TestRun_Success_Ruby_DefaultFile(t *testing.T) { 74 | t.Parallel() 75 | 76 | recParser := &recorderParser{list: []parser.LibInfo{{Name: "a", RepositoryURL: "https://github.com/u/a"}}} 77 | stubAnal := &stubAnalyzer{} 78 | recPresenter := &recorderPresenter{} 79 | 80 | deps := Deps{ 81 | NewAnalyzer: func(_ string, _ analyzer.ParameterWeights) AnalyzerPort { return stubAnal }, 82 | SelectParser: func(_ string) (parser.Parser, error) { return recParser, nil }, 83 | SelectPresenter: func(_ string, _ []presenter.AnalyzedLibInfo) PresenterPort { return recPresenter }, 84 | } 85 | _ = os.Unsetenv("GITHUB_TOKEN") 86 | 87 | err := run("ruby", "", "markdown", "tok", "", false, deps) 88 | if err != nil { 89 | t.Fatalf("unexpected error: %v", err) 90 | } 91 | 92 | if recParser.lastFile != "Gemfile" { 93 | t.Fatalf("expected default Gemfile, got %q", recParser.lastFile) 94 | } 95 | } 96 | 97 | func TestRun_SkipAll_DoesNotCallAnalyzer(t *testing.T) { 98 | t.Parallel() 99 | 100 | recParser := &recorderParser{list: []parser.LibInfo{{Name: "a", Skip: true, SkipReason: "skip"}}} 101 | called := false 102 | recPresenter := &recorderPresenter{} 103 | 104 | deps := Deps{ 105 | NewAnalyzer: func(_ string, _ analyzer.ParameterWeights) AnalyzerPort { return &stubAnalyzer{called: true} }, 106 | SelectParser: func(_ string) (parser.Parser, error) { return recParser, nil }, 107 | SelectPresenter: func(_ string, _ []presenter.AnalyzedLibInfo) PresenterPort { return recPresenter }, 108 | } 109 | 110 | // Wrap NewAnalyzer to detect if it's used later via FetchGithubInfo 111 | deps.NewAnalyzer = func(_ string, _ analyzer.ParameterWeights) AnalyzerPort { 112 | return AnalyzerPort(rtFuncAnalyzer(func(_ []string) []analyzer.GitHubRepoInfo { 113 | called = true 114 | 115 | return nil 116 | })) 117 | } 118 | 119 | _ = os.Unsetenv("GITHUB_TOKEN") 120 | 121 | err := run("go", "", "markdown", "tok", "", false, deps) 122 | if err != nil { 123 | t.Fatalf("unexpected: %v", err) 124 | } 125 | 126 | if called { 127 | t.Fatalf("analyzer should not be called when all skipped") 128 | } 129 | 130 | if !recPresenter.displayed { 131 | t.Fatalf("presenter should be called even when skipped") 132 | } 133 | } 134 | 135 | // Analyzer adapter via function for testing 136 | type rtFuncAnalyzer func([]string) []analyzer.GitHubRepoInfo 137 | 138 | func (f rtFuncAnalyzer) FetchGithubInfo(urls []string) []analyzer.GitHubRepoInfo { return f(urls) } 139 | 140 | func TestRun_UnsupportedAndFormatAndTokenErrors(t *testing.T) { 141 | t.Parallel() 142 | 143 | deps := Deps{} 144 | 145 | err := run("python", "", "markdown", "tok", "", false, deps) 146 | if err == nil { 147 | t.Fatalf("expected unsupported language error") 148 | } 149 | 150 | err = run("go", "", "json", "tok", "", false, deps) 151 | if err == nil { 152 | t.Fatalf("expected unsupported format error") 153 | } 154 | 155 | _ = os.Unsetenv("GITHUB_TOKEN") 156 | 157 | err = run("go", "", "markdown", "", "", false, deps) 158 | if err == nil { 159 | t.Fatalf("expected missing token error") 160 | } 161 | } 162 | 163 | func TestRun_WithConfigFileBranch(t *testing.T) { 164 | t.Parallel() 165 | dir := t.TempDir() 166 | cfg := filepath.Join(dir, "weights.yml") 167 | 168 | err := os.WriteFile(cfg, []byte("watchers: 1\n"), 0o600) 169 | if err != nil { 170 | t.Fatal(err) 171 | } 172 | 173 | recParser := &recorderParser{list: []parser.LibInfo{{Name: "a", RepositoryURL: "https://github.com/u/a"}}} 174 | recPresenter := &recorderPresenter{} 175 | 176 | deps := Deps{ 177 | NewAnalyzer: func(_ string, _ analyzer.ParameterWeights) AnalyzerPort { return &stubAnalyzer{} }, 178 | SelectParser: func(_ string) (parser.Parser, error) { return recParser, nil }, 179 | SelectPresenter: func(_ string, _ []presenter.AnalyzedLibInfo) PresenterPort { return recPresenter }, 180 | } 181 | _ = os.Unsetenv("GITHUB_TOKEN") 182 | 183 | err = run("go", "", "markdown", "tok", cfg, false, deps) 184 | if err != nil { 185 | t.Fatalf("unexpected: %v", err) 186 | } 187 | 188 | if !recPresenter.displayed { 189 | t.Fatalf("expected presenter called") 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /analyzer/github_repo_analyzer_test.go: -------------------------------------------------------------------------------- 1 | package analyzer_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/jarcoal/httpmock" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/uzumaki-inc/stay_or_go/analyzer" 11 | ) 12 | 13 | //nolint:paralleltest // httpmock uses global state, cannot run in parallel 14 | func TestFetchGithubInfo(t *testing.T) { 15 | // httpmockを有効化 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | // モックレスポンスを設定 20 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo", 21 | httpmock.NewStringResponder(200, `{ 22 | "name": "example-repo", 23 | "subscribers_count": 10, 24 | "stargazers_count": 50, 25 | "forks_count": 5, 26 | "open_issues_count": 3, 27 | "archived": false, 28 | "default_branch": "main" 29 | }`)) 30 | 31 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo/pulls", 32 | httpmock.NewStringResponder(200, `[]`)) // 空のプルリクエストリスト 33 | 34 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo/commits/main", 35 | httpmock.NewStringResponder(200, `{ 36 | "commit": { 37 | "committer": { 38 | "date": "2023-10-01T12:00:00Z" 39 | } 40 | } 41 | }`)) 42 | 43 | // テスト用のGitHubRepoAnalyzerを作成 44 | analyzer := analyzer.NewGitHubRepoAnalyzer("dummy-token", analyzer.ParameterWeights{ 45 | Forks: 1.0, 46 | OpenIssues: 1.0, 47 | LastCommitDate: 1.0, 48 | Archived: 1.0, 49 | }) 50 | 51 | // テスト実行 52 | repoURLs := []string{"https://github.com/example-owner/example-repo"} 53 | repoInfos := analyzer.FetchGithubInfo(repoURLs) 54 | 55 | assert.Len(t, repoInfos, 1, "Expected 1 repo info") 56 | 57 | repoInfo := repoInfos[0] 58 | 59 | assert.Equal(t, "example-repo", repoInfo.RepositoryName, "RepositoryName mismatch") 60 | assert.Equal(t, 10, repoInfo.Watchers, "Watchers mismatch") 61 | assert.Equal(t, 50, repoInfo.Stars, "Stars mismatch") 62 | assert.Equal(t, 5, repoInfo.Forks, "Forks mismatch") 63 | assert.Equal(t, 3, repoInfo.OpenIssues, "OpenIssues mismatch") 64 | assert.False(t, repoInfo.Archived, "Archived should be false") 65 | assert.False(t, repoInfo.Skip, "Skip should be false") 66 | } 67 | 68 | //nolint:paralleltest // httpmock uses global state, cannot run in parallel 69 | func TestScoreIncludesWatchers(t *testing.T) { 70 | httpmock.Activate() 71 | defer httpmock.DeactivateAndReset() 72 | 73 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo", 74 | httpmock.NewStringResponder(200, `{ 75 | "name": "example-repo", 76 | "subscribers_count": 10, 77 | "stargazers_count": 50, 78 | "forks_count": 5, 79 | "open_issues_count": 3, 80 | "archived": false, 81 | "default_branch": "main" 82 | }`)) 83 | 84 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo/pulls", 85 | httpmock.NewStringResponder(200, `[]`)) // 空のプルリクエストリスト 86 | 87 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/example-owner/example-repo/commits/main", 88 | httpmock.NewStringResponder(200, `{ 89 | "commit": { 90 | "committer": { 91 | "date": "2023-10-01T12:00:00Z" 92 | } 93 | } 94 | }`)) 95 | 96 | analyzer := analyzer.NewGitHubRepoAnalyzer("dummy-token", analyzer.ParameterWeights{ 97 | Watchers: 2.0, 98 | Stars: 0.0, 99 | Forks: 0.0, 100 | OpenIssues: 0.0, 101 | LastCommitDate: 0.0, 102 | Archived: 0.0, 103 | }) 104 | 105 | repoInfos := analyzer.FetchGithubInfo([]string{"https://github.com/example-owner/example-repo"}) 106 | 107 | assert.Len(t, repoInfos, 1) 108 | assert.Equal(t, 20, repoInfos[0].Score) 109 | } 110 | 111 | //nolint:paralleltest // httpmock uses global state, cannot run in parallel 112 | func TestFetchGithubInfo_ConcurrentProcessingMaintainsOrder(t *testing.T) { 113 | httpmock.Activate() 114 | defer httpmock.DeactivateAndReset() 115 | 116 | // 複数のリポジトリをセットアップ(順序を確認するため) 117 | repoURLs := []string{ 118 | "https://github.com/owner/repo1", 119 | "https://github.com/owner/repo2", 120 | "https://github.com/owner/repo3", 121 | "https://github.com/owner/repo4", 122 | "https://github.com/owner/repo5", 123 | } 124 | 125 | // 各リポジトリに対してモックレスポンスを設定 126 | for idx := range repoURLs { 127 | repoName := fmt.Sprintf("repo%d", idx+1) 128 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/"+repoName, 129 | httpmock.NewStringResponder(200, fmt.Sprintf(`{ 130 | "name": "%s", 131 | "subscribers_count": %d, 132 | "stargazers_count": %d, 133 | "forks_count": %d, 134 | "open_issues_count": %d, 135 | "archived": false, 136 | "default_branch": "main" 137 | }`, repoName, idx+1, (idx+1)*10, (idx+1)*2, (idx+1)*3))) 138 | 139 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/"+repoName+"/pulls", 140 | httpmock.NewStringResponder(200, `[]`)) // 空のプルリクエストリスト 141 | 142 | httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/"+repoName+"/commits/main", 143 | httpmock.NewStringResponder(200, `{ 144 | "commit": { 145 | "committer": { 146 | "date": "2023-10-01T12:00:00Z" 147 | } 148 | } 149 | }`)) 150 | } 151 | 152 | analyzer := analyzer.NewGitHubRepoAnalyzer("dummy-token", analyzer.ParameterWeights{ 153 | Watchers: 0.1, 154 | Stars: 0.1, 155 | Forks: 0.1, 156 | OpenIssues: 0.01, 157 | LastCommitDate: -0.05, 158 | Archived: -1000000, 159 | }) 160 | 161 | // 並列処理でも順序が維持されることをテスト 162 | repoInfos := analyzer.FetchGithubInfo(repoURLs) 163 | 164 | assert.Len(t, repoInfos, 5, "Expected 5 repo infos") 165 | 166 | // 順序が入力と同じであることを確認 167 | for i, repoInfo := range repoInfos { 168 | expectedName := fmt.Sprintf("repo%d", i+1) 169 | assert.Equal(t, expectedName, repoInfo.RepositoryName, 170 | "Repository order not maintained at index %d", i) 171 | assert.Equal(t, repoURLs[i], repoInfo.GithubRepoURL, 172 | "URL order not maintained at index %d", i) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /presenter/presenter_test.go: -------------------------------------------------------------------------------- 1 | package presenter_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/uzumaki-inc/stay_or_go/analyzer" 11 | "github.com/uzumaki-inc/stay_or_go/parser" 12 | "github.com/uzumaki-inc/stay_or_go/presenter" 13 | ) 14 | 15 | // Disable parallel testing to test standard output 16 | // 17 | //nolint:paralleltest,funlen // Test manipulates os.Stdout, complex test cases 18 | func TestDisplay(t *testing.T) { 19 | // Avoid running in parallel since this test manipulates os.Stdout 20 | testCases := []struct { 21 | name string 22 | presenterFunc func([]presenter.AnalyzedLibInfo) presenter.Presenter 23 | expectedOutput string 24 | }{ 25 | { 26 | name: "MarkDown Presenter", 27 | presenterFunc: func(analyzedLibInfos []presenter.AnalyzedLibInfo) presenter.Presenter { 28 | return presenter.NewMarkdownPresenter(analyzedLibInfos) 29 | }, 30 | 31 | //nolint:lll 32 | expectedOutput: "| Name | RepositoryURL | Watchers | Stars | Forks | OpenIssues | LastCommitDate | Archived | Score | Skip | SkipReason |\n" + 33 | "| ---- | ------------- | -------- | ----- | ----- | ---------- | -------------- | -------- | ----- | ---- | ---------- |\n" + 34 | "|lib1|https://github.com/lib1|100|200|50|10|2023-10-10|false|85|false|N/A|\n" + 35 | "|lib2|https://github.com/lib2|150|250|60|15|2023-10-11|false|90|false|N/A|\n", 36 | }, 37 | { 38 | name: "TSV Presenter", 39 | presenterFunc: func(analyzedLibInfos []presenter.AnalyzedLibInfo) presenter.Presenter { 40 | return presenter.NewTsvPresenter(analyzedLibInfos) 41 | }, 42 | //nolint:lll 43 | expectedOutput: "Name\tRepositoryURL\tWatchers\tStars\tForks\tOpenIssues\tLastCommitDate\tArchived\tScore\tSkip\tSkipReason\n" + 44 | "lib1\thttps://github.com/lib1\t100\t200\t50\t10\t2023-10-10\tfalse\t85\tfalse\tN/A\n" + 45 | "lib2\thttps://github.com/lib2\t150\t250\t60\t15\t2023-10-11\tfalse\t90\tfalse\tN/A\n", 46 | }, 47 | { 48 | name: "CSV Presenter", 49 | presenterFunc: func(analyzedLibInfos []presenter.AnalyzedLibInfo) presenter.Presenter { 50 | return presenter.NewCsvPresenter(analyzedLibInfos) 51 | }, 52 | //nolint:lll 53 | expectedOutput: "Name, RepositoryURL, Watchers, Stars, Forks, OpenIssues, LastCommitDate, Archived, Score, Skip, SkipReason\n" + 54 | "lib1, https://github.com/lib1, 100, 200, 50, 10, 2023-10-10, false, 85, false, N/A\n" + 55 | "lib2, https://github.com/lib2, 150, 250, 60, 15, 2023-10-11, false, 90, false, N/A\n", 56 | }, 57 | } 58 | 59 | //nolint:paralleltest 60 | for _, testCase := range testCases { 61 | t.Run(testCase.name, func(t *testing.T) { 62 | libInfo1 := parser.LibInfo{Name: "lib1", RepositoryURL: "https://github.com/lib1"} 63 | repoInfo1 := analyzer.GitHubRepoInfo{ 64 | RepositoryName: "lib1", Watchers: 100, Stars: 200, Forks: 50, 65 | OpenIssues: 10, LastCommitDate: "2023-10-10", Archived: false, Score: 85, 66 | } 67 | libInfo2 := parser.LibInfo{Name: "lib2", RepositoryURL: "https://github.com/lib2"} 68 | repoInfo2 := analyzer.GitHubRepoInfo{ 69 | RepositoryName: "lib2", Watchers: 150, Stars: 250, Forks: 60, 70 | OpenIssues: 15, LastCommitDate: "2023-10-11", Archived: false, Score: 90, 71 | } 72 | 73 | analyzedLibInfos := []presenter.AnalyzedLibInfo{ 74 | {LibInfo: &libInfo1, GitHubRepoInfo: &repoInfo1}, 75 | {LibInfo: &libInfo2, GitHubRepoInfo: &repoInfo2}, 76 | } 77 | 78 | presenter := testCase.presenterFunc(analyzedLibInfos) 79 | 80 | // 標準出力をキャプチャするためのバッファを作成 81 | readPipe, writePipe, _ := os.Pipe() 82 | originalStdout := os.Stdout 83 | 84 | defer func() { os.Stdout = originalStdout }() // テスト後に元に戻す 85 | 86 | os.Stdout = writePipe 87 | 88 | // Displayメソッドを呼び出す 89 | presenter.Display() 90 | 91 | // 書き込みを閉じてから、キャプチャした出力を取得 92 | writePipe.Close() 93 | 94 | var buf bytes.Buffer 95 | 96 | _, err := buf.ReadFrom(readPipe) 97 | if err != nil { 98 | t.Fatalf("failed to read from pipe: %v", err) 99 | } 100 | 101 | output := buf.String() 102 | 103 | // 期待される出力を検証 104 | assert.Equal(t, testCase.expectedOutput, output) 105 | }) 106 | } 107 | } 108 | 109 | // TestMakeAnalyzedLibInfoList_PointerBug tests that each AnalyzedLibInfo 110 | // has its own LibInfo instance, not sharing the same pointer 111 | func TestMakeAnalyzedLibInfoList_PointerBug(t *testing.T) { 112 | t.Parallel() 113 | 114 | // Create test data 115 | libInfoList := []parser.LibInfo{ 116 | {Name: "lib1", RepositoryURL: "https://github.com/owner/lib1", Others: []string{"v1.0.0"}}, 117 | {Name: "lib2", RepositoryURL: "https://github.com/owner/lib2", Others: []string{"v2.0.0"}}, 118 | {Name: "lib3", RepositoryURL: "https://github.com/owner/lib3", Others: []string{"v3.0.0"}}, 119 | } 120 | 121 | gitHubRepoInfos := []analyzer.GitHubRepoInfo{ 122 | { 123 | GithubRepoURL: "https://github.com/owner/lib1", 124 | RepositoryName: "lib1", 125 | Stars: 100, 126 | }, 127 | { 128 | GithubRepoURL: "https://github.com/owner/lib2", 129 | RepositoryName: "lib2", 130 | Stars: 200, 131 | }, 132 | } 133 | 134 | // Call the function under test 135 | result := presenter.MakeAnalyzedLibInfoList(libInfoList, gitHubRepoInfos) 136 | 137 | // Verify that we have the expected number of results 138 | assert.Len(t, result, 3) 139 | 140 | // Verify that each AnalyzedLibInfo has a unique LibInfo pointer 141 | // and correct values 142 | assert.Equal(t, "lib1", result[0].LibInfo.Name) 143 | assert.Equal(t, []string{"v1.0.0"}, result[0].LibInfo.Others) 144 | assert.NotNil(t, result[0].GitHubRepoInfo) 145 | assert.Equal(t, 100, result[0].GitHubRepoInfo.Stars) 146 | 147 | assert.Equal(t, "lib2", result[1].LibInfo.Name) 148 | assert.Equal(t, []string{"v2.0.0"}, result[1].LibInfo.Others) 149 | assert.NotNil(t, result[1].GitHubRepoInfo) 150 | assert.Equal(t, 200, result[1].GitHubRepoInfo.Stars) 151 | 152 | assert.Equal(t, "lib3", result[2].LibInfo.Name) 153 | assert.Equal(t, []string{"v3.0.0"}, result[2].LibInfo.Others) 154 | assert.Nil(t, result[2].GitHubRepoInfo) 155 | 156 | // Most important: verify that modifying one LibInfo doesn't affect others 157 | // This would fail with the pointer bug 158 | result[0].LibInfo.Name = "modified" 159 | assert.Equal(t, "modified", result[0].LibInfo.Name) 160 | assert.Equal(t, "lib2", result[1].LibInfo.Name) 161 | assert.Equal(t, "lib3", result[2].LibInfo.Name) 162 | } 163 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/uzumaki-inc/stay_or_go/cmd" 13 | ) 14 | 15 | // Helper-driven subprocess tests to validate error paths without affecting parent test process. 16 | func TestRootCommand_ErrorScenarios(t *testing.T) { 17 | t.Parallel() 18 | 19 | cases := []struct { 20 | name string 21 | scenario string 22 | expect string 23 | }{ 24 | {name: "no args", scenario: "NOARGS", expect: "Please Enter specify a language"}, 25 | {name: "unsupported language", scenario: "UNSUPPORTED", expect: "Error: Unsupported language"}, 26 | {name: "bad format", scenario: "BADFORMAT", expect: "Error: Unsupported output format"}, 27 | {name: "missing token", scenario: "NOTOKEN", expect: "Please provide a GitHub token"}, 28 | } 29 | 30 | for _, testCase := range cases { 31 | t.Run(testCase.name, func(t *testing.T) { 32 | t.Parallel() 33 | 34 | dir := t.TempDir() 35 | capPath := filepath.Join(dir, "stderr.txt") 36 | 37 | //nolint:gosec // launching test subprocess intentionally 38 | cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess_CobraRoot") 39 | cmd.Env = append(os.Environ(), 40 | "GO_WANT_HELPER_PROCESS_COBRA=1", 41 | "COBRA_SCENARIO="+testCase.scenario, 42 | "COBRA_CAPTURE="+capPath, 43 | ) 44 | 45 | err := cmd.Run() 46 | if err == nil { 47 | t.Fatalf("expected process to exit with error") 48 | } 49 | 50 | // read captured stderr 51 | data, readErr := os.ReadFile(capPath) 52 | if readErr != nil { 53 | t.Fatalf("failed reading capture: %v", readErr) 54 | } 55 | 56 | if !strings.Contains(string(data), testCase.expect) { 57 | t.Fatalf("expected stderr to contain %q, got: %s", testCase.expect, string(data)) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | // Test helper that runs in a subprocess to exercise cobra command paths that call os.Exit. 64 | // 65 | //nolint:paralleltest,funlen // Test helper process for subprocess testing, t unused but required 66 | func TestHelperProcess_CobraRoot(t *testing.T) { 67 | if os.Getenv("GO_WANT_HELPER_PROCESS_COBRA") != "1" { 68 | return 69 | } 70 | 71 | // Redirect stderr to capture file 72 | capPath := os.Getenv("COBRA_CAPTURE") 73 | f, _ := os.Create(capPath) 74 | 75 | defer f.Close() 76 | 77 | os.Stderr = f 78 | 79 | scenario := os.Getenv("COBRA_SCENARIO") 80 | goMod := `module example.com 81 | 82 | require ( 83 | github.com/replaced/mod v1.0.0 84 | ) 85 | 86 | replace ( 87 | github.com/replaced/mod v1.0.0 => ./local/mod 88 | )` 89 | gemfile := `source 'https://rubygems.org' 90 | 91 | # git specified to be skipped 92 | 93 | gem 'nokogiri', git: 'https://example.com/sparklemotion/nokogiri.git' 94 | ` 95 | handlers := map[string]func(){ 96 | "NOARGS": func() { cmd.GetRootCmd().SetArgs([]string{}) }, 97 | "UNSUPPORTED": func() { cmd.GetRootCmd().SetArgs([]string{"python"}) }, 98 | "BADFORMAT": func() { cmd.GetRootCmd().SetArgs([]string{"go", "-f", "json", "-g", "dummy"}) }, 99 | "NOTOKEN": func() { _ = os.Unsetenv("GITHUB_TOKEN"); cmd.GetRootCmd().SetArgs([]string{"go"}) }, 100 | "GO_DEFAULT": func() { 101 | dir := t.TempDir() 102 | _ = os.WriteFile(dir+"/go.mod", []byte(goMod), 0o600) 103 | t.Chdir(dir) 104 | t.Setenv("GITHUB_TOKEN", "dummy") 105 | cmd.GetRootCmd().SetArgs([]string{"go"}) 106 | }, 107 | "RUBY_DEFAULT": func() { 108 | dir := t.TempDir() 109 | _ = os.WriteFile(dir+"/Gemfile", []byte(gemfile), 0o600) 110 | t.Chdir(dir) 111 | t.Setenv("GITHUB_TOKEN", "dummy") 112 | cmd.GetRootCmd().SetArgs([]string{"ruby"}) 113 | }, 114 | "GO_VERBOSE": func() { 115 | dir := t.TempDir() 116 | _ = os.WriteFile(dir+"/go.mod", []byte(goMod), 0o600) 117 | t.Chdir(dir) 118 | t.Setenv("GITHUB_TOKEN", "dummy") 119 | cmd.GetRootCmd().SetArgs([]string{"go", "-v"}) 120 | }, 121 | "GO_CSV": func() { 122 | dir := t.TempDir() 123 | _ = os.WriteFile(dir+"/go.mod", []byte(goMod), 0o600) 124 | t.Chdir(dir) 125 | t.Setenv("GITHUB_TOKEN", "dummy") 126 | cmd.GetRootCmd().SetArgs([]string{"go", "-f", "csv"}) 127 | }, 128 | "GO_CONFIG": func() { 129 | dir := t.TempDir() 130 | _ = os.WriteFile(dir+"/go.mod", []byte(goMod), 0o600) 131 | cfg := dir + "/weights.yml" 132 | content := "watestCasehers: 1\n" + 133 | "stars: 2\n" + 134 | "forks: 3\n" + 135 | "open_issues: 4\n" + 136 | "last_commit_date: -5\n" + 137 | "archived: -6\n" 138 | _ = os.WriteFile(cfg, []byte(content), 0o600) 139 | t.Chdir(dir) 140 | t.Setenv("GITHUB_TOKEN", "dummy") 141 | cmd.GetRootCmd().SetArgs([]string{"go", "-c", cfg}) 142 | }, 143 | } 144 | 145 | if h, ok := handlers[scenario]; ok { 146 | h() 147 | } else { 148 | cmd.GetRootCmd().SetArgs([]string{}) 149 | } 150 | 151 | // Avoid printing to stdout in tests; ensure buffer present 152 | var devnull bytes.Buffer 153 | 154 | _ = devnull 155 | 156 | // This will call os.Exit in error paths, terminating subprocess with code 1. 157 | cmd.Execute() 158 | } 159 | 160 | func TestRootCommand_DefaultInputsAndVerbose(t *testing.T) { 161 | t.Parallel() 162 | 163 | cases := []struct { 164 | name string 165 | scenario string 166 | expectErr bool 167 | expectStderrContains string 168 | }{ 169 | {name: "go default input", scenario: "GO_DEFAULT", expectErr: false}, 170 | {name: "ruby default input", scenario: "RUBY_DEFAULT", expectErr: false}, 171 | {name: "go verbose logs", scenario: "GO_VERBOSE", expectErr: false, expectStderrContains: "Selected Language: go"}, 172 | {name: "go with csv format", scenario: "GO_CSV", expectErr: false}, 173 | {name: "go with config file", scenario: "GO_CONFIG", expectErr: false}, 174 | } 175 | 176 | for _, testCase := range cases { 177 | t.Run(testCase.name, func(t *testing.T) { 178 | t.Parallel() 179 | 180 | dir := t.TempDir() 181 | capPath := filepath.Join(dir, "stderr.txt") 182 | 183 | //nolint:gosec // launching test subprocess intentionally 184 | cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess_CobraRoot") 185 | cmd.Env = append(os.Environ(), 186 | "GO_WANT_HELPER_PROCESS_COBRA=1", 187 | "COBRA_SCENARIO="+testCase.scenario, 188 | "COBRA_CAPTURE="+capPath, 189 | ) 190 | 191 | err := cmd.Run() 192 | if testCase.expectErr { 193 | if err == nil { 194 | t.Fatalf("expected error, got nil") 195 | } 196 | } else { 197 | if err != nil { 198 | t.Fatalf("unexpected error: %v", err) 199 | } 200 | } 201 | 202 | if testCase.expectStderrContains != "" { 203 | stderrData, readErr := os.ReadFile(capPath) 204 | if readErr != nil { 205 | t.Fatalf("failed to read stderr capture: %v", readErr) 206 | } 207 | 208 | if !strings.Contains(string(stderrData), testCase.expectStderrContains) { 209 | t.Fatalf("stderr missing expected text %q, got: %s", testCase.expectStderrContains, string(stderrData)) 210 | } 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/uzumaki-inc/stay_or_go/analyzer" 13 | "github.com/uzumaki-inc/stay_or_go/parser" 14 | "github.com/uzumaki-inc/stay_or_go/presenter" 15 | "github.com/uzumaki-inc/stay_or_go/utils" 16 | ) 17 | 18 | // var greeting string 19 | var ( 20 | filePath string 21 | outputFormat string 22 | githubToken string 23 | configFilePath string 24 | 25 | supportedLanguages = []string{"ruby", "go"} 26 | languageConfigMap = map[string]string{ 27 | "ruby": "Gemfile", 28 | "go": "go.mod", 29 | } 30 | supportedOutputFormats = map[string]bool{ 31 | "csv": true, 32 | "tsv": true, 33 | "markdown": true, 34 | } 35 | 36 | // Sentinel errors for wrapping 37 | ErrUnsupportedFormat = errors.New("unsupported format") 38 | ErrMissingGithubToken = errors.New("missing github token") 39 | ) 40 | 41 | // AnalyzerPort is a minimal adapter for analyzer used by cmd to enable testing with stubs. 42 | type AnalyzerPort interface { 43 | FetchGithubInfo(repositoryUrls []string) []analyzer.GitHubRepoInfo 44 | } 45 | 46 | // PresenterPort narrows the presenter to only what's used here. 47 | type PresenterPort interface { 48 | Display() 49 | } 50 | 51 | // Deps bundles injectable constructors/selectors for testability. 52 | type Deps struct { 53 | NewAnalyzer func(token string, weights analyzer.ParameterWeights) AnalyzerPort 54 | SelectParser func(language string) (parser.Parser, error) 55 | SelectPresenter func(format string, analyzedLibInfos []presenter.AnalyzedLibInfo) PresenterPort 56 | } 57 | 58 | var defaultDeps = Deps{ 59 | NewAnalyzer: func(token string, weights analyzer.ParameterWeights) AnalyzerPort { 60 | return analyzer.NewGitHubRepoAnalyzer(token, weights) 61 | }, 62 | SelectParser: parser.SelectParser, 63 | SelectPresenter: func(format string, analyzedLibInfos []presenter.AnalyzedLibInfo) PresenterPort { 64 | return presenter.SelectPresenter(format, analyzedLibInfos) 65 | }, 66 | } 67 | 68 | // 引数を全部設定するlintを回避 69 | // 70 | //nolint:exhaustruct, lll 71 | var rootCmd = &cobra.Command{ 72 | Use: "stay_or_go", 73 | Version: "0.1.2", 74 | Short: "Analyze and score your Go and Ruby dependencies for popularity and maintenance", 75 | Long: `stay_or_go scans your Go (go.mod) and Ruby (Gemfile) dependency files to evaluate each library's popularity and maintenance status. 76 | It generates scores to help you decide whether to keep (‘Stay’) or replace (‘Go’) your dependencies. 77 | Output the results in Markdown, CSV, or TSV formats.`, 78 | Run: func(_ *cobra.Command, args []string) { 79 | if len(args) == 0 { 80 | fmt.Fprintln(os.Stderr, "Please Enter specify a language ("+ 81 | strings.Join(supportedLanguages, " or ")+")") 82 | os.Exit(1) 83 | } 84 | 85 | language := args[0] 86 | // Delegate to testable runner 87 | err := run(language, filePath, outputFormat, githubToken, configFilePath, utils.Verbose, defaultDeps) 88 | if err != nil { 89 | os.Exit(1) 90 | } 91 | }, 92 | } 93 | 94 | func isSupportedLanguage(language string) bool { 95 | return slices.Contains(supportedLanguages, language) 96 | } 97 | 98 | // run executes the core logic with injectable dependencies. Returns error instead of exiting. 99 | // 100 | //nolint:funlen,cyclop // readability is prioritized 101 | func run(language, inFile, format, token, config string, _ bool, deps Deps) error { 102 | if !isSupportedLanguage(language) { 103 | utils.StdErrorPrintln("Error: Unsupported language: %s. Supported languages are: %s\n", 104 | language, strings.Join(supportedLanguages, ", ")) 105 | 106 | return fmt.Errorf("%w: %s", parser.ErrUnsupportedLanguage, language) 107 | } 108 | 109 | file := inFile 110 | if file == "" { 111 | file = languageConfigMap[language] 112 | } 113 | 114 | if !supportedOutputFormats[format] { 115 | var keys []string 116 | for key := range supportedOutputFormats { 117 | keys = append(keys, key) 118 | } 119 | 120 | utils.StdErrorPrintln("Error: Unsupported output format: %s. Supported output formats are: %s\n", 121 | format, strings.Join(keys, ", ")) 122 | 123 | return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) 124 | } 125 | 126 | if token == "" { 127 | token = os.Getenv("GITHUB_TOKEN") 128 | if token == "" { 129 | //nolint:lll // error message kept in one line for clarity when printed 130 | fmt.Fprintln(os.Stderr, "Please provide a GitHub token using the --github-token flag or set the GITHUB_TOKEN environment variable") 131 | 132 | return fmt.Errorf("%w", ErrMissingGithubToken) 133 | } 134 | } 135 | 136 | utils.DebugPrintln("Selected Language: " + language) 137 | utils.DebugPrintln("Reading file: " + file) 138 | utils.DebugPrintln("Output format: " + format) 139 | 140 | var weights analyzer.ParameterWeights 141 | 142 | if config != "" { 143 | utils.DebugPrintln("Config file: " + config) 144 | 145 | var err error 146 | 147 | weights, err = analyzer.NewParameterWeightsFromFile(config) 148 | if err != nil { 149 | utils.StdErrorPrintln("Failed to load config file: %v", err) 150 | os.Exit(1) 151 | } 152 | } else { 153 | weights = analyzer.NewParameterWeights() 154 | } 155 | 156 | analyzerSvc := deps.NewAnalyzer(token, weights) 157 | 158 | utils.StdErrorPrintln("Selecting language... ") 159 | 160 | selectedParser, err := deps.SelectParser(language) 161 | if err != nil { 162 | utils.StdErrorPrintln("Error selecting parser: %v", err) 163 | 164 | return fmt.Errorf("select parser: %w", err) 165 | } 166 | 167 | utils.StdErrorPrintln("Parsing file...") 168 | 169 | libInfoList, err := selectedParser.Parse(file) 170 | if err != nil { 171 | utils.StdErrorPrintln("Error parsing file: %v", err) 172 | 173 | return fmt.Errorf("parse file: %w", err) 174 | } 175 | 176 | utils.StdErrorPrintln("Getting repository URLs...") 177 | selectedParser.GetRepositoryURL(libInfoList) 178 | 179 | var repoURLs []string 180 | 181 | for _, info := range libInfoList { 182 | if !info.Skip { 183 | repoURLs = append(repoURLs, info.RepositoryURL) 184 | } 185 | } 186 | 187 | utils.StdErrorPrintln("Analyzing libraries with Github...") 188 | 189 | var gitHubRepoInfos []analyzer.GitHubRepoInfo 190 | if len(repoURLs) > 0 { 191 | gitHubRepoInfos = analyzerSvc.FetchGithubInfo(repoURLs) 192 | } else { 193 | gitHubRepoInfos = []analyzer.GitHubRepoInfo{} 194 | } 195 | 196 | utils.StdErrorPrintln("Making dataset...") 197 | 198 | analyzedLibInfos := presenter.MakeAnalyzedLibInfoList(libInfoList, gitHubRepoInfos) 199 | presenterInst := deps.SelectPresenter(format, analyzedLibInfos) 200 | 201 | utils.StdErrorPrintln("Displaying result...\n") 202 | presenterInst.Display() 203 | 204 | return nil 205 | } 206 | 207 | // GetRootCmd returns the root command for testing purposes. 208 | func GetRootCmd() *cobra.Command { 209 | return rootCmd 210 | } 211 | 212 | func Execute() { 213 | err := rootCmd.Execute() 214 | if err != nil { 215 | os.Exit(1) 216 | } 217 | } 218 | 219 | func init() { 220 | rootCmd.Flags().StringVarP(&filePath, "input", "i", "", "Specify the file to read") 221 | rootCmd.Flags().StringVarP(&outputFormat, "format", "f", "markdown", "Specify the output format (csv, tsv, markdown)") 222 | rootCmd.Flags().StringVarP(&githubToken, "github-token", "g", "", "GitHub token for authentication") 223 | rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 224 | rootCmd.Flags().BoolVarP(&utils.Verbose, "verbose", "v", false, "Enable verbose output") 225 | rootCmd.Flags().StringVarP(&configFilePath, "config", "c", "", "Modify evaluate parameters") 226 | } 227 | -------------------------------------------------------------------------------- /parser/go_parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/uzumaki-inc/stay_or_go/utils" 15 | ) 16 | 17 | type GoParser struct{} 18 | 19 | func (p GoParser) Parse(filePath string) ([]LibInfo, error) { 20 | file, err := os.Open(filePath) 21 | if err != nil { 22 | utils.StdErrorPrintln("%v: %v", ErrFailedToReadFile, err) 23 | 24 | return nil, ErrFailedToReadFile 25 | } 26 | defer file.Close() 27 | 28 | replaceModules, err := p.collectReplaceModules(file) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | libInfoList, err := p.processRequireBlock(file, replaceModules) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return libInfoList, nil 39 | } 40 | 41 | func (p GoParser) GetRepositoryURL(libInfoList []LibInfo) []LibInfo { 42 | client := &http.Client{} 43 | 44 | for i := range libInfoList { 45 | libInfo := &libInfoList[i] 46 | 47 | if libInfo.Skip { 48 | continue 49 | } 50 | 51 | if len(libInfo.Others) < 2 { 52 | libInfo.Skip = true 53 | libInfo.SkipReason = "Missing version information" 54 | utils.StdErrorPrintln("%s missing version information", libInfo.Name) 55 | 56 | continue 57 | } 58 | 59 | name := libInfo.Others[0] 60 | version := libInfo.Others[1] 61 | 62 | repoURL, err := p.getGitHubRepositoryURL(client, name, version) 63 | if err != nil { 64 | libInfo.Skip = true 65 | libInfo.SkipReason = "Does not support libraries hosted outside of Github" 66 | 67 | utils.StdErrorPrintln("%s does not support libraries hosted outside of Github: %s", name, err) 68 | 69 | continue 70 | } 71 | 72 | libInfo.RepositoryURL = repoURL 73 | } 74 | 75 | return libInfoList 76 | } 77 | 78 | func (p GoParser) collectReplaceModules(file *os.File) ([]string, error) { 79 | var replaceModules []string 80 | 81 | var inReplaceBlock bool 82 | 83 | scanner := bufio.NewScanner(file) 84 | for scanner.Scan() { 85 | line := strings.TrimSpace(scanner.Text()) 86 | 87 | if line == "replace (" { 88 | inReplaceBlock = true 89 | 90 | continue 91 | } 92 | 93 | if line == ")" && inReplaceBlock { 94 | inReplaceBlock = false 95 | 96 | continue 97 | } 98 | 99 | if inReplaceBlock { 100 | parts := strings.Fields(line) 101 | if len(parts) > 0 { 102 | replaceModules = append(replaceModules, parts[0]) 103 | } 104 | } 105 | } 106 | 107 | err := scanner.Err() 108 | if err != nil { 109 | utils.StdErrorPrintln("%v: %v", ErrFailedToScanFile, err) 110 | 111 | return nil, ErrFailedToScanFile 112 | } 113 | 114 | _, err = file.Seek(0, 0) // Reset file pointer for next pass 115 | if err != nil { 116 | utils.StdErrorPrintln("%v: %v", ErrFailedToResetFilePointer, err) 117 | 118 | return nil, ErrFailedToResetFilePointer 119 | } 120 | 121 | return replaceModules, nil 122 | } 123 | 124 | func (p GoParser) processRequireBlock(file *os.File, replaceModules []string) ([]LibInfo, error) { 125 | var libInfoList []LibInfo 126 | 127 | var inRequireBlock bool 128 | 129 | scanner := bufio.NewScanner(file) 130 | for scanner.Scan() { 131 | line := strings.TrimSpace(scanner.Text()) 132 | 133 | if strings.HasPrefix(line, "require") { 134 | rest := strings.TrimSpace(strings.TrimPrefix(line, "require")) 135 | if strings.HasPrefix(rest, "(") { 136 | inRequireBlock = true 137 | 138 | continue 139 | } 140 | 141 | if strings.Contains(line, "// indirect") { 142 | continue 143 | } 144 | 145 | p.handleRequireEntry(rest, replaceModules, &libInfoList) 146 | 147 | continue 148 | } 149 | 150 | if !inRequireBlock { 151 | continue 152 | } 153 | 154 | if line == ")" { 155 | inRequireBlock = false 156 | 157 | continue 158 | } 159 | 160 | if strings.Contains(line, "// indirect") { 161 | continue 162 | } 163 | 164 | p.handleRequireEntry(line, replaceModules, &libInfoList) 165 | } 166 | 167 | err := scanner.Err() 168 | if err != nil { 169 | utils.StdErrorPrintln("%v: %v", ErrFailedToScanFile, err) 170 | 171 | return nil, ErrFailedToScanFile 172 | } 173 | 174 | return libInfoList, nil 175 | } 176 | 177 | func (p GoParser) handleRequireEntry(line string, replaceModules []string, libInfoList *[]LibInfo) { 178 | module, version, ok := extractModuleAndVersion(line) 179 | if !ok { 180 | return 181 | } 182 | 183 | libParts := strings.Split(module, "/") 184 | libName := libParts[len(libParts)-1] 185 | 186 | var newLib LibInfo 187 | 188 | if contains(replaceModules, module) { 189 | newLib = NewLibInfo(libName, WithSkip(true), WithSkipReason("replaced module")) 190 | } else { 191 | newLib = NewLibInfo(libName, WithOthers([]string{module, version})) 192 | } 193 | 194 | *libInfoList = append(*libInfoList, newLib) 195 | } 196 | 197 | func extractModuleAndVersion(line string) (string, string, bool) { 198 | trimmed := strings.TrimSpace(line) 199 | if trimmed == "" { 200 | return "", "", false 201 | } 202 | 203 | if commentIndex := strings.Index(trimmed, "//"); commentIndex != -1 { 204 | trimmed = strings.TrimSpace(trimmed[:commentIndex]) 205 | } 206 | 207 | parts := strings.Fields(trimmed) 208 | if len(parts) < 2 { 209 | return "", "", false 210 | } 211 | 212 | return parts[0], parts[1], true 213 | } 214 | 215 | func contains(slice []string, item string) bool { 216 | for _, s := range slice { 217 | if s == item { 218 | return true 219 | } 220 | } 221 | 222 | return false 223 | } 224 | 225 | type GoRepository struct { 226 | Version string `json:"version"` 227 | Time string `json:"time"` 228 | Origin Origin `json:"origin"` 229 | } 230 | 231 | type Origin struct { 232 | VCS string `json:"vcs"` 233 | URL string `json:"url"` 234 | Ref string `json:"ref"` 235 | Hash string `json:"hash"` 236 | } 237 | 238 | func (p GoParser) getGitHubRepositoryURL( 239 | client *http.Client, 240 | name, 241 | version string, 242 | ) (string, error) { 243 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second) 244 | defer cancel() 245 | 246 | baseURL := "https://proxy.golang.org/" 247 | repoURL := baseURL + name + "/@v/" + version + ".info" 248 | utils.DebugPrintln("Fetching: " + repoURL) 249 | 250 | parsedURL, err := url.Parse(repoURL) 251 | if err != nil { 252 | return "", ErrFailedToGetRepository 253 | } 254 | 255 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) 256 | if err != nil { 257 | return "", ErrFailedToGetRepository 258 | } 259 | 260 | response, err := client.Do(req) 261 | if err != nil { 262 | return "", ErrFailedToGetRepository 263 | } 264 | 265 | defer response.Body.Close() 266 | 267 | if response.StatusCode != http.StatusOK { 268 | return "", ErrNotAGitHubRepository 269 | } 270 | 271 | bodyBytes, err := io.ReadAll(response.Body) 272 | if err != nil { 273 | return "", ErrFailedToReadResponseBody 274 | } 275 | 276 | repoURLfromGithub, err := extractRepoURL(bodyBytes, name) 277 | if err != nil { 278 | return "", err 279 | } 280 | 281 | return repoURLfromGithub, nil 282 | } 283 | 284 | func extractRepoURL(bodyBytes []byte, name string) (string, error) { 285 | var repo GoRepository 286 | 287 | err := json.Unmarshal(bodyBytes, &repo) 288 | if err != nil { 289 | return "", ErrFailedToUnmarshalJSON 290 | } 291 | 292 | repoURLfromGithub := repo.Origin.URL 293 | 294 | // If there is no URL, use the package name 295 | if repoURLfromGithub == "" && strings.Contains(name, "github.com") { 296 | repoURLfromGithub = "https://" + name 297 | } 298 | 299 | if repoURLfromGithub == "" || !strings.Contains(repoURLfromGithub, "github.com") { 300 | return "", ErrNotAGitHubRepository 301 | } 302 | 303 | return repoURLfromGithub, nil 304 | } 305 | -------------------------------------------------------------------------------- /analyzer/github_repo_analyzer.go: -------------------------------------------------------------------------------- 1 | package analyzer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/uzumaki-inc/stay_or_go/utils" 13 | ) 14 | 15 | var ( 16 | ErrGitHubTokenNotSet = errors.New("GitHub token not set") 17 | ErrFailedToAssertDefaultBranch = errors.New("failed to assert type for default_branch") 18 | ErrFailedToAssertDate = errors.New("failed to assert type for date") 19 | 20 | ErrFailedToAssertName = errors.New("failed to assert type for name") 21 | ErrFailedToAssertSubscribersCount = errors.New("failed to assert type for subscribers_count") 22 | ErrFailedToAssertStargazersCount = errors.New("failed to assert type for stargazers_count") 23 | ErrFailedToAssertForksCount = errors.New("failed to assert type for forks_count") 24 | ErrFailedToAssertOpenIssuesCount = errors.New("failed to assert type for open_issues_count") 25 | ErrFailedToAssertArchived = errors.New("failed to assert type for archived") 26 | ErrUnexpectedStatusCode = errors.New("unexpected status code") 27 | ) 28 | 29 | const ( 30 | hoursOfDay = 24 31 | timeOutSec = 5 32 | ) 33 | 34 | type RepoData struct { 35 | Name string `json:"name"` 36 | SubscribersCount int `json:"subscribers_count"` 37 | StargazersCount int `json:"stargazers_count"` 38 | ForksCount int `json:"forks_count"` 39 | OpenIssuesCount int `json:"open_issues_count"` 40 | Archived bool `json:"archived"` 41 | DefaultBranch string `json:"default_branch"` 42 | } 43 | 44 | type CommitData struct { 45 | Commit struct { 46 | Committer struct { 47 | Date string `json:"date"` 48 | } `json:"committer"` 49 | } `json:"commit"` 50 | } 51 | 52 | type GitHubRepoInfo struct { 53 | RepositoryName string 54 | Watchers int 55 | Stars int 56 | Forks int 57 | OpenIssues int 58 | LastCommitDate string 59 | GithubRepoURL string 60 | Archived bool 61 | Score int 62 | Skip bool // スキップするかどうかのフラグ 63 | SkipReason string // スキップ理由 64 | } 65 | 66 | type GitHubRepoAnalyzer struct { 67 | githubToken string 68 | weights ParameterWeights 69 | } 70 | 71 | func NewGitHubRepoAnalyzer(token string, weights ParameterWeights) *GitHubRepoAnalyzer { 72 | return &GitHubRepoAnalyzer{ 73 | githubToken: token, 74 | weights: weights, 75 | } 76 | } 77 | 78 | // FetchInfo fetches information for each repository using concurrent processing 79 | func (g *GitHubRepoAnalyzer) FetchGithubInfo(repositoryUrls []string) []GitHubRepoInfo { 80 | if len(repositoryUrls) == 0 { 81 | return []GitHubRepoInfo{} 82 | } 83 | 84 | // Create result slice with proper capacity and initialization 85 | results := make([]GitHubRepoInfo, len(repositoryUrls)) 86 | 87 | // Use worker pool with limited concurrency 88 | const maxWorkers = 10 89 | 90 | numWorkers := min(maxWorkers, len(repositoryUrls)) 91 | 92 | // Channels for work distribution 93 | jobs := make(chan jobRequest, len(repositoryUrls)) 94 | done := make(chan jobResult, len(repositoryUrls)) 95 | 96 | // Start workers 97 | for range numWorkers { 98 | go g.worker(jobs, done) 99 | } 100 | 101 | // Send jobs 102 | for idx, repoURL := range repositoryUrls { 103 | jobs <- jobRequest{index: idx, repoURL: repoURL} 104 | } 105 | 106 | close(jobs) 107 | 108 | // Collect results 109 | for range repositoryUrls { 110 | result := <-done 111 | results[result.index] = result.info 112 | } 113 | 114 | return results 115 | } 116 | 117 | type jobRequest struct { 118 | index int 119 | repoURL string 120 | } 121 | 122 | type jobResult struct { 123 | index int 124 | info GitHubRepoInfo 125 | } 126 | 127 | func (g *GitHubRepoAnalyzer) worker(jobs <-chan jobRequest, results chan<- jobResult) { 128 | client := &http.Client{} 129 | 130 | for job := range jobs { 131 | utils.DebugPrintln("Fetching: " + job.repoURL) 132 | 133 | libraryInfo, err := g.getGitHubInfo(client, job.repoURL) 134 | if err != nil { 135 | libraryInfo = &GitHubRepoInfo{ 136 | Skip: true, 137 | SkipReason: "Failed fetching " + job.repoURL + " from GitHub", 138 | } 139 | 140 | utils.StdErrorPrintln("Failed fetching %s, error details: %v", job.repoURL, err) 141 | } 142 | 143 | libraryInfo.GithubRepoURL = job.repoURL 144 | results <- jobResult{index: job.index, info: *libraryInfo} 145 | } 146 | } 147 | 148 | func (g *GitHubRepoAnalyzer) getGitHubInfo( 149 | client *http.Client, 150 | repoURL string, 151 | ) (*GitHubRepoInfo, error) { 152 | if g.githubToken == "" { 153 | return nil, ErrGitHubTokenNotSet 154 | } 155 | 156 | owner, repo := parseRepoURL(repoURL) 157 | 158 | headers := map[string]string{ 159 | "Authorization": "token " + g.githubToken, 160 | } 161 | 162 | repoData, err := fetchRepoData(client, owner, repo, headers) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | lastCommitDate, err := fetchLastCommitDate(client, owner, repo, repoData, headers) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | repoInfo := createRepoInfo(repoData, lastCommitDate) 173 | 174 | calcScore(repoInfo, &g.weights) 175 | 176 | return repoInfo, nil 177 | } 178 | 179 | func parseRepoURL(repoURL string) (string, string) { 180 | repoURL = strings.TrimSuffix(repoURL, "/") 181 | parts := strings.Split(repoURL, "/") 182 | 183 | var owner, repo string 184 | 185 | owner, repo = parts[3], parts[4] 186 | repo = strings.TrimSuffix(repo, ".git") 187 | 188 | return owner, repo 189 | } 190 | 191 | func fetchRepoData( 192 | client *http.Client, 193 | owner, repo string, 194 | headers map[string]string, 195 | ) (*RepoData, error) { 196 | var repoData RepoData 197 | 198 | err := fetchJSONData(client, fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo), headers, &repoData) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | return &repoData, nil 204 | } 205 | 206 | func fetchLastCommitDate(client *http.Client, owner, repo string, 207 | repoData *RepoData, headers map[string]string, 208 | ) (string, error) { 209 | commitURL := "https://api.github.com/repos/" + owner + "/" + repo + "/commits/" + repoData.DefaultBranch 210 | 211 | var commitData CommitData 212 | 213 | err := fetchJSONData(client, commitURL, headers, &commitData) 214 | if err != nil { 215 | return "", err 216 | } 217 | 218 | return commitData.Commit.Committer.Date, nil 219 | } 220 | 221 | func createRepoInfo( 222 | repoData *RepoData, 223 | lastCommitDate string, 224 | ) *GitHubRepoInfo { 225 | return &GitHubRepoInfo{ 226 | RepositoryName: repoData.Name, 227 | Watchers: repoData.SubscribersCount, 228 | Stars: repoData.StargazersCount, 229 | Forks: repoData.ForksCount, 230 | OpenIssues: repoData.OpenIssuesCount, 231 | LastCommitDate: lastCommitDate, 232 | Archived: repoData.Archived, 233 | Skip: false, 234 | SkipReason: "", 235 | } 236 | } 237 | 238 | func calcScore(repoInfo *GitHubRepoInfo, weights *ParameterWeights) { 239 | days, err := daysSince(repoInfo.LastCommitDate) 240 | if err != nil { 241 | repoInfo.Skip = true 242 | 243 | repoInfo.SkipReason = "Date Format Error: " + repoInfo.LastCommitDate 244 | 245 | utils.StdErrorPrintln("Date Format Error: %v", err) 246 | } 247 | 248 | score := float64(repoInfo.Watchers) * weights.Watchers 249 | score += float64(repoInfo.Stars) * weights.Stars 250 | score += float64(repoInfo.Forks) * weights.Forks 251 | score += float64(repoInfo.OpenIssues) * weights.OpenIssues 252 | score += float64(days) * weights.LastCommitDate 253 | intArchived := map[bool]float64{true: 1.0, false: 0.0}[repoInfo.Archived] 254 | score += (intArchived) * weights.Archived 255 | 256 | repoInfo.Score = int(score) 257 | } 258 | 259 | // 日付文字列から現在日までの経過日数を返す関数 260 | func daysSince(dateStr string) (int, error) { 261 | // 入力された日付文字列をパース(UTCフォーマット) 262 | layout := "2006-01-02T15:04:05Z" 263 | 264 | parsedTime, err := time.Parse(layout, dateStr) 265 | if err != nil { 266 | return 0, fmt.Errorf("failed to parse date '%s': %w", dateStr, err) 267 | } 268 | 269 | currentTime := time.Now() 270 | duration := currentTime.Sub(parsedTime) 271 | days := int(duration.Hours() / hoursOfDay) 272 | 273 | return days, nil 274 | } 275 | 276 | func fetchJSONData( 277 | client *http.Client, 278 | url string, 279 | headers map[string]string, 280 | result interface{}, 281 | ) error { 282 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second) 283 | defer cancel() 284 | 285 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 286 | if err != nil { 287 | return fmt.Errorf("failed to create new HTTP request for URL %s: %w", url, err) 288 | } 289 | 290 | for key, value := range headers { 291 | req.Header.Set(key, value) 292 | } 293 | 294 | resp, err := client.Do(req) 295 | if err != nil { 296 | return fmt.Errorf("failed to execute HTTP request for URL %s: %w", url, err) 297 | } 298 | defer resp.Body.Close() 299 | 300 | if resp.StatusCode != http.StatusOK { 301 | return fmt.Errorf("%w: %d for URL %s", ErrUnexpectedStatusCode, resp.StatusCode, url) 302 | } 303 | 304 | err = json.NewDecoder(resp.Body).Decode(result) 305 | if err != nil { 306 | return fmt.Errorf("failed to decode JSON response for URL %s: %w", url, err) 307 | } 308 | 309 | return nil 310 | } 311 | 312 | func indexOf(slice []string, value string) int { 313 | for i, v := range slice { 314 | if v == value { 315 | return i 316 | } 317 | } 318 | 319 | return -1 320 | } 321 | -------------------------------------------------------------------------------- /parser/go_parser_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jarcoal/httpmock" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/uzumaki-inc/stay_or_go/parser" 12 | ) 13 | 14 | //nolint:funlen // Complex test logic requires many assertions 15 | func TestGoParser_Parse_RequireReplaceAndIndirect(t *testing.T) { 16 | t.Parallel() 17 | 18 | content := `module example.com/demo 19 | 20 | require ( 21 | github.com/user/libone v1.2.3 22 | golang.org/x/sys v0.1.0 // indirect 23 | github.com/user/libtwo v0.9.0 24 | code.gitea.io/sdk v1.0.0 25 | github.com/replaced/mod v1.0.0 26 | ) 27 | 28 | replace ( 29 | github.com/replaced/mod v1.0.0 => ./local/mod 30 | ) 31 | ` 32 | 33 | tmpFile, err := os.CreateTemp(t.TempDir(), "go.mod-*.tmp") 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | defer os.Remove(tmpFile.Name()) 39 | 40 | _, err = tmpFile.WriteString(content) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | _ = tmpFile.Close() 46 | 47 | p := parser.GoParser{} 48 | 49 | libs, err := p.Parse(tmpFile.Name()) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | // Expect: libone, libtwo, sdk, mod(replaced) => 4 entries (indirect excluded) 55 | assert.Len(t, libs, 4) 56 | 57 | // helper to find by name 58 | byName := func(name string) *parser.LibInfo { 59 | for i := range libs { 60 | if libs[i].Name == name { 61 | return &libs[i] 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | libone := byName("libone") 69 | libtwo := byName("libtwo") 70 | sdk := byName("sdk") 71 | replaced := byName("mod") 72 | 73 | if libone == nil || libtwo == nil || sdk == nil || replaced == nil { 74 | t.Fatalf("expected all libs to be found, got: %+v", libs) 75 | } 76 | 77 | assert.False(t, libone.Skip) 78 | assert.Equal(t, []string{"github.com/user/libone", "v1.2.3"}, libone.Others) 79 | 80 | assert.False(t, libtwo.Skip) 81 | assert.Equal(t, []string{"github.com/user/libtwo", "v0.9.0"}, libtwo.Others) 82 | 83 | assert.False(t, sdk.Skip) 84 | assert.Equal(t, []string{"code.gitea.io/sdk", "v1.0.0"}, sdk.Others) 85 | 86 | assert.True(t, replaced.Skip) 87 | assert.Equal(t, "replaced module", replaced.SkipReason) 88 | } 89 | 90 | func TestGoParser_Parse_SingleLineRequire(t *testing.T) { 91 | t.Parallel() 92 | 93 | content := `module example.com/demo 94 | 95 | require example.com/direct v1.2.3 96 | require example.com/indirect v1.3.0 // indirect 97 | require example.com/commented v1.4.5 // keep this module 98 | 99 | require ( 100 | github.com/block/module v0.9.0 101 | ) 102 | 103 | replace ( 104 | example.com/commented v1.4.5 => ./local/module 105 | ) 106 | ` 107 | 108 | libs := mustParseGoMod(t, content) 109 | 110 | direct := findLibInfo(libs, "direct") 111 | commented := findLibInfo(libs, "commented") 112 | block := findLibInfo(libs, "module") 113 | indirect := findLibInfo(libs, "indirect") 114 | 115 | if direct == nil || commented == nil || block == nil { 116 | t.Fatalf("expected required modules to be parsed, got: %+v", libs) 117 | } 118 | 119 | assert.False(t, direct.Skip) 120 | assert.Equal(t, []string{"example.com/direct", "v1.2.3"}, direct.Others) 121 | 122 | assert.True(t, commented.Skip) 123 | assert.Equal(t, "replaced module", commented.SkipReason) 124 | 125 | assert.False(t, block.Skip) 126 | assert.Equal(t, []string{"github.com/block/module", "v0.9.0"}, block.Others) 127 | 128 | assert.Nil(t, indirect) 129 | } 130 | 131 | func mustParseGoMod(t *testing.T, content string) []parser.LibInfo { 132 | t.Helper() 133 | 134 | tmpFile, err := os.CreateTemp(t.TempDir(), "go.mod-*.tmp") 135 | require.NoError(t, err) 136 | 137 | _, err = tmpFile.WriteString(content) 138 | require.NoError(t, err) 139 | 140 | require.NoError(t, tmpFile.Close()) 141 | 142 | p := parser.GoParser{} 143 | 144 | libs, err := p.Parse(tmpFile.Name()) 145 | require.NoError(t, err) 146 | 147 | return libs 148 | } 149 | 150 | func findLibInfo(libs []parser.LibInfo, name string) *parser.LibInfo { 151 | for i := range libs { 152 | if libs[i].Name == name { 153 | return &libs[i] 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | //nolint:paralleltest,funlen // Uses httpmock which doesn't support parallel tests, complex setup 161 | func TestGoParser_GetRepositoryURL_SetsURLAndSkips(t *testing.T) { 162 | // Prepare initial lib list as if parsed 163 | libs := []parser.LibInfo{ 164 | parser.NewLibInfo("libone", parser.WithOthers([]string{"github.com/user/libone", "v1.2.3"})), 165 | parser.NewLibInfo("libtwo", parser.WithOthers([]string{"github.com/user/libtwo", "v0.9.0"})), 166 | parser.NewLibInfo("sdk", parser.WithOthers([]string{"code.gitea.io/sdk", "v1.0.0"})), 167 | parser.NewLibInfo("mod", parser.WithSkip(true), parser.WithSkipReason("replaced module")), 168 | } 169 | 170 | httpmock.Activate() 171 | defer httpmock.DeactivateAndReset() 172 | 173 | // Success: libone -> explicit GitHub URL in origin 174 | httpmock.RegisterResponder( 175 | "GET", 176 | "https://proxy.golang.org/github.com/user/libone/@v/v1.2.3.info", 177 | httpmock.NewStringResponder(200, 178 | `{"version":"v1.2.3","time":"2024-01-01T00:00:00Z",`+ 179 | `"origin":{"vcs":"git","url":"https://github.com/user/libone","ref":"main","hash":"deadbeef"}}`), 180 | ) 181 | 182 | // Success via fallback: libtwo -> origin.url empty but module path contains github.com 183 | httpmock.RegisterResponder( 184 | "GET", 185 | "https://proxy.golang.org/github.com/user/libtwo/@v/v0.9.0.info", 186 | httpmock.NewStringResponder(200, 187 | `{"version":"v0.9.0","time":"2024-01-02T00:00:00Z",`+ 188 | `"origin":{"vcs":"git","url":"","ref":"main","hash":"deadbeef"}}`), 189 | ) 190 | 191 | // Non-GitHub or error -> mark skip 192 | httpmock.RegisterResponder( 193 | "GET", 194 | "https://proxy.golang.org/code.gitea.io/sdk/@v/v1.0.0.info", 195 | httpmock.NewStringResponder(404, `not found`), 196 | ) 197 | 198 | p := parser.GoParser{} 199 | updated := p.GetRepositoryURL(libs) 200 | 201 | // Map by name for assertions 202 | get := func(name string) *parser.LibInfo { 203 | for i := range updated { 204 | if updated[i].Name == name { 205 | return &updated[i] 206 | } 207 | } 208 | 209 | return nil 210 | } 211 | 212 | libone := get("libone") 213 | libtwo := get("libtwo") 214 | sdk := get("sdk") 215 | replaced := get("mod") 216 | 217 | if libone == nil || libtwo == nil || sdk == nil || replaced == nil { 218 | t.Fatalf("expected all libs to be found, got: %+v", updated) 219 | } 220 | 221 | assert.Equal(t, "https://github.com/user/libone", libone.RepositoryURL) 222 | assert.Equal(t, "https://github.com/user/libtwo", libtwo.RepositoryURL) 223 | 224 | assert.True(t, sdk.Skip) 225 | assert.Equal(t, "Does not support libraries hosted outside of Github", sdk.SkipReason) 226 | assert.Empty(t, sdk.RepositoryURL) 227 | 228 | // replaced item should remain skipped and untouched 229 | assert.True(t, replaced.Skip) 230 | assert.Equal(t, "replaced module", replaced.SkipReason) 231 | } 232 | 233 | func TestGoParser_Parse_FileOpenError(t *testing.T) { 234 | t.Parallel() 235 | 236 | p := parser.GoParser{} 237 | 238 | // Try to parse a non-existent file 239 | _, err := p.Parse("/path/does/not/exist/go.mod") 240 | 241 | // Should return an error, not call os.Exit 242 | require.Error(t, err) 243 | assert.Contains(t, err.Error(), "failed to read file") 244 | } 245 | 246 | func TestGoParser_Parse_FileSeekError(t *testing.T) { 247 | t.Parallel() 248 | 249 | // Create a temporary file that simulates seek failure 250 | // We'll use a closed file handle to simulate this 251 | tmpFile, err := os.CreateTemp(t.TempDir(), "go.mod-*.tmp") 252 | if err != nil { 253 | t.Fatal(err) 254 | } 255 | 256 | content := `module example.com/demo 257 | 258 | replace ( 259 | github.com/replaced/mod v1.0.0 => ./local/mod 260 | ) 261 | 262 | require ( 263 | github.com/user/lib v1.0.0 264 | ) 265 | ` 266 | 267 | _, err = tmpFile.WriteString(content) 268 | if err != nil { 269 | t.Fatal(err) 270 | } 271 | 272 | tmpFileName := tmpFile.Name() 273 | _ = tmpFile.Close() 274 | 275 | defer os.Remove(tmpFileName) 276 | 277 | p := parser.GoParser{} 278 | 279 | // This should now return an error properly 280 | libs, err := p.Parse(tmpFileName) 281 | 282 | // For now this test expects no error since the file is valid 283 | // After fix, we'll handle seek errors properly 284 | require.NoError(t, err) 285 | assert.NotEmpty(t, libs) 286 | } 287 | 288 | func TestGoParser_Parse_ScannerError(t *testing.T) { 289 | t.Parallel() 290 | 291 | // Create a file with invalid UTF-8 to trigger scanner error 292 | tmpFile, err := os.CreateTemp(t.TempDir(), "go.mod-*.tmp") 293 | if err != nil { 294 | t.Fatal(err) 295 | } 296 | 297 | defer os.Remove(tmpFile.Name()) 298 | 299 | // Write some initial valid content 300 | content := `module example.com/demo 301 | 302 | require ( 303 | ` 304 | 305 | _, err = tmpFile.WriteString(content) 306 | if err != nil { 307 | t.Fatal(err) 308 | } 309 | 310 | // Write invalid UTF-8 bytes 311 | invalidBytes := []byte{0xff, 0xfe, 0xfd} 312 | 313 | _, err = tmpFile.Write(invalidBytes) 314 | if err != nil { 315 | t.Fatal(err) 316 | } 317 | 318 | _ = tmpFile.Close() 319 | 320 | p := parser.GoParser{} 321 | 322 | // The file has invalid UTF-8 but Go's scanner is resilient to it 323 | // The test verifies error handling is in place 324 | libs, err := p.Parse(tmpFile.Name()) 325 | 326 | // The parser should handle the file gracefully 327 | // Empty slice is expected since the file has no valid require statements 328 | require.NoError(t, err) 329 | assert.Empty(t, libs) 330 | } 331 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/uzumaki-inc/stay_or_go 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.7 6 | 7 | require ( 8 | github.com/air-verse/air v1.61.1 9 | github.com/golangci/golangci-lint/v2 v2.4.0 10 | github.com/jarcoal/httpmock v1.3.1 11 | github.com/joho/godotenv v1.5.1 12 | github.com/spf13/cobra v1.9.1 13 | github.com/stretchr/testify v1.10.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 19 | 4d63.com/gochecknoglobals v0.2.2 // indirect 20 | codeberg.org/chavacava/garif v0.2.0 // indirect 21 | dario.cat/mergo v1.0.1 // indirect 22 | dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect 23 | dev.gaijin.team/go/golib v0.6.0 // indirect 24 | github.com/4meepo/tagalign v1.4.3 // indirect 25 | github.com/Abirdcfly/dupword v0.1.6 // indirect 26 | github.com/AlwxSin/noinlineerr v1.0.5 // indirect 27 | github.com/Antonboom/errname v1.1.0 // indirect 28 | github.com/Antonboom/nilnil v1.1.0 // indirect 29 | github.com/Antonboom/testifylint v1.6.1 // indirect 30 | github.com/BurntSushi/toml v1.5.0 // indirect 31 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect 32 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 33 | github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect 34 | github.com/alecthomas/chroma/v2 v2.20.0 // indirect 35 | github.com/alecthomas/go-check-sumtype v0.3.1 // indirect 36 | github.com/alexkohler/nakedret/v2 v2.0.6 // indirect 37 | github.com/alexkohler/prealloc v1.0.0 // indirect 38 | github.com/alfatraining/structtag v1.0.0 // indirect 39 | github.com/alingse/asasalint v0.0.11 // indirect 40 | github.com/alingse/nilnesserr v0.2.0 // indirect 41 | github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect 42 | github.com/ashanbrown/makezero/v2 v2.0.1 // indirect 43 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 44 | github.com/beorn7/perks v1.0.1 // indirect 45 | github.com/bep/godartsass/v2 v2.3.2 // indirect 46 | github.com/bep/golibsass v1.2.0 // indirect 47 | github.com/bkielbasa/cyclop v1.2.3 // indirect 48 | github.com/blizzy78/varnamelen v0.8.0 // indirect 49 | github.com/bombsimon/wsl/v4 v4.7.0 // indirect 50 | github.com/bombsimon/wsl/v5 v5.1.1 // indirect 51 | github.com/breml/bidichk v0.3.3 // indirect 52 | github.com/breml/errchkjson v0.4.1 // indirect 53 | github.com/butuzov/ireturn v0.4.0 // indirect 54 | github.com/butuzov/mirror v1.3.0 // indirect 55 | github.com/catenacyber/perfsprint v0.9.1 // indirect 56 | github.com/ccojocar/zxcvbn-go v1.0.4 // indirect 57 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 | github.com/charithe/durationcheck v0.0.10 // indirect 59 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 60 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 61 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 62 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 63 | github.com/charmbracelet/x/term v0.2.1 // indirect 64 | github.com/ckaznocha/intrange v0.3.1 // indirect 65 | github.com/cli/safeexec v1.0.1 // indirect 66 | github.com/creack/pty v1.1.23 // indirect 67 | github.com/curioswitch/go-reassign v0.3.0 // indirect 68 | github.com/daixiang0/gci v0.13.7 // indirect 69 | github.com/dave/dst v0.27.3 // indirect 70 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 71 | github.com/denis-tingaikin/go-header v0.5.0 // indirect 72 | github.com/dlclark/regexp2 v1.11.5 // indirect 73 | github.com/ettle/strcase v0.2.0 // indirect 74 | github.com/fatih/color v1.18.0 // indirect 75 | github.com/fatih/structtag v1.2.0 // indirect 76 | github.com/firefart/nonamedreturns v1.0.6 // indirect 77 | github.com/fsnotify/fsnotify v1.8.0 // indirect 78 | github.com/fzipp/gocyclo v0.6.0 // indirect 79 | github.com/ghostiam/protogetter v0.3.15 // indirect 80 | github.com/go-critic/go-critic v0.13.0 // indirect 81 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 82 | github.com/go-toolsmith/astcast v1.1.0 // indirect 83 | github.com/go-toolsmith/astcopy v1.1.0 // indirect 84 | github.com/go-toolsmith/astequal v1.2.0 // indirect 85 | github.com/go-toolsmith/astfmt v1.1.0 // indirect 86 | github.com/go-toolsmith/astp v1.1.0 // indirect 87 | github.com/go-toolsmith/strparse v1.1.0 // indirect 88 | github.com/go-toolsmith/typep v1.1.0 // indirect 89 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 90 | github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect 91 | github.com/gobwas/glob v0.2.3 // indirect 92 | github.com/gofrs/flock v0.12.1 // indirect 93 | github.com/gohugoio/hugo v0.139.4 // indirect 94 | github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect 95 | github.com/golangci/go-printf-func-name v0.1.0 // indirect 96 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect 97 | github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect 98 | github.com/golangci/misspell v0.7.0 // indirect 99 | github.com/golangci/plugin-module-register v0.1.2 // indirect 100 | github.com/golangci/revgrep v0.8.0 // indirect 101 | github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect 102 | github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect 103 | github.com/google/go-cmp v0.7.0 // indirect 104 | github.com/gordonklaus/ineffassign v0.1.0 // indirect 105 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 106 | github.com/gostaticanalysis/comment v1.5.0 // indirect 107 | github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect 108 | github.com/gostaticanalysis/nilerr v0.1.1 // indirect 109 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect 110 | github.com/hashicorp/go-version v1.7.0 // indirect 111 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 112 | github.com/hashicorp/hcl v1.0.1-vault-5 // indirect 113 | github.com/hexops/gotextdiff v1.0.3 // indirect 114 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 115 | github.com/invopop/yaml v0.3.1 // indirect 116 | github.com/jgautheron/goconst v1.8.2 // indirect 117 | github.com/jingyugao/rowserrcheck v1.1.1 // indirect 118 | github.com/jjti/go-spancheck v0.6.5 // indirect 119 | github.com/julz/importas v0.2.0 // indirect 120 | github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect 121 | github.com/kisielk/errcheck v1.9.0 // indirect 122 | github.com/kkHAIKE/contextcheck v1.1.6 // indirect 123 | github.com/kulti/thelper v0.6.3 // indirect 124 | github.com/kunwardeep/paralleltest v1.0.14 // indirect 125 | github.com/lasiar/canonicalheader v1.1.2 // indirect 126 | github.com/ldez/exptostd v0.4.4 // indirect 127 | github.com/ldez/gomoddirectives v0.7.0 // indirect 128 | github.com/ldez/grignotin v0.10.0 // indirect 129 | github.com/ldez/tagliatelle v0.7.1 // indirect 130 | github.com/ldez/usetesting v0.5.0 // indirect 131 | github.com/leonklingele/grouper v1.1.2 // indirect 132 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 133 | github.com/macabu/inamedparam v0.2.0 // indirect 134 | github.com/magiconair/properties v1.8.7 // indirect 135 | github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect 136 | github.com/manuelarte/funcorder v0.5.0 // indirect 137 | github.com/maratori/testableexamples v1.0.0 // indirect 138 | github.com/maratori/testpackage v1.1.1 // indirect 139 | github.com/matoous/godox v1.1.0 // indirect 140 | github.com/mattn/go-colorable v0.1.14 // indirect 141 | github.com/mattn/go-isatty v0.0.20 // indirect 142 | github.com/mattn/go-runewidth v0.0.16 // indirect 143 | github.com/mgechev/revive v1.11.0 // indirect 144 | github.com/mitchellh/go-homedir v1.1.0 // indirect 145 | github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect 146 | github.com/moricho/tparallel v0.3.2 // indirect 147 | github.com/muesli/termenv v0.16.0 // indirect 148 | github.com/nakabonne/nestif v0.3.1 // indirect 149 | github.com/nishanths/exhaustive v0.12.0 // indirect 150 | github.com/nishanths/predeclared v0.2.2 // indirect 151 | github.com/nunnatsa/ginkgolinter v0.20.0 // indirect 152 | github.com/pelletier/go-toml v1.9.5 // indirect 153 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 154 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 155 | github.com/polyfloyd/go-errorlint v1.8.0 // indirect 156 | github.com/prometheus/client_golang v1.19.0 // indirect 157 | github.com/prometheus/client_model v0.6.0 // indirect 158 | github.com/prometheus/common v0.51.1 // indirect 159 | github.com/prometheus/procfs v0.12.0 // indirect 160 | github.com/quasilyte/go-ruleguard v0.4.4 // indirect 161 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect 162 | github.com/quasilyte/gogrep v0.5.0 // indirect 163 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect 164 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect 165 | github.com/raeperd/recvcheck v0.2.0 // indirect 166 | github.com/rivo/uniseg v0.4.7 // indirect 167 | github.com/rogpeppe/go-internal v1.14.1 // indirect 168 | github.com/ryancurrah/gomodguard v1.4.1 // indirect 169 | github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect 170 | github.com/sagikazarmark/locafero v0.4.0 // indirect 171 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 172 | github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect 173 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect 174 | github.com/sashamelentyev/interfacebloat v1.1.0 // indirect 175 | github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect 176 | github.com/securego/gosec/v2 v2.22.7 // indirect 177 | github.com/sirupsen/logrus v1.9.3 // indirect 178 | github.com/sivchari/containedctx v1.0.3 // indirect 179 | github.com/sonatard/noctx v0.4.0 // indirect 180 | github.com/sourcegraph/conc v0.3.0 // indirect 181 | github.com/sourcegraph/go-diff v0.7.0 // indirect 182 | github.com/spf13/afero v1.14.0 // indirect 183 | github.com/spf13/cast v1.7.0 // indirect 184 | github.com/spf13/pflag v1.0.7 // indirect 185 | github.com/spf13/viper v1.18.2 // indirect 186 | github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect 187 | github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect 188 | github.com/stretchr/objx v0.5.2 // indirect 189 | github.com/subosito/gotenv v1.6.0 // indirect 190 | github.com/tdakkota/asciicheck v0.4.1 // indirect 191 | github.com/tdewolff/parse/v2 v2.7.15 // indirect 192 | github.com/tetafro/godot v1.5.1 // indirect 193 | github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect 194 | github.com/timonwong/loggercheck v0.11.0 // indirect 195 | github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect 196 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect 197 | github.com/ultraware/funlen v0.2.0 // indirect 198 | github.com/ultraware/whitespace v0.2.0 // indirect 199 | github.com/uudashr/gocognit v1.2.0 // indirect 200 | github.com/uudashr/iface v1.4.1 // indirect 201 | github.com/xen0n/gosmopolitan v1.3.0 // indirect 202 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 203 | github.com/yagipy/maintidx v1.0.0 // indirect 204 | github.com/yeya24/promlinter v0.3.0 // indirect 205 | github.com/ykadowak/zerologlint v0.1.5 // indirect 206 | gitlab.com/bosi/decorder v0.4.2 // indirect 207 | go-simpler.org/musttag v0.13.1 // indirect 208 | go-simpler.org/sloglint v0.11.1 // indirect 209 | go.augendre.info/arangolint v0.2.0 // indirect 210 | go.augendre.info/fatcontext v0.8.0 // indirect 211 | go.uber.org/automaxprocs v1.6.0 // indirect 212 | go.uber.org/multierr v1.11.0 // indirect 213 | go.uber.org/zap v1.27.0 // indirect 214 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 215 | golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect 216 | golang.org/x/mod v0.27.0 // indirect 217 | golang.org/x/sync v0.16.0 // indirect 218 | golang.org/x/sys v0.35.0 // indirect 219 | golang.org/x/text v0.27.0 // indirect 220 | golang.org/x/tools v0.36.0 // indirect 221 | google.golang.org/protobuf v1.36.6 // indirect 222 | gopkg.in/ini.v1 v1.67.0 // indirect 223 | honnef.co/go/tools v0.6.1 // indirect 224 | mvdan.cc/gofumpt v0.8.0 // indirect 225 | mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect 226 | ) 227 | -------------------------------------------------------------------------------- /.sample_files/go.mod.sample: -------------------------------------------------------------------------------- 1 | // This is a generated file. Do not edit directly. 2 | // Ensure you've carefully read 3 | // https://git.k8s.io/community/contributors/devel/sig-architecture/vendor.md 4 | // Run hack/pin-dependency.sh to change pinned dependency versions. 5 | // Run hack/update-vendor.sh to update go.mod files and the vendor directory. 6 | 7 | module k8s.io/kubernetes 8 | 9 | go 1.23.0 10 | 11 | godebug default=go1.23 12 | 13 | require ( 14 | bitbucket.org/bertimus9/systemstat v0.5.0 15 | github.com/JeffAshton/win_pdh v0.0.0-20161109143554-76bb4ee9f0ab 16 | github.com/Microsoft/go-winio v0.6.2 17 | github.com/Microsoft/hnslib v0.0.7 18 | github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 19 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 20 | github.com/blang/semver/v4 v4.0.0 21 | github.com/container-storage-interface/spec v1.9.0 22 | github.com/coredns/corefile-migration v1.0.24 23 | github.com/coreos/go-oidc v2.2.1+incompatible 24 | github.com/coreos/go-systemd/v22 v22.5.0 25 | github.com/cpuguy83/go-md2man/v2 v2.0.4 26 | github.com/cyphar/filepath-securejoin v0.3.4 27 | github.com/distribution/reference v0.6.0 28 | github.com/docker/go-units v0.5.0 29 | github.com/emicklei/go-restful/v3 v3.11.0 30 | github.com/fsnotify/fsnotify v1.7.0 31 | github.com/go-logr/logr v1.4.2 32 | github.com/go-openapi/jsonreference v0.20.2 33 | github.com/godbus/dbus/v5 v5.1.0 34 | github.com/gogo/protobuf v1.3.2 35 | github.com/golang/protobuf v1.5.4 36 | github.com/google/cadvisor v0.51.0 37 | github.com/google/cel-go v0.21.0 38 | github.com/google/gnostic-models v0.6.8 39 | github.com/google/go-cmp v0.6.0 40 | github.com/google/gofuzz v1.2.0 41 | github.com/google/uuid v1.6.0 42 | github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 43 | github.com/libopenstorage/openstorage v1.0.0 44 | github.com/lithammer/dedent v1.1.0 45 | github.com/moby/ipvs v1.1.0 46 | github.com/moby/sys/userns v0.1.0 47 | github.com/mrunalp/fileutils v0.5.1 48 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 49 | github.com/onsi/ginkgo/v2 v2.21.0 50 | github.com/onsi/gomega v1.35.1 51 | github.com/opencontainers/runc v1.2.1 52 | github.com/opencontainers/selinux v1.11.0 53 | github.com/pkg/errors v0.9.1 54 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 55 | github.com/prometheus/client_golang v1.19.1 56 | github.com/prometheus/client_model v0.6.1 57 | github.com/prometheus/common v0.55.0 58 | github.com/robfig/cron/v3 v3.0.1 59 | github.com/spf13/cobra v1.8.1 60 | github.com/spf13/pflag v1.0.5 61 | github.com/stretchr/testify v1.9.0 62 | github.com/vishvananda/netlink v1.3.1-0.20240905180732-b1ce50cfa9be 63 | github.com/vishvananda/netns v0.0.4 64 | go.etcd.io/etcd/api/v3 v3.5.16 65 | go.etcd.io/etcd/client/pkg/v3 v3.5.16 66 | go.etcd.io/etcd/client/v3 v3.5.16 67 | go.opentelemetry.io/contrib/instrumentation/github.com/emicklei/go-restful/otelrestful v0.42.0 68 | go.opentelemetry.io/otel v1.28.0 69 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 70 | go.opentelemetry.io/otel/metric v1.28.0 71 | go.opentelemetry.io/otel/sdk v1.28.0 72 | go.opentelemetry.io/otel/trace v1.28.0 73 | go.opentelemetry.io/proto/otlp v1.3.1 74 | go.uber.org/goleak v1.3.0 75 | go.uber.org/zap v1.27.0 76 | golang.org/x/crypto v0.28.0 77 | golang.org/x/net v0.30.0 78 | golang.org/x/oauth2 v0.23.0 79 | golang.org/x/sync v0.8.0 80 | golang.org/x/sys v0.26.0 81 | golang.org/x/term v0.25.0 82 | golang.org/x/time v0.7.0 83 | golang.org/x/tools v0.26.0 84 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 85 | google.golang.org/grpc v1.65.0 86 | google.golang.org/protobuf v1.35.1 87 | gopkg.in/evanphx/json-patch.v4 v4.12.0 88 | gopkg.in/square/go-jose.v2 v2.6.0 89 | k8s.io/api v0.0.0 90 | k8s.io/apiextensions-apiserver v0.0.0 91 | k8s.io/apimachinery v0.0.0 92 | k8s.io/apiserver v0.0.0 93 | k8s.io/cli-runtime v0.0.0 94 | k8s.io/client-go v0.0.0 95 | k8s.io/cloud-provider v0.0.0 96 | k8s.io/cluster-bootstrap v0.0.0 97 | k8s.io/code-generator v0.0.0 98 | k8s.io/component-base v0.0.0 99 | k8s.io/component-helpers v0.0.0 100 | k8s.io/controller-manager v0.0.0 101 | k8s.io/cri-api v0.0.0 102 | k8s.io/cri-client v0.0.0 103 | k8s.io/csi-translation-lib v0.0.0 104 | k8s.io/dynamic-resource-allocation v0.0.0 105 | k8s.io/endpointslice v0.0.0 106 | k8s.io/klog/v2 v2.130.1 107 | k8s.io/kms v0.0.0 108 | k8s.io/kube-aggregator v0.0.0 109 | k8s.io/kube-controller-manager v0.0.0 110 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f 111 | k8s.io/kube-proxy v0.0.0 112 | k8s.io/kube-scheduler v0.0.0 113 | k8s.io/kubectl v0.0.0 114 | k8s.io/kubelet v0.0.0 115 | k8s.io/metrics v0.0.0 116 | k8s.io/mount-utils v0.0.0 117 | k8s.io/pod-security-admission v0.0.0 118 | k8s.io/sample-apiserver v0.0.0 119 | k8s.io/system-validators v1.9.1 120 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 121 | sigs.k8s.io/knftables v0.0.17 122 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 123 | sigs.k8s.io/yaml v1.4.0 124 | ) 125 | 126 | require ( 127 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 128 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 129 | github.com/NYTimes/gziphandler v1.1.1 // indirect 130 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 131 | github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect 132 | github.com/beorn7/perks v1.0.1 // indirect 133 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 134 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 135 | github.com/chai2010/gettext-go v1.0.2 // indirect 136 | github.com/containerd/containerd/api v1.7.19 // indirect 137 | github.com/containerd/errdefs v0.1.0 // indirect 138 | github.com/containerd/log v0.1.0 // indirect 139 | github.com/containerd/ttrpc v1.2.5 // indirect 140 | github.com/coredns/caddy v1.1.1 // indirect 141 | github.com/coreos/go-semver v0.3.1 // indirect 142 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 143 | github.com/dustin/go-humanize v1.0.1 // indirect 144 | github.com/euank/go-kmsg-parser v2.0.0+incompatible // indirect 145 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 146 | github.com/fatih/camelcase v1.0.0 // indirect 147 | github.com/felixge/httpsnoop v1.0.4 // indirect 148 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 149 | github.com/go-errors/errors v1.4.2 // indirect 150 | github.com/go-logr/stdr v1.2.2 // indirect 151 | github.com/go-logr/zapr v1.3.0 // indirect 152 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 153 | github.com/go-openapi/swag v0.23.0 // indirect 154 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 155 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 156 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 157 | github.com/google/btree v1.0.1 // indirect 158 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect 159 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 160 | github.com/gorilla/websocket v1.5.0 // indirect 161 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 162 | github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect 163 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect 164 | github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect 165 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect 166 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 167 | github.com/jonboulle/clockwork v0.4.0 // indirect 168 | github.com/josharian/intern v1.0.0 // indirect 169 | github.com/json-iterator/go v1.1.12 // indirect 170 | github.com/karrick/godirwalk v1.17.0 // indirect 171 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 172 | github.com/mailru/easyjson v0.7.7 // indirect 173 | github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible // indirect 174 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 175 | github.com/moby/spdystream v0.5.0 // indirect 176 | github.com/moby/sys/mountinfo v0.7.2 // indirect 177 | github.com/moby/term v0.5.0 // indirect 178 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 179 | github.com/modern-go/reflect2 v1.0.2 // indirect 180 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 181 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 182 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 183 | github.com/opencontainers/go-digest v1.0.0 // indirect 184 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 185 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 186 | github.com/pquerna/cachecontrol v0.1.0 // indirect 187 | github.com/prometheus/procfs v0.15.1 // indirect 188 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 189 | github.com/sirupsen/logrus v1.9.3 // indirect 190 | github.com/soheilhy/cmux v0.1.5 // indirect 191 | github.com/stoewer/go-strcase v1.3.0 // indirect 192 | github.com/stretchr/objx v0.5.2 // indirect 193 | github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect 194 | github.com/x448/float16 v0.8.4 // indirect 195 | github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 // indirect 196 | github.com/xlab/treeprint v1.2.0 // indirect 197 | go.etcd.io/bbolt v1.3.11 // indirect 198 | go.etcd.io/etcd/client/v2 v2.305.16 // indirect 199 | go.etcd.io/etcd/pkg/v3 v3.5.16 // indirect 200 | go.etcd.io/etcd/raft/v3 v3.5.16 // indirect 201 | go.etcd.io/etcd/server/v3 v3.5.16 // indirect 202 | go.opencensus.io v0.24.0 // indirect 203 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect 204 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 205 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect 206 | go.uber.org/multierr v1.11.0 // indirect 207 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 208 | golang.org/x/mod v0.21.0 // indirect 209 | golang.org/x/text v0.19.0 // indirect 210 | google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect 211 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect 212 | gopkg.in/inf.v0 v0.9.1 // indirect 213 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 214 | gopkg.in/yaml.v3 v3.0.1 // indirect 215 | k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect 216 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect 217 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 218 | sigs.k8s.io/kustomize/api v0.18.0 // indirect 219 | sigs.k8s.io/kustomize/kustomize/v5 v5.5.0 // indirect 220 | sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect 221 | ) 222 | 223 | replace ( 224 | k8s.io/api => ./staging/src/k8s.io/api 225 | k8s.io/apiextensions-apiserver => ./staging/src/k8s.io/apiextensions-apiserver 226 | k8s.io/apimachinery => ./staging/src/k8s.io/apimachinery 227 | k8s.io/apiserver => ./staging/src/k8s.io/apiserver 228 | k8s.io/cli-runtime => ./staging/src/k8s.io/cli-runtime 229 | k8s.io/client-go => ./staging/src/k8s.io/client-go 230 | k8s.io/cloud-provider => ./staging/src/k8s.io/cloud-provider 231 | k8s.io/cluster-bootstrap => ./staging/src/k8s.io/cluster-bootstrap 232 | k8s.io/code-generator => ./staging/src/k8s.io/code-generator 233 | k8s.io/component-base => ./staging/src/k8s.io/component-base 234 | k8s.io/component-helpers => ./staging/src/k8s.io/component-helpers 235 | k8s.io/controller-manager => ./staging/src/k8s.io/controller-manager 236 | k8s.io/cri-api => ./staging/src/k8s.io/cri-api 237 | k8s.io/cri-client => ./staging/src/k8s.io/cri-client 238 | k8s.io/csi-translation-lib => ./staging/src/k8s.io/csi-translation-lib 239 | k8s.io/dynamic-resource-allocation => ./staging/src/k8s.io/dynamic-resource-allocation 240 | k8s.io/endpointslice => ./staging/src/k8s.io/endpointslice 241 | k8s.io/kms => ./staging/src/k8s.io/kms 242 | k8s.io/kube-aggregator => ./staging/src/k8s.io/kube-aggregator 243 | k8s.io/kube-controller-manager => ./staging/src/k8s.io/kube-controller-manager 244 | k8s.io/kube-proxy => ./staging/src/k8s.io/kube-proxy 245 | k8s.io/kube-scheduler => ./staging/src/k8s.io/kube-scheduler 246 | k8s.io/kubectl => ./staging/src/k8s.io/kubectl 247 | k8s.io/kubelet => ./staging/src/k8s.io/kubelet 248 | k8s.io/metrics => ./staging/src/k8s.io/metrics 249 | k8s.io/mount-utils => ./staging/src/k8s.io/mount-utils 250 | k8s.io/pod-security-admission => ./staging/src/k8s.io/pod-security-admission 251 | k8s.io/sample-apiserver => ./staging/src/k8s.io/sample-apiserver 252 | k8s.io/sample-cli-plugin => ./staging/src/k8s.io/sample-cli-plugin 253 | k8s.io/sample-controller => ./staging/src/k8s.io/sample-controller 254 | ) 255 | -------------------------------------------------------------------------------- /coverage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | analyzer: Go Coverage Report 7 | 52 | 53 | 54 |
55 | 84 |
85 | not tracked 86 | 87 | no coverage 88 | low coverage 89 | * 90 | * 91 | * 92 | * 93 | * 94 | * 95 | * 96 | * 97 | high coverage 98 | 99 |
100 |
101 |
102 | 103 | 384 | 385 | 441 | 442 | 637 | 638 | 655 | 656 | 894 | 895 | 979 | 980 | 1205 | 1206 | 1234 | 1235 | 1268 | 1269 | 1504 | 1505 | 1533 | 1534 | 1593 | 1594 |
1595 | 1596 | 1623 | 1624 | --------------------------------------------------------------------------------