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
4 changes: 4 additions & 0 deletions .github/workflows/update-index.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Update content index

Check warning on line 1 in .github/workflows/update-index.yml

View workflow job for this annotation

GitHub Actions / flag-out-of-scope-changes

This modified file is outside every Sites/*/Fixlets directory and the top-level Signatures/ directory, so it was not automatically validated and needs manual review.

# Keeps pages/index.json (the file listing consumed by the GitHub Pages viewer,
# published from the repo root - see index.html there) up to date with the
Expand Down Expand Up @@ -52,6 +52,10 @@
with:
ref: ${{ github.ref_name }}
token: ${{ steps.generate-token.outputs.token }}
# Full history: generate_index.py reads git log to credit each file's
# original author on the viewer's welcome page (a shallow clone would
# attribute everything to the latest commit).
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
Expand Down
130 changes: 130 additions & 0 deletions pages/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use strict";

Check warning on line 1 in pages/app.js

View workflow job for this annotation

GitHub Actions / flag-out-of-scope-changes

This modified file is outside every Sites/*/Fixlets directory and the top-level Signatures/ directory, so it was not automatically validated and needs manual review.

/* ---------------------------------------------------------------------
* Config: figure out which repo/branch this content came from, purely to
Expand Down Expand Up @@ -85,6 +85,7 @@
const sidebarShowBtn = document.getElementById("sidebar-show-btn");

let allFiles = []; // [{path, name, dir}]
let recentContributions = []; // [{path, added, author, login}] - from index.json's "recent"
let activePath = null;

collapseAllBtn.addEventListener("click", () => {
Expand Down Expand Up @@ -132,8 +133,12 @@
if (!res.ok) throw new Error(`Could not load index.json (${res.status})`);
const data = await res.json();
allFiles = (data.files || []).slice().sort((a, b) => a.path.localeCompare(b.path));
recentContributions = data.recent || []; // absent in an older/CI-less index: welcome page just omits the list
renderTree(allFiles);
statusTextEl.textContent = `${allFiles.length} files`;
// A manual Refresh while on the welcome page should update its list too
// (initial load is handled by init(), which knows whether ?file= is set).
if (forceRefresh && !activePath) showWelcome();
} catch (err) {
statusTextEl.textContent = "Failed to load file list";
treeRootEl.innerHTML = "";
Expand Down Expand Up @@ -291,9 +296,132 @@
if (path) {
activePath = path;
loadAndRenderFile(path);
} else {
// Back to the entry that had no ?file= : the default view.
activePath = null;
document.querySelectorAll(".tree-file.active").forEach((el) => el.classList.remove("active"));
showWelcome();
}
});

/* ---------------------------------------------------------------------
* Welcome (default) view
* Shown when no file is selected: a greeting, a pointer to the source repo
* for full search/browse, and the most recent contributions. The list comes
* from index.json's "recent" array, which generate_index.py builds from git
* history and credits to each file's ORIGINAL author (not the bot that later
* renamed it) - this page never talks to GitHub's API.
* ------------------------------------------------------------------- */

function showWelcome() {
mainEl.innerHTML = "";
const root = document.createElement("section");
root.className = "welcome";

const h1 = document.createElement("h1");
h1.textContent = "Welcome to the BigFix Community Content Viewer";
root.appendChild(h1);

const lead = document.createElement("p");
lead.className = "welcome-lead";
lead.textContent =
"Browse the Fixlets, Tasks, Analyses, Baselines and Inventory Signatures shared by the BigFix community. " +
"Pick an item from the file list to preview its description, relevance and actions.";
root.appendChild(lead);

// The filter box only matches file paths, so be upfront about what this viewer can't do.
const notice = document.createElement("div");
notice.className = "welcome-notice";
const strong = document.createElement("strong");
strong.textContent = "Looking for something specific? ";
notice.appendChild(strong);
notice.appendChild(
document.createTextNode(
"This viewer is a lightweight preview, and its filter only matches file names. " +
"For full searchability and browsing, refer back to the source GitHub repository: "
)
);
const repoA = document.createElement("a");
repoA.href = repoLinkEl.href;
repoA.target = "_blank";
repoA.rel = "noopener";
repoA.textContent = repoLinkEl.textContent;
notice.appendChild(repoA);
notice.appendChild(document.createTextNode("."));
root.appendChild(notice);

const h2 = document.createElement("h2");
h2.textContent = "Recent contributions";
root.appendChild(h2);

const byPath = new Map(allFiles.map((f) => [f.path, f]));
// Skip anything no longer in the file list (e.g. index.json regenerated mid-session).
const items = recentContributions.filter((r) => byPath.has(r.path));
if (items.length === 0) {
const none = document.createElement("p");
none.className = "welcome-empty";
none.textContent = "Recent contribution details aren't available right now.";
root.appendChild(none);
} else {
const list = document.createElement("ol");
list.className = "recent-list";
for (const r of items) list.appendChild(renderRecentItem(r, byPath.get(r.path)));
root.appendChild(list);
}

mainEl.appendChild(root);
mainEl.scrollTop = 0;
}

