diff --git a/.github/workflows/adj-escalafon.yaml b/.github/workflows/adj-escalafon.yaml new file mode 100644 index 0000000..0ee6a04 --- /dev/null +++ b/.github/workflows/adj-escalafon.yaml @@ -0,0 +1,55 @@ +name: ADJ Escalafón + +on: + push: + schedule: + # Days 1 and 15 of every month at 11:05 (UTC). + - cron: "5 11 1,15 * *" + workflow_dispatch: + +jobs: + escalafon: + runs-on: ubuntu-latest + env: + CHART: ${{ github.workspace }}/action_success_by_user.png + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up R + uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - name: Install R packages + run: | + Rscript -e 'install.packages(c("httr", "jsonlite", "ggplot2", "png"))' + + - name: Generate escalafón chart + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + Rscript .github/workflows/scripts/adj-compact/action_success_by_user.R \ + "${{ github.repository }}" \ + --out "$CHART" \ + --logo .github/workflows/scripts/adj-compact/logo.png + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: adj-escalafon + path: ${{ env.CHART }} + + - name: Notify Slack + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + jq -n \ + --arg repo "${{ github.repository }}" \ + --arg run_url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '{ + text: " 📊 ADJ Escalafón\n\nRepository: \($repo)\nNuevo escalafón de éxito de Actions por usuario generado.\n\nDescarga el gráfico (artifact \"adj-escalafon\") desde el run:\n\($run_url)" + }' | curl -X POST -H "Content-type: application/json" \ + --data @- \ + $SLACK_WEBHOOK diff --git a/.github/workflows/adj-tester.yaml b/.github/workflows/adj-tester.yaml index af65939..d89174a 100644 --- a/.github/workflows/adj-tester.yaml +++ b/.github/workflows/adj-tester.yaml @@ -68,7 +68,13 @@ jobs: --arg message "chore: add compacted ADJ from ${{ github.repository }}@${{ github.sha }}" \ --rawfile content "${{ runner.temp }}/adj.b64" \ --arg branch "$TARGET_BRANCH" \ - '{message: $message, content: $content, branch: $branch}' \ + '{ + message: $message, + content: $content, + branch: $branch, + committer: {name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com"}, + author: {name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com"} + }' \ > "${{ runner.temp }}/body.json" curl --fail-with-body -sS -X PUT \ -H "Authorization: Bearer $GH_TOKEN" \ diff --git a/.github/workflows/scripts/adj-compact/action_success_by_user.R b/.github/workflows/scripts/adj-compact/action_success_by_user.R new file mode 100644 index 0000000..a5d586b --- /dev/null +++ b/.github/workflows/scripts/adj-compact/action_success_by_user.R @@ -0,0 +1,232 @@ +#!/usr/bin/env Rscript + +# action_success_by_user.R +# --------------------------------------------------------------------------- +# Fetch GitHub Actions workflow-run usage and plot the relative percentage of +# successful runs, grouped by the user who triggered each run. +# +# Usage: +# Rscript action_success_by_user.R owner/repo +# Rscript action_success_by_user.R --org my-org +# Rscript action_success_by_user.R owner/repo --out chart.png --max 1000 +# +# Auth: +# Set a token in the environment to avoid the 60 req/hour unauthenticated +# limit (and to read private repos): +# export GITHUB_TOKEN=ghp_xxx +# --------------------------------------------------------------------------- + +suppressWarnings(suppressMessages({ + ok <- requireNamespace("httr", quietly = TRUE) && + requireNamespace("jsonlite", quietly = TRUE) +})) +if (!ok) { + stop("This script needs the 'httr' and 'jsonlite' packages.\n", + "Install them with: install.packages(c('httr','jsonlite'))", + call. = FALSE) +} + +# ---- argument parsing ------------------------------------------------------ + +DEFAULT_REPO <- "Hyperloop-UPV/adj" + +parse_args <- function(args) { + out <- list(repo = NULL, org = NULL, out = "action_success_by_user.png", + max = Inf, logo = NULL) + i <- 1 + while (i <= length(args)) { + a <- args[[i]] + if (a == "--org") { + out$org <- args[[i + 1]]; i <- i + 2 + } else if (a == "--out") { + out$out <- args[[i + 1]]; i <- i + 2 + } else if (a == "--max") { + out$max <- as.numeric(args[[i + 1]]); i <- i + 2 + } else if (a == "--logo") { + out$logo <- args[[i + 1]]; i <- i + 2 + } else if (!startsWith(a, "--")) { + out$repo <- a; i <- i + 1 + } else { + stop("Unknown argument: ", a, call. = FALSE) + } + } + out +} + +opts <- parse_args(commandArgs(trailingOnly = TRUE)) + +if (is.null(opts$repo) && is.null(opts$org)) { + opts$repo <- DEFAULT_REPO + message("No repo argument given - using default: ", DEFAULT_REPO) +} + +# Auto-detect a logo.png next to the script if none was passed. +if (is.null(opts$logo) && file.exists("logo.png")) opts$logo <- "logo.png" + +token <- Sys.getenv("GITHUB_TOKEN", Sys.getenv("GH_TOKEN", "")) +gh_headers <- httr::add_headers( + Accept = "application/vnd.github+json", + `X-GitHub-Api-Version` = "2022-11-28", + Authorization = if (nzchar(token)) paste("Bearer", token) else NULL +) +if (!nzchar(token)) { + message("No GITHUB_TOKEN found - using unauthenticated API (60 req/hour limit).") +} + +# ---- generic paginated GET ------------------------------------------------- + +gh_get_all <- function(url, query = list(), item_key = NULL, max_items = Inf) { + per_page <- 100 + page <- 1 + items <- list() + repeat { + q <- modifyList(query, list(per_page = per_page, page = page)) + resp <- httr::GET(url, gh_headers, query = q) + if (httr::status_code(resp) == 403 && + identical(httr::headers(resp)[["x-ratelimit-remaining"]], "0")) { + stop("GitHub API rate limit exceeded. Set GITHUB_TOKEN to raise it.", + call. = FALSE) + } + httr::stop_for_status(resp, task = paste("fetch", url)) + body <- httr::content(resp, as = "text", encoding = "UTF-8") + parsed <- jsonlite::fromJSON(body, simplifyVector = FALSE) + batch <- if (is.null(item_key)) parsed else parsed[[item_key]] + if (length(batch) == 0) break + items <- c(items, batch) + if (length(items) >= max_items) { + items <- items[seq_len(min(length(items), max_items))] + break + } + if (length(batch) < per_page) break + page <- page + 1 + } + items +} + +# ---- collect workflow runs ------------------------------------------------- + +list_org_repos <- function(org) { + message("Listing repositories for org/user: ", org) + # Try org endpoint first, fall back to user endpoint. + url <- sprintf("https://api.github.com/orgs/%s/repos", org) + resp <- httr::GET(url, gh_headers, query = list(per_page = 1)) + if (httr::status_code(resp) == 404) { + url <- sprintf("https://api.github.com/users/%s/repos", org) + } + repos <- gh_get_all(url) + vapply(repos, function(r) r$full_name, character(1)) +} + +fetch_runs_for_repo <- function(full_name, max_items = Inf) { + message("Fetching workflow runs for ", full_name, " ...") + url <- sprintf("https://api.github.com/repos/%s/actions/runs", full_name) + gh_get_all(url, item_key = "workflow_runs", max_items = max_items) +} + +repos <- if (!is.null(opts$org)) list_org_repos(opts$org) else opts$repo + +runs <- list() +for (r in repos) { + remaining <- opts$max - length(runs) + if (remaining <= 0) break + runs <- c(runs, fetch_runs_for_repo(r, max_items = remaining)) +} + +if (length(runs) == 0) { + stop("No workflow runs found.", call. = FALSE) +} +message("Collected ", length(runs), " workflow runs.") + +# ---- reduce to a tidy data frame ------------------------------------------ + +safe <- function(x, default = NA_character_) if (is.null(x)) default else x + +df <- do.call(rbind, lapply(runs, function(run) { + data.frame( + user = safe(run$actor$login, "(unknown)"), + status = safe(run$status), + conclusion = safe(run$conclusion, "(none)"), + stringsAsFactors = FALSE + ) +})) + +# Users to exclude from the analysis (e.g. bots). +EXCLUDE_USERS <- c("Copilot") +df <- df[!df$user %in% EXCLUDE_USERS, , drop = FALSE] + +# Only count runs that have actually finished (have a conclusion). +df <- df[df$status == "completed" & !is.na(df$conclusion), , drop = FALSE] +if (nrow(df) == 0) { + stop("No completed runs with a conclusion to summarise.", call. = FALSE) +} + +# ---- per-user success percentage ------------------------------------------ + +agg <- aggregate( + list(total = rep(1, nrow(df)), + success = as.integer(df$conclusion == "success")), + by = list(user = df$user), + FUN = sum +) +agg$pct_success <- round(100 * agg$success / agg$total, 1) +# Order from lowest to highest success rate. +agg <- agg[order(agg$pct_success, agg$total), ] + +message("\nSuccess rate by user:") +print(agg, row.names = FALSE) + +# ---- plot ------------------------------------------------------------------ + +title <- "Hyperloop-UPV" +stamp <- format(Sys.time(), "Generado: %Y-%m-%d %H:%M:%S") + +if (requireNamespace("ggplot2", quietly = TRUE)) { + library(ggplot2) + # Keep the low-to-high ordering on the x axis (vertical bars). + agg$user <- factor(agg$user, levels = agg$user) + p <- ggplot(agg, aes(x = user, y = pct_success)) + + geom_col(fill = "#20274c") + + geom_text(aes(label = sprintf("%.0f%%\n(%d/%d)", pct_success, success, total)), + vjust = -0.3, size = 3, lineheight = 0.9) + + scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 20), + expand = c(0, 0)) + + scale_x_discrete(expand = expansion(add = 0.7)) + + coord_cartesian(ylim = c(0, 100), clip = "off") + + labs(title = title, + subtitle = "Percentage of completed workflow runs that succeeded (by user)", + caption = stamp, + x = NULL, y = "Success rate (%)") + + theme_minimal(base_size = 12) + + theme(axis.text.x = element_text(angle = 45, hjust = 1), + panel.border = element_rect(color = "black", fill = NA, linewidth = 0.8), + plot.margin = margin(t = 20, r = 10, b = 5, l = 5)) + + w <- max(7, 0.7 * nrow(agg) + 2); h <- 6 + png(opts$out, width = w, height = h, units = "in", res = 120) + print(p) + # Overlay the team logo in the top-right corner, preserving its aspect ratio. + if (!is.null(opts$logo) && file.exists(opts$logo) && + requireNamespace("png", quietly = TRUE)) { + logo <- png::readPNG(opts$logo) + grid::grid.raster(logo, x = 0.985, y = 0.97, width = grid::unit(0.05, "npc"), + just = c("right", "top")) + } else if (!is.null(opts$logo)) { + message("Logo not drawn (file or 'png' package missing): ", opts$logo) + } + dev.off() +} else { + message("ggplot2 not installed - using base R barplot.") + png(opts$out, width = max(700, 70 * nrow(agg) + 120), height = 600) + par(mar = c(9, 5, 4, 2)) + bp <- barplot(agg$pct_success, names.arg = agg$user, horiz = FALSE, + las = 2, col = "#20274c", border = NA, ylim = c(0, 100), + ylab = "Success rate (%)", main = title) + box() # black frame around the plot + text(bp, agg$pct_success + 4, + labels = sprintf("%.0f%% (%d/%d)", agg$pct_success, agg$success, agg$total), + cex = 0.8, xpd = TRUE) + mtext(stamp, side = 1, line = 7, adj = 1, cex = 0.7, col = "gray30") + dev.off() +} + +message("\nSaved chart to: ", normalizePath(opts$out)) diff --git a/.github/workflows/scripts/adj-compact/logo.png b/.github/workflows/scripts/adj-compact/logo.png new file mode 100644 index 0000000..9096371 Binary files /dev/null and b/.github/workflows/scripts/adj-compact/logo.png differ diff --git a/.github/workflows/scripts/adj-tester/schema/board.schema.json b/.github/workflows/scripts/adj-tester/schema/board.schema.json index f640ac7..3c189c8 100644 --- a/.github/workflows/scripts/adj-tester/schema/board.schema.json +++ b/.github/workflows/scripts/adj-tester/schema/board.schema.json @@ -6,9 +6,7 @@ "additionalProperties": true, "required": [ "board_id", - "board_ip", - "measurements", - "packets" + "board_ip" ], "properties": { "board_id": { diff --git a/.github/workflows/scripts/adj-tester/schema/socket.schema.json b/.github/workflows/scripts/adj-tester/schema/socket.schema.json index 5c9644c..79ef9e9 100644 --- a/.github/workflows/scripts/adj-tester/schema/socket.schema.json +++ b/.github/workflows/scripts/adj-tester/schema/socket.schema.json @@ -43,10 +43,20 @@ "description": "Remote IPv4 address" }, "remote_port": { - "type": "integer", - "minimum": 1, - "maximum": 65535, - "description": "Remote port number for Socket" + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "string", + "enum": [ + "backend" + ] + } + ], + "description": "Remote port number for Socket or the string \"backend\"" } } } diff --git a/README.md b/README.md index 4531829..c6059bf 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Array of packet definitions for network communication. Packets are separated by ``` **Field Descriptions:** -- `id`: Optional 32-bit unsigned integer packet identifier +- `id`: Optional 16-bit unsigned integer packet identifier. **MUST BE DIFFERENT TO 1 & 700** (BLCU uses) - `type`: Packet type string (e.g., "data", "order", "status") - `name`: Human-readable packet name - `variables`: Array of variable names/measurement IDs included in this packet @@ -235,8 +235,8 @@ Array of socket definitions for network communication. - `name`: Socket name - `port`: Optional number that describes the port number used for a ServerSocket or a DatagramSocket - `local_port`: Optional number that describes the local port number used for a Socket -- `remote_ip`: Optional string that describes the remote ip you want to connect to in a DatagramSocket or Socket -- `remote_port`: Optional number that describes the remote port number used for a Socket +- `remote_ip`: Optional string that describes the remote ip you want to connect to in a DatagramSocket or Socket. If set to `"backend"`, the address defined under `addresses.backend` in `general_info` is used +- `remote_port`: Optional number that describes the remote port number used for a Socket. If set to `"backend"`, the UDP port defined under `ports.UDP` in `general_info` is used **Example:** ```json