├── .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 | 
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 |
56 |
83 |
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 |
package analyzer
104 |
105 | import (
106 | "context"
107 | "encoding/json"
108 | "errors"
109 | "fmt"
110 | "net/http"
111 | "strings"
112 | "time"
113 |
114 | "github.com/uzumaki-inc/stay_or_go/utils"
115 | )
116 |
117 | var (
118 | ErrGitHubTokenNotSet = errors.New("GitHub token not set")
119 | ErrFailedToAssertDefaultBranch = errors.New("failed to assert type for default_branch")
120 | ErrFailedToAssertDate = errors.New("failed to assert type for date")
121 |
122 | ErrFailedToAssertName = errors.New("failed to assert type for name")
123 | ErrFailedToAssertSubscribersCount = errors.New("failed to assert type for subscribers_count")
124 | ErrFailedToAssertStargazersCount = errors.New("failed to assert type for stargazers_count")
125 | ErrFailedToAssertForksCount = errors.New("failed to assert type for forks_count")
126 | ErrFailedToAssertOpenIssuesCount = errors.New("failed to assert type for open_issues_count")
127 | ErrFailedToAssertArchived = errors.New("failed to assert type for archived")
128 | ErrUnexpectedStatusCode = errors.New("unexpected status code")
129 | )
130 |
131 | const (
132 | hoursOfDay = 24
133 | timeOutSec = 5
134 | )
135 |
136 | type RepoData struct {
137 | Name string `json:"name"`
138 | SubscribersCount int `json:"subscribers_count"`
139 | StargazersCount int `json:"stargazers_count"`
140 | ForksCount int `json:"forks_count"`
141 | OpenIssuesCount int `json:"open_issues_count"`
142 | Archived bool `json:"archived"`
143 | DefaultBranch string `json:"default_branch"`
144 | }
145 |
146 | type CommitData struct {
147 | Commit struct {
148 | Committer struct {
149 | Date string `json:"date"`
150 | } `json:"committer"`
151 | } `json:"commit"`
152 | }
153 |
154 | type GitHubRepoInfo struct {
155 | RepositoryName string
156 | Watchers int
157 | Stars int
158 | Forks int
159 | OpenIssues int
160 | LastCommitDate string
161 | GithubRepoURL string
162 | Archived bool
163 | Score int
164 | Skip bool // スキップするかどうかのフラグ
165 | SkipReason string // スキップ理由
166 | }
167 |
168 | type GitHubRepoAnalyzer struct {
169 | githubToken string
170 | weights ParameterWeights
171 | }
172 |
173 | func NewGitHubRepoAnalyzer(token string, weights ParameterWeights) *GitHubRepoAnalyzer {
174 | return &GitHubRepoAnalyzer{
175 | githubToken: token,
176 | weights: weights,
177 | }
178 | }
179 |
180 | // FetchInfo fetches information for each repository
181 | func (g *GitHubRepoAnalyzer) FetchGithubInfo(repositoryUrls []string) []GitHubRepoInfo {
182 | libraryInfoList := make([]GitHubRepoInfo, 0, len(repositoryUrls))
183 | client := &http.Client{}
184 |
185 | for _, repoURL := range repositoryUrls {
186 | utils.DebugPrintln("Fetching: " + repoURL)
187 |
188 | libraryInfo, err := g.getGitHubInfo(client, repoURL)
189 | if err != nil {
190 | libraryInfo = &GitHubRepoInfo{
191 | Skip: true,
192 | SkipReason: "Failed fetching " + repoURL + " from GitHub",
193 | }
194 |
195 | utils.StdErrorPrintln("Failed fetching %s, error details: %v", repoURL, err)
196 | }
197 |
198 | libraryInfo.GithubRepoURL = repoURL
199 | libraryInfoList = append(libraryInfoList, *libraryInfo)
200 | }
201 |
202 | return libraryInfoList
203 | }
204 |
205 | func (g *GitHubRepoAnalyzer) getGitHubInfo(
206 | client *http.Client,
207 | repoURL string,
208 | ) (*GitHubRepoInfo, error) {
209 | if g.githubToken == "" {
210 | return nil, ErrGitHubTokenNotSet
211 | }
212 |
213 | owner, repo := parseRepoURL(repoURL)
214 |
215 | headers := map[string]string{
216 | "Authorization": "token " + g.githubToken,
217 | }
218 |
219 | repoData, err := fetchRepoData(client, owner, repo, headers)
220 | if err != nil {
221 | return nil, err
222 | }
223 |
224 | lastCommitDate, err := fetchLastCommitDate(client, owner, repo, repoData, headers)
225 | if err != nil {
226 | return nil, err
227 | }
228 |
229 | repoInfo := createRepoInfo(repoData, lastCommitDate)
230 |
231 | calcScore(repoInfo, &g.weights)
232 |
233 | return repoInfo, nil
234 | }
235 |
236 | func parseRepoURL(repoURL string) (string, string) {
237 | repoURL = strings.TrimSuffix(repoURL, "/")
238 | parts := strings.Split(repoURL, "/")
239 |
240 | var owner, repo string
241 |
242 | if strings.Contains(repoURL, "/tree/") {
243 | baseIndex := indexOf(parts, "github.com") + 1
244 | owner, repo = parts[baseIndex], parts[baseIndex+1]
245 | } else {
246 | owner, repo = parts[len(parts)-2], parts[len(parts)-1]
247 | }
248 |
249 | repo = strings.TrimSuffix(repo, ".git")
250 |
251 | return owner, repo
252 | }
253 |
254 | func fetchRepoData(
255 | client *http.Client,
256 | owner, repo string,
257 | headers map[string]string,
258 | ) (*RepoData, error) {
259 | var repoData RepoData
260 |
261 | err := fetchJSONData(client, fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo), headers, &repoData)
262 | if err != nil {
263 | return nil, err
264 | }
265 |
266 | return &repoData, nil
267 | }
268 |
269 | func fetchLastCommitDate(client *http.Client, owner, repo string,
270 | repoData *RepoData, headers map[string]string,
271 | ) (string, error) {
272 | commitURL := "https://api.github.com/repos/" + owner + "/" + repo + "/commits/" + repoData.DefaultBranch
273 |
274 | var commitData CommitData
275 |
276 | err := fetchJSONData(client, commitURL, headers, &commitData)
277 | if err != nil {
278 | return "", err
279 | }
280 |
281 | return commitData.Commit.Committer.Date, nil
282 | }
283 |
284 | func createRepoInfo(
285 | repoData *RepoData,
286 | lastCommitDate string,
287 | ) *GitHubRepoInfo {
288 | return &GitHubRepoInfo{
289 | RepositoryName: repoData.Name,
290 | Watchers: repoData.SubscribersCount,
291 | Stars: repoData.StargazersCount,
292 | Forks: repoData.ForksCount,
293 | OpenIssues: repoData.OpenIssuesCount,
294 | LastCommitDate: lastCommitDate,
295 | Archived: repoData.Archived,
296 | Skip: false,
297 | SkipReason: "",
298 | }
299 | }
300 |
301 | func calcScore(repoInfo *GitHubRepoInfo, weights *ParameterWeights) {
302 | days, err := daysSince(repoInfo.LastCommitDate)
303 | if err != nil {
304 | repoInfo.Skip = true
305 |
306 | repoInfo.SkipReason = "Date Format Error: " + repoInfo.LastCommitDate
307 |
308 | utils.StdErrorPrintln("Date Format Error: %v", err)
309 | }
310 |
311 | score := float64(repoInfo.Watchers) * weights.Watchers
312 | score += float64(repoInfo.Stars) * weights.Stars
313 | score += float64(repoInfo.Forks) * weights.Forks
314 | score += float64(repoInfo.OpenIssues) * weights.OpenIssues
315 | score += float64(days) * weights.LastCommitDate
316 | intArchived := map[bool]float64{true: 1.0, false: 0.0}[repoInfo.Archived]
317 | score += (intArchived) * weights.Archived
318 |
319 | repoInfo.Score = int(score)
320 | }
321 |
322 | // 日付文字列から現在日までの経過日数を返す関数
323 | func daysSince(dateStr string) (int, error) {
324 | // 入力された日付文字列をパース(UTCフォーマット)
325 | layout := "2006-01-02T15:04:05Z"
326 |
327 | parsedTime, err := time.Parse(layout, dateStr)
328 | if err != nil {
329 | return 0, fmt.Errorf("failed to parse date '%s': %w", dateStr, err)
330 | }
331 |
332 | currentTime := time.Now()
333 | duration := currentTime.Sub(parsedTime)
334 | days := int(duration.Hours() / hoursOfDay)
335 |
336 | return days, nil
337 | }
338 |
339 | func fetchJSONData(
340 | client *http.Client,
341 | url string,
342 | headers map[string]string,
343 | result interface{},
344 | ) error {
345 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second)
346 | defer cancel()
347 |
348 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
349 | if err != nil {
350 | return fmt.Errorf("failed to create new HTTP request for URL %s: %w", url, err)
351 | }
352 |
353 | for key, value := range headers {
354 | req.Header.Set(key, value)
355 | }
356 |
357 | resp, err := client.Do(req)
358 | if err != nil {
359 | return fmt.Errorf("failed to execute HTTP request for URL %s: %w", url, err)
360 | }
361 | defer resp.Body.Close()
362 |
363 | if resp.StatusCode != http.StatusOK {
364 | return fmt.Errorf("%w: %d for URL %s", ErrUnexpectedStatusCode, resp.StatusCode, url)
365 | }
366 |
367 | if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
368 | return fmt.Errorf("failed to decode JSON response for URL %s: %w", url, err)
369 | }
370 |
371 | return nil
372 | }
373 |
374 | func indexOf(slice []string, value string) int {
375 | for i, v := range slice {
376 | if v == value {
377 | return i
378 | }
379 | }
380 |
381 | return -1
382 | }
383 |
384 |
385 |
package analyzer
386 |
387 | import (
388 | "os"
389 |
390 | "github.com/spf13/viper"
391 | "github.com/uzumaki-inc/stay_or_go/utils"
392 | )
393 |
394 | const (
395 | defaultWatcherWeight = 0.1
396 | defaultStarWeight = 0.1
397 | defaultForkWeight = 0.1
398 | defaultOpenPullRequestWeight = 0.01
399 | defaultOpenIssueWeight = 0.01
400 | defaultLastCommitDateWeight = -0.05
401 | defaultArchivedWeight = -1000000
402 | )
403 |
404 | type ParameterWeights struct {
405 | Watchers float64 `mapstructure:"watchers"`
406 | Stars float64 `mapstructure:"stars"`
407 | Forks float64 `mapstructure:"forks"`
408 | OpenIssues float64 `mapstructure:"open_issues"`
409 | LastCommitDate float64 `mapstructure:"last_commit_date"`
410 | Archived float64 `mapstructure:"archived"`
411 | }
412 |
413 | func NewParameterWeights() ParameterWeights {
414 | return ParameterWeights{
415 | Watchers: defaultWatcherWeight,
416 | Stars: defaultStarWeight,
417 | Forks: defaultForkWeight,
418 | OpenIssues: defaultOpenIssueWeight,
419 | LastCommitDate: defaultLastCommitDateWeight,
420 | Archived: defaultArchivedWeight,
421 | }
422 | }
423 |
424 | func NewParameterWeightsFromConfiFile(configFilePath string) ParameterWeights {
425 | viper.SetConfigFile(configFilePath)
426 |
427 | if err := viper.ReadInConfig(); err != nil {
428 | utils.StdErrorPrintln("Failed to read the configuration file: %v\n", err)
429 | os.Exit(1)
430 | }
431 |
432 | var weights ParameterWeights
433 | if err := viper.Unmarshal(&weights); err != nil {
434 | utils.StdErrorPrintln("Failed to unmarshal the configuration: %v\n", err)
435 | os.Exit(1)
436 | }
437 |
438 | return weights
439 | }
440 |
441 |
442 |
package cmd
443 |
444 | import (
445 | "fmt"
446 | "os"
447 | "strings"
448 |
449 | "github.com/spf13/cobra"
450 | "github.com/uzumaki-inc/stay_or_go/analyzer"
451 | "github.com/uzumaki-inc/stay_or_go/parser"
452 | "github.com/uzumaki-inc/stay_or_go/presenter"
453 | "github.com/uzumaki-inc/stay_or_go/utils"
454 | )
455 |
456 | // var greeting string
457 | var (
458 | filePath string
459 | outputFormat string
460 | githubToken string
461 | configFilePath string
462 |
463 | supportedLanguages = []string{"ruby", "go"}
464 | languageConfigMap = map[string]string{
465 | "ruby": "Gemfile",
466 | "go": "go.mod",
467 | }
468 | supportedOutputFormats = map[string]bool{
469 | "csv": true,
470 | "tsv": true,
471 | "markdown": true,
472 | }
473 | )
474 |
475 | // AnalyzerPort is a minimal adapter for analyzer used by cmd to enable testing with stubs.
476 | type AnalyzerPort interface {
477 | FetchGithubInfo(repositoryUrls []string) []analyzer.GitHubRepoInfo
478 | }
479 |
480 | // PresenterPort narrows the presenter to only what's used here.
481 | type PresenterPort interface {
482 | Display()
483 | }
484 |
485 | // Deps bundles injectable constructors/selectors for testability.
486 | type Deps struct {
487 | NewAnalyzer func(token string, weights analyzer.ParameterWeights) AnalyzerPort
488 | SelectParser func(language string) (parser.Parser, error)
489 | SelectPresenter func(format string, analyzedLibInfos []presenter.AnalyzedLibInfo) PresenterPort
490 | }
491 |
492 | var defaultDeps = Deps{
493 | NewAnalyzer: func(token string, weights analyzer.ParameterWeights) AnalyzerPort {
494 | return analyzer.NewGitHubRepoAnalyzer(token, weights)
495 | },
496 | SelectParser: parser.SelectParser,
497 | SelectPresenter: func(format string, analyzedLibInfos []presenter.AnalyzedLibInfo) PresenterPort {
498 | return presenter.SelectPresenter(format, analyzedLibInfos)
499 | },
500 | }
501 |
502 | // 引数を全部設定するlintを回避
503 | //
504 | //nolint:exhaustruct, lll
505 | var rootCmd = &cobra.Command{
506 | Use: "stay_or_go",
507 | Version: "0.1.2",
508 | Short: "Analyze and score your Go and Ruby dependencies for popularity and maintenance",
509 | Long: `stay_or_go scans your Go (go.mod) and Ruby (Gemfile) dependency files to evaluate each library's popularity and maintenance status.
510 | It generates scores to help you decide whether to keep (‘Stay’) or replace (‘Go’) your dependencies.
511 | Output the results in Markdown, CSV, or TSV formats.`,
512 | Run: func(_ *cobra.Command, args []string) {
513 | if len(args) == 0 {
514 | fmt.Fprintln(os.Stderr, "Please Enter specify a language ("+
515 | strings.Join(supportedLanguages, " or ")+")")
516 | os.Exit(1)
517 | }
518 |
519 | language := args[0]
520 | // Delegate to testable runner
521 | if err := run(language, filePath, outputFormat, githubToken, configFilePath, utils.Verbose, defaultDeps); err != nil {
522 | os.Exit(1)
523 | }
524 | },
525 | }
526 |
527 | func isSupportedLanguage(language string) bool {
528 | for _, l := range supportedLanguages {
529 | if l == language {
530 | return true
531 | }
532 | }
533 |
534 | return false
535 | }
536 |
537 | // run executes the core logic with injectable dependencies. Returns error instead of exiting.
538 | func run(language, inFile, format, token, config string, verbose bool, deps Deps) error {
539 | if !isSupportedLanguage(language) {
540 | utils.StdErrorPrintln("Error: Unsupported language: %s. Supported languages are: %s\n",
541 | language, strings.Join(supportedLanguages, ", "))
542 | return fmt.Errorf("unsupported language: %s", language)
543 | }
544 |
545 | file := inFile
546 | if file == "" {
547 | file = languageConfigMap[language]
548 | }
549 |
550 | if !supportedOutputFormats[format] {
551 | var keys []string
552 | for key := range supportedOutputFormats {
553 | keys = append(keys, key)
554 | }
555 | utils.StdErrorPrintln("Error: Unsupported output format: %s. Supported output formats are: %s\n",
556 | format, strings.Join(keys, ", "))
557 | return fmt.Errorf("unsupported format: %s", format)
558 | }
559 |
560 | if token == "" {
561 | token = os.Getenv("GITHUB_TOKEN")
562 | if token == "" {
563 | fmt.Fprintln(os.Stderr, "Please provide a GitHub token using the --github-token flag or set the GITHUB_TOKEN environment variable")
564 | return fmt.Errorf("missing github token")
565 | }
566 | }
567 |
568 | utils.DebugPrintln("Selected Language: " + language)
569 | utils.DebugPrintln("Reading file: " + file)
570 | utils.DebugPrintln("Output format: " + format)
571 |
572 | var weights analyzer.ParameterWeights
573 | if config != "" {
574 | utils.DebugPrintln("Config file: " + config)
575 | weights = analyzer.NewParameterWeightsFromConfiFile(config)
576 | } else {
577 | weights = analyzer.NewParameterWeights()
578 | }
579 | az := deps.NewAnalyzer(token, weights)
580 |
581 | utils.StdErrorPrintln("Selecting language... ")
582 | pr, err := deps.SelectParser(language)
583 | if err != nil {
584 | utils.StdErrorPrintln("Error selecting parser: %v", err)
585 | return err
586 | }
587 | utils.StdErrorPrintln("Parsing file...")
588 | libInfoList, err := pr.Parse(file)
589 | if err != nil {
590 | utils.StdErrorPrintln("Error parsing file: %v", err)
591 | return err
592 | }
593 | utils.StdErrorPrintln("Getting repository URLs...")
594 | pr.GetRepositoryURL(libInfoList)
595 |
596 | var repoURLs []string
597 | for _, info := range libInfoList {
598 | if !info.Skip {
599 | repoURLs = append(repoURLs, info.RepositoryURL)
600 | }
601 | }
602 |
603 | utils.StdErrorPrintln("Analyzing libraries with Github...")
604 | var gitHubRepoInfos []analyzer.GitHubRepoInfo
605 | if len(repoURLs) > 0 {
606 | gitHubRepoInfos = az.FetchGithubInfo(repoURLs)
607 | } else {
608 | gitHubRepoInfos = []analyzer.GitHubRepoInfo{}
609 | }
610 |
611 | utils.StdErrorPrintln("Making dataset...")
612 | analyzedLibInfos := presenter.MakeAnalyzedLibInfoList(libInfoList, gitHubRepoInfos)
613 | pz := deps.SelectPresenter(format, analyzedLibInfos)
614 |
615 | utils.StdErrorPrintln("Displaying result...\n")
616 | pz.Display()
617 |
618 | return nil
619 | }
620 |
621 | func Execute() {
622 | err := rootCmd.Execute()
623 | if err != nil {
624 | os.Exit(1)
625 | }
626 | }
627 |
628 | func init() {
629 | rootCmd.Flags().StringVarP(&filePath, "input", "i", "", "Specify the file to read")
630 | rootCmd.Flags().StringVarP(&outputFormat, "format", "f", "markdown", "Specify the output format (csv, tsv, markdown)")
631 | rootCmd.Flags().StringVarP(&githubToken, "github-token", "g", "", "GitHub token for authentication")
632 | rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
633 | rootCmd.Flags().BoolVarP(&utils.Verbose, "verbose", "v", false, "Enable verbose output")
634 | rootCmd.Flags().StringVarP(&configFilePath, "config", "c", "", "Modify evaluate parameters")
635 | }
636 |
637 |
638 |
/*
639 | Copyright © 2024 @konyu
640 | */
641 | package main
642 |
643 | import (
644 | "github.com/joho/godotenv"
645 | "github.com/uzumaki-inc/stay_or_go/cmd"
646 | )
647 |
648 | func main() {
649 | // .envファイルを読み込む
650 | _ = godotenv.Load()
651 |
652 | cmd.Execute()
653 | }
654 |
655 |
656 |
package parser
657 |
658 | import (
659 | "bufio"
660 | "context"
661 | "encoding/json"
662 | "io"
663 | "net/http"
664 | "net/url"
665 | "os"
666 | "strings"
667 | "time"
668 |
669 | "github.com/uzumaki-inc/stay_or_go/utils"
670 | )
671 |
672 | type GoParser struct{}
673 |
674 | func (p GoParser) Parse(filePath string) ([]LibInfo, error) {
675 | file, err := os.Open(filePath)
676 | if err != nil {
677 | utils.StdErrorPrintln("%v: %v", ErrFailedToReadFile, err)
678 | os.Exit(1)
679 | }
680 | defer file.Close()
681 |
682 | replaceModules := p.collectReplaceModules(file)
683 | libInfoList := p.processRequireBlock(file, replaceModules)
684 |
685 | return libInfoList, nil
686 | }
687 |
688 | func (p GoParser) collectReplaceModules(file *os.File) []string {
689 | var replaceModules []string
690 |
691 | var inReplaceBlock bool
692 |
693 | scanner := bufio.NewScanner(file)
694 | for scanner.Scan() {
695 | line := strings.TrimSpace(scanner.Text())
696 |
697 | if line == "replace (" {
698 | inReplaceBlock = true
699 |
700 | continue
701 | }
702 |
703 | if line == ")" && inReplaceBlock {
704 | inReplaceBlock = false
705 |
706 | continue
707 | }
708 |
709 | if inReplaceBlock {
710 | parts := strings.Fields(line)
711 | if len(parts) > 0 {
712 | replaceModules = append(replaceModules, parts[0])
713 | }
714 | }
715 | }
716 |
717 | if _, err := file.Seek(0, 0); err != nil { // Reset file pointer for next pass
718 | utils.StdErrorPrintln("%v: %v", ErrFailedToResetFilePointer, err)
719 | os.Exit(1)
720 | }
721 |
722 | return replaceModules
723 | }
724 |
725 | func (p GoParser) processRequireBlock(file *os.File, replaceModules []string) []LibInfo {
726 | var libInfoList []LibInfo
727 |
728 | var inRequireBlock bool
729 |
730 | scanner := bufio.NewScanner(file)
731 | for scanner.Scan() {
732 | line := strings.TrimSpace(scanner.Text())
733 |
734 | if line == "require (" {
735 | inRequireBlock = true
736 |
737 | continue
738 | }
739 |
740 | if line == ")" && inRequireBlock {
741 | inRequireBlock = false
742 |
743 | continue
744 | }
745 |
746 | if inRequireBlock && !strings.Contains(line, "// indirect") {
747 | parts := strings.Fields(line)
748 | if len(parts) > 0 {
749 | module := parts[0]
750 | libParts := strings.Split(parts[0], "/")
751 | libName := libParts[len(libParts)-1]
752 |
753 | var newLib LibInfo
754 |
755 | if contains(replaceModules, module) {
756 | newLib = NewLibInfo(libName, WithSkip(true), WithSkipReason("replaced module"))
757 | } else {
758 | newLib = NewLibInfo(libName, WithOthers([]string{parts[0], parts[1]}))
759 | }
760 |
761 | libInfoList = append(libInfoList, newLib)
762 | }
763 | }
764 | }
765 |
766 | if err := scanner.Err(); err != nil {
767 | utils.StdErrorPrintln("%v: %v", ErrFailedToScanFile, err)
768 | os.Exit(1)
769 | }
770 |
771 | return libInfoList
772 | }
773 |
774 | func contains(slice []string, item string) bool {
775 | for _, s := range slice {
776 | if s == item {
777 | return true
778 | }
779 | }
780 |
781 | return false
782 | }
783 |
784 | func (p GoParser) GetRepositoryURL(libInfoList []LibInfo) []LibInfo {
785 | client := &http.Client{}
786 |
787 | for i := range libInfoList {
788 | libInfo := &libInfoList[i]
789 |
790 | if libInfo.Skip {
791 | continue
792 | }
793 |
794 | name := libInfo.Others[0]
795 | version := libInfo.Others[1]
796 |
797 | repoURL, err := p.getGitHubRepositoryURL(client, name, version)
798 | if err != nil {
799 | libInfo.Skip = true
800 | libInfo.SkipReason = "Does not support libraries hosted outside of Github"
801 |
802 | utils.StdErrorPrintln("%s does not support libraries hosted outside of Github: %s", name, err)
803 |
804 | continue
805 | }
806 |
807 | libInfo.RepositoryURL = repoURL
808 | }
809 |
810 | return libInfoList
811 | }
812 |
813 | type GoRepository struct {
814 | Version string `json:"version"`
815 | Time string `json:"time"`
816 | Origin Origin `json:"origin"`
817 | }
818 |
819 | type Origin struct {
820 | VCS string `json:"vcs"`
821 | URL string `json:"url"`
822 | Ref string `json:"ref"`
823 | Hash string `json:"hash"`
824 | }
825 |
826 | func (p GoParser) getGitHubRepositoryURL(
827 | client *http.Client,
828 | name,
829 | version string,
830 | ) (string, error) {
831 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second)
832 | defer cancel()
833 |
834 | baseURL := "https://proxy.golang.org/"
835 | repoURL := baseURL + name + "/@v/" + version + ".info"
836 | utils.DebugPrintln("Fetching: " + repoURL)
837 |
838 | parsedURL, err := url.Parse(repoURL)
839 | if err != nil {
840 | return "", ErrFailedToGetRepository
841 | }
842 |
843 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
844 | if err != nil {
845 | return "", ErrFailedToGetRepository
846 | }
847 |
848 | response, err := client.Do(req)
849 | if err != nil {
850 | return "", ErrFailedToGetRepository
851 | }
852 |
853 | defer response.Body.Close()
854 |
855 | if response.StatusCode != http.StatusOK {
856 | return "", ErrNotAGitHubRepository
857 | }
858 |
859 | bodyBytes, err := io.ReadAll(response.Body)
860 | if err != nil {
861 | return "", ErrFailedToReadResponseBody
862 | }
863 |
864 | repoURLfromGithub, err := extractRepoURL(bodyBytes, name)
865 | if err != nil {
866 | return "", err
867 | }
868 |
869 | return repoURLfromGithub, nil
870 | }
871 |
872 | func extractRepoURL(bodyBytes []byte, name string) (string, error) {
873 | var repo GoRepository
874 |
875 | err := json.Unmarshal(bodyBytes, &repo)
876 | if err != nil {
877 | return "", ErrFailedToUnmarshalJSON
878 | }
879 |
880 | repoURLfromGithub := repo.Origin.URL
881 |
882 | // If there is no URL, use the package name
883 | if repoURLfromGithub == "" && strings.Contains(name, "github.com") {
884 | repoURLfromGithub = "https://" + name
885 | }
886 |
887 | if repoURLfromGithub == "" || !strings.Contains(repoURLfromGithub, "github.com") {
888 | return "", ErrNotAGitHubRepository
889 | }
890 |
891 | return repoURLfromGithub, nil
892 | }
893 |
894 |
895 |
package parser
896 |
897 | import (
898 | "errors"
899 | "fmt"
900 | )
901 |
902 | var (
903 | ErrMethodNotFound = errors.New("method not found in struct")
904 | ErrFiledToOpenFile = errors.New("error opening file")
905 | ErrFailedToReadFile = errors.New("failed to read file")
906 | ErrFailedToResetFilePointer = errors.New("failed to reset file pointer")
907 | ErrFailedToScanFile = errors.New("failed to scan file")
908 | ErrFailedToGetRepository = errors.New("can't get the gem repository, skipping")
909 | ErrNotAGitHubRepository = errors.New("not a GitHub repository, skipping")
910 | ErrFailedToReadResponseBody = errors.New("failed to read response body")
911 | ErrFailedToUnmarshalJSON = errors.New("failed to unmarshal JSON response")
912 | ErrInvalidLineFormat = errors.New("invalid line format")
913 | ErrMissingGemName = errors.New("missing gem name")
914 | ErrUnsupportedLanguage = errors.New("unsupported language")
915 | )
916 |
917 | const timeOutSec = 30
918 |
919 | type LibInfo struct {
920 | Skip bool // スキップするかどうかのフラグ
921 | SkipReason string // スキップ理由
922 | Name string // ライブラリの名前
923 | Others []string // その他のライブラリの設定値
924 | RepositoryURL string // githubのりポトリのURL
925 | }
926 |
927 | type LibInfoOption func(*LibInfo)
928 |
929 | func WithSkip(skip bool) LibInfoOption {
930 | return func(l *LibInfo) {
931 | l.Skip = skip
932 | }
933 | }
934 |
935 | func WithSkipReason(reason string) LibInfoOption {
936 | return func(l *LibInfo) {
937 | l.SkipReason = reason
938 | }
939 | }
940 |
941 | func WithOthers(others []string) LibInfoOption {
942 | return func(l *LibInfo) {
943 | l.Others = others
944 | }
945 | }
946 |
947 | func NewLibInfo(name string, options ...LibInfoOption) LibInfo {
948 | libInfo := LibInfo{
949 | Name: name,
950 | Skip: false,
951 | SkipReason: "",
952 | Others: nil,
953 | RepositoryURL: "",
954 | }
955 |
956 | for _, option := range options {
957 | option(&libInfo)
958 | }
959 |
960 | return libInfo
961 | }
962 |
963 | type Parser interface {
964 | Parse(file string) ([]LibInfo, error)
965 | GetRepositoryURL(AnalyzedLibInfoList []LibInfo) []LibInfo
966 | }
967 |
968 | func SelectParser(language string) (Parser, error) {
969 | switch language {
970 | case "ruby":
971 | return RubyParser{}, nil
972 | case "go":
973 | return GoParser{}, nil
974 | default:
975 | return nil, fmt.Errorf("%w: %s", ErrUnsupportedLanguage, language)
976 | }
977 | }
978 |
979 |
980 |
package parser
981 |
982 | import (
983 | "bufio"
984 | "context"
985 | "encoding/json"
986 | "fmt"
987 | "io"
988 | "net/http"
989 | "net/url"
990 | "os"
991 | "regexp"
992 | "strings"
993 | "time"
994 |
995 | "github.com/uzumaki-inc/stay_or_go/utils"
996 | )
997 |
998 | type RubyParser struct{}
999 |
1000 | type RubyRepository struct {
1001 | SourceCodeURI string `json:"source_code_uri"`
1002 | HomepageURI string `json:"homepage_uri"`
1003 | }
1004 |
1005 | // Parse メソッド
1006 | func (p RubyParser) Parse(filePath string) ([]LibInfo, error) {
1007 | lines, err := p.readLines(filePath)
1008 | if err != nil {
1009 | return nil, err
1010 | }
1011 |
1012 | var libs []LibInfo
1013 |
1014 | inOtherBlock := false
1015 |
1016 | for _, line := range lines {
1017 | if p.isOtherBlockStart(line) {
1018 | inOtherBlock = true
1019 |
1020 | continue
1021 | }
1022 |
1023 | if p.isBlockEnd(line) {
1024 | inOtherBlock = false
1025 |
1026 | continue
1027 | }
1028 |
1029 | // gem を解析
1030 | if gemName := p.extractGemName(line); gemName != "" {
1031 | isNgGem := p.containsInvalidKeywords(line)
1032 | lib := p.createLibInfo(gemName, isNgGem, inOtherBlock)
1033 | libs = append(libs, lib)
1034 | }
1035 | }
1036 |
1037 | return libs, nil
1038 | }
1039 |
1040 | // ファイルの内容を行ごとに読み取る
1041 | func (p *RubyParser) readLines(filePath string) ([]string, error) {
1042 | file, err := os.Open(filePath)
1043 | if err != nil {
1044 | return nil, fmt.Errorf("%w: %w", ErrFiledToOpenFile, err)
1045 | }
1046 | defer file.Close()
1047 |
1048 | var lines []string
1049 |
1050 | scanner := bufio.NewScanner(file)
1051 | for scanner.Scan() {
1052 | lines = append(lines, strings.TrimSpace(scanner.Text()))
1053 | }
1054 |
1055 | if err := scanner.Err(); err != nil {
1056 | return nil, fmt.Errorf("%w: %w", ErrFailedToReadFile, err)
1057 | }
1058 |
1059 | return lines, nil
1060 | }
1061 |
1062 | // その他のブロックの開始か判定
1063 | func (p *RubyParser) isOtherBlockStart(line string) bool {
1064 | sourceStartRegex := regexp.MustCompile(`source\s+['"].+['"]\s+do`)
1065 | platformsStartRegex := regexp.MustCompile(`platforms\s+[:\w,]+\s+do`)
1066 | installIfStartRegex := regexp.MustCompile(`install_if\s+->\s+\{.*\}\s+do`)
1067 |
1068 | return sourceStartRegex.MatchString(line) ||
1069 | platformsStartRegex.MatchString(line) ||
1070 | installIfStartRegex.MatchString(line)
1071 | }
1072 |
1073 | // ブロックの終了か判定
1074 | func (p *RubyParser) isBlockEnd(line string) bool {
1075 | endRegex := regexp.MustCompile(`^end$`)
1076 |
1077 | return endRegex.MatchString(line)
1078 | }
1079 |
1080 | // gem 名を抽出
1081 | func (p *RubyParser) extractGemName(line string) string {
1082 | gemRegex := regexp.MustCompile(`gem ['"]([^'"]+)['"]`)
1083 |
1084 | if matches := gemRegex.FindStringSubmatch(line); matches != nil {
1085 | return matches[1]
1086 | }
1087 |
1088 | return ""
1089 | }
1090 |
1091 | func (p *RubyParser) containsInvalidKeywords(line string) bool {
1092 | // カンマ区切りで分割
1093 | parts := strings.Split(line, ",")
1094 |
1095 | // 判定するキーワード
1096 | ngKeywords := []string{"source", "git", "github"}
1097 |
1098 | // 2番目以降をチェック
1099 | for _, part := range parts[1:] {
1100 | trimmedPart := strings.TrimSpace(part)
1101 | for _, keyword := range ngKeywords {
1102 | if strings.Contains(trimmedPart, keyword) {
1103 | return true
1104 | }
1105 | }
1106 | }
1107 |
1108 | return false
1109 | }
1110 |
1111 | func (p *RubyParser) createLibInfo(gemName string, isNgGem bool, inOtherBlock bool) LibInfo {
1112 | lib := LibInfo{Name: gemName}
1113 | if isNgGem {
1114 | lib.Skip = true
1115 | lib.SkipReason = "Not hosted on Github"
1116 | } else if inOtherBlock {
1117 | lib.Skip = true
1118 | lib.SkipReason = "Not hosted on Github"
1119 | }
1120 |
1121 | return lib
1122 | }
1123 |
1124 | func (p RubyParser) GetRepositoryURL(libInfoList []LibInfo) []LibInfo {
1125 | client := &http.Client{}
1126 |
1127 | for i := range libInfoList {
1128 | // ポインタを取得
1129 | libInfo := &libInfoList[i]
1130 | name := libInfo.Name
1131 |
1132 | if libInfo.Skip {
1133 | continue
1134 | }
1135 |
1136 | repoURL, err := p.getGitHubRepositoryURL(client, name)
1137 | if err != nil {
1138 | libInfo.Skip = true
1139 | libInfo.SkipReason = "Does not support libraries hosted outside of Github"
1140 |
1141 | utils.StdErrorPrintln("%s does not support libraries hosted outside of Github: %s", name, err)
1142 |
1143 | continue
1144 | }
1145 |
1146 | libInfo.RepositoryURL = repoURL
1147 | }
1148 |
1149 | return libInfoList
1150 | }
1151 |
1152 | func (p RubyParser) getGitHubRepositoryURL(client *http.Client, name string) (string, error) {
1153 | ctx, cancel := context.WithTimeout(context.Background(), timeOutSec*time.Second)
1154 | defer cancel()
1155 |
1156 | baseURL := "https://rubygems.org/api/v1/gems/"
1157 | repoURL := baseURL + name + ".json"
1158 | utils.DebugPrintln("Fetching: " + repoURL)
1159 |
1160 | parsedURL, err := url.Parse(repoURL)
1161 | if err != nil {
1162 | return "", ErrFailedToGetRepository
1163 | }
1164 |
1165 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
1166 | if err != nil {
1167 | return "", ErrFailedToGetRepository
1168 | }
1169 |
1170 | response, err := client.Do(req)
1171 | if err != nil {
1172 | return "", ErrFailedToGetRepository
1173 | }
1174 | defer response.Body.Close()
1175 |
1176 | if response.StatusCode != http.StatusOK {
1177 | return "", ErrNotAGitHubRepository
1178 | }
1179 |
1180 | bodyBytes, err := io.ReadAll(response.Body)
1181 | if err != nil {
1182 | return "", ErrFailedToReadResponseBody
1183 | }
1184 |
1185 | var repo RubyRepository
1186 |
1187 | err = json.Unmarshal(bodyBytes, &repo)
1188 | if err != nil {
1189 | return "", ErrFailedToUnmarshalJSON
1190 | }
1191 |
1192 | repoURLfromRubyGems := repo.SourceCodeURI
1193 |
1194 | if repoURLfromRubyGems == "" {
1195 | repoURLfromRubyGems = repo.HomepageURI
1196 | }
1197 |
1198 | if repoURLfromRubyGems == "" || !strings.Contains(repoURLfromRubyGems, "github.com") {
1199 | return "", ErrNotAGitHubRepository
1200 | }
1201 |
1202 | return repoURLfromRubyGems, nil
1203 | }
1204 |
1205 |
1206 |
package presenter
1207 |
1208 | import (
1209 | "strings"
1210 | )
1211 |
1212 | type CsvPresenter struct {
1213 | analyzedLibInfos []AnalyzedLibInfo
1214 | }
1215 |
1216 | func (p CsvPresenter) Display() {
1217 | Display(p)
1218 | }
1219 |
1220 | func (p CsvPresenter) makeHeader() []string {
1221 | headerRow := strings.Join(headerString, ", ")
1222 |
1223 | return []string{headerRow}
1224 | }
1225 |
1226 | func (p CsvPresenter) makeBody() []string {
1227 | return makeBody(p.analyzedLibInfos, ", ")
1228 | }
1229 |
1230 | func NewCsvPresenter(infos []AnalyzedLibInfo) CsvPresenter {
1231 | return CsvPresenter{analyzedLibInfos: infos}
1232 | }
1233 |
1234 |
1235 |
package presenter
1236 |
1237 | import (
1238 | "strings"
1239 | )
1240 |
1241 | type MarkdownPresenter struct {
1242 | analyzedLibInfos []AnalyzedLibInfo
1243 | }
1244 |
1245 | func (p MarkdownPresenter) Display() {
1246 | Display(p)
1247 | }
1248 |
1249 | func (p MarkdownPresenter) makeHeader() []string {
1250 | headerRow := "| " + strings.Join(headerString, " | ") + " |"
1251 |
1252 | separatorRow := "|"
1253 | for _, header := range headerString {
1254 | separatorRow += " " + strings.Repeat("-", len(header)) + " |"
1255 | }
1256 |
1257 | return []string{headerRow, separatorRow}
1258 | }
1259 |
1260 | func (p MarkdownPresenter) makeBody() []string {
1261 | return makeBody(p.analyzedLibInfos, "|")
1262 | }
1263 |
1264 | func NewMarkdownPresenter(infos []AnalyzedLibInfo) MarkdownPresenter {
1265 | return MarkdownPresenter{analyzedLibInfos: infos}
1266 | }
1267 |
1268 |
1269 |
package presenter
1270 |
1271 | import (
1272 | "fmt"
1273 | "os"
1274 | "reflect"
1275 |
1276 | "github.com/uzumaki-inc/stay_or_go/analyzer"
1277 | "github.com/uzumaki-inc/stay_or_go/parser"
1278 | "github.com/uzumaki-inc/stay_or_go/utils"
1279 | )
1280 |
1281 | type AnalyzedLibInfo struct {
1282 | LibInfo *parser.LibInfo
1283 | GitHubRepoInfo *analyzer.GitHubRepoInfo
1284 | }
1285 |
1286 | func (ainfo AnalyzedLibInfo) Name() *string {
1287 | if ainfo.LibInfo.Name != "" {
1288 | return &ainfo.LibInfo.Name
1289 | }
1290 |
1291 | return nil
1292 | }
1293 |
1294 | func (ainfo AnalyzedLibInfo) RepositoryURL() *string {
1295 | if ainfo.LibInfo.RepositoryURL != "" {
1296 | return &ainfo.LibInfo.RepositoryURL
1297 | }
1298 |
1299 | return nil
1300 | }
1301 |
1302 | func (ainfo AnalyzedLibInfo) Watchers() *int {
1303 | if ainfo.GitHubRepoInfo != nil {
1304 | return &ainfo.GitHubRepoInfo.Watchers
1305 | }
1306 |
1307 | return nil
1308 | }
1309 |
1310 | func (ainfo AnalyzedLibInfo) Stars() *int {
1311 | if ainfo.GitHubRepoInfo != nil {
1312 | return &ainfo.GitHubRepoInfo.Stars
1313 | }
1314 |
1315 | return nil
1316 | }
1317 |
1318 | func (ainfo AnalyzedLibInfo) Forks() *int {
1319 | if ainfo.GitHubRepoInfo != nil {
1320 | return &ainfo.GitHubRepoInfo.Forks
1321 | }
1322 |
1323 | return nil
1324 | }
1325 |
1326 | func (ainfo AnalyzedLibInfo) OpenIssues() *int {
1327 | if ainfo.GitHubRepoInfo != nil {
1328 | return &ainfo.GitHubRepoInfo.OpenIssues
1329 | }
1330 |
1331 | return nil
1332 | }
1333 |
1334 | func (ainfo AnalyzedLibInfo) LastCommitDate() *string {
1335 | if ainfo.GitHubRepoInfo != nil {
1336 | return &ainfo.GitHubRepoInfo.LastCommitDate
1337 | }
1338 |
1339 | return nil
1340 | }
1341 |
1342 | func (ainfo AnalyzedLibInfo) GithubRepoURL() *string {
1343 | if ainfo.GitHubRepoInfo != nil {
1344 | return &ainfo.GitHubRepoInfo.GithubRepoURL
1345 | }
1346 |
1347 | return nil
1348 | }
1349 |
1350 | func (ainfo AnalyzedLibInfo) Archived() *bool {
1351 | if ainfo.GitHubRepoInfo != nil {
1352 | return &ainfo.GitHubRepoInfo.Archived
1353 | }
1354 |
1355 | return nil
1356 | }
1357 |
1358 | func (ainfo AnalyzedLibInfo) Score() *int {
1359 | if ainfo.GitHubRepoInfo != nil {
1360 | return &ainfo.GitHubRepoInfo.Score
1361 | }
1362 |
1363 | return nil
1364 | }
1365 |
1366 | func (ainfo AnalyzedLibInfo) Skip() *bool {
1367 | trueValue := true
1368 | falseValue := false
1369 |
1370 | if ainfo.LibInfo.Skip {
1371 | return &trueValue
1372 | } else if ainfo.GitHubRepoInfo.Skip {
1373 | return &trueValue
1374 | }
1375 |
1376 | return &falseValue
1377 | }
1378 |
1379 | func (ainfo AnalyzedLibInfo) SkipReason() *string {
1380 | if ainfo.LibInfo.Skip {
1381 | return &ainfo.LibInfo.SkipReason
1382 | } else if ainfo.GitHubRepoInfo.Skip {
1383 | return &ainfo.GitHubRepoInfo.SkipReason
1384 | }
1385 |
1386 | return nil
1387 | }
1388 |
1389 | func MakeAnalyzedLibInfoList(
1390 | libInfoList []parser.LibInfo,
1391 | gitHubRepoInfos []analyzer.GitHubRepoInfo,
1392 | ) []AnalyzedLibInfo {
1393 | analyzedLibInfos := make([]AnalyzedLibInfo, 0, len(libInfoList))
1394 |
1395 | repoIndex := 0
1396 |
1397 | for _, info := range libInfoList {
1398 | analyzedLibInfo := AnalyzedLibInfo{
1399 | LibInfo: &info,
1400 | GitHubRepoInfo: nil,
1401 | }
1402 |
1403 | if repoIndex < len(gitHubRepoInfos) && info.RepositoryURL == gitHubRepoInfos[repoIndex].GithubRepoURL {
1404 | analyzedLibInfo.GitHubRepoInfo = &gitHubRepoInfos[repoIndex]
1405 | repoIndex++
1406 | }
1407 |
1408 | analyzedLibInfos = append(analyzedLibInfos, analyzedLibInfo)
1409 | }
1410 |
1411 | return analyzedLibInfos
1412 | }
1413 |
1414 | type Presenter interface {
1415 | Display()
1416 | makeHeader() []string
1417 | makeBody() []string
1418 | }
1419 |
1420 | func Display(p Presenter) {
1421 | header := p.makeHeader()
1422 | body := p.makeBody()
1423 |
1424 | for _, line := range header {
1425 | fmt.Println(line)
1426 | }
1427 |
1428 | for _, line := range body {
1429 | fmt.Println(line)
1430 | }
1431 | }
1432 |
1433 | func makeBody(analyzedLibInfos []AnalyzedLibInfo, separator string) []string {
1434 | rows := []string{}
1435 |
1436 | for _, info := range analyzedLibInfos {
1437 | row := ""
1438 | val := reflect.ValueOf(info)
1439 |
1440 | if val.Kind() == reflect.Ptr {
1441 | val = val.Elem()
1442 | }
1443 |
1444 | for index, header := range headerString {
1445 | method := val.MethodByName(header)
1446 |
1447 | if method.IsValid() {
1448 | result := method.Call(nil)
1449 |
1450 | var resultStr interface{}
1451 |
1452 | if len(result) > 0 && result[0].IsValid() && !result[0].IsNil() {
1453 | resultStr = result[0].Elem().Interface()
1454 | } else {
1455 | resultStr = "N/A"
1456 | }
1457 |
1458 | row += fmt.Sprintf("%v", resultStr)
1459 | // 最後の要素でない場合にのみseparatorを追加
1460 | if index < len(headerString)-1 {
1461 | row += separator
1462 | }
1463 | } else {
1464 | utils.StdErrorPrintln("method %s not found in %v", header, info)
1465 | os.Exit(1)
1466 | }
1467 | }
1468 |
1469 | if separator == "|" {
1470 | row = "|" + row + "|"
1471 | }
1472 |
1473 | rows = append(rows, row)
1474 | }
1475 |
1476 | return rows
1477 | }
1478 |
1479 | var headerString = []string{
1480 | "Name",
1481 | "RepositoryURL",
1482 | "Watchers",
1483 | "Stars",
1484 | "Forks",
1485 | "OpenIssues",
1486 | "LastCommitDate",
1487 | "Archived",
1488 | "Score",
1489 | "Skip",
1490 | "SkipReason",
1491 | }
1492 |
1493 | func SelectPresenter(format string, analyzedLibInfos []AnalyzedLibInfo) Presenter {
1494 | switch format {
1495 | case "tsv":
1496 | return TsvPresenter{analyzedLibInfos}
1497 | case "csv":
1498 | return CsvPresenter{analyzedLibInfos}
1499 | default:
1500 | return MarkdownPresenter{analyzedLibInfos}
1501 | }
1502 | }
1503 |
1504 |
1505 |
package presenter
1506 |
1507 | import (
1508 | "strings"
1509 | )
1510 |
1511 | type TsvPresenter struct {
1512 | analyzedLibInfos []AnalyzedLibInfo
1513 | }
1514 |
1515 | func (p TsvPresenter) Display() {
1516 | Display(p)
1517 | }
1518 |
1519 | func (p TsvPresenter) makeHeader() []string {
1520 | headerRow := strings.Join(headerString, "\t")
1521 |
1522 | return []string{headerRow}
1523 | }
1524 |
1525 | func (p TsvPresenter) makeBody() []string {
1526 | return makeBody(p.analyzedLibInfos, "\t")
1527 | }
1528 |
1529 | func NewTsvPresenter(infos []AnalyzedLibInfo) TsvPresenter {
1530 | return TsvPresenter{analyzedLibInfos: infos}
1531 | }
1532 |
1533 |
1534 |
package utils
1535 |
1536 | import (
1537 | "fmt"
1538 | "os"
1539 | "reflect"
1540 | )
1541 |
1542 | var Verbose bool
1543 |
1544 | func DebugPrintln(message string) {
1545 | if Verbose {
1546 | StdErrorPrintln(message)
1547 | }
1548 | }
1549 |
1550 | func StdErrorPrintln(message string, a ...interface{}) {
1551 | fmt.Fprintf(os.Stderr, message+"\n", a...)
1552 | }
1553 |
1554 | func PrintStructFields(structObj interface{}) {
1555 | if structObj == nil {
1556 | fmt.Println("nil value provided")
1557 |
1558 | return
1559 | }
1560 |
1561 | val := reflect.ValueOf(structObj)
1562 | typ := reflect.TypeOf(structObj)
1563 |
1564 | if val.Kind() == reflect.Ptr {
1565 | if val.IsNil() {
1566 | fmt.Println("nil pointer provided")
1567 |
1568 | return
1569 | }
1570 |
1571 | val = val.Elem()
1572 | typ = typ.Elem()
1573 | }
1574 |
1575 | if val.Kind() != reflect.Struct {
1576 | fmt.Println("provided value is not a struct")
1577 |
1578 | return
1579 | }
1580 |
1581 | for i := range make([]struct{}, val.NumField()) {
1582 | fieldName := typ.Field(i).Name
1583 | fieldValue := val.Field(i)
1584 |
1585 | if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() {
1586 | fmt.Printf("%s: nil\n", fieldName)
1587 | } else {
1588 | fmt.Printf("%s: %v\n", fieldName, fieldValue.Interface())
1589 | }
1590 | }
1591 | }
1592 |
1593 |
1594 |
1595 |
1596 |
1623 |
1624 |
--------------------------------------------------------------------------------