function renderRecentItem(r, file) {
const li = document.createElement("li");
li.className = "recent-item";

const dot = document.createElement("span");
dot.className = `type-dot ${typeDotClass(file.type)}`;
li.appendChild(dot);

// A real href (the same ?file= URL selectFile pushes) so open-in-new-tab and
// copy-link work, while a plain click is handled in-page without a reload.
const link = document.createElement("a");
const url = new URL(location.href);
url.search = "";
url.searchParams.set("file", r.path);
link.href = url.href;
link.className = "recent-title";
link.textContent = file.title || file.name.replace(/\.bes$/i, "");
link.onclick = (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.button !== 0) return;
ev.preventDefault();
selectFile(r.path);
};
li.appendChild(link);

const meta = document.createElement("span");
meta.className = "recent-meta";
meta.appendChild(document.createTextNode(`${file.type || "Other"} · by `));
if (r.login) {
const who = document.createElement("a");
who.href = `https://${GITHUB_SITE}/${encodeURIComponent(r.login)}`;
who.target = "_blank";
who.rel = "noopener";
who.textContent = r.author || r.login;
meta.appendChild(who);
} else {
meta.appendChild(document.createTextNode(r.author || "unknown"));
}
const when = new Date(r.added);
if (!isNaN(when)) {
meta.appendChild(
document.createTextNode(
` · ${when.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}`
)
);
}
li.appendChild(meta);
return li;
}

async function loadAndRenderFile(path) {
mainEl.innerHTML = `<div class="spinner-line">Loading ${escapeText(path)}…</div>`;
try {
Expand Down Expand Up @@ -1002,5 +1130,7 @@
activePath = initialPath;
renderTree(allFiles);
loadAndRenderFile(initialPath);
} else {
showWelcome();
}
})();
124 changes: 124 additions & 0 deletions pages/generate_index.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python3

Check warning on line 1 in pages/generate_index.py

View workflow job for this annotation

GitHub Actions / flag-out-of-scope-changes

