Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.3.6']

steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true

- name: Run tests
run: bundle exec rspec
104 changes: 103 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,37 @@ Or install it yourself as:

## Usage

TODO: Write usage instructions here
```bash
analyze <repository> <source> [context]
```

| Argument | Description |
|----------|-------------|
| `repository` | The repository name (e.g., `jobber`) |
| `source` | The package manager: `gemfile`, `npm`, or `pnpm` |
| `context` | *(Optional, pnpm only)* A specific pnpm workspace to analyze |

### Examples

```bash
# Analyze all gems
analyze my-repo gemfile

# Analyze all npm packages
analyze my-repo npm

# Analyze all pnpm workspaces
analyze my-repo pnpm

# Analyze a specific pnpm workspace
analyze my-repo pnpm packages/ui
```

### The `context` parameter

When using the `pnpm` source, the tool analyzes all workspaces by default. The optional `context` argument filters analysis to a single workspace by name. If the provided workspace name doesn't match any discovered workspace, the tool prints the available workspaces and exits.

This parameter is ignored for `gemfile` and `npm` sources.

## Development

Expand Down Expand Up @@ -65,6 +95,78 @@ ln -s ../library_version_analysis .
source library_version_anaysis/version.sh
library_version_analysis/run.sh

## CI Pipeline Requirements for pnpm Workspaces

For pnpm workspace repositories (monorepos), the CI pipeline must generate per-workspace libyear files before running the analysis. Each workspace gets its own libyear file with a hyphen-based naming convention.

### File Naming Convention

| Workspace Path | Libyear Filename |
|---------------|------------------|
| Root (.) | `libyear_root.txt` |
| apps/client | `libyear_apps-client.txt` |
| apps/server | `libyear_apps-server.txt` |
| packages/ui | `libyear_packages-ui.txt` |
| Non-workspace repo | `libyear_report.txt` |

### Example CI Configuration (CircleCI/GitHub Actions)

```yaml
- name: Generate libyear reports
run: |
# Root workspace
pnpx libyear --package-manager pnpm --json > libyear_root.txt

# Each workspace (skip root at index 0)
for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do
relative_path="${workspace#$(pwd)/}"
filename="libyear_${relative_path//\//-}.txt"
pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true
done

- name: Run library analysis
run: ./exe/analyze $REPO_NAME pnpm
```

### Non-workspace Repositories

For non-workspace pnpm repositories (single package.json), continue using the existing single file approach:

```bash
pnpx libyear --package-manager pnpm --all --json > libyear_report.txt
```

## Library Tracking Server Integration

