diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 10933c2..a8393f5 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -33,6 +33,7 @@ on: - main paths: - "R/**" + - "tests/**" - ".github/workflows/r.yml" jobs: @@ -78,14 +79,17 @@ jobs: - name: Install R dependencies run: | Rscript -e ' - packages <- c("httr", "jsonlite", "dplyr", "magrittr", "showtext", "ggplot2", "treemapify", "svglite") + packages <- c("httr", "jsonlite", "dplyr", "showtext", "sysfonts", "ggplot2", "treemapify", "svglite") new_packages <- packages[!(packages %in% installed.packages()[,"Package"])] if(length(new_packages)) { - install.packages(new_packages, repos="https://cloud.r-project.org", dependencies=TRUE) + install.packages(new_packages, repos="https://cloud.r-project.org", dependencies=NA) } ' shell: bash + - name: Run tests + run: Rscript tests/run_tests.R + - name: Run analysis env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/R/fetch.R b/R/fetch.R index 8423576..5955473 100644 --- a/R/fetch.R +++ b/R/fetch.R @@ -12,17 +12,6 @@ # Last Modified: 2025-10-25 17:25:34 ## -# Load required packages -if (!requireNamespace("httr", quietly = TRUE)) { - install.packages("httr") -} -library(httr) - -if (!requireNamespace("jsonlite", quietly = TRUE)) { - install.packages("jsonlite") -} -library(jsonlite) - #' Fetch GitHub Repository Data #' #' Fetches repository information for a GitHub user via the GitHub API. @@ -65,20 +54,20 @@ fetch_json <- function(username, proxy = NULL) { } # Make HTTP request - response <- GET( + response <- httr::GET( url, - add_headers(.headers = headers), - if (!is.null(proxy)) use_proxy(proxy) + httr::add_headers(.headers = headers), + if (!is.null(proxy)) httr::use_proxy(proxy) ) # Check response status - if (status_code(response) != 200) { - stop("HTTP request failed with status code: ", status_code(response)) + if (httr::status_code(response) != 200) { + stop("HTTP request failed with status code: ", httr::status_code(response)) } # Parse JSON response - content_text <- content(response, "text", encoding = "UTF-8") - return(fromJSON(content_text)) + content_text <- httr::content(response, "text", encoding = "UTF-8") + return(jsonlite::fromJSON(content_text)) }, error = function(e) { message("Error fetching JSON data: ", e$message) diff --git a/R/fmt.R b/R/fmt.R index 0384dc0..15b975b 100644 --- a/R/fmt.R +++ b/R/fmt.R @@ -12,9 +12,8 @@ # Last Modified: 2025-10-25 23:57:46 ## -if (!require("styler", quietly = TRUE)) { - install.packages("styler") - library(styler) +if (!requireNamespace("styler", quietly = TRUE)) { + stop("Missing required package: styler. Install it with install.packages().") } #' Format R Code Files diff --git a/R/helpers.R b/R/helpers.R new file mode 100644 index 0000000..14e0a8e --- /dev/null +++ b/R/helpers.R @@ -0,0 +1,20 @@ +setup_visualization_font <- function(font_path = file.path("assets", "FiraCode.ttf")) { + font_family <- "sans" + + if (!file.exists(font_path)) { + warning("Custom font not found at ", font_path, "; using default font.") + return(font_family) + } + + tryCatch( + { + sysfonts::font_add("FiraCode", regular = font_path) + showtext::showtext_auto() + "FiraCode" + }, + error = function(error) { + warning("Failed to load custom font, using default: ", error$message) + font_family + } + ) +} diff --git a/R/main.R b/R/main.R index b12ed93..96a1afd 100644 --- a/R/main.R +++ b/R/main.R @@ -14,23 +14,31 @@ # Last Modified: 2025-10-25 16:18:16 ## -# Load required packages -if (!require("jsonlite", quietly = TRUE)) { - install.packages("jsonlite") - library(jsonlite) +required_packages <- c( + "dplyr", "ggplot2", "httr", "jsonlite", "showtext", "svglite", "sysfonts", + "treemapify" +) +missing_packages <- required_packages[ + !vapply(required_packages, requireNamespace, logical(1), quietly = TRUE) +] +if (length(missing_packages) > 0) { + stop( + "Missing required packages: ", paste(missing_packages, collapse = ", "), + ". Install them with install.packages()." + ) } +source_module <- function(path) { + tryCatch( + suppressMessages(source(path)), + error = function(error) stop("Failed to source ", path, ": ", error$message) + ) +} -# Source helper functions -tryCatch( - suppressMessages(source("R/fetch.R")), - error = function(e) stop("Failed to source R/fetch.R: ", e$message) -) - -tryCatch( - suppressMessages(source("R/plot.R")), - error = function(e) stop("Failed to source R/plot.R: ", e$message) -) +invisible(lapply( + c("R/helpers.R", "R/fetch.R", "R/plot.R", "R/pie.R", "R/treemap.R"), + source_module +)) # Get GitHub username from environment or use default username <- Sys.getenv("GITHUB_USERNAME", unset = "mingcheng") @@ -50,12 +58,12 @@ plotted_file_name <- file.path(dir_name, paste0("plotted_", today, ".csv")) # Fetch or load cached repository data if (file.exists(raw_file_name)) { - json_data <- fromJSON(raw_file_name) + json_data <- jsonlite::fromJSON(raw_file_name) message("Loaded cached data from ", raw_file_name) } else { # Check for HTTP proxy settings proxy <- Sys.getenv("HTTP_PROXY", unset = "") - if (is.null(proxy) || proxy == "") { + if (!nzchar(proxy)) { proxy <- NULL message("No proxy configured.") } else { @@ -68,7 +76,7 @@ if (file.exists(raw_file_name)) { } message("Fetched ", length(json_data), " repositories") - write_json(json_data, raw_file_name, pretty = TRUE, auto_unbox = TRUE) + jsonlite::write_json(json_data, raw_file_name, pretty = TRUE, auto_unbox = TRUE) message("Raw data saved to ", raw_file_name) } @@ -83,25 +91,8 @@ message("Successfully processed repository data") write.csv(repos, plotted_file_name, row.names = FALSE) message("Statistical data saved to ", plotted_file_name) -# Generate visualizations - -# Generate pie chart -tryCatch( - suppressMessages(source("R/pie.R")), - error = function(e) stop("Failed to source R/pie.R: ", e$message) -) - generate_pie_chart(username, repos, dir_name, json_data) -# Generate treemap -tryCatch( - suppressMessages(source("R/treemap.R")), - error = function(e) stop("Failed to source R/treemap.R: ", e$message) -) - generate_treemap(username, repos, dir_name, json_data) - - - message("Analysis complete!") diff --git a/R/pie.R b/R/pie.R index c11f2b9..7bc2462 100644 --- a/R/pie.R +++ b/R/pie.R @@ -12,22 +12,8 @@ # Last Modified: 2025-10-26 11:19:07 ## -if (!require("showtext", quietly = TRUE)) { - install.packages("showtext") - library(showtext) -} - generate_pie_chart <- function(username, repos, dir_name, json_data) { - # Setup font for visualization - tryCatch( - font_add("FiraCode", regular = "../assets/FiraCode.ttf"), - error = function(e) { - warning("Failed to load custom font, using default: ", e$message) - } - ) - - # Enable showtext before opening graphics device - showtext_auto() + font_family <- setup_visualization_font() # Define function to generate chart generate_chart <- function(username, repos, colors, total_repos, date_str) { @@ -35,7 +21,7 @@ generate_pie_chart <- function(username, repos, dir_name, json_data) { bg = "white", mai = c(0.5, 0.5, 0.8, 0.5), lwd = 1, - family = "FiraCode" + family = font_family ) pie( @@ -58,6 +44,7 @@ generate_pie_chart <- function(username, repos, dir_name, json_data) { colors <- rainbow(nrow(repos)) total_repos <- nrow(json_data) date_str <- format(Sys.Date(), "%Y-%m-%d") + today <- format(Sys.Date(), "%Y%m%d") # Define chart files chart_files <- list( diff --git a/R/plot.R b/R/plot.R index e778255..d404d80 100644 --- a/R/plot.R +++ b/R/plot.R @@ -12,22 +12,6 @@ # Last Modified: 2025-10-25 17:08:48 ## -# Load required packages -if (!require("dplyr", quietly = TRUE)) { - install.packages("dplyr") - library(dplyr) -} - -if (!require("magrittr", quietly = TRUE)) { - install.packages("magrittr") - library(magrittr) -} - -if (!require("magrittr", quietly = TRUE)) { - install.packages("magrittr") - library(magrittr) -} - #' Process and Analyze Repository Data #' #' Calculate weighted language distribution scores based on repository @@ -44,8 +28,8 @@ plot_repos <- function(repos_data) { cat("\n") # Calculate weighted scores and aggregate by language - repos_df <- repos_data %>% - mutate( + repos_df <- repos_data |> + dplyr::mutate( # Calculate update recency weight (most important factor) days_since_update = as.numeric(Sys.Date() - as.Date(updated_at)), # Exponential decay: 1 year decay constant @@ -56,27 +40,27 @@ plot_repos <- function(repos_data) { # Weighted score: priority = update time > repo count > stars > forks # Note: repo count is calculated after grouping weighted_score = (star_score * 0.2 + fork_score * 0.1 + 1.5) * (date_weight^1.5) - ) %>% + ) |> # Group by language and aggregate - group_by(language) %>% - summarise( + dplyr::group_by(language) |> + dplyr::summarise( total_score = sum(weighted_score, na.rm = TRUE), - repo_count = n(), + repo_count = dplyr::n(), total_stars = sum(stargazers_count, na.rm = TRUE), total_forks = sum(forks_count, na.rm = TRUE), avg_days_since_update = mean(days_since_update, na.rm = TRUE), avg_date_weight = mean(date_weight, na.rm = TRUE), .groups = "drop" - ) %>% + ) |> # Filter out NA languages - filter(!is.na(language)) %>% + dplyr::filter(!is.na(language)) |> # Add repository count weight (second most important factor) - mutate( + dplyr::mutate( repo_count_score = log1p(repo_count) * 3.5, # Final score = base score (with time weight) + repo count bonus final_score = total_score + repo_count_score - ) %>% - arrange(desc(final_score)) + ) |> + dplyr::arrange(dplyr::desc(final_score)) # Print statistical results message("Language statistics:") @@ -84,8 +68,8 @@ plot_repos <- function(repos_data) { cat("\n") # Calculate percentages and create labels - repos_df <- repos_df %>% - mutate( + repos_df <- repos_df |> + dplyr::mutate( percentage = round(final_score / sum(final_score) * 100, 1), label = paste0(language, " (", percentage, "%)") ) diff --git a/R/treemap.R b/R/treemap.R index 5152c8a..ff25204 100644 --- a/R/treemap.R +++ b/R/treemap.R @@ -12,27 +12,6 @@ # Last Modified: 2025-10-26 11:18:57 ## -# Load required packages -if (!require("ggplot2", quietly = TRUE)) { - install.packages("ggplot2") - library(ggplot2) -} - -if (!require("treemapify", quietly = TRUE)) { - install.packages("treemapify") - library(treemapify) -} - -if (!require("showtext", quietly = TRUE)) { - install.packages("showtext") - library(showtext) -} - -if (!require("svglite", quietly = TRUE)) { - install.packages("svglite") - library(svglite) -} - #' Generate Treemap Visualization #' #' Creates a treemap visualization of repository language distribution @@ -44,16 +23,7 @@ if (!require("svglite", quietly = TRUE)) { #' @param json_data Raw repository data #' @export generate_treemap <- function(username, repos, dir_name, json_data) { - # Setup font for visualization - tryCatch( - font_add("FiraCode", regular = "./assets/FiraCode.ttf"), - error = function(e) { - warning("Failed to load custom font, using default: ", e$message) - } - ) - - # Enable showtext - showtext_auto() + font_family <- setup_visualization_font() # Prepare data for treemap total_repos <- nrow(json_data) @@ -71,34 +41,34 @@ generate_treemap <- function(username, repos, dir_name, json_data) { message("Displaying ", nrow(repos_filtered), " languages in treemap (filtered >= 1%)") # Create the treemap plot - p <- ggplot(repos_filtered, aes( + p <- ggplot2::ggplot(repos_filtered, ggplot2::aes( area = final_score, fill = language, # label = paste0(language, "\n", percentage, "%\n(", repo_count, " repos)") label = paste0(language, "\n", percentage, "%") )) + - geom_treemap(colour = "white", size = 2) + - geom_treemap_text( - aes(size = percentage), + treemapify::geom_treemap(colour = "white", size = 2) + + treemapify::geom_treemap_text( + ggplot2::aes(size = percentage), place = "centre", - family = "FiraCode", + family = font_family, min.size = 12 ) + - scale_size_continuous(range = c(12, 36)) + - labs( + ggplot2::scale_size_continuous(range = c(12, 36)) + + ggplot2::labs( title = "Repository Distribution by Language", subtitle = sprintf( "GitHub User: %s | Total Repositories: %d | Updated on %s", username, total_repos, date_str ) ) + - theme_minimal(base_family = "FiraCode") + - theme( - plot.title = element_text( + ggplot2::theme_minimal(base_family = font_family) + + ggplot2::theme( + plot.title = ggplot2::element_text( hjust = 0.5, size = 24 ), - plot.subtitle = element_text( + plot.subtitle = ggplot2::element_text( hjust = 0.5, size = 10, colour = "#777777" @@ -120,7 +90,7 @@ generate_treemap <- function(username, repos, dir_name, json_data) { # Save SVG versions for (svg_file in treemap_files$svg) { - ggsave( + ggplot2::ggsave( svg_file, plot = p, width = 10, @@ -132,7 +102,7 @@ generate_treemap <- function(username, repos, dir_name, json_data) { # Save PNG versions for (png_file in treemap_files$png) { - ggsave( + ggplot2::ggsave( png_file, plot = p, width = 10, diff --git a/README.md b/README.md index 7fe4e9c..8c5ecaf 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ export GITHUB_USERNAME="your_username" Rscript R/main.R ``` +### Tests + +```bash +Rscript tests/run_tests.R +``` + ### GitHub Actions The workflow runs automatically on the 1st of each month, or can be triggered manually via workflow dispatch. Results are committed to the `data` branch with the following structure: @@ -61,7 +67,11 @@ You can link to these images in your GitHub README or personal website to showca ## Requirements - R >= 4 (tested on 4.5.1) -- R packages: `httr`, `jsonlite`, `dplyr`, `magrittr`, `showtext`, `ggplot2`, `treemapify`, `svglite` +- R packages: `httr`, `jsonlite`, `dplyr`, `showtext`, `sysfonts`, `ggplot2`, `treemapify`, `svglite` + +```r +install.packages(c("httr", "jsonlite", "dplyr", "showtext", "sysfonts", "ggplot2", "treemapify", "svglite")) +``` ## Configuration diff --git a/tests/run_tests.R b/tests/run_tests.R new file mode 100644 index 0000000..6dfd31b --- /dev/null +++ b/tests/run_tests.R @@ -0,0 +1,80 @@ +#!/usr/bin/env Rscript + +source("R/helpers.R") +source("R/plot.R") +source("R/pie.R") +source("R/treemap.R") + +run_test <- function(name, code) { + tryCatch( + { + force(code) + message("PASS: ", name) + }, + error = function(error) { + stop("FAIL: ", name, "\n", error$message, call. = FALSE) + } + ) +} + +run_test("all R files parse", { + files <- list.files("R", pattern = "[.]R$", full.names = TRUE) + invisible(lapply(files, parse)) +}) + +fixture <- data.frame( + language = c("R", "R", "Go", NA), + updated_at = as.character(Sys.Date() - c(0, 365, 0, 0)), + stargazers_count = c(10, 0, 1, 100), + forks_count = c(2, 0, 0, 100) +) + +run_test("repository statistics are aggregated and ranked", { + invisible(capture.output(repos <- suppressMessages(plot_repos(fixture)))) + + stopifnot( + identical(as.character(repos$language), c("R", "Go")), + identical(repos$repo_count, c(2L, 1L)), + identical(repos$total_stars, c(10, 1)), + identical(repos$total_forks, c(2, 0)), + isTRUE(all.equal(sum(repos$percentage), 100)), + identical(repos$label, paste0(repos$language, " (", repos$percentage, "%)")) + ) +}) + +run_test("bundled custom font is loaded", { + font_family <- suppressWarnings(setup_visualization_font()) + stopifnot(identical(font_family, "FiraCode")) +}) + +run_test("missing custom font falls back to sans", { + font_family <- suppressWarnings(setup_visualization_font(tempfile())) + stopifnot(identical(font_family, "sans")) +}) + +run_test("pie chart and treemap files are generated", { + invisible(capture.output(repos <- suppressMessages(plot_repos(fixture)))) + output_dir <- tempfile("langstat-test-") + dir.create(output_dir) + on.exit(unlink(output_dir, recursive = TRUE), add = TRUE) + + suppressMessages(generate_pie_chart("test-user", repos, output_dir, fixture)) + suppressMessages(generate_treemap("test-user", repos, output_dir, fixture)) + + today <- format(Sys.Date(), "%Y%m%d") + expected_files <- c( + paste0("piechart_", today, ".svg"), + paste0("piechart_", today, ".png"), + "latest.svg", + "latest.png", + paste0("treemap_", today, ".svg"), + paste0("treemap_", today, ".png"), + "treemap_latest.svg", + "treemap_latest.png" + ) + output_files <- file.path(output_dir, expected_files) + + stopifnot(all(file.exists(output_files)), all(file.size(output_files) > 0)) +}) + +message("All tests passed.")