This modified file is outside every Sites/*/Fixlets directory and the top-level Signatures/ directory, so it was not automatically validated and needs manual review.
"""
Generate pages/index.json: a listing of every Sites/**/*.bes file (Fixlets,
Tasks, Analyses, Baselines, ...) plus every Signatures/*.xml file, with
Expand All @@ -14,7 +14,9 @@
python scripts/generate_index.py
"""
import json
from datetime import datetime
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
Expand All @@ -24,6 +26,14 @@
SIGNATURE_ROOT = REPO_ROOT / "Signatures"
OUTPUT_PATH = REPO_ROOT / "pages" / "index.json"

# How many entries the viewer's welcome page lists under "Recent contributions".
RECENT_COUNT = 10

# GitHub's privacy-preserving commit emails: "<id>+<login>@users.noreply.github.com"
# (current) or "<login>@users.noreply.github.com" (legacy). The only place a git
# commit reveals a GitHub login without calling the API.
NOREPLY_EMAIL_RE = re.compile(r"^(?:\d+\+)?([^@+]+)@users\.noreply\.github\.com$")

KNOWN_ROOT_TAGS = {"Task", "Fixlet", "Analysis", "Baseline", "TaskCondition", "ComputerGroup"}

# Both .bes exports and Signatures/*.xml files carry their metadata as
Expand Down Expand Up @@ -164,12 +174,126 @@
return entry


def git(*args):
"""Run git in the repo and return stdout as text; raises on failure."""
result = subprocess.run(
["git", "-c", "core.quotepath=false", *args],
cwd=REPO_ROOT, check=True, capture_output=True,
)
return result.stdout.decode("utf-8", errors="replace")


def parse_author(header):
"""Split a '<name>\\x1f<email>\\x1f<iso date>' record into a contribution dict."""
name, email, date = (header.split("\x1f") + ["", "", ""])[:3]
m = NOREPLY_EMAIL_RE.match(email)
return {
"author": name,
# Only trust a noreply email for a profile link, and never for bots.
"login": m.group(1) if m and not name.endswith("[bot]") else "",
"date": date,
}


def newest_paths_on_main():
"""Current content paths, most recently landed on main first.

Replays the first-parent (main line) history oldest -> newest with rename
detection, so a file's "landing" position survives the update-index
workflow's ID-prefix renames: A(dd) stamps the path with the commit's
position, R(ename) carries the stamp to the new path, D(elete) drops it.
First-parent (with -m, so merge commits diff against their first parent)
keeps the replay linear and correct even when content arrived via a
pull-request merge after being deleted and re-added on other branches.
This only ORDERS files by recency; who actually authored them is resolved
separately (see original_author), because a merge/bot commit's own author
is not the contributor.
"""
out = git(
"log", "-z", "--first-parent", "-m", "--reverse", "-M", "-l0",
"--name-status", "--format=%x01", "--", "Sites", "Signatures",
)
# With -z, --name-status emits NUL-separated tokens: a "\x01" header per
# commit, then per file "<status>", "<path>" (renames: "<old>", "<new>").
tokens = [t.lstrip("\n") for t in out.split("\0")]
landed: dict[str, int] = {}
seq = 0
i = 0
while i < len(tokens):
tok = tokens[i]
i += 1
if not tok:
continue
if tok.startswith("\x01"):
seq += 1
continue
status = tok[0] # A, M, D, R, C, T, ...
if status in "RC" and i + 1 < len(tokens):
old, new = tokens[i], tokens[i + 1]
i += 2
landed[new] = landed.pop(old, seq) if status == "R" else seq
elif i < len(tokens):
path = tokens[i]
i += 1
if status == "A":
landed[path] = seq
elif status == "D":
landed.pop(path, None)
return [p for p, _seq in sorted(landed.items(), key=lambda kv: (kv[1], kv[0]), reverse=True)]


def original_author(path):
"""Who first added `path`, following it back across renames (or None).

`--follow` walks the file's history through the bot's ID-prefix rename to
its original name; --diff-filter=A keeps only the add commits, and the
oldest of those is the original contribution.
"""
out = git(
"log", "--follow", "--diff-filter=A", "-M", "--format=%an%x1f%ae%x1f%aI", "--", path
)
lines = [ln for ln in out.splitlines() if ln.strip()]
return parse_author(lines[-1]) if lines else None


def recent_contributions(existing_paths):
"""The RECENT_COUNT most recent contributions, newest first, as
[{path, added, author, login}], credited to the original author.

Needs full git history (CI must check out with fetch-depth: 0); returns []
with a warning when history is unavailable so the index still builds and
the viewer simply omits the list.
"""
try:
if git("rev-parse", "--is-shallow-repository").strip() == "true":
print("warning: shallow clone - contribution data would be wrong; "
"check out with fetch-depth: 0. Skipping 'recent'.", file=sys.stderr)
return []
candidates = [p for p in newest_paths_on_main() if p in existing_paths]
# A pull request can land many files at once, so look past RECENT_COUNT,
# then order by each file's original authored date.
rows = []
for path in candidates[: RECENT_COUNT * 5]:
info = original_author(path)
if info and info["date"]:
rows.append({"path": path, "added": info["date"],
"author": info["author"], "login": info["login"]})
except (OSError, subprocess.CalledProcessError) as e:
print(f"warning: git history unavailable, skipping 'recent': {e}", file=sys.stderr)
return []
rows.sort(key=lambda r: (datetime.fromisoformat(r["added"]), r["path"]), reverse=True)
return rows[:RECENT_COUNT]


def main():
rel_paths = list(find_source_bes_files())
entries = [describe(rel) for rel in rel_paths]
signature_rel_paths = list(find_source_signature_files())
entries += [describe_signature(rel) for rel in signature_rel_paths]
payload = {
# Drives the viewer's welcome page; credited to whoever first added each
# file, not to the bots/maintainers that later renamed or touched it.
"recent": recent_contributions({e["path"] for e in entries}),
"files": entries,
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
Expand Down
Loading
Loading