This gem uploads per-workspace data to the [library_tracking](https://github.com/GetJobber/library_tracking) Rails app via `POST /api/libraries/upload`.

### Upload Payload

For each pnpm workspace, the gem sends a separate upload with:

```json
{
"source": "<workspace_name>",
"repository": "<repository>",
"libraries": [...],
"new_versions": [...],
"vulnerabilities": [...],
"dependencies": [...]
}
```

| Field | Value | Example |
|-------|-------|---------|
| `repository` | The first CLI argument, passed through | `"jobber-frontend"` |
| `source` | The workspace name (root workspace becomes `"root"`, nested workspaces use their relative path) | `"root"`, `"packages/ui"` |

For non-workspace pnpm repos, `source` is `"pnpm"`. For other package managers, `source` matches the CLI argument (`"gemfile"`, `"npm"`).

### Database Disambiguation

The library_tracking database uniquely identifies a library by the composite index `(name, source, repository_id)`. This means the same library (e.g., `react`) can exist independently in multiple workspaces within the same repository, each tracked with its own version history, vulnerabilities, and dependency graph.

> **Note:** The [DB diagram](https://dbdiagram.io/d/versions-63dfe88e296d97641d7e9064) is outdated and does not show the `source` column on the `libraries` table. Refer to `db/schema.rb` in the [library_tracking repo](https://github.com/GetJobber/library_tracking) for the current schema.

## Contributing

Expand Down
20 changes: 18 additions & 2 deletions exe/analyze
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,32 @@ if ARGV.count == 1
spreadsheet_id = ARGV[0]
repository = "jobber-mobile"
source = "npm"
context = nil
elsif ARGV.count == 2
# this supports legacy calls
spreadsheet_id = ""
repository = ARGV[0]
source = ARGV[1]
context = nil
elsif ARGV.count == 3
# pnpm workspace support: analyze <repository> pnpm <workspace>
spreadsheet_id = ""
repository = ARGV[0]
source = ARGV[1]
context = ARGV[2]
else
puts "Usage: analyze <spreadsheet_id> <repository> <source>"
puts "Usage: analyze <repository> <source> [context]"
puts " Examples:"
puts " analyze my-repo pnpm # analyze all pnpm workspaces"
puts " analyze my-repo pnpm packages/ui # analyze specific workspace"

Kernel.exit(1)
end

results = LibraryVersionAnalysis::CheckVersionStatus.run(spreadsheet_id: spreadsheet_id, repository: repository, source: source)
results = LibraryVersionAnalysis::CheckVersionStatus.run(
spreadsheet_id: spreadsheet_id,
repository: repository,
source: source,
context: context
)
puts JSON.generate(results)
1 change: 1 addition & 0 deletions lib/library_version_analysis.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "library_version_analysis/github"
require "library_version_analysis/gemfile"
require "library_version_analysis/npm"
require "library_version_analysis/pnpm"
require "library_version_analysis/version"
require "library_version_analysis/slack_notify"
require "pry-byebug"
Expand Down
133 changes: 125 additions & 8 deletions lib/library_version_analysis/check_version_status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ def self.dev_output?

class CheckVersionStatus
# TODO: joint - Need to change Jobbers https://github.com/GetJobber/Jobber/blob/dea12cebf8e6c65b2cafb5318bd42c1f3bf7d7a3/lib/code_analysis/code_analyzer/online_version_analysis.rb#L6 to run three times. One for each.
def self.run(spreadsheet_id: "", repository: "", source: "")
def self.run(spreadsheet_id: "", repository: "", source: "", context: nil)
# check for env vars before we do anything
keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN LIBRARY_UPLOAD_URL UPLOAD_KEY)
missing_keys = keys.reject { |key| !ENV[key].nil? && !ENV[key].empty? }

raise "Missing ENV vars: #{missing_keys}" if missing_keys.any?

c = CheckVersionStatus.new
mode_results = c.go(spreadsheet_id: spreadsheet_id, repository: repository, source: source)
mode_results = c.go(spreadsheet_id: spreadsheet_id, repository: repository, source: source, context: context)

mode_key = "#{repository}/#{source}"

Expand All @@ -72,7 +72,12 @@ def self.run(spreadsheet_id: "", repository: "", source: "")
result_key = mode_key
end

results = {result_key => c.mode_results_specific(mode_results, mode_key.to_sym)}
# For pnpm, mode_results contains all_modes hash with each workspace's metrics
if source == "pnpm"
results = { result_key => c.pnpm_results_all_workspaces(mode_results, mode_key.to_sym) }
else
results = { result_key => c.mode_results_specific(mode_results, mode_key.to_sym) }
end
return results
end

Expand All @@ -94,7 +99,7 @@ def obfuscate(data)
return ":#{@word_list[idx]}" # note: the colon is required in the dependency graph obfuscation
end

def go(spreadsheet_id:, repository:, source:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def go(spreadsheet_id:, repository:, source:, context: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength

if spreadsheet_id.nil? || spreadsheet_id.empty?
@update_server = true
Expand All @@ -113,6 +118,8 @@ def go(spreadsheet_id:, repository:, source:) # rubocop:disable Metrics/AbcSize,
meta_data, mode = go_npm(spreadsheet_id, repository, source)
when "gemfile"
meta_data, mode = go_gemfile(spreadsheet_id, repository, source)
when "pnpm"
meta_data, mode = go_pnpm(spreadsheet_id, repository, context)
else
puts "Don't recognize source #{source}"
exit(-1)
Expand Down Expand Up @@ -151,6 +158,59 @@ def go_npm(spreadsheet_id, repository, source)
return meta_data, mode
end

def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
puts " pnpm" if LibraryVersionAnalysis.dev_output?
pnpm = Pnpm.new(repository)

# Get results for ALL workspaces (or single repo)
results_by_workspace = pnpm.get_versions_for_all_workspaces

# Filter to specific workspace if context provided
if context
if results_by_workspace.key?(context)
results_by_workspace = { context => results_by_workspace[context] }
else
available = results_by_workspace.keys.join(", ")
puts "Workspace '#{context}' not found. Available workspaces: #{available}"
exit(-1)
end
end

all_modes = {}
combined_meta_data = nil

# Upload each workspace separately
results_by_workspace.each do |workspace_name, data|
parsed_results = data[:results]
meta_data = data[:meta_data]
mode = get_mode_summary(parsed_results, meta_data)

# Store first workspace's meta_data for print_summary (backwards compatible)
combined_meta_data ||= meta_data

all_modes[workspace_name] = mode

if @update_spreadsheet
puts " updating spreadsheet #{workspace_name}" if LibraryVersionAnalysis.dev_output?
data = spreadsheet_data(parsed_results, repository, workspace_name)
update_spreadsheet(spreadsheet_id, "PnpmVersionData!A:Q", data)
end

if @update_server
puts " updating server for #{workspace_name}" if LibraryVersionAnalysis.dev_output?
# Use workspace_name as the source for pnpm workspaces
server_payload = server_data(parsed_results, repository, workspace_name)
log_server_payload(server_payload)
LibraryTracking.upload(server_payload.to_json)
end
end

puts "All Done! Uploaded #{results_by_workspace.keys.count} workspace(s)" if LibraryVersionAnalysis.dev_output?

# Return all_modes hash for pnpm (contains all workspace metrics)
return combined_meta_data, all_modes
end

def get_version_summary(parser, range, spreadsheet_id, repository, source)
parsed_results, meta_data = parser.get_versions(source)
mode = get_mode_summary(parsed_results, meta_data)
Expand All @@ -163,7 +223,9 @@ def get_version_summary(parser, range, spreadsheet_id, repository, source)

if @update_server
puts " updating server" if LibraryVersionAnalysis.dev_output?
data = server_data(parsed_results, repository, source).to_json
server_payload = server_data(parsed_results, repository, source)
log_server_payload(server_payload)
data = server_payload.to_json
LibraryTracking.upload(data)
end

Expand Down Expand Up @@ -238,9 +300,11 @@ def spreadsheet_data(results, repository, source)
legacy_source= "MOBILE"
end
when "gemfile"
legacy_source= "ONLINE"
legacy_source = "ONLINE"
when "pnpm", "root", /^apps\//, /^packages\//
legacy_source = source
else
legacy_source= "UNKNOWN"
legacy_source = "UNKNOWN"
end

data << ["Updated: #{Time.now.utc}"]
Expand Down Expand Up @@ -316,7 +380,7 @@ def get_mode_summary(results, meta_data) # rubocop:disable Metrics/AbcSize, Metr
mode_summary.patch = mode_summary.patch + 1
end

mode_summary.unowned_issues = mode_summary.unowned_issues + 1 if line.owner == :attention_needed
mode_summary.unowned_issues = mode_summary.unowned_issues + 1 if line.owner == :attention_needed || line.owner == :unspecified
end

mode_summary.one_number = one_number(mode_summary)
Expand Down Expand Up @@ -347,8 +411,61 @@ def mode_results_specific(mode_results, source)
}
end

# Format pnpm results with all workspaces included
def pnpm_results_all_workspaces(mode_results, source)
all_modes = mode_results[source]
return {} if all_modes.nil?

result = {}
all_modes.each do |workspace_name, mode|
result[workspace_name] = {
one_major: mode[:one_major],
two_major: mode[:two_major],
three_plus_major: mode[:three_plus_major],
minor: mode[:minor],
unowned_issues: mode[:unowned_issues],
one_number: mode[:one_number],
}
end
result
end

def print_summary(source, meta_data, mode_data)
puts "#{source}: #{meta_data}, #{mode_data}" if LibraryVersionAnalysis.dev_output?
end

def log_server_payload(payload)
warn "[upload] Preparing to upload data for #{payload[:repository]}/#{payload[:source]}"
warn "[upload] Libraries: #{payload[:libraries]&.count || 0}"
warn "[upload] New versions: #{payload[:new_versions]&.count || 0}"
warn "[upload] Vulnerabilities: #{payload[:vulnerabilities]&.count || 0}"
warn "[upload] Dependencies: #{payload[:dependencies]&.count || 0}"

# Log sample of libraries (first 10)
if payload[:libraries]&.any?
warn "[upload] Sample libraries (first 10):"
payload[:libraries].first(10).each do |lib|
warn "[upload] - #{lib[:name]} @ #{lib[:version]} (owner: #{lib[:owner]})"
end
warn "[upload] ... and #{payload[:libraries].count - 10} more" if payload[:libraries].count > 10
end

# Log libraries with version updates
if payload[:new_versions]&.any?
outdated = payload[:new_versions].select { |v| v[:major]&.positive? }
warn "[upload] Libraries with major updates: #{outdated.count}"
outdated.first(5).each do |lib|
warn "[upload] - #{lib[:name]}: #{lib[:major]} major, #{lib[:minor]} minor, #{lib[:patch]} patch behind"
end
end

# Log vulnerabilities
if payload[:vulnerabilities]&.any?
warn "[upload] Vulnerabilities found:"
payload[:vulnerabilities].first(10).each do |vuln|
warn "[upload] - #{vuln[:library]}: #{vuln[:assigned_severity]} (#{vuln[:state]})"
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/library_version_analysis/github.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Github
SOURCES = {
"npm": "NPM",
"gemfile": "RUBYGEMS",
"pnpm": "NPM",
}.freeze

HTTP_ADAPTER = GraphQL::Client::HTTP.new(URL) do
Expand Down
Loading