Skip to content
Open
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
55 changes: 55 additions & 0 deletions .github/workflows/adj-escalafon.yaml
Original file line number Diff line number Diff line change
@@ -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: "<!channel> 📊 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
8 changes: 7 additions & 1 deletion .github/workflows/adj-tester.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
232 changes: 232 additions & 0 deletions .github/workflows/scripts/adj-compact/action_success_by_user.R
Original file line number Diff line number Diff line change
@@ -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))
Binary file added .github/workflows/scripts/adj-compact/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
"additionalProperties": true,
"required": [
"board_id",
"board_ip",
"measurements",
"packets"
"board_ip"
],
"properties": {
"board_id": {
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/scripts/adj-tester/schema/socket.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading