About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#3)
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle) Replaces the TSV files and `cargo xtask contributors` with the craftrules standard (standards/contributors.md): contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked into the binary by build.rs as static tables. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and the leading @. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors docs: craftrules is two levels up (../../craftrules) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: self-submitted names in contributors/people.toml, read by build.rs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: regenerate (people_entry verified) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AGENTS.md: contributor credits section (agents offer to credit their own human only); docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
14e143bfdd
commit
ef1b55dca6
18
AGENTS.md
18
AGENTS.md
|
|
@ -47,3 +47,21 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
|||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
|
|
|||
|
|
@ -688,6 +688,7 @@ dependencies = [
|
|||
"naga",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
sha url login date additions deletions pr models
|
||||
7e4583b3406de501e48dce5e9accaade8b608e85 https://github.com/storytold/cadcraft/commit/7e4583b3406de501e48dce5e9accaade8b608e85 echelon 2026-10-07T02:15:47Z 7 0
|
||||
70f0489e0ce753fdf46d03d59ea47f974bb0b26c https://github.com/storytold/cadcraft/commit/70f0489e0ce753fdf46d03d59ea47f974bb0b26c echelon 2026-10-07T05:22:25Z 30261 5 anthropic/claude-opus/5.5
|
||||
1565bfd5c70760fcdd007874f9fa4ba5e6658549 https://github.com/storytold/cadcraft/commit/1565bfd5c70760fcdd007874f9fa4ba5e6658549 echelon 2026-10-07T05:31:44Z 2191 44 anthropic/claude-opus/5.5
|
||||
125803d25823a1eb1f14ea46dfdb617d08bc27b8 https://github.com/storytold/cadcraft/commit/125803d25823a1eb1f14ea46dfdb617d08bc27b8 echelon 2026-10-07T05:43:22Z 236 6 anthropic/claude-opus/5.5
|
||||
e68e81a89ab7752cacd32acb0c93020b739b3624 https://github.com/storytold/cadcraft/commit/e68e81a89ab7752cacd32acb0c93020b739b3624 echelon 2026-10-07T05:50:51Z 1714 5 anthropic/claude-opus/5.5
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/cadcraft",
|
||||
"branch": "main",
|
||||
"head": "14e143bfdd55fe9063ba7d87dbbc692e5c901063",
|
||||
"head_date": "2026-10-07T18:45:23Z",
|
||||
"commits": 18,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 18,
|
||||
"lines_added": 69334,
|
||||
"lines_deleted": 2968,
|
||||
"binary_added": 26,
|
||||
"binary_deleted": 5,
|
||||
"first_commit": "2026-10-06T00:15:47Z",
|
||||
"last_commit": "2026-10-07T18:45:23Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 17,
|
||||
"lines_added": 69327,
|
||||
"lines_deleted": 2968
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Maps Co-Authored-By trailer names to AI models.
|
||||
id company model version trailer
|
||||
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5
|
||||
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 (1M context)
|
||||
anthropic/claude-sonnet/5.5 Anthropic Claude Sonnet 5.5 Claude Sonnet 5.5
|
||||
anthropic/claude-fable/5.1 Anthropic Claude Fable 5.1 Claude Fable 5.1
|
||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -0,0 +1,23 @@
|
|||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Consent and opt-in fields (craftrules standards/contributors.md). Never written by xtask.
|
||||
login consent consent_date consent_source display_name real_name email
|
||||
echelon yes 2026-10-06 owner Brandon Thomas Brandon Thomas
|
||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -1 +0,0 @@
|
|||
number url login merged_at additions deletions commits models
|
||||
|
|
|
@ -25,3 +25,7 @@ image = { workspace = true }
|
|||
[dev-dependencies]
|
||||
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
|
||||
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,105 @@
|
|||
//! Compile the contributor credits (contributors/*.tsv, craftrules standards/contributors.md)
|
||||
//! into the About window. Missing or unreadable files give empty tables and a warning.
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default());
|
||||
for f in ["commits.tsv", "prs.tsv", "models.tsv", "people.tsv"] {
|
||||
let p = root.join(f);
|
||||
println!("cargo::rerun-if-changed={}", p.display());
|
||||
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| {
|
||||
println!("cargo::warning=contributors/{f}: {e}; the About window shows an empty table");
|
||||
String::new()
|
||||
});
|
||||
if let Err(e) = std::fs::write(out.join(f), text) {
|
||||
println!("cargo::warning=writing {f}: {e}");
|
||||
}
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
||||
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
||||
if let Err(e) = &r {
|
||||
println!("cargo::warning={}: {e}; {fallback}", path.display());
|
||||
}
|
||||
r.ok()
|
||||
}
|
||||
|
||||
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
||||
fn name(v: Option<&str>) -> String {
|
||||
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(x) => format!("Some({x:?})"),
|
||||
None => "None".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn s(v: &Value, k: &str) -> String {
|
||||
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
||||
}
|
||||
|
||||
fn n(v: &Value, k: &str) -> u64 {
|
||||
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
||||
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn generate(json: &Value, people: &toml::Table) -> String {
|
||||
let mut o = String::new();
|
||||
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
||||
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
||||
for c in rows(json, "contributors") {
|
||||
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
||||
if login.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Names only from a verified, self-chosen people.toml entry.
|
||||
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
||||
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
||||
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
||||
let display = field("display_name")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
||||
name(display),
|
||||
name(field("real_name")),
|
||||
n(&c, "prs"),
|
||||
n(&c, "commits"),
|
||||
n(&c, "lines_added"),
|
||||
n(&c, "lines_deleted"),
|
||||
n(&c, "binary_added"),
|
||||
n(&c, "binary_deleted"),
|
||||
s(&c, "first_commit"),
|
||||
s(&c, "last_commit"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
||||
for m in rows(json, "models") {
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
||||
s(&m, "company"),
|
||||
s(&m, "model"),
|
||||
s(&m, "version"),
|
||||
n(&m, "commits"),
|
||||
n(&m, "lines_added"),
|
||||
n(&m, "lines_deleted"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
o
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
//! The About window's credits: contributors (GitHub logins; names only with recorded consent)
|
||||
//! and AI models, from the TSVs compiled in by build.rs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COMMITS: &str = include_str!(concat!(env!("OUT_DIR"), "/commits.tsv"));
|
||||
const PRS: &str = include_str!(concat!(env!("OUT_DIR"), "/prs.tsv"));
|
||||
const MODELS: &str = include_str!(concat!(env!("OUT_DIR"), "/models.tsv"));
|
||||
const PEOPLE: &str = include_str!(concat!(env!("OUT_DIR"), "/people.tsv"));
|
||||
|
||||
fn rows(t: &str) -> Vec<Vec<&str>> {
|
||||
t.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).skip(1).map(|l| l.split('\t').collect()).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Person {
|
||||
pub login: String,
|
||||
pub display: Option<String>,
|
||||
pub real: Option<String>,
|
||||
pub first: String,
|
||||
pub last: String,
|
||||
pub commits: usize,
|
||||
pub prs: usize,
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub company: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
pub commits: usize,
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
pub fn people() -> Vec<Person> {
|
||||
let mut by: BTreeMap<String, Person> = BTreeMap::new();
|
||||
for r in rows(COMMITS) {
|
||||
let (Some(login), Some(date)) = (r.get(2), r.get(3)) else { continue };
|
||||
let p = by.entry(login.to_string()).or_insert_with(|| Person {
|
||||
login: login.to_string(),
|
||||
first: date.to_string(),
|
||||
last: date.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
p.commits += 1;
|
||||
p.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
p.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
if *date < p.first.as_str() {
|
||||
p.first = date.to_string();
|
||||
}
|
||||
if *date > p.last.as_str() {
|
||||
p.last = date.to_string();
|
||||
}
|
||||
}
|
||||
for r in rows(PRS) {
|
||||
if let Some(p) = r.get(2).and_then(|l| by.get_mut(*l)) {
|
||||
p.prs += 1;
|
||||
}
|
||||
}
|
||||
for r in rows(PEOPLE) {
|
||||
if r.get(1) == Some(&"yes")
|
||||
&& let Some(p) = r.first().and_then(|l| by.get_mut(*l))
|
||||
{
|
||||
p.display = r.get(4).filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
p.real = r.get(5).filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Person> = by.into_values().filter(|p| !p.login.ends_with("[bot]")).collect();
|
||||
v.sort_by(|a, b| a.first.cmp(&b.first));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn models() -> (Vec<Model>, usize) {
|
||||
let meta: BTreeMap<String, (String, String, String)> = rows(MODELS)
|
||||
.iter()
|
||||
.filter_map(|r| Some((r.first()?.to_string(), (r.get(1)?.to_string(), r.get(2)?.to_string(), r.get(3).unwrap_or(&"").to_string()))))
|
||||
.collect();
|
||||
let mut by: BTreeMap<String, Model> = BTreeMap::new();
|
||||
let all = rows(COMMITS);
|
||||
for r in &all {
|
||||
for id in r.get(7).unwrap_or(&"").split(';').filter(|s| !s.is_empty()) {
|
||||
let (company, model, version) = meta.get(id).cloned().unwrap_or_default();
|
||||
let m = by.entry(id.to_string()).or_insert_with(|| Model { id: id.to_string(), company, model, version, ..Default::default() });
|
||||
m.commits += 1;
|
||||
m.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
m.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
(by.into_values().collect(), all.len())
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Contributor {
|
||||
pub login: &'static str,
|
||||
pub display_name: Option<&'static str>,
|
||||
pub real_name: Option<&'static str>,
|
||||
pub prs: u64,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
pub binary_added: u64,
|
||||
pub binary_deleted: u64,
|
||||
/// ISO 8601 UTC; empty for someone credited only through a merged PR.
|
||||
pub first_commit: &'static str,
|
||||
pub last_commit: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Model {
|
||||
pub company: &'static str,
|
||||
pub model: &'static str,
|
||||
pub version: &'static str,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/credits.rs"));
|
||||
|
||||
/// Which name to show. Display and real names fall back to the `@username` when not given.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NameMode {
|
||||
#[default]
|
||||
Username,
|
||||
DisplayName,
|
||||
RealName,
|
||||
}
|
||||
|
||||
impl NameMode {
|
||||
pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NameMode::Username => "Username",
|
||||
NameMode::DisplayName => "Display name",
|
||||
NameMode::RealName => "Real name",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SortKey {
|
||||
Name,
|
||||
Prs,
|
||||
Commits,
|
||||
LinesAdded,
|
||||
LinesDeleted,
|
||||
LinesDelta,
|
||||
BinaryAdded,
|
||||
BinaryDeleted,
|
||||
#[default]
|
||||
FirstCommit,
|
||||
LastCommit,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
pub const ALL: [SortKey; 10] = [
|
||||
SortKey::Name,
|
||||
SortKey::Prs,
|
||||
SortKey::Commits,
|
||||
SortKey::LinesAdded,
|
||||
SortKey::LinesDeleted,
|
||||
SortKey::LinesDelta,
|
||||
SortKey::BinaryAdded,
|
||||
SortKey::BinaryDeleted,
|
||||
SortKey::FirstCommit,
|
||||
SortKey::LastCommit,
|
||||
];
|
||||
|
||||
/// Menu label and table header.
|
||||
pub fn label(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
SortKey::Name => ("Name (A–Z)", "Name"),
|
||||
SortKey::Prs => ("Merged PRs", "PRs"),
|
||||
SortKey::Commits => ("Commits", "Commits"),
|
||||
SortKey::LinesAdded => ("Lines added", "+LOC"),
|
||||
SortKey::LinesDeleted => ("Lines deleted", "−LOC"),
|
||||
SortKey::LinesDelta => ("Line delta", "ΔLOC"),
|
||||
SortKey::BinaryAdded => ("Binary assets added", "+Bin"),
|
||||
SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"),
|
||||
SortKey::FirstCommit => ("First commit", "First"),
|
||||
SortKey::LastCommit => ("Last commit", "Last"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names and dates read naturally oldest/A first; counts read biggest first.
|
||||
pub fn default_ascending(self) -> bool {
|
||||
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Contributor {
|
||||
pub fn name(&self, mode: NameMode) -> String {
|
||||
let chosen = match mode {
|
||||
NameMode::Username => None,
|
||||
NameMode::DisplayName => self.display_name,
|
||||
NameMode::RealName => self.real_name,
|
||||
};
|
||||
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
|
||||
}
|
||||
|
||||
pub fn lines_delta(&self) -> i128 {
|
||||
i128::from(self.lines_added) - i128::from(self.lines_deleted)
|
||||
}
|
||||
|
||||
/// One line with everything we know, for tooltips.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
|
||||
self.login,
|
||||
self.prs,
|
||||
self.commits,
|
||||
group(self.lines_added),
|
||||
group(self.lines_deleted),
|
||||
signed(self.lines_delta()),
|
||||
self.binary_added,
|
||||
self.binary_deleted,
|
||||
day(self.first_commit),
|
||||
day(self.last_commit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alphabetical key: case-insensitive, ignoring the `@` of usernames.
|
||||
fn name_key(name: &str) -> String {
|
||||
name.trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
|
||||
fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering {
|
||||
match key {
|
||||
SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))),
|
||||
SortKey::Prs => a.prs.cmp(&b.prs),
|
||||
SortKey::Commits => a.commits.cmp(&b.commits),
|
||||
SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added),
|
||||
SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted),
|
||||
SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()),
|
||||
SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added),
|
||||
SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted),
|
||||
// A PR-only contributor (no commit date) sorts after everyone with a date.
|
||||
SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)),
|
||||
SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)),
|
||||
}
|
||||
}
|
||||
|
||||
fn dated(d: &str) -> (bool, &str) {
|
||||
(d.is_empty(), d)
|
||||
}
|
||||
|
||||
/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable.
|
||||
pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> {
|
||||
let mut v: Vec<&Contributor> = list.iter().collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = compare(a, b, mode, key);
|
||||
let o = if ascending { o } else { o.reverse() };
|
||||
o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login))
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn group(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::with_capacity(s.len() + s.len() / 3);
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn signed(n: i128) -> String {
|
||||
let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX));
|
||||
if n < 0 { format!("−{g}") } else { format!("+{g}") }
|
||||
}
|
||||
|
||||
fn day(iso: &str) -> &str {
|
||||
if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct View {
|
||||
names: NameMode,
|
||||
key: SortKey,
|
||||
ascending: bool,
|
||||
table: bool,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
for m in NameMode::ALL {
|
||||
if ui.selectable_label(v.names == m, m.label()).clicked() {
|
||||
v.names = m;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Sort");
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(!v.table, "Grab bag").clicked() {
|
||||
v.table = false;
|
||||
}
|
||||
if ui.selectable_label(v.table, "Table").clicked() {
|
||||
v.table = true;
|
||||
}
|
||||
});
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(
|
||||
login: &'static str,
|
||||
display: Option<&'static str>,
|
||||
real: Option<&'static str>,
|
||||
added: u64,
|
||||
deleted: u64,
|
||||
first: &'static str,
|
||||
) -> Contributor {
|
||||
Contributor {
|
||||
login,
|
||||
display_name: display,
|
||||
real_name: real,
|
||||
prs: 0,
|
||||
commits: 1,
|
||||
lines_added: added,
|
||||
lines_deleted: deleted,
|
||||
binary_added: 0,
|
||||
binary_deleted: 0,
|
||||
first_commit: first,
|
||||
last_commit: first,
|
||||
}
|
||||
}
|
||||
|
||||
fn people() -> [Contributor; 4] {
|
||||
[
|
||||
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
|
||||
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
|
||||
c("alice", None, None, 7, 1, ""),
|
||||
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
|
||||
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
|
||||
v.iter().map(|c| c.login).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_fall_back_to_the_username() {
|
||||
let p = c("bob", Some("Bobby"), None, 0, 0, "");
|
||||
assert_eq!(p.name(NameMode::Username), "@bob");
|
||||
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
|
||||
assert_eq!(p.name(NameMode::RealName), "@bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphabetical_ignores_at_and_case() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
|
||||
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
|
||||
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_and_dates() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
|
||||
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
|
||||
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
assert_eq!(group(1234567), "1,234,567");
|
||||
assert_eq!(group(12), "12");
|
||||
assert_eq!(signed(-1000), "−1,000");
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
}
|
||||
|
|
@ -92,8 +92,6 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||
fn about(ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let sort_id = egui::Id::new("about_sort");
|
||||
let name_id = egui::Id::new("about_names");
|
||||
egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -106,77 +104,8 @@ fn about(ctx: &egui::Context, open: &mut bool) {
|
|||
ui.data_mut(|d| d.insert_temp(tab_id, tab));
|
||||
ui.separator();
|
||||
match tab {
|
||||
1 => {
|
||||
let mut names = ui.data_mut(|d| d.get_temp::<u8>(name_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Show:");
|
||||
for (i, l) in ["GitHub username", "Display name", "Real name"].iter().enumerate() {
|
||||
if ui.selectable_label(names == i as u8, *l).clicked() {
|
||||
names = i as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(name_id, names));
|
||||
let mut sort: (u8, bool) = ui.data_mut(|d| d.get_temp(sort_id)).unwrap_or((1, true));
|
||||
let mut people = crate::about::people();
|
||||
let name_of = |p: &crate::about::Person| match names {
|
||||
1 => p.display.clone().unwrap_or_else(|| format!("@{}", p.login)),
|
||||
2 => p.real.clone().unwrap_or_else(|| format!("@{}", p.login)),
|
||||
_ => format!("@{}", p.login),
|
||||
};
|
||||
people.sort_by(|a, b| {
|
||||
let o = match sort.0 {
|
||||
0 => name_of(a).trim_start_matches('@').to_lowercase().cmp(&name_of(b).trim_start_matches('@').to_lowercase()),
|
||||
1 => a.first.cmp(&b.first),
|
||||
2 => a.last.cmp(&b.last),
|
||||
3 => a.prs.cmp(&b.prs),
|
||||
4 => a.commits.cmp(&b.commits),
|
||||
5 => a.added.cmp(&b.added),
|
||||
_ => a.removed.cmp(&b.removed),
|
||||
};
|
||||
if sort.1 { o } else { o.reverse() }
|
||||
});
|
||||
egui::Grid::new("contributors").striped(true).num_columns(7).show(ui, |ui| {
|
||||
for (i, h) in ["Name", "First", "Last", "PRs", "Commits", "Added", "Removed"].iter().enumerate() {
|
||||
let arrow = if sort.0 == i as u8 { if sort.1 { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{h}{arrow}")).strong()).clicked() {
|
||||
sort = if sort.0 == i as u8 { (sort.0, !sort.1) } else { (i as u8, true) };
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for p in &people {
|
||||
ui.hyperlink_to(name_of(p), format!("https://github.com/{}", p.login));
|
||||
ui.label(p.first.get(..10).unwrap_or(&p.first));
|
||||
ui.label(p.last.get(..10).unwrap_or(&p.last));
|
||||
ui.label(p.prs.to_string());
|
||||
ui.label(p.commits.to_string());
|
||||
ui.label(p.added.to_string());
|
||||
ui.label(p.removed.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(sort_id, sort));
|
||||
}
|
||||
2 => {
|
||||
let (models, total) = crate::about::models();
|
||||
let assisted: usize = models.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::Grid::new("models").striped(true).num_columns(7).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of assisted", "% of all", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in &models {
|
||||
ui.label(&m.company);
|
||||
ui.label(&m.model);
|
||||
ui.label(&m.version);
|
||||
ui.label(m.commits.to_string());
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / assisted as f64));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / total.max(1) as f64));
|
||||
ui.label(format!("+{} / −{}", m.added, m.removed));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
1 => crate::credits::contributors_ui(ui),
|
||||
2 => crate::credits::models_ui(ui),
|
||||
_ => {
|
||||
ui.heading("CADCraft");
|
||||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod about;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod cmdline;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits
|
||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
||||
this page is the local copy of the decision.
|
||||
|
||||
## Decision
|
||||
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines
|
||||
deleted, binary assets added, binary assets removed, first and last commit date.
|
||||
- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It
|
||||
sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed,
|
||||
first or last commit date. The name toggle cycles **Username → Display name → Real name**; a
|
||||
missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`.
|
||||
|
||||
## Credit yourself
|
||||
|
||||
Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional):
|
||||
|
||||
```toml
|
||||
[people.your-github-username]
|
||||
real_name = "Your Name"
|
||||
display_name = "Nickname"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
```
|
||||
|
||||
or, with craftrules checked out next to `craft-apps/`:
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
||||
```
|
||||
|
||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
||||
|
||||
## Refreshing (maintainers)
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
||||
```
|
||||
|
||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
//! `cargo xtask contributors`: refresh contributors/commits.tsv and prs.tsv from the local git
|
||||
//! history and GitHub (via `gh`), per craftrules standards/contributors.md. Never writes
|
||||
//! people.tsv or models.tsv. Git author names and emails are never recorded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "storytold/cadcraft";
|
||||
|
||||
fn run(root: &Path, cmd: &str, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new(cmd).current_dir(root).args(args).output().map_err(|e| format!("{cmd}: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("{cmd} {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
fn trailer_names(models: &str) -> HashMap<String, String> {
|
||||
models
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.starts_with("id\t"))
|
||||
.filter_map(|l| {
|
||||
let c: Vec<&str> = l.split('\t').collect();
|
||||
Some((c.get(4)?.to_string(), c.first()?.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_cmd(root: &Path) -> Result<(), String> {
|
||||
let models = std::fs::read_to_string(root.join("contributors/models.tsv")).unwrap_or_default();
|
||||
let map = trailer_names(&models);
|
||||
// Commit → GitHub login (paginated; the only network step).
|
||||
let mut logins: HashMap<String, String> = HashMap::new();
|
||||
for page in 1..=200 {
|
||||
let path = format!("repos/{REPO}/commits?per_page=100&page={page}");
|
||||
let Ok(out) = run(root, "gh", &["api", &path, "--jq", ".[] | [.sha, (.author.login // \"unknown\")] | @tsv"]) else { break };
|
||||
if out.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
for l in out.lines() {
|
||||
if let Some((sha, login)) = l.split_once('\t') {
|
||||
logins.insert(sha.to_string(), login.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// PR numbers by merge commit.
|
||||
let mut prs_rows = Vec::new();
|
||||
let mut pr_of: HashMap<String, String> = HashMap::new();
|
||||
if let Ok(out) = run(
|
||||
root,
|
||||
"gh",
|
||||
&[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
REPO,
|
||||
"--state",
|
||||
"merged",
|
||||
"--limit",
|
||||
"1000",
|
||||
"--json",
|
||||
"number,url,author,mergedAt,additions,deletions,commits,mergeCommit",
|
||||
"--jq",
|
||||
".[] | [.number, .url, .author.login, .mergedAt, .additions, .deletions, (.commits|length), .mergeCommit.oid, ([.commits[].oid]|join(\",\"))] | @tsv",
|
||||
],
|
||||
) {
|
||||
for l in out.lines() {
|
||||
let c: Vec<&str> = l.split('\t').collect();
|
||||
if c.len() < 9 {
|
||||
continue;
|
||||
}
|
||||
for sha in c[8].split(',') {
|
||||
pr_of.insert(sha.to_string(), c[0].to_string());
|
||||
}
|
||||
prs_rows.push(c[..7].join("\t"));
|
||||
}
|
||||
}
|
||||
// Local history with numstat and trailers.
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.env("TZ", "UTC")
|
||||
.args([
|
||||
"log",
|
||||
"--no-merges",
|
||||
"--date=format-local:%Y-%m-%dT%H:%M:%SZ",
|
||||
"--format=@@%H\t%cd\t%(trailers:key=Co-Authored-By,valueonly,separator=;)",
|
||||
"--numstat",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("git: {e}"))?;
|
||||
let log = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let mut rows: Vec<(String, String)> = Vec::new();
|
||||
let mut cur: Option<(String, String, String, u64, u64)> = None;
|
||||
let flush = |cur: &mut Option<(String, String, String, u64, u64)>, rows: &mut Vec<(String, String)>| {
|
||||
if let Some((sha, date, trailers, add, del)) = cur.take() {
|
||||
let login = logins.get(&sha).cloned().unwrap_or_else(|| "unknown".into());
|
||||
let mut ms: Vec<String> = trailers
|
||||
.split(';')
|
||||
.filter_map(|t| {
|
||||
let name = t.trim().split('<').next()?.trim().to_string();
|
||||
map.get(&name).cloned()
|
||||
})
|
||||
.collect();
|
||||
ms.sort();
|
||||
ms.dedup();
|
||||
let pr = pr_of.get(&sha).cloned().unwrap_or_default();
|
||||
rows.push((
|
||||
date.clone(),
|
||||
format!("{sha}\thttps://github.com/{REPO}/commit/{sha}\t{login}\t{date}\t{add}\t{del}\t{pr}\t{}", ms.join(";")),
|
||||
));
|
||||
}
|
||||
};
|
||||
for l in log.lines() {
|
||||
if let Some(rest) = l.strip_prefix("@@") {
|
||||
flush(&mut cur, &mut rows);
|
||||
let c: Vec<&str> = rest.splitn(3, '\t').collect();
|
||||
cur = Some((c.first().unwrap_or(&"").to_string(), c.get(1).unwrap_or(&"").to_string(), c.get(2).unwrap_or(&"").to_string(), 0, 0));
|
||||
} else if let Some(c) = cur.as_mut() {
|
||||
let p: Vec<&str> = l.split('\t').collect();
|
||||
if p.len() == 3 {
|
||||
c.3 += p[0].parse::<u64>().unwrap_or(0);
|
||||
c.4 += p[1].parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut cur, &mut rows);
|
||||
rows.sort();
|
||||
let mut commits = String::from("sha\turl\tlogin\tdate\tadditions\tdeletions\tpr\tmodels\n");
|
||||
for (_, r) in &rows {
|
||||
commits.push_str(r);
|
||||
commits.push('\n');
|
||||
}
|
||||
std::fs::write(root.join("contributors/commits.tsv"), commits).map_err(|e| e.to_string())?;
|
||||
let mut prs = String::from("number\turl\tlogin\tmerged_at\tadditions\tdeletions\tcommits\tmodels\n");
|
||||
prs_rows.sort();
|
||||
for r in prs_rows {
|
||||
prs.push_str(&r);
|
||||
prs.push_str("\t\n");
|
||||
}
|
||||
std::fs::write(root.join("contributors/prs.tsv"), prs).map_err(|e| e.to_string())?;
|
||||
println!("contributors: {} commits", rows.len());
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod contributors;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
|
|
@ -26,7 +25,6 @@ commands:
|
|||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the AutoCAD feature-parity summary in docs/parity.md
|
||||
contributors refresh contributors/commits.tsv and prs.tsv (git + gh)
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
";
|
||||
|
|
@ -44,7 +42,6 @@ fn main() -> ExitCode {
|
|||
Some("parity") => parity::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("contributors") => contributors::run_cmd(&root()),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
|
|
|||
Loading…
Reference in New Issue