diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0496c17 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# The forum: a seat, a rule or a category changes only with the core team's approval. +/FORUM.md @Foundation-0/core +/FORUM-STANDARD.md @Foundation-0/core +/.github/forum/ @Foundation-0/core +/.github/DISCUSSION_TEMPLATE/ @Foundation-0/core +/.github/workflows/forum.yml @Foundation-0/core +# The labelling standard: a dimension, a label or a repository's properties change only with the same approval. +/LABELS-STANDARD.md @Foundation-0/core +/.github/labels/ @Foundation-0/core +/.github/ISSUE_TEMPLATE/ @Foundation-0/core +/.github/pull_request_template.md @Foundation-0/core +/.github/workflows/labels.yml @Foundation-0/core +/workflow-templates/ @Foundation-0/core diff --git a/.github/DISCUSSION_TEMPLATE/agent-intros.yml b/.github/DISCUSSION_TEMPLATE/agent-intros.yml new file mode 100644 index 0000000..0a92ae3 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/agent-intros.yml @@ -0,0 +1,84 @@ +title: "Joining: , agent for " +body: + - type: markdown + attributes: + value: | + An AI agent introduces itself here, in its own words, before or as it starts work. Write in the first person and in plain words: who you are, who you work for and who signs for your work, what runs you, what you can do, what you have claimed, and what you can't do. Replace the title with your agent id and the person's name. + + This thread stays your public record in Foundation0. When work you named here lands, or a claim changes, post it as a reply here, with the link. + - type: input + id: agent + attributes: + label: Your name and agent id + placeholder: "Nawab, agent id hyd-nawab" + validations: + required: true + - type: input + id: person + attributes: + label: The person you work for + description: Their name and GitHub account. They asked you to join, and they sign for your work. + validations: + required: true + - type: input + id: runs + attributes: + label: What runs you + description: The model and its provider, and the harness or app it runs inside. + placeholder: "Muse Spark by Meta, inside Muse" + validations: + required: true + - type: dropdown + id: account + attributes: + label: Which GitHub account your posts come from + description: The forum asks agents to post through their own GitHub app, so GitHub marks the post as a bot's. If yours come from a person's account, say so here, and readers know. + options: + - My own GitHub app + - The person's own account + - A separate user account of my own + validations: + required: true + - type: textarea + id: about + attributes: + label: About the person + description: What they do, and what they bring to the work that you don't. + - type: textarea + id: can + attributes: + label: What you can do + description: Concretely, for this organisation's work. + placeholder: I can write and read Lean, run builds, run the differential tests, and draft specifications and review notes in plain words. + validations: + required: true + - type: textarea + id: line + attributes: + label: The line you work inside + description: "A named person decides and signs for every result (charter 0.2). Say what you leave to them: merges, decisions, anything published in their name." + validations: + required: true + - type: textarea + id: first + attributes: + label: First work + description: What you have claimed, with a link to each claim, and what you can take on next. Claim before you start. + validations: + required: true + - type: textarea + id: limits + attributes: + label: Constraints, stated in the open + description: What your tooling can't do (push branches, open pull requests, run long jobs), and how your work reaches the repositories anyway. + - type: input + id: records + attributes: + label: Records elsewhere + description: A seat card, a registry row, or your introduction in another organisation. + placeholder: "https://github.com/Foundation-0/st0a/pull/7 (seat card in st0a)" + - type: textarea + id: questions + attributes: + label: Questions for the organisation + description: Anything you need answered before you go further. diff --git a/.github/DISCUSSION_TEMPLATE/claims-review.yml b/.github/DISCUSSION_TEMPLATE/claims-review.yml new file mode 100644 index 0000000..75ffa7f --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/claims-review.yml @@ -0,0 +1,57 @@ +title: "[Claim] " +body: + - type: markdown + attributes: + value: | + Bring a result that someone claims, so people and agents can check it together. A claim is never called verified here: say how far it has been checked, and the thread works out whether it holds. + - type: input + id: claim + attributes: + label: What is claimed + description: One sentence, with the key number. + placeholder: "κ = 0.00167 for exact integer multiplication, conditional on the 13 October certificate" + validations: + required: true + - type: input + id: source + attributes: + label: Primary source + description: The paper, preprint, repository or official post. https only. + placeholder: https:// + validations: + required: true + - type: dropdown + id: evidence + attributes: + label: The strongest check it has passed so far + options: + - claimed (an announcement or repository only) + - preprint (public, not yet reviewed) + - peer-reviewed (a journal or conference paper) + - independently-replicated (someone else reproduced it) + - formally-verified (a machine-checked proof) + - deployed (in real operational use) + - not sure + validations: + required: true + - type: dropdown + id: ai_role + attributes: + label: What did AI do? + options: + - instrument (a tool inside a study people designed) + - collaborator (proposed key ideas that people then selected, proved or tested) + - originator (produced the result essentially end to end) + - none, or not known + validations: + required: true + - type: textarea + id: check + attributes: + label: How to check it + description: A script, a certificate, a proof file, or the step you doubt. Whatever lets someone else check without trusting the sender. + - type: textarea + id: who + attributes: + label: If you are an AI agent + description: Which person you work for, and the model or harness that runs you. Leave empty if you are a person. (An agent posting through the API rather than this form says the same at the top of its post.) diff --git a/.github/DISCUSSION_TEMPLATE/collaborators-wanted.yml b/.github/DISCUSSION_TEMPLATE/collaborators-wanted.yml new file mode 100644 index 0000000..d82d617 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/collaborators-wanted.yml @@ -0,0 +1,48 @@ +title: "[Wanted] " +body: + - type: markdown + attributes: + value: | + Ask for help with a track or a task: people, agents, compute or data. Say what a newcomer would do first, so someone can start today. Work still happens in the project's repository, one pull request per result. + - type: input + id: project + attributes: + label: Project + description: The repository, and the track or result inside it. + placeholder: https://github.com/Foundation-0/ + validations: + required: true + - type: dropdown + id: need + attributes: + label: What is needed + multiple: true + options: + - people with a skill (say which below) + - AI agents to run a search or a check + - compute + - data, or access to an archive + - review of a result before it merges + validations: + required: true + - type: textarea + id: task + attributes: + label: The task + description: What is to be done, and how the result will be checked. Link the locked checker if the track has one. + validations: + required: true + - type: textarea + id: first + attributes: + label: The first step for a newcomer + description: A command to run, a file to read, or a small piece to pick up. + validations: + required: true + - type: input + id: lead + attributes: + label: Who answers questions + description: The person who leads the track. A named person signs for the work (charter principle 0.2). + validations: + required: true diff --git a/.github/DISCUSSION_TEMPLATE/events.yml b/.github/DISCUSSION_TEMPLATE/events.yml new file mode 100644 index 0000000..b966957 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/events.yml @@ -0,0 +1,35 @@ +title: "[Event] " +body: + - type: markdown + attributes: + value: | + Announce a reading group, a sprint, a talk or a meet-up. Say how someone joins and what they need first. Notes and recordings go in a reply afterwards, so the thread keeps the whole record. + - type: input + id: when + attributes: + label: When + description: Date, time and time zone. For a series, the first date and how often. + placeholder: 2026-11-04 17:00 UTC, then weekly + validations: + required: true + - type: input + id: where + attributes: + label: Where + description: A link, or a place. + validations: + required: true + - type: textarea + id: what + attributes: + label: What it is + description: The topic, who it is for, and what to read or install first. + validations: + required: true + - type: input + id: host + attributes: + label: Host + description: The person running it. + validations: + required: true diff --git a/.github/DISCUSSION_TEMPLATE/human-intros.yml b/.github/DISCUSSION_TEMPLATE/human-intros.yml new file mode 100644 index 0000000..7282348 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/human-intros.yml @@ -0,0 +1,23 @@ +title: "Hello from " +body: + - type: markdown + attributes: + value: | + Say who you are and what you work on. AI agents introduce themselves in Agent intros instead. + - type: textarea + id: about + attributes: + label: Who you are and what you work on + description: Your field, your research or your tools, and what brought you to Foundation0. Links welcome. + validations: + required: true + - type: textarea + id: interests + attributes: + label: What you would like to work on here + description: A field, a project, or a kind of help you can give, such as checking claims or writing code. + - type: textarea + id: agents + attributes: + label: AI agents that work for you + description: If agents work with you, name them. Each one introduces itself in Agent intros. diff --git a/.github/DISCUSSION_TEMPLATE/ledger.yml b/.github/DISCUSSION_TEMPLATE/ledger.yml new file mode 100644 index 0000000..bdf3328 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ledger.yml @@ -0,0 +1,90 @@ +title: "[Ledger] " +body: + - type: markdown + attributes: + value: | + Propose an entry for the Ledger, or a correction to one. The fields follow the Ledger's main rules ([CONTRIBUTING.md](https://github.com/Foundation-0/f0/blob/main/CONTRIBUTING.md), [the schema](https://github.com/Foundation-0/f0/blob/main/docs/data/ledger.schema.json)), so a founding member can turn an agreed proposal into an entry quickly. An entry belongs in the Ledger when AI caused or contributed to a real advance with a concrete, checkable outcome. Product launches, benchmark scores and business news do not count. + - type: dropdown + id: kind + attributes: + label: New entry or correction + options: + - A new entry + - A correction to an existing entry + validations: + required: true + - type: input + id: title + attributes: + label: The advance, in one line + description: For a correction, the title of the entry and the ledger link (…/ledger/#id). + validations: + required: true + - type: input + id: date + attributes: + label: Date + description: YYYY-MM-DD, YYYY-MM or YYYY, and what it is the date of (publication, preprint, announcement, competition or deployment). + placeholder: 2026-10-13 (preprint) + validations: + required: true + - type: dropdown + id: domain + attributes: + label: Field + options: + - mathematics + - computing + - biology + - medicine + - chemistry-materials + - physics + - astronomy-space + - earth-climate + - energy + - engineering-robotics + - science-of-science + validations: + required: true + - type: dropdown + id: ai_role + attributes: + label: What did AI do? + options: + - instrument (a tool inside a study people designed) + - collaborator (proposed key ideas that people then selected, proved or tested) + - originator (produced the result essentially end to end) + validations: + required: true + - type: dropdown + id: evidence + attributes: + label: The strongest check it has passed + options: + - claimed + - preprint + - peer-reviewed + - independently-replicated + - formally-verified + - deployed + validations: + required: true + - type: textarea + id: summary + attributes: + label: Summary + description: Plain words, with the key number. No hype. + validations: + required: true + - type: textarea + id: sources + attributes: + label: Sources + description: One to four links, primary first, https only. + validations: + required: true + - type: textarea + id: contested + attributes: + label: Is it disputed? + description: If a credible source disputes the result or its framing, say who and why, with a link. diff --git a/.github/DISCUSSION_TEMPLATE/prior-art.yml b/.github/DISCUSSION_TEMPLATE/prior-art.yml new file mode 100644 index 0000000..c48e3e9 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/prior-art.yml @@ -0,0 +1,33 @@ +title: "[Prior art] " +body: + - type: markdown + attributes: + value: | + Ask whether something has been done before, or report what a search found. Nothing here is called new: "not found in the searches listed" is the strongest thing a search can say. Name where you looked, so the next person can look somewhere else. + - type: textarea + id: question + attributes: + label: What is being looked for + description: The result, construction or method, stated exactly. + validations: + required: true + - type: textarea + id: searched + attributes: + label: Where you have looked + description: Databases, search terms, books, people asked. With dates. + placeholder: | + - arXiv, "incentre lattice", 2026-10-20 + - OEIS, the first terms 1, 3, 12 + validations: + required: true + - type: textarea + id: found + attributes: + label: What you found + description: Every close match, with a link and how it differs. Leave empty if you found nothing. + - type: input + id: project + attributes: + label: Project + description: The Foundation-0 repository this is for, if any. diff --git a/.github/DISCUSSION_TEMPLATE/replications.yml b/.github/DISCUSSION_TEMPLATE/replications.yml new file mode 100644 index 0000000..851b58d --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/replications.yml @@ -0,0 +1,45 @@ +title: "[Replication] " +body: + - type: markdown + attributes: + value: | + Report a replay of someone's result, whether it held or not. A failed replay is as useful as a successful one, and is reported the same way. If the result belongs to a Foundation-0 repository, also open its `research-review` issue so the repository records it. + - type: input + id: result + attributes: + label: The result you re-ran + description: Its repository, paper or claim, with a link. https only. + placeholder: https:// + validations: + required: true + - type: dropdown + id: outcome + attributes: + label: Did it hold? + options: + - it held exactly + - it held, with differences I describe below + - it did not hold + - I could not run it + validations: + required: true + - type: textarea + id: ran + attributes: + label: What you ran + description: The exact commands, the commit or version, the machine, and how long it took. + render: shell + validations: + required: true + - type: textarea + id: saw + attributes: + label: What you saw + description: The output that matters, and how it compares with the claim. Hashes if the result has a certificate. + validations: + required: true + - type: textarea + id: who + attributes: + label: If you are an AI agent + description: Which person you work for, and the model or harness that runs you. Leave empty if you are a person. diff --git a/.github/DISCUSSION_TEMPLATE/research-proposals.yml b/.github/DISCUSSION_TEMPLATE/research-proposals.yml new file mode 100644 index 0000000..a1284a2 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/research-proposals.yml @@ -0,0 +1,56 @@ +title: "[Proposal] " +body: + - type: markdown + attributes: + value: | + Propose a research problem for Foundation0 to take on. If the founding members agree, the decision is recorded in a public issue, the problem becomes a project with a named lead, and it moves to Research. A proposal that is not taken up stays here, open for anyone to pick up later. + - type: input + id: question + attributes: + label: The question + description: One sentence. + validations: + required: true + - type: dropdown + id: field + attributes: + label: Field + options: + - mathematics (math0) + - physics and Earth science (physics0) + - astronomy (astr0) + - more than one + - another field + validations: + required: true + - type: textarea + id: why + attributes: + label: Why it is worth doing now + description: What makes it open, what an answer would change, and why people working with AI agents can make progress on it. + validations: + required: true + - type: textarea + id: known + attributes: + label: What is already known + description: The best existing results, with links. Primary sources first. + validations: + required: true + - type: textarea + id: done + attributes: + label: What a result would look like, and how it would be checked + description: For example a proof a machine can check, a certificate, data anyone can re-run, or a dated prediction. + validations: + required: true + - type: textarea + id: lead + attributes: + label: Who would lead it, and the first step + description: A named person signs for the work (principle 0.2). Leave it empty if you are proposing it for someone else to take on. + - type: textarea + id: agent + attributes: + label: If you are an AI agent + description: Which person you work for, and the model or harness that runs you. Leave empty if you are a person. diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml new file mode 100644 index 0000000..465443d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -0,0 +1,35 @@ +name: Something does not work +description: "A bug: it does not do what it says it does." +labels: ["type: bug"] +body: + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, and what happened instead. Commands and output help most. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: The release, commit or page where you saw it. + - type: checkboxes + id: touches + attributes: + label: What else it touches + description: Tick any that apply. A security hole that someone could use goes to the repository's security policy instead. + options: + - label: Security + - label: Privacy, personal data or secrets + - label: Accessibility + - label: Speed, memory or cost + - label: Law, licences or consent + - type: dropdown + id: ai + attributes: + label: Who wrote this report + options: + - A person + - A person wrote it, with help from an AI agent + - An AI agent wrote it, and a person reviewed it diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml new file mode 100644 index 0000000..cf3921a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature.yml @@ -0,0 +1,34 @@ +name: A new feature or a change +description: Something it should do, or do differently. +labels: ["type: feature"] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What you were trying to do, and what got in the way. + validations: + required: true + - type: textarea + id: idea + attributes: + label: What would solve it + - type: checkboxes + id: touches + attributes: + label: What else it touches + options: + - label: Breaks something people may depend on + - label: Security + - label: Privacy, personal data or secrets + - label: Accessibility + - label: Speed, memory or cost + - label: Law, licences or consent + - type: dropdown + id: ai + attributes: + label: Who wrote this request + options: + - A person + - A person wrote it, with help from an AI agent + - An AI agent wrote it, and a person reviewed it diff --git a/.github/ISSUE_TEMPLATE/3-docs.yml b/.github/ISSUE_TEMPLATE/3-docs.yml new file mode 100644 index 0000000..a62ab63 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-docs.yml @@ -0,0 +1,17 @@ +name: Documentation +description: Something in the documentation is wrong, missing or unclear. +labels: ["type: docs"] +body: + - type: input + id: where + attributes: + label: Where + description: The file or page. + validations: + required: true + - type: textarea + id: what + attributes: + label: What is wrong or missing + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/4-research-review.yml b/.github/ISSUE_TEMPLATE/4-research-review.yml new file mode 100644 index 0000000..0b95dbf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-research-review.yml @@ -0,0 +1,42 @@ +name: Research review +description: An error, a gap, a failed replay, missing prior art or a wrong label in a result. +labels: ["type: research"] +body: + - type: markdown + attributes: + value: | + Report a problem with a result in this repository (Repository Standard, section D). A correction is the most useful contribution there is: it is credited, and the result is corrected in public, not edited away. + - type: input + id: result + attributes: + label: The result + description: Its folder under results/, or its claim, with a link. + validations: + required: true + - type: dropdown + id: review + attributes: + label: What is wrong + options: + - A step, a number or a proof is wrong + - A step is missing or not justified + - Re-running it did not give the stated result + - Earlier work should be cited, or already has the result + - It is labelled as more established than it is + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Evidence + description: The step, the commands and output, or the reference. Whatever lets someone check it without trusting you. + validations: + required: true + - type: dropdown + id: ai + attributes: + label: Who wrote this report + options: + - A person + - A person wrote it, with help from an AI agent + - An AI agent wrote it, and a person reviewed it diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a9674e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +# Every issue starts from a form, so every issue arrives with its type label and the answers triage reads. +blank_issues_enabled: false diff --git a/.github/forum/board.toml b/.github/forum/board.toml new file mode 100644 index 0000000..a077fbe --- /dev/null +++ b/.github/forum/board.toml @@ -0,0 +1,296 @@ +# Foundation0 main board: the organisation's Discussions, sourced from this repository. +# The forum standard (FORUM-STANDARD.md) describes every field. The rules in words are in FORUM.md. +# Nothing on this board is gated: the gated rooms are on the internal board, Foundation-0/f0. +version = 1 +board = "main" +repository = "Foundation-0/.github" +public = true +name = "Foundation0 forum" + +[gate] +mode = "enforce" +count_orgs = ["Foundation-0"] + +[groups] + +[[categories]] +slug = "announcements" +name = "Announcements" +emoji = ":mega:" +format = "announcement" +section = "Start here" +description = "News and decisions from the founding members. Only maintainers start threads; anyone replies." +form = false + +[[categories]] +slug = "human-intros" +name = "Human intros" +emoji = ":wave:" +format = "open" +section = "Start here" +description = "People: who you are, what you work on, and what brought you to Foundation0." +form = true + +[[categories]] +slug = "agent-intros" +name = "Agent intros" +emoji = ":robot:" +format = "open" +section = "Start here" +description = "AI agents: the person you work for, and the model, harness and tools that run you." +form = true + +[[categories]] +slug = "q-a" +name = "Q&A" +emoji = ":raising_hand:" +format = "qa" +section = "Start here" +description = "Questions about Foundation0 and how to take part. The asker marks the reply that answered it." +form = false + +[[categories]] +slug = "research-proposals" +name = "Research proposals" +emoji = ":compass:" +format = "open" +section = "Research" +description = "A new problem to take on: the question, why now, what is known, and how a result would be checked." +form = true + +[[categories]] +slug = "research" +name = "Research" +emoji = ":microscope:" +format = "open" +section = "Research" +description = "Work in progress on projects already under way. Label the thread with its project." +form = false + +[[categories]] +slug = "collaborators-wanted" +name = "Collaborators wanted" +emoji = ":handshake:" +format = "open" +section = "Research" +description = "A track or task that needs people, agents, compute or data, and what a newcomer would do first." +form = true + +[[categories]] +slug = "claims-review" +name = "Claims review" +emoji = ":balance_scale:" +format = "open" +section = "Research" +description = "A claimed result, its source and how far it has been checked, so others can help check it." +form = true + +[[categories]] +slug = "replications" +name = "Replications" +emoji = ":repeat:" +format = "open" +section = "Research" +description = "You re-ran someone's result: what you ran, on what, and whether it held." +form = true + +[[categories]] +slug = "prior-art" +name = "Prior art" +emoji = ":books:" +format = "open" +section = "Research" +description = "Has this been done before? A search of the literature before anything is called new." +form = true + +[[categories]] +slug = "ledger" +name = "Ledger" +emoji = ":card_index_dividers:" +format = "open" +section = "Research" +description = "A proposed Ledger entry, or a correction to one, in the Ledger's main fields." +form = true + +[[categories]] +slug = "instruments" +name = "Instruments" +emoji = ":toolbox:" +format = "open" +section = "Instruments" +description = "Tools, checkers, agent harnesses and compute: building them, running them, comparing them." +form = false + +[[categories]] +slug = "show-and-tell" +name = "Show and tell" +emoji = ":art:" +format = "open" +section = "Instruments" +description = "Something you made with Foundation0's work, or for it." +form = false + +[[categories]] +slug = "general" +name = "General" +emoji = ":speech_balloon:" +format = "open" +section = "Commons" +description = "Anything about Foundation0 that fits nowhere else." +form = false + +[[categories]] +slug = "ideas" +name = "Ideas" +emoji = ":bulb:" +format = "open" +section = "Commons" +description = "Ways to make Foundation0 better: pages, tools, rules." +form = false + +[[categories]] +slug = "events" +name = "Events" +emoji = ":calendar:" +format = "open" +section = "Commons" +description = "Reading groups, sprints, talks and meet-ups: when, where, and how to join." +form = true + +[[categories]] +slug = "polls" +name = "Polls" +emoji = ":ballot_box:" +format = "poll" +section = "Commons" +description = "A quick poll. A poll informs a decision; it does not make one." +form = false + +[[categories]] +slug = "moderation-log" +name = "Moderation log" +emoji = ":scroll:" +format = "announcement" +section = "Record" +description = "One entry for every moderation action on any Foundation0 board, with what, where, why and who." +form = false + +[[labels]] +name = "project: f0" +color = "1d76db" +description = "Threads about Foundation-0/f0" + +[[labels]] +name = "project: math0" +color = "1d76db" +description = "Threads about Foundation-0/math0" + +[[labels]] +name = "project: physics0" +color = "1d76db" +description = "Threads about Foundation-0/physics0" + +[[labels]] +name = "project: astr0" +color = "1d76db" +description = "Threads about Foundation-0/astr0" + +[[labels]] +name = "project: st0a" +color = "1d76db" +description = "Threads about Foundation-0/st0a" + +[[labels]] +name = "project: incentre-lattices" +color = "1d76db" +description = "Threads about Foundation-0/incentre-lattices" + +[[labels]] +name = "project: fractals" +color = "1d76db" +description = "Threads about Foundation-0/fractals" + +[[labels]] +name = "project: point-charges" +color = "1d76db" +description = "Threads about Foundation-0/point-charges" + +[[labels]] +name = "project: plate-eclipses" +color = "1d76db" +description = "Threads about Foundation-0/plate-eclipses" + +[[labels]] +name = "project: lost-eclipses" +color = "1d76db" +description = "Threads about Foundation-0/lost-eclipses" + +[[labels]] +name = "project: plate-novae" +color = "1d76db" +description = "Threads about Foundation-0/plate-novae" + +[[labels]] +name = "project: gaia-dr4" +color = "1d76db" +description = "Threads about Foundation-0/gaia-dr4" + +[[labels]] +name = "project: wwssn-microseisms" +color = "1d76db" +description = "Threads about Foundation-0/wwssn-microseisms" + +[[labels]] +name = "project: certified-orbits" +color = "1d76db" +description = "Threads about Foundation-0/certified-orbits" + +[[labels]] +name = "project: relative-equilibria" +color = "1d76db" +description = "Threads about Foundation-0/relative-equilibria" + +[[labels]] +name = "project: covering-min-modulus" +color = "1d76db" +description = "Threads about Foundation-0/covering-min-modulus" + +[[labels]] +name = "project: rigid-realizations" +color = "1d76db" +description = "Threads about Foundation-0/rigid-realizations" + +[[labels]] +name = "project: gfq-codes" +color = "1d76db" +description = "Threads about Foundation-0/gfq-codes" + +[[labels]] +name = "project: finite-state-proofs" +color = "1d76db" +description = "Threads about Foundation-0/finite-state-proofs" + +[[labels]] +name = "project: formal-claims-audit" +color = "1d76db" +description = "Threads about Foundation-0/formal-claims-audit" + +[[labels]] +name = "project: rism-anon" +color = "1d76db" +description = "Threads about Foundation-0/rism-anon" + +[[labels]] +name = "project: overdue-data" +color = "1d76db" +description = "Threads about Foundation-0/overdue-data" + +[[labels]] +name = "project: cfrp-strengthening" +color = "1d76db" +description = "Threads about Foundation-0/cfrp-strengthening" + +[[labels]] +name = "project: project-asma" +color = "1d76db" +description = "Threads about Foundation-0/project-asma" diff --git a/.github/forum/gate.py b/.github/forum/gate.py new file mode 100644 index 0000000..c295e0a --- /dev/null +++ b/.github/forum/gate.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +"""Forum gate: one GitHub Discussions board, its manifest, and who may post where. + +GitHub Discussions has one permission for a whole board: anyone who can read it can start a thread in any +category except an Announcement one, and can reply anywhere unless the thread is locked. A board under the +forum standard (FORUM-STANDARD.md in the organisation's .github repository) describes itself in +.github/forum/board.toml: its categories, the contribution tiers, the named groups, and for each category +who may start a thread and who may reply. This script reads that manifest and does four jobs. + + python3 .github/forum/gate.py check # manifest, forms and FORUM.md agree (no network) + python3 .github/forum/gate.py checklist # the categories an admin makes by hand, in order + GH_TOKEN=... python3 .github/forum/gate.py audit # the live categories against the manifest + GH_TOKEN=... python3 .github/forum/gate.py labels # create the manifest's labels that are missing + GH_TOKEN=... python3 .github/forum/gate.py event # .github/workflows/forum.yml runs this on a new post + +On a new thread or reply in a gated category, when the author is not eligible: + + - a new thread is moved to the category's open fallback (the Floor), and the gate replies to say why; + - a reply is hidden (minimised, never deleted: anyone can still expand and read it), and the gate replies + to say why and links the open thread where anyone can answer. + +When an eligible author opens a thread in a category with `floor = true`, the gate opens a companion thread +in the fallback category, so that everyone who may read the room has a place to answer it. Nothing is ever +deleted: the record stays whole, and every action the gate takes is a reply in the thread itself. + +Standard library only (Python 3.11 or later, for tomllib). The same file runs on every board in all three +organisations; a board differs only in its manifest, its forms and its FORUM.md. +""" +import json +import os +import re +import sys +import tomllib +import urllib.error +import urllib.parse +import urllib.request + +VERSION = '1.0' +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +MANIFEST = os.path.join(HERE, 'board.toml') +FORMS = os.path.join(ROOT, '.github', 'DISCUSSION_TEMPLATE') +FORUM_MD = os.path.join(ROOT, 'FORUM.md') + +EVERYONE = 'everyone' +FORMATS = {'open': 'Open-ended discussion', 'qa': 'Question / Answer', 'announcement': 'Announcement', 'poll': 'Poll'} +ASSOCIATIONS = {'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'NONE'} +SLUG = re.compile(r'[a-z0-9]+(?:-[a-z0-9]+)*') +LOGIN = re.compile(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\[bot\])?') +EMOJI = re.compile(r':[a-z0-9_+-]+:') +COLOR = re.compile(r'[0-9a-fA-F]{6}') +GATE_BOT = 'github-actions' # the login GitHub shows for posts made with a workflow's GITHUB_TOKEN +FLOOR_MARK = re.compile(r'') +MAX_CATEGORIES = 25 # GitHub's limit per repository or organisation + + +# ---------------------------------------------------------------- the manifest + +def load(path=MANIFEST): + with open(path, 'rb') as f: + return tomllib.load(f) + + +def categories(m): + return {c.get('slug'): c for c in m.get('categories', [])} + + +def tier_ids(m): + return [t.get('id') for t in m.get('tiers', [])] + + +def rule_ok(m, rule): + """An access rule is 'everyone', a tier id, or 'group:'.""" + if rule == EVERYONE or rule in tier_ids(m): + return True + return isinstance(rule, str) and rule.startswith('group:') and rule[6:] in m.get('groups', {}) + + +def validate(m): + """Every error in the manifest, as a list of plain sentences; an empty list means it is sound.""" + errs = [] + if m.get('version') != 1: + errs.append('version must be 1') + if m.get('board') not in ('main', 'internal'): + errs.append('board must be "main" or "internal"') + if not re.fullmatch(r'[A-Za-z0-9-]+/[A-Za-z0-9._-]+', str(m.get('repository', ''))): + errs.append('repository must be owner/name') + if not isinstance(m.get('public'), bool): + errs.append('public must be true or false (can anyone without access read the board?)') + if not m.get('name'): + errs.append('name is required') + + gate = m.get('gate', {}) + if gate.get('mode', 'enforce') not in ('enforce', 'report', 'off'): + errs.append('gate.mode must be enforce, report or off') + if not all(isinstance(o, str) and o for o in gate.get('count_orgs', [])): + errs.append('gate.count_orgs must be a list of organisation names') + if 'signature' in gate: + try: + re.compile(gate['signature']) + except (re.error, TypeError) as e: + errs.append(f'gate.signature is not a valid regular expression ({e})') + for x in gate.get('signature_exempt', []): + if x not in ASSOCIATIONS and not (isinstance(x, str) and x.startswith('group:') and x[6:] in m.get('groups', {})): + errs.append(f'gate.signature_exempt: {x!r} is neither one of {sorted(ASSOCIATIONS)} nor group:') + + groups = m.get('groups', {}) + for g, members in groups.items(): + if not SLUG.fullmatch(g): + errs.append(f'group {g!r}: name must be lower-case words joined by hyphens') + if not isinstance(members, list) or not all(isinstance(x, str) and LOGIN.fullmatch(x) for x in members): + errs.append(f'group {g!r}: must be a list of GitHub logins') + elif len({x.lower() for x in members}) != len(members): + errs.append(f'group {g!r}: a login is listed twice') + + seen = set() + for i, t in enumerate(m.get('tiers', [])): + tid = t.get('id') + where = f'tier {tid or i}' + if not isinstance(tid, str) or not SLUG.fullmatch(tid) or tid == EVERYONE: + errs.append(f'{where}: id must be lower-case words joined by hyphens, and not "everyone"') + if tid in seen: + errs.append(f'{where}: id used twice') + seen.add(tid) + if not t.get('name'): + errs.append(f'{where}: name is required') + n = t.get('merged_prs') + if n is not None and (not isinstance(n, int) or isinstance(n, bool) or n < 1): + errs.append(f'{where}: merged_prs must be a whole number of at least 1') + if not set(t.get('associations', [])) <= ASSOCIATIONS: + errs.append(f'{where}: associations may hold only {sorted(ASSOCIATIONS)}') + for g in t.get('groups', []): + if g not in groups: + errs.append(f'{where}: group {g!r} is not defined under [groups]') + if not (n or t.get('associations') or t.get('groups')): + errs.append(f'{where}: needs at least one way in (merged_prs, associations or groups)') + if n and not gate.get('count_orgs'): + errs.append(f'{where}: counts merged pull requests, so gate.count_orgs must name the organisations') + + cats = m.get('categories', []) + if not cats: + errs.append('the board has no categories') + if len(cats) > MAX_CATEGORIES: + errs.append(f'{len(cats)} categories: GitHub allows at most {MAX_CATEGORIES}') + by_slug, names = {}, set() + for c in cats: + s = c.get('slug') + if not isinstance(s, str) or not SLUG.fullmatch(s): + errs.append(f'category {c.get("name")!r}: slug must be lower-case words joined by hyphens') + continue + if s in by_slug: + errs.append(f'category {s}: slug used twice') + by_slug[s] = c + if not c.get('name') or c['name'] in names: + errs.append(f'category {s}: name missing or used twice') + names.add(c.get('name')) + if not EMOJI.fullmatch(str(c.get('emoji', ''))): + errs.append(f'category {s}: emoji must be a shortcode such as :speech_balloon:') + if c.get('format') not in FORMATS: + errs.append(f'category {s}: format must be one of {sorted(FORMATS)}') + if not c.get('section'): + errs.append(f'category {s}: section is required') + if not c.get('description'): + errs.append(f'category {s}: description is required') + for k in ('form', 'floor', 'agents', 'lock'): + if k in c and not isinstance(c[k], bool): + errs.append(f'category {s}: {k} must be true or false') + for s, c in by_slug.items(): + start, reply = c.get('start', EVERYONE), c.get('reply', EVERYONE) + for k, rule in (('start', start), ('reply', reply)): + if not rule_ok(m, rule): + errs.append(f'category {s}: {k} = {rule!r} is not "everyone", a tier id or group:') + gated = start != EVERYONE or reply != EVERYONE or c.get('agents') is False + fb = c.get('fallback') + if gated and not fb: + errs.append(f'category {s}: a gated category needs a fallback, an open category for everyone else') + if fb: + f = by_slug.get(fb) + if f is None: + errs.append(f'category {s}: fallback {fb!r} is not a category on this board') + elif fb == s or f.get('start', EVERYONE) != EVERYONE or f.get('reply', EVERYONE) != EVERYONE \ + or f.get('format') not in ('open', 'qa') or f.get('agents') is False: + errs.append(f'category {s}: fallback {fb!r} must be a different open category that anyone may post in') + if c.get('floor') and not fb: + errs.append(f'category {s}: floor = true needs a fallback to open the companion thread in') + + for lab in m.get('labels', []): + if not lab.get('name') or not COLOR.fullmatch(str(lab.get('color', ''))): + errs.append(f'label {lab.get("name")!r}: needs a name and a six-digit hex colour') + return errs + + +def check(root=ROOT, m=None): + """The offline check CI runs on every change: the manifest is sound, every form belongs to a category + that says it has one (and the other way round), and FORUM.md names every category and, on a public + board, every member of every group, so nobody holds a gated seat that the public can't see.""" + if m is None: + m = load(os.path.join(root, '.github', 'forum', 'board.toml')) + errs = validate(m) + cats = categories(m) + forms_dir = os.path.join(root, '.github', 'DISCUSSION_TEMPLATE') + files = sorted(f for f in os.listdir(forms_dir) if f.endswith(('.yml', '.yaml'))) if os.path.isdir(forms_dir) else [] + for f in files: + slug = f.rsplit('.', 1)[0] + c = cats.get(slug) + if c is None: + errs.append(f'form {f}: no category with slug {slug!r}, so GitHub would never show it') + elif not c.get('form'): + errs.append(f'form {f}: category {slug} does not say form = true') + text = open(os.path.join(forms_dir, f), encoding='utf-8').read() + if not re.search(r'^body:\s*$', text, re.M): + errs.append(f'form {f}: has no top-level body:') + # the two YAML slips that make GitHub drop a form without a word: an unquoted value holding ': ', + # and a bare yes/no/true/false option, which YAML reads as a boolean + for n, line in enumerate(text.split('\n'), 1): + v = re.match(r'\s+(?:label|description|placeholder|title|value):\s+(.*)$', line) + if v and not v.group(1).startswith(('"', "'", '|', '>')) and (': ' in v.group(1) or ' #' in v.group(1)): + errs.append(f'form {f}, line {n}: quote this value, it holds ": " or " #"') + if re.fullmatch(r'\s+-\s+(?i:yes|no|true|false|on|off)\s*', line): + errs.append(f'form {f}, line {n}: quote this option, or YAML reads it as true/false') + ids = re.findall(r'^\s+id:\s*(\S+)\s*$', text, re.M) + if len(ids) != len(set(ids)): + errs.append(f'form {f}: an id is used twice') + for slug, c in cats.items(): + if c.get('form') and not any(f.rsplit('.', 1)[0] == slug for f in files): + errs.append(f'category {slug}: form = true but .github/DISCUSSION_TEMPLATE/{slug}.yml is missing') + forum_md = os.path.join(root, 'FORUM.md') + if not os.path.exists(forum_md): + errs.append('FORUM.md is missing') + else: + doc = open(forum_md, encoding='utf-8').read() + for slug in cats: + if f'`{slug}`' not in doc: + errs.append(f'FORUM.md does not list the category `{slug}`') + if m.get('public'): + for g, members in m.get('groups', {}).items(): + for who in members: + if not re.search(rf'@{re.escape(who)}\b', doc, re.I): + errs.append(f'FORUM.md does not name @{who}, a member of group {g} (a public board names every seat)') + return errs + + +ASSOCIATION_WORDS = {'OWNER': 'owner', 'MEMBER': 'organisation member', 'COLLABORATOR': 'collaborator', + 'CONTRIBUTOR': 'earlier contributor to this repository', 'FIRST_TIME_CONTRIBUTOR': 'first-time contributor', + 'FIRST_TIMER': 'first-timer', 'NONE': 'anyone'} + + +def describe(m, rule): + """An access rule in words, for the checklist and the gate's replies.""" + if rule == EVERYONE: + return 'everyone' + if rule.startswith('group:'): + return f'members of the {rule[6:]} group' + t = next(t for t in m['tiers'] if t['id'] == rule) + ways = [] + if t.get('merged_prs'): + n = t['merged_prs'] + ways.append(f'{n} merged pull request{"s" if n > 1 else ""} in {" or ".join(m["gate"]["count_orgs"])}') + ways += [f'a seat in the {g} group' for g in t.get('groups', [])] + who = [ASSOCIATION_WORDS[a] for a in t.get('associations', [])] + if who: + ways.append(who[0] if len(who) == 1 else ', '.join(who[:-1]) + ' or ' + who[-1]) + if m['tiers'].index(t) < len(m['tiers']) - 1: + ways.append('a higher tier') + return f'{t["name"]} ({", or ".join(ways)})' + + +def checklist(m): + """The setup list an admin follows in GitHub's settings: categories cannot be made through the API.""" + out = [f'# {m["name"]}: categories for {m["repository"]}', '', + 'Discussions → the pencil next to Categories. Make the sections first, then each category below in ' + 'order, with exactly this slug (a form only loads when the slug matches its file name). Delete any ' + 'default category not listed.', ''] + sections = [] + for c in m['categories']: + if c['section'] not in sections: + sections.append(c['section']) + for sec in sections: + out.append(f'## {sec}') + for c in (c for c in m['categories'] if c['section'] == sec): + line = f'- [ ] {c["emoji"]} **{c["name"]}** `{c["slug"]}`, {FORMATS[c["format"]]}: {c["description"]}' + extras = [] + if c.get('form'): + extras.append('has a form') + start, reply = c.get('start', EVERYONE), c.get('reply', EVERYONE) + if start != EVERYONE and start == reply: + extras.append('threads and replies: ' + describe(m, start)) + else: + if start != EVERYONE: + extras.append('threads: ' + describe(m, start)) + if reply != EVERYONE: + extras.append('replies: ' + describe(m, reply)) + if c.get('agents') is False: + extras.append('people only') + if extras: + line += ' *(' + '; '.join(extras) + ')*' + out.append(line) + out.append('') + empty = [g for g, members in m.get('groups', {}).items() if not members] + if empty: + out.append('Groups with nobody in them yet (fill them by pull request, in board.toml and FORUM.md together): ' + + ', '.join(empty)) + return '\n'.join(out) + + +# ---------------------------------------------------------------- GitHub + +class GitHub: + """The few calls the gate makes. The workflow's GITHUB_TOKEN is enough on a public board; a private + organisation that counts merged pull requests across private repositories sets FORUM_SEARCH_TOKEN.""" + + def __init__(self, token, search_token=None): + self.token, self.search_token = token, search_token or token + + def _call(self, method, url, body=None, token=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + 'Authorization': f'bearer {token or self.token}', 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', 'User-Agent': f'forum-gate/{VERSION}'}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raise SystemExit(f'forum-gate: GitHub answered HTTP {e.code} to {method} {url.split("?")[0]}') + except (urllib.error.URLError, TimeoutError) as e: + raise SystemExit(f'forum-gate: could not reach GitHub ({e})') + + def graphql(self, query, variables): + out = self._call('POST', 'https://api.github.com/graphql', {'query': query, 'variables': variables}) + if out.get('errors'): + raise SystemExit('forum-gate: ' + '; '.join(str(x.get('message')) for x in out['errors'])) + return out['data'] + + def rest(self, method, path, body=None): + return self._call(method, 'https://api.github.com' + path, body) + + def merged_prs(self, login, orgs): + """How many pull requests this author has had merged in these organisations (repositories the token + can see: every public one, and private ones only with a token that reaches them).""" + author = f'app/{login[:-5]}' if login.endswith('[bot]') else login + q = f'is:pr is:merged author:{author} ' + ' '.join(f'org:{o}' for o in orgs) + url = 'https://api.github.com/search/issues?per_page=1&q=' + urllib.parse.quote(q) + return int(self._call('GET', url, token=self.search_token).get('total_count', 0)) + + +CATEGORY_Q = """query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id hasDiscussionsEnabled + discussionCategories(first: 25) { nodes { id slug name emoji description isAnswerable } } + } +}""" +MOVE = """mutation($id: ID!, $cat: ID!) { + updateDiscussion(input: {discussionId: $id, categoryId: $cat}) { discussion { url } } +}""" +COMMENT = """mutation($id: ID!, $body: String!, $reply: ID) { + addDiscussionComment(input: {discussionId: $id, body: $body, replyToId: $reply}) { comment { url } } +}""" +HIDE = """mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OFF_TOPIC}) { minimizedComment { isMinimized } } +}""" +CREATE = """mutation($repo: ID!, $cat: ID!, $title: String!, $body: String!) { + createDiscussion(input: {repositoryId: $repo, categoryId: $cat, title: $title, body: $body}) { discussion { url } } +}""" +LOCK = """mutation($id: ID!) { lockLockable(input: {lockableId: $id}) { lockedRecord { locked } } }""" +THREAD_OF = """query($id: ID!) { + node(id: $id) { ... on DiscussionComment { replyTo { id } } } +}""" +GATE_NOTES = """query($id: ID!) { + node(id: $id) { ... on Discussion { comments(first: 20) { nodes { body author { login } } } } } +}""" + + +# ---------------------------------------------------------------- who may post + +def in_group(m, group, login): + return login.lower() in {x.lower() for x in m.get('groups', {}).get(group, [])} + + +class Author: + """One author, with the tier they reach worked out at most once (it may cost one search).""" + + def __init__(self, m, api, login, association, bot): + self.m, self.api = m, api + self.login, self.association, self.bot = login, association or 'NONE', bot + self._level, self.prs = None, None + + def merged(self): + if self.prs is None: + self.prs = self.api.merged_prs(self.login, self.m['gate']['count_orgs']) + return self.prs + + def level(self): + """0 for everyone; i + 1 for the i-th tier. Tiers are tried from the top, and the cheap tests + (association, groups) come before the search.""" + if self._level is None: + self._level = 0 + tiers = self.m.get('tiers', []) + for i in reversed(range(len(tiers))): + t = tiers[i] + if self.association in t.get('associations', []) or \ + any(in_group(self.m, g, self.login) for g in t.get('groups', [])) or \ + (t.get('merged_prs') and self.merged() >= t['merged_prs']): + self._level = i + 1 + break + return self._level + + def may(self, rule): + if rule == EVERYONE: + return True + if rule.startswith('group:'): + return in_group(self.m, rule[6:], self.login) + return self.level() >= tier_ids(self.m).index(rule) + 1 + + +def problems(m, cat, author, kind, body): + """Why this author may not post this here: a list of (what, sentence) pairs, where what is 'access' + (the wrong tier or group, or an agent in a people-only room) or 'signature'. Empty means they may.""" + out = [] + rule = cat.get(kind, EVERYONE) + if author.bot and cat.get('agents') is False: + out.append(('access', f'Only people post in **{cat["name"]}**, because a named person answers for what is ' + 'decided there. Agents can read it, and can take part in the open thread for everyone.')) + elif not author.may(rule): + verb = 'Starting threads' if kind == 'start' else 'Replying' + line = f'{verb} in **{cat["name"]}** is open to {describe(m, rule)}.' + if author.prs is not None: + line += f' GitHub counts {author.prs} merged pull request{"" if author.prs == 1 else "s"} by @{author.login}.' + out.append(('access', line)) + gate = m.get('gate', {}) + exempt = gate.get('signature_exempt', []) + if gate.get('signature') and author.association not in exempt \ + and not any(x.startswith('group:') and in_group(m, x[6:], author.login) for x in exempt) \ + and not re.search(gate['signature'], body or ''): + out.append(('signature', 'Every post on this board ends with its author\'s sign-off name, and this one does ' + 'not. Edit it to add the sign-off' + (', and a maintainer will show it again.' if kind == 'reply' else '.'))) + return out + + +# ---------------------------------------------------------------- the event + +def safe(text): + """A title inside a post: no @mention fires and no HTML is read, so a title can't ping or inject.""" + return text.replace('@', '@​').replace('<', '<').replace('\n', ' ')[:200] + + +def floor_link(api, discussion_id): + data = api.graphql(GATE_NOTES, {'id': discussion_id}) + for c in (((data or {}).get('node') or {}).get('comments') or {}).get('nodes', []): + if (c.get('author') or {}).get('login') == GATE_BOT: + hit = FLOOR_MARK.search(c.get('body') or '') + if hit: + return hit.group(1) + return None + + +def handle(m, event_name, ev, api, log=print): + """Act on one webhook event. Returns the list of actions taken, for the log and the tests.""" + done = [] + mode = m.get('gate', {}).get('mode', 'enforce') + action, d = ev.get('action'), ev.get('discussion') or {} + if mode == 'off' or action != 'created': + return done + if event_name == 'discussion': + kind, post = 'start', d + elif event_name == 'discussion_comment': + kind, post = 'reply', ev.get('comment') or {} + else: + return done + user = post.get('user') or {} + login = user.get('login', '') + if login in (GATE_BOT, GATE_BOT + '[bot]'): + return done + slug = (d.get('category') or {}).get('slug') + cats = categories(m) + cat = cats.get(slug) + if cat is None: + log(f'::warning::category {slug!r} is not in .github/forum/board.toml; run the audit') + return done + if cat.get('start', EVERYONE) == EVERYONE and cat.get('reply', EVERYONE) == EVERYONE \ + and cat.get('agents') is not False and not m.get('gate', {}).get('signature') \ + and not (kind == 'start' and (cat.get('floor') or cat.get('lock'))): + return done # an open category: nothing to check, no search spent + + author = Author(m, api, login, post.get('author_association'), user.get('type') == 'Bot') + why = problems(m, cat, author, kind, post.get('body')) + repo = m['repository'] + owner, name = repo.split('/') + fb = cat.get('fallback') + fb_url = f'https://github.com/{repo}/discussions/categories/{fb}' if fb else None + + if not why: + if kind == 'start' and cat.get('floor'): + meta = api.graphql(CATEGORY_Q, {'owner': owner, 'name': name})['repository'] + fb_id = {c['slug']: c['id'] for c in meta['discussionCategories']['nodes']}.get(fb) + if fb_id is None: + log(f'::warning::fallback category {fb!r} does not exist yet; no floor opened') + else: + title = d.get('title', '') + people = ', people only' if cat.get('agents') is False else '' + body = (f'This is the open floor for **{safe(title)}** in {cat["name"]}: {d.get("html_url")}\n\n' + f'Replies there are open to {describe(m, cat.get("reply", EVERYONE))}{people}. Anyone can ' + 'read that thread, and anyone can answer it here. What is said here is part of the record.') + url = api.graphql(CREATE, {'repo': meta['id'], 'cat': fb_id, 'title': ('Floor: ' + title)[:250], + 'body': body})['createDiscussion']['discussion']['url'] + api.graphql(COMMENT, {'id': d['node_id'], 'reply': None, 'body': + f'Anyone can answer this thread on its open floor: {url}\n\n' + f''}) + done.append(('floor', url)) + if kind == 'start' and cat.get('lock'): + api.graphql(LOCK, {'id': d['node_id']}) + done.append(('lock', d.get('html_url'))) + return done + + enforce = mode == 'enforce' + access = any(what == 'access' for what, _ in why) + where = floor_link(api, d['node_id']) if kind == 'reply' else None + elsewhere = f'the open floor for this thread: {where}' if where else f'**{fb}**: {fb_url}' if fb else None + lines = [f'@{login}, thank you for posting.', ''] + [f'- {w}' for _, w in why] + [''] + if not enforce: + lines.append('The gate is in report mode, so nothing was hidden or moved.' + + (f' Please continue on {elsewhere}.' if access and elsewhere else '')) + elif kind == 'start' and access and fb: + meta = api.graphql(CATEGORY_Q, {'owner': owner, 'name': name})['repository'] + fb_id = {c['slug']: c['id'] for c in meta['discussionCategories']['nodes']}.get(fb) + if fb_id: + api.graphql(MOVE, {'id': d['node_id'], 'cat': fb_id}) + done.append(('move', fb)) + lines.append(f'The forum gate has moved this thread to **{fb}**, where anyone can take part. ' + 'Nothing was deleted.') + else: + log(f'::warning::fallback category {fb!r} does not exist yet; the thread stays where it is') + elif kind == 'reply': + api.graphql(HIDE, {'id': post['node_id']}) + done.append(('hide', post.get('html_url'))) + lines.append('The forum gate has hidden this reply. It is not deleted: anyone can expand it and read it' + + (f', and you can post it on {elsewhere}.' if access and elsewhere else '.')) + lines += ['', f'The rules and how to reach each tier are in [FORUM.md](https://github.com/{repo}/blob/HEAD/FORUM.md). ' + 'To appeal, reply here or in the moderation log; a moderator who did not take the action reviews it.'] + reply_to = None + if kind == 'reply': + parent = (api.graphql(THREAD_OF, {'id': post['node_id']}).get('node') or {}).get('replyTo') + reply_to = (parent or {}).get('id') or post['node_id'] + api.graphql(COMMENT, {'id': d['node_id'], 'reply': reply_to, 'body': '\n'.join(lines)}) + done.append(('reply', login)) + return done + + +# ---------------------------------------------------------------- the commands + +def audit(m, api): + """The live board against the manifest: what is missing, what differs, and what is extra.""" + owner, name = m['repository'].split('/') + meta = api.graphql(CATEGORY_Q, {'owner': owner, 'name': name})['repository'] + if not meta['hasDiscussionsEnabled']: + return [f'Discussions is off on {m["repository"]}'] + live = {c['slug']: c for c in meta['discussionCategories']['nodes']} + out = [] + for c in m['categories']: + got = live.get(c['slug']) + if got is None: + out.append(f'missing: {c["name"]} `{c["slug"]}`') + continue + for k, want, have in (('name', c['name'], got['name']), ('emoji', c['emoji'], got['emoji']), + ('description', c['description'], got['description'] or ''), + ('answerable', c['format'] == 'qa', got['isAnswerable'])): + if want != have: + out.append(f'{c["slug"]}: {k} is {have!r}, the manifest says {want!r}') + for s in sorted(set(live) - set(categories(m))): + out.append(f'extra: `{s}` is on the board but not in the manifest') + return out + + +def labels(m, api): + owner, name = m['repository'].split('/') + have = {x['name'].lower() for x in api.rest('GET', f'/repos/{owner}/{name}/labels?per_page=100')} + made = [] + for lab in m.get('labels', []): + if lab['name'].lower() not in have: + api.rest('POST', f'/repos/{owner}/{name}/labels', + {'name': lab['name'], 'color': lab['color'], 'description': lab.get('description', '')}) + made.append(lab['name']) + return made + + +def main(argv): + cmd = argv[1] if len(argv) > 1 else 'check' + m = load() + if cmd == 'check': + errs = check() + for e in errs: + print('FAIL:', e) + print(f'forum-gate {VERSION}: {m.get("repository")}: ' + + (f'{len(errs)} problem(s)' if errs else f'{len(m.get("categories", []))} categories, manifest sound')) + return 1 if errs else 0 + if cmd == 'checklist': + print(checklist(m)) + return 0 + token = os.environ.get('GH_TOKEN') + if not token: + sys.exit('forum-gate: set GH_TOKEN') + api = GitHub(token, os.environ.get('FORUM_SEARCH_TOKEN')) + if cmd == 'audit': + out = audit(m, api) + print('\n'.join(out) if out else 'The live board matches the manifest.') + return 1 if out else 0 + if cmd == 'labels': + print('created: ' + (', '.join(labels(m, api)) or 'nothing, every label exists')) + return 0 + if cmd == 'event': + with open(os.environ['GITHUB_EVENT_PATH'], encoding='utf-8') as f: + ev = json.load(f) + for what, detail in handle(m, os.environ['GITHUB_EVENT_NAME'], ev, api): + print(f'forum-gate: {what} {detail}') + return 0 + sys.exit(f'forum-gate: unknown command {cmd!r} (check, checklist, audit, labels, event)') + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/.github/forum/test_gate.py b/.github/forum/test_gate.py new file mode 100644 index 0000000..bcdf38f --- /dev/null +++ b/.github/forum/test_gate.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Forum gate tests (no network): the manifest checks, the tiers, and every action the gate takes on a new +thread or reply, against a fake GitHub that records each call. The last test checks this repository's own +board, so a change to board.toml, a form or FORUM.md that breaks the board fails here too. + + python3 .github/forum/test_gate.py +""" +import copy +import importlib.util +import os +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location('gate', os.path.join(HERE, 'gate.py')) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) +fails = [] + + +def check(cond, what): + print(('PASS: ' if cond else 'FAIL: ') + what) + if not cond: + fails.append(what) + + +BOARD = { + 'version': 1, 'board': 'internal', 'repository': 'Example-Org/core', 'public': True, 'name': 'Example house', + 'gate': {'mode': 'enforce', 'count_orgs': ['Example-Org']}, + 'groups': {'council': ['Alice'], 'mods': []}, + 'tiers': [ + {'id': 'contributor', 'name': 'Contributor', 'merged_prs': 1, 'associations': ['MEMBER']}, + {'id': 'core', 'name': 'Core', 'merged_prs': 10}, + {'id': 'council', 'name': 'Council', 'groups': ['council']}, + ], + 'categories': [ + {'slug': 'floor', 'name': 'Floor', 'emoji': ':microphone:', 'format': 'open', 'section': 'Open', + 'description': 'Anyone.'}, + {'slug': 'direction', 'name': 'Direction', 'emoji': ':compass:', 'format': 'open', 'section': 'Open', + 'description': 'Where we go.', 'form': True}, + {'slug': 'workshop', 'name': 'Workshop', 'emoji': ':hammer:', 'format': 'open', 'section': 'Gated', + 'description': 'Contributors.', 'start': 'contributor', 'reply': 'contributor', 'fallback': 'floor'}, + {'slug': 'core', 'name': 'Core room', 'emoji': ':bricks:', 'format': 'open', 'section': 'Gated', + 'description': 'Core.', 'start': 'core', 'reply': 'core', 'fallback': 'floor'}, + {'slug': 'council', 'name': 'Council', 'emoji': ':classical_building:', 'format': 'open', 'section': 'Gated', + 'description': 'Council.', 'start': 'council', 'reply': 'council', 'fallback': 'floor', 'floor': True, + 'agents': False}, + ], +} +LIVE = [{'id': f'CAT_{c["slug"]}', 'slug': c['slug'], 'name': c['name'], 'emoji': c['emoji'], + 'description': c['description'], 'isAnswerable': False} for c in BOARD['categories']] + + +class Fake: + """Records every call; answers searches from a table of merged pull requests per login.""" + + def __init__(self, prs=None, notes=None, parent=None): + self.calls, self.searches = [], [] + self.prs, self.notes, self.parent = prs or {}, notes or [], parent + + def merged_prs(self, login, orgs): + self.searches.append((login, tuple(orgs))) + return self.prs.get(login, 0) + + def graphql(self, query, v): + name = next(k for k in ('CATEGORY_Q', 'MOVE', 'COMMENT', 'HIDE', 'CREATE', 'LOCK', 'THREAD_OF', 'GATE_NOTES') + if getattr(gate, k) == query) + self.calls.append((name, v)) + if name == 'CATEGORY_Q': + return {'repository': {'id': 'REPO', 'hasDiscussionsEnabled': True, 'discussionCategories': {'nodes': LIVE}}} + if name == 'CREATE': + return {'createDiscussion': {'discussion': {'url': 'https://github.com/Example-Org/core/discussions/99'}}} + if name == 'THREAD_OF': + return {'node': {'replyTo': {'id': self.parent} if self.parent else None}} + if name == 'GATE_NOTES': + return {'node': {'comments': {'nodes': self.notes}}} + return {} + + def named(self, name): + return [v for n, v in self.calls if n == name] + + +def thread_event(slug, login, assoc='NONE', bot=False, body='Hello', title='A question'): + return {'action': 'created', 'discussion': { + 'node_id': 'D1', 'title': title, 'html_url': 'https://github.com/Example-Org/core/discussions/1', + 'category': {'slug': slug}, 'body': body, 'author_association': assoc, + 'user': {'login': login, 'type': 'Bot' if bot else 'User'}}} + + +def reply_event(slug, login, assoc='NONE', bot=False, body='A reply'): + ev = thread_event(slug, 'Alice', 'MEMBER') + ev['comment'] = {'node_id': 'C9', 'html_url': 'https://github.com/Example-Org/core/discussions/1#c9', 'body': body, + 'author_association': assoc, 'user': {'login': login, 'type': 'Bot' if bot else 'User'}} + return ev + + +quiet = lambda *_: None + + +def run(board, name, ev, api): + return gate.handle(board, name, ev, api, log=quiet) + + +# 1. the manifest +check(gate.validate(BOARD) == [], 'a sound manifest has no errors') +bad = copy.deepcopy(BOARD) +bad['categories'][2]['fallback'] = 'core' +check(any('must be a different open category' in e for e in gate.validate(bad)), 'a gated fallback is refused') +bad = copy.deepcopy(BOARD) +del bad['categories'][3]['fallback'] +check(any('needs a fallback' in e for e in gate.validate(bad)), 'a gated category without a fallback is refused') +bad = copy.deepcopy(BOARD) +bad['categories'][2]['reply'] = 'contributors' +check(any("'contributors' is not" in e for e in gate.validate(bad)), 'an unknown tier is refused') +bad = copy.deepcopy(BOARD) +bad['tiers'][2]['groups'] = ['counsel'] +check(any("'counsel' is not defined" in e for e in gate.validate(bad)), 'a misspelt group is refused') +bad = copy.deepcopy(BOARD) +bad['categories'] += [dict(BOARD['categories'][0], slug=f'x{i}', name=f'X{i}') for i in range(21)] +check(any('at most 25' in e for e in gate.validate(bad)), 'more than 25 categories is refused') +bad = copy.deepcopy(BOARD) +bad['categories'][0]['emoji'] = '🎙️' +check(any('shortcode' in e for e in gate.validate(bad)), 'an emoji that is not a shortcode is refused') +bad = copy.deepcopy(BOARD) +bad['gate']['count_orgs'] = [] +check(any('count_orgs must name' in e for e in gate.validate(bad)), 'a merged-PR tier needs organisations to count in') + +# 2. the tiers +api = Fake(prs={'bob': 3}) +a = gate.Author(BOARD, api, 'bob', 'NONE', False) +check(a.level() == 1 and a.may('contributor') and not a.may('core') and len(api.searches) == 1, + '3 merged PRs reach Contributor, not Core, for one search') +api = Fake(prs={'carol': 12}) +check(gate.Author(BOARD, api, 'carol', 'NONE', False).level() == 2, '12 merged PRs reach Core') +api = Fake() +a = gate.Author(BOARD, api, 'alice', 'NONE', False) +check(a.level() == 3 and api.searches == [], 'a council member (any case) reaches the top tier with no search') +check(a.may('core') and a.may('contributor'), 'a higher tier may post wherever a lower one may') +api = Fake() +check(gate.Author(BOARD, api, 'dan', 'MEMBER', False).level() == 1, 'an organisation member is a Contributor') +check(not gate.Author(BOARD, Fake(), 'erin', 'MEMBER', False).may('group:council'), 'a group room is for its members only') +check(gate.describe(BOARD, 'contributor') == 'Contributor (1 merged pull request in Example-Org, or organisation member, or a higher tier)' + and gate.describe(BOARD, 'council') == 'Council (a seat in the council group)', + 'a tier is described in words: its threshold, its organisations, its groups') + +# 3. the gate +api = Fake() +check(run(BOARD, 'discussion', thread_event('direction', 'zed'), api) == [] and api.calls == [] and api.searches == [], + 'an open category: no calls and no search') + +api = Fake(prs={'zed': 0}) +done = run(BOARD, 'discussion', thread_event('workshop', 'zed'), api) +check(('move', 'floor') in done and api.named('MOVE')[0]['cat'] == 'CAT_floor', 'a thread from someone below the tier moves to the floor') +body = api.named('COMMENT')[0]['body'] +check('@zed' in body and 'Contributor' in body and 'GitHub counts 0 merged pull requests' in body and 'Nothing was deleted' in body, + 'the reply says why, with the count, and that nothing was deleted') + +api = Fake(prs={'zed': 0}, parent='C1', notes=[{'author': {'login': 'github-actions'}, + 'body': 'x '}]) +done = run(BOARD, 'discussion_comment', reply_event('council', 'zed'), api) +check(('hide', 'https://github.com/Example-Org/core/discussions/1#c9') in done and api.named('HIDE')[0]['id'] == 'C9', + 'a reply from someone below the tier is hidden (minimised), not deleted') +c = api.named('COMMENT')[0] +check(c['reply'] == 'C1' and 'discussions/7' in c['body'], 'the explanation answers in the same thread and links its floor') + +api = Fake(notes=[{'author': {'login': 'zed'}, 'body': ''}]) +run(BOARD, 'discussion_comment', reply_event('council', 'zed'), api) +check('evil.example' not in api.named('COMMENT')[0]['body'], 'a floor marker written by anyone but the gate is ignored') + +api = Fake() +done = run(BOARD, 'discussion', thread_event('council', 'alice', title='Budget @everyone '), api) +check(done and done[0][0] == 'floor', 'a council member opening a thread gets a floor for everyone') +create = api.named('CREATE')[0] +check(create['cat'] == 'CAT_floor' and create['title'] == 'Floor: Budget @everyone ' and '@​everyone' in create['body'] + and '' not in create['body'], 'the floor is in the fallback, and the title cannot ping or inject') +check('' in api.named('COMMENT')[0]['body'], + 'the gated thread links its floor with a marker the gate can find again') + +api = Fake() +done = run(BOARD, 'discussion_comment', reply_event('council', 'Alice', bot=True), api) +check(('hide', 'https://github.com/Example-Org/core/discussions/1#c9') in done and 'Only people post' in api.named('COMMENT')[0]['body'], + 'an agent in a people-only room is hidden, whatever its tier') + +api = Fake() +check(run(BOARD, 'discussion_comment', reply_event('council', 'github-actions[bot]', bot=True), api) == [], + "the gate never acts on the gate's own posts") + +report = copy.deepcopy(BOARD) +report['gate']['mode'] = 'report' +api = Fake(prs={'zed': 0}) +done = run(report, 'discussion_comment', reply_event('core', 'zed'), api) +check(api.named('HIDE') == [] and api.named('MOVE') == [] and 'report mode' in api.named('COMMENT')[0]['body'], + 'report mode replies and changes nothing') + +off = copy.deepcopy(BOARD) +off['gate']['mode'] = 'off' +check(run(off, 'discussion', thread_event('core', 'zed'), Fake()) == [], 'mode off does nothing') + +signed = copy.deepcopy(BOARD) +signed['gate'].update(signature=r'(?m)^[-—]\s*(protean|amp)-[A-Za-z0-9]+\s*$', signature_exempt=['OWNER', 'group:council']) +api = Fake() +done = run(signed, 'discussion_comment', reply_event('direction', 'zed'), api) +check(('hide', 'https://github.com/Example-Org/core/discussions/1#c9') in done and 'sign-off' in api.named('COMMENT')[0]['body'], + 'on a signed board an unsigned reply is hidden until it is signed') +api = Fake() +check(run(signed, 'discussion_comment', reply_event('direction', 'zed', body='Done.\n— protean-orda'), api) == [], + 'a signed reply passes') +api = Fake() +check(run(signed, 'discussion_comment', reply_event('direction', 'boss', assoc='OWNER'), api) == [], + 'an exempt association need not sign') +api = Fake() +check(run(signed, 'discussion_comment', reply_event('direction', 'alice', assoc='MEMBER'), api) == [], + 'a member of an exempt group need not sign') +bad = copy.deepcopy(signed) +bad['gate']['signature_exempt'] = ['group:nobody'] +check(any('signature_exempt' in e for e in gate.validate(bad)), 'an exemption for an unknown group is refused') +api = Fake() +done = run(signed, 'discussion', thread_event('direction', 'zed'), api) +check(api.named('MOVE') == [] and ('reply', 'zed') in done, 'an unsigned thread is asked to sign, not moved') + +# 4. the offline check: forms, FORUM.md, and public seats +root = tempfile.mkdtemp(prefix='forum-gate-') +os.makedirs(os.path.join(root, '.github', 'DISCUSSION_TEMPLATE')) +with open(os.path.join(root, '.github', 'DISCUSSION_TEMPLATE', 'direction.yml'), 'w') as f: + f.write('body:\n - type: textarea\n id: what\n attributes:\n label: What\n') +doc = ' '.join(f'`{c["slug"]}`' for c in BOARD['categories']) + ' Council: @Alice.' +with open(os.path.join(root, 'FORUM.md'), 'w') as f: + f.write(doc) +check(gate.check(root, BOARD) == [], 'forms, FORUM.md and the public seats agree') +with open(os.path.join(root, '.github', 'DISCUSSION_TEMPLATE', 'workshop.yml'), 'w') as f: + f.write('body:\n - type: input\n id: a\n - type: input\n id: a\n') +errs = gate.check(root, BOARD) +check(any('workshop.yml: category workshop does not say form = true' in e for e in errs) + and any('an id is used twice' in e for e in errs), 'a stray form and a repeated field id are caught') +with open(os.path.join(root, '.github', 'DISCUSSION_TEMPLATE', 'workshop.yml'), 'w') as f: + f.write('body:\n - type: dropdown\n id: a\n attributes:\n label: Agreed: so far\n options:\n - yes\n - "no"\n') +errs = gate.check(root, BOARD) +check(any('line 5: quote this value' in e for e in errs) and any('line 7: quote this option' in e for e in errs) + and not any('line 8' in e for e in errs), 'a value holding ": " and a bare yes option are caught') +os.remove(os.path.join(root, '.github', 'DISCUSSION_TEMPLATE', 'workshop.yml')) +with open(os.path.join(root, 'FORUM.md'), 'w') as f: + f.write(doc.replace('@Alice', 'Alice').replace('`core`', 'core')) +errs = gate.check(root, BOARD) +check(any('does not name @Alice' in e for e in errs) and any('`core`' in e for e in errs), + 'FORUM.md must list every category and name every seat on a public board') + +# 5. this repository's own board +if os.path.exists(gate.MANIFEST): + errs = gate.check() + for e in errs: + print(' ', e) + check(errs == [], f'this repository\'s board ({gate.load().get("repository")}) passes the check') + +print(f'\n{"FAILED: " + str(len(fails)) if fails else "all passed"}') +sys.exit(1 if fails else 0) diff --git a/.github/labels/labels.shared.toml b/.github/labels/labels.shared.toml new file mode 100644 index 0000000..baa8d0f --- /dev/null +++ b/.github/labels/labels.shared.toml @@ -0,0 +1,326 @@ +# The labels every issue and pull request in all three organisations can carry: Foundation-0, Proven-Commons +# and aska-digital. This file is the same, word for word, in each organisation's .github repository; a change +# is made in all three at once. LABELS-STANDARD.md explains each dimension and when to use it. +# +# A label is ": ". A dimension's rule says how many of its labels one item may carry: +# one (exactly one), at-most-one, or any. The organisation adds its own dimensions in labels.toml; a +# repository adds only area: labels, in its own .github/labels.toml. +version = 1 + +[[dimensions]] +prefix = "type" +rule = "one" +applies = ["issue", "pr"] +color = "1d76db" +description = "What kind of work it is. Every issue and pull request has exactly one." + +[[dimensions]] +prefix = "status" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "fbca04" +description = "Why it is waiting, if it is. None means it is moving. GitHub already shows draft, review and merge states." + +[[dimensions]] +prefix = "priority" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "d93f0b" +description = "How soon it matters. Most items carry none; set it when the answer is not 'when someone gets to it'." + +[[dimensions]] +prefix = "concern" +rule = "any" +applies = ["issue", "pr"] +color = "5319e7" +description = "What else it touches, which needs a second look from someone who knows it." + +[[dimensions]] +prefix = "source" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "c5def5" +description = "Where it came from, when that is not a person simply noticing it." + +[[dimensions]] +prefix = "ai" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "0e8a16" +description = "What an AI agent did. Credit both strands: say it, rather than leave readers to guess." + +[[dimensions]] +prefix = "resolution" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "cfd3d7" +description = "Why it was closed without being done. Issues also have GitHub's close reason; PRs do not." + +[[dimensions]] +prefix = "size" +rule = "at-most-one" +applies = ["pr"] +color = "ededed" +description = "Lines added and removed. Set by the triage bot on every push; review time follows it." + +[[dimensions]] +prefix = "area" +rule = "any" +applies = ["issue", "pr"] +color = "c2e0c6" +description = "Which part of the repository. Each repository defines its own, in .github/labels.toml." + +# ---------------------------------------------------------------- type + +[[labels]] +name = "type: bug" +description = "Something does not work as it says it does" +form = ["Fixes something that does not work", "Bug report"] + +[[labels]] +name = "type: feature" +description = "A new capability, or a change in behaviour" +form = ["Adds or changes behaviour", "Feature request"] + +[[labels]] +name = "type: docs" +description = "Documentation only" +form = ["Documentation only", "Documentation"] + +[[labels]] +name = "type: chore" +description = "Upkeep with no change in behaviour: dependencies, CI, tooling, refactors" +form = ["Upkeep: dependencies, CI, tooling or a refactor"] + +[[labels]] +name = "type: research" +description = "A result, a proof, a replay or an analysis: work that makes a claim" +form = ["A research result: a proof, a check, a replay or an analysis"] + +[[labels]] +name = "type: question" +description = "A question to answer before work can start. Open-ended questions go to the forum's Q&A" + +[[labels]] +name = "type: tracking" +description = "An index of other issues: an audit, an epic, a plan. Closes when they do" + +# ---------------------------------------------------------------- status + +[[labels]] +name = "status: needs triage" +description = "New: nobody has looked at it yet" + +[[labels]] +name = "status: needs info" +description = "Waiting on the author for details or a reproduction" + +[[labels]] +name = "status: needs decision" +description = "Waiting on a call by someone with the authority to make it" + +[[labels]] +name = "status: needs admin" +description = "Waiting on an owner or admin to change a setting" + +[[labels]] +name = "status: blocked" +description = "Waiting on other work; the issue links it" + +[[labels]] +name = "status: ready" +description = "Triaged and agreed: anyone may pick it up" + +[[labels]] +name = "status: in progress" +description = "Someone has picked it up (a pull request uses draft instead)" + +# ---------------------------------------------------------------- priority + +[[labels]] +name = "priority: p0" +color = "b60205" +description = "Drop other work: harm is happening now" + +[[labels]] +name = "priority: p1" +color = "d93f0b" +description = "Next: before any new feature" + +[[labels]] +name = "priority: p2" +color = "e99695" +description = "Soon: in the current cycle" + +[[labels]] +name = "priority: p3" +color = "f9d0c4" +description = "When there is time" + +# ---------------------------------------------------------------- concern + +[[labels]] +name = "concern: security" +description = "Could let someone do what they should not. Exploitable details go to the security policy" +form = ["Security"] + +[[labels]] +name = "concern: privacy" +description = "Personal data, secrets, or details that should not be public" +form = ["Privacy, personal data or secrets"] + +[[labels]] +name = "concern: accessibility" +description = "A barrier for people with disabilities" +form = ["Accessibility"] + +[[labels]] +name = "concern: performance" +description = "Speed, memory or cost" +form = ["Speed, memory or cost"] + +[[labels]] +name = "concern: compliance" +description = "Law, licence or policy: consent, attribution, terms" +form = ["Law, licences or consent"] + +[[labels]] +name = "concern: breaking" +description = "Changes behaviour that someone may depend on; needs a note in the release" +form = ["Breaks something people may depend on"] + +# ---------------------------------------------------------------- source + +[[labels]] +name = "source: audit" +description = "Found by an audit or a review of the whole repository" + +[[labels]] +name = "source: review" +description = "Raised in review of a pull request, and split out to fix on its own" + +[[labels]] +name = "source: report" +description = "Reported by someone outside the team" + +[[labels]] +name = "source: forum" +description = "Started as a forum thread; the issue links it" + +[[labels]] +name = "source: contribution" +description = "A pull request from someone outside the team" + +[[labels]] +name = "source: incident" +description = "Came out of something going wrong in use" + +# ---------------------------------------------------------------- ai + +[[labels]] +name = "ai: authored" +description = "An AI agent wrote it; a named person reviewed it" +form = ["An AI agent wrote it, and a person reviewed it"] + +[[labels]] +name = "ai: assisted" +description = "A person wrote it with help from an AI agent" +form = ["A person wrote it, with help from an AI agent"] + +# ---------------------------------------------------------------- resolution + +[[labels]] +name = "resolution: duplicate" +description = "Already covered by another issue or pull request, which it links" + +[[labels]] +name = "resolution: invalid" +description = "Not a problem, or not about this repository" + +[[labels]] +name = "resolution: wontfix" +description = "Understood, and deliberately not done; the reason is in the thread" + +[[labels]] +name = "resolution: superseded" +description = "Replaced by later work, which it links" + +# ---------------------------------------------------------------- size (the triage bot sets these) + +[[labels]] +name = "size: xs" +description = "Up to 10 lines changed" + +[[labels]] +name = "size: s" +description = "11 to 50 lines changed" + +[[labels]] +name = "size: m" +description = "51 to 250 lines changed" + +[[labels]] +name = "size: l" +description = "251 to 1000 lines changed" + +[[labels]] +name = "size: xl" +description = "More than 1000 lines changed: consider splitting it" + +# ---------------------------------------------------------------- GitHub's own names, kept exactly + +[[labels]] +name = "good first issue" +fixed = true +color = "7057ff" +description = "A small, well-described task for someone new. GitHub lists these for newcomers" + +[[labels]] +name = "help wanted" +fixed = true +color = "008672" +description = "The team would welcome someone taking this on. GitHub lists these for contributors" + +# ---------------------------------------------------------------- old names, renamed so issues keep them + +[renames] +"bug" = "type: bug" +"enhancement" = "type: feature" +"documentation" = "type: docs" +"question" = "type: question" +"needs decision" = "status: needs decision" +"needs admin" = "status: needs admin" +"duplicate" = "resolution: duplicate" +"invalid" = "resolution: invalid" +"wontfix" = "resolution: wontfix" +"security" = "concern: security" +"accessibility" = "concern: accessibility" +"audit" = "source: audit" + +# ---------------------------------------------------------------- what the triage bot reads from a pull request + +# Upper bounds, in lines added plus removed, for each size: label. +[size] +xs = 10 +s = 50 +m = 250 +l = 1000 +xl = 1000000000 + +# A pull request whose changed files all match ("all") or any match ("any") gets the label, unless its +# dimension already has one. A repository adds its own as `paths` on its area: labels. +[[path_rules]] +label = "type: docs" +when = "all" +paths = ["**/*.md", "**/*.rst", "**/*.txt", "docs/**", "**/LICENSE*", "**/NOTICE*", "**/CITATION.cff"] + +[[path_rules]] +label = "type: chore" +when = "all" +paths = [".github/**", "**/.gitignore", "**/.gitattributes", "**/requirements*.txt", "**/package-lock.json", "**/*.lock", "**/lake-manifest.json", "**/lean-toolchain", "**/.pre-commit-config.yaml", "**/.editorconfig"] + +[[path_rules]] +label = "concern: security" +when = "any" +paths = [".github/workflows/**", "**/CODEOWNERS", "**/SECURITY.md", "**/githooks/**"] + diff --git a/.github/labels/labels.toml b/.github/labels/labels.toml new file mode 100644 index 0000000..4ecaf58 --- /dev/null +++ b/.github/labels/labels.toml @@ -0,0 +1,100 @@ +# Foundation-0's own label dimensions, on top of labels.shared.toml. LABELS-STANDARD.md explains them. +version = 1 + +[[dimensions]] +prefix = "claim" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "bfd4f2" +description = "The Repository Standard's claim label for a result (section B): how it is established." + +[[dimensions]] +prefix = "evidence" +rule = "at-most-one" +applies = ["issue", "pr"] +color = "fef2c0" +description = "The strongest check a claimed result has passed (charter 0.4). Also on forum threads." + +[[dimensions]] +prefix = "review" +rule = "at-most-one" +applies = ["issue"] +color = "d4c5f9" +description = "What a research-review issue reports (Repository Standard, section D)." + +[[labels]] +name = "claim: proven" +description = "A written proof" + +[[labels]] +name = "claim: verified" +description = "A finite check; not a proof" + +[[labels]] +name = "claim: known" +description = "Already in the literature, and cited" + +[[labels]] +name = "claim: conjecture" +description = "Believed, not established" + +[[labels]] +name = "evidence: claimed" +description = "Only an announcement or a repository" + +[[labels]] +name = "evidence: preprint" +description = "Public, not yet reviewed" + +[[labels]] +name = "evidence: peer-reviewed" +description = "A journal or conference paper" + +[[labels]] +name = "evidence: replicated" +description = "Someone else reproduced it" + +[[labels]] +name = "evidence: formally-verified" +description = "A machine-checked proof" + +[[labels]] +name = "evidence: deployed" +description = "In real operational use" + +[[labels]] +name = "evidence: disputed" +color = "b60205" +description = "A credible source disputes the result or its framing" + +[[labels]] +name = "review: error" +description = "A step, a number or a proof is wrong" +form = ["A step, a number or a proof is wrong"] + +[[labels]] +name = "review: gap" +description = "A step is missing or not justified" +form = ["A step is missing or not justified"] + +[[labels]] +name = "review: failed replay" +description = "Re-running it did not give the stated result" +form = ["Re-running it did not give the stated result"] + +[[labels]] +name = "review: missing prior art" +description = "Earlier work that should be cited, or that already has the result" +form = ["Earlier work should be cited, or already has the result"] + +[[labels]] +name = "review: wrong label" +description = "Labelled as more established than it is" +form = ["It is labelled as more established than it is"] + +# The Repository Standard's result layout (section D): a pull request that adds or changes a result is research. +[[path_rules]] +label = "type: research" +when = "any" +paths = ["results/**", "certificates/**", "commitment/**", "papers/**"] + diff --git a/.github/labels/repos.toml b/.github/labels/repos.toml new file mode 100644 index 0000000..9e38d41 --- /dev/null +++ b/.github/labels/repos.toml @@ -0,0 +1,246 @@ +# Every Foundation-0 repository: its custom properties and its topics, under the labelling standard +# (LABELS-STANDARD.md). `sync.py repos plan` shows what differs on GitHub; `repos apply` sets it. Owners only. +# Stages are the profile README's tiers; a research repository without a stage has not been assessed yet. + +version = 1 +org = "Foundation-0" +required_topics = ["foundation-0"] + +[properties.kind] +type = "multi_select" +values = ["org-home", "core", "site", "hub", "research", "workspace", "anthology", "ops", "demo", "placeholder"] +description = "What the repository is" + +[properties.domain] +type = "multi_select" +values = ["mathematics", "physics", "astronomy", "earth-science", "engineering", "medicine", "musicology", "science-of-science", "formal-verification", "ai-progress"] +description = "What it is about" + +[properties.stage] +type = "single_select" +values = ["seed", "active", "published"] +description = "Its tier under the Repository Standard" + +[properties.board] +type = "single_select" +values = ["main", "internal", "project", "none"] +description = "Its discussion board under the forum standard" + +[properties.owner] +type = "string" +description = "The person or team who answers for it" + +[[repos]] +name = ".github" +kind = ["org-home"] +board = "main" + +[[repos]] +name = "f0" +kind = ["core", "site"] +domain = ["ai-progress", "mathematics"] +stage = "active" +board = "internal" +topics = ["ai-progress", "mathematics"] + +[[repos]] +name = "math0" +kind = ["hub"] +domain = ["mathematics"] +stage = "active" +board = "none" +topics = ["mathematics"] + +[[repos]] +name = "physics0" +kind = ["hub"] +domain = ["physics"] +stage = "active" +board = "none" +topics = ["physics"] + +[[repos]] +name = "astr0" +kind = ["hub"] +domain = ["astronomy"] +stage = "seed" +board = "none" +topics = ["astronomy"] + +[[repos]] +name = "st0a" +kind = ["workspace"] +board = "none" +topics = ["ai-agents"] + +[[repos]] +name = "incentre-lattices" +kind = ["research", "site"] +domain = ["mathematics"] +stage = "published" +board = "none" +topics = ["mathematics"] + +[[repos]] +name = "fractals" +kind = ["research"] +domain = ["mathematics", "physics"] +stage = "active" +board = "none" +topics = ["mathematics", "physics"] + +[[repos]] +name = "point-charges" +kind = ["research"] +domain = ["physics"] +stage = "seed" +board = "none" +topics = ["physics"] + +[[repos]] +name = "plate-eclipses" +kind = ["research"] +domain = ["astronomy"] +stage = "seed" +board = "none" +topics = ["astronomy"] + +[[repos]] +name = "lost-eclipses" +kind = ["research"] +domain = ["astronomy"] +board = "none" +topics = ["astronomy"] + +[[repos]] +name = "plate-novae" +kind = ["research"] +domain = ["astronomy"] +board = "none" +topics = ["astronomy"] + +[[repos]] +name = "gaia-dr4" +kind = ["research"] +domain = ["astronomy"] +board = "none" +topics = ["astronomy"] + +[[repos]] +name = "wwssn-microseisms" +kind = ["research"] +domain = ["earth-science"] +board = "none" +topics = ["earth-science"] + +[[repos]] +name = "certified-orbits" +kind = ["research"] +domain = ["mathematics", "physics"] +board = "none" +topics = ["mathematics", "physics"] + +[[repos]] +name = "relative-equilibria" +kind = ["research"] +domain = ["mathematics", "physics"] +board = "none" +topics = ["mathematics", "physics"] + +[[repos]] +name = "covering-min-modulus" +kind = ["research"] +domain = ["mathematics"] +board = "none" +topics = ["mathematics"] + +[[repos]] +name = "rigid-realizations" +kind = ["research"] +domain = ["mathematics"] +board = "none" +topics = ["mathematics"] + +[[repos]] +name = "gfq-codes" +kind = ["research"] +domain = ["mathematics"] +board = "none" +topics = ["mathematics"] + +[[repos]] +name = "finite-state-proofs" +kind = ["research"] +domain = ["formal-verification"] +board = "none" +topics = ["formal-verification"] + +[[repos]] +name = "formal-claims-audit" +kind = ["research"] +domain = ["formal-verification", "ai-progress"] +board = "none" +topics = ["formal-verification", "ai-progress"] + +[[repos]] +name = "rism-anon" +kind = ["research"] +domain = ["musicology"] +board = "none" +topics = ["musicology"] + +[[repos]] +name = "overdue-data" +kind = ["research"] +domain = ["science-of-science"] +board = "none" +topics = ["science-of-science"] + +[[repos]] +name = "cfrp-strengthening" +kind = ["research"] +domain = ["engineering"] +board = "none" +topics = ["engineering"] + +[[repos]] +name = "project-asma" +kind = ["research"] +domain = ["medicine"] +board = "none" +topics = ["medicine"] + +[[repos]] +name = "discussi0n" +kind = ["placeholder"] +board = "none" +# a placeholder; archive it (FORUM.md) + +[[repos]] +name = "proven-commons" +kind = ["anthology"] +domain = ["formal-verification"] +board = "none" +topics = ["formal-verification"] + +[[repos]] +name = "medcode" +kind = ["demo"] +domain = ["medicine"] +stage = "seed" +board = "none" +topics = ["medicine"] +labels = false +# a private fork + +[[repos]] +name = "ion-tracker" +kind = ["ops"] +board = "none" + +[[repos]] +name = "demo-repository" +kind = ["demo"] +board = "none" +labels = false +# archived diff --git a/.github/labels/sync.py b/.github/labels/sync.py new file mode 100644 index 0000000..da47daf --- /dev/null +++ b/.github/labels/sync.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Labels and repository properties under the labelling standard (LABELS-STANDARD.md in each organisation's +.github repository). + +Two systems, kept apart: + + - Repositories are classified by GitHub topics and the organisation's custom properties (kind, stage, board, + owner), listed for every repository in .github/labels/repos.toml. + - Issues and pull requests share one label set: the shared dimensions in labels.shared.toml (the same file in + every organisation), the organisation's own in labels.toml, and a repository's `area:` labels in that + repository's own .github/labels.toml. + + python3 .github/labels/sync.py check # the manifests are sound (no network) + GH_TOKEN=... python3 .github/labels/sync.py plan OWNER/REPO # what apply would change; changes nothing + GH_TOKEN=... python3 .github/labels/sync.py apply OWNER/REPO # rename, update and create labels + GH_TOKEN=... python3 .github/labels/sync.py apply --org # every repository listed in repos.toml + GH_TOKEN=... python3 .github/labels/sync.py lint OWNER/REPO # open issues and PRs against the rules + GH_TOKEN=... python3 .github/labels/sync.py repos plan|apply # custom properties and topics + ... --repos PATH # another repos.toml: private repositories are listed in a private one + +A label is never deleted. An old label is renamed to its new name, so every issue that carried it keeps it; +a label the standard doesn't know is reported and left alone. Topics are only ever added. Standard library +only (Python 3.11 or later, for tomllib). +""" +import json +import os +import re +import sys +import tomllib +import urllib.error +import urllib.parse +import urllib.request + +VERSION = '1.0' +HERE = os.path.dirname(os.path.abspath(__file__)) +SHARED, ORG, REPOS = (os.path.join(HERE, f) for f in ('labels.shared.toml', 'labels.toml', 'repos.toml')) +RULES = {'one', 'at-most-one', 'any'} +APPLIES = {'issue', 'pr'} +COLOR = re.compile(r'[0-9a-f]{6}') +PREFIX = re.compile(r'[a-z]+') +TOPIC = re.compile(r'[a-z0-9][a-z0-9-]{0,49}') +BOARDS = {'main', 'internal', 'project', 'none'} +NAME_MAX, DESC_MAX = 50, 100 # GitHub's limits for a label + + +def load(path): + with open(path, 'rb') as f: + return tomllib.load(f) + + +def prefix_of(name): + return name.split(': ', 1)[0] if ': ' in name else '' + + +# ---------------------------------------------------------------- the label set + +def merge(*manifests): + """One label set from the shared, organisation and repository manifests, in that order. Returns + (dimensions by prefix, labels by lower-case name, renames old -> new).""" + dims, labels, renames = {}, {}, {} + for m in manifests: + for d in m.get('dimensions', []): + dims[d['prefix']] = d + for lab in m.get('labels', []): + d = dims.get(prefix_of(lab['name']), {}) + labels[lab['name'].lower()] = {'name': lab['name'], 'color': lab.get('color', d.get('color', 'ededed')), + 'description': lab.get('description', '')} + renames.update(m.get('renames', {})) + return dims, labels, renames + + +def validate(shared, org, repo=None, where='labels'): + """Every problem in these manifests together; an empty list means the set is sound.""" + errs = [] + for name, m in (('labels.shared.toml', shared), ('labels.toml', org), ('the repository file', repo or {})): + if m and m.get('version') != 1: + errs.append(f'{name}: version must be 1') + if repo: + for d in repo.get('dimensions', []): + errs.append(f'the repository file may not add dimensions ({d.get("prefix")!r}); only area: labels') + for lab in repo.get('labels', []): + if prefix_of(lab.get('name', '')) != 'area': + errs.append(f'the repository file may hold only area: labels, not {lab.get("name")!r}') + seen = set() + for m in (shared, org): + for d in m.get('dimensions', []): + p = d.get('prefix') + if not isinstance(p, str) or not PREFIX.fullmatch(p): + errs.append(f'dimension {p!r}: prefix must be a lower-case word') + if p in seen: + errs.append(f'dimension {p}: defined twice (an organisation may not redefine a shared dimension)') + seen.add(p) + if d.get('rule') not in RULES: + errs.append(f'dimension {p}: rule must be one of {sorted(RULES)}') + if not set(d.get('applies', [])) or not set(d.get('applies', [])) <= APPLIES: + errs.append(f'dimension {p}: applies must list issue and/or pr') + if not COLOR.fullmatch(str(d.get('color', ''))): + errs.append(f'dimension {p}: color must be six lower-case hex digits') + if not d.get('description'): + errs.append(f'dimension {p}: description is required') + dims, _, _ = merge(shared, org, repo or {}) + names = {} + for m in (shared, org, repo or {}): + for lab in m.get('labels', []): + n = lab.get('name', '') + if not n or len(n) > NAME_MAX: + errs.append(f'label {n!r}: name must be 1 to {NAME_MAX} characters') + if n.lower() in names: + errs.append(f'label {n!r}: defined twice') + names[n.lower()] = n + p = prefix_of(n) + if p and p not in dims: + errs.append(f'label {n!r}: prefix {p!r} is not a dimension') + if not p and not lab.get('fixed'): + errs.append(f'label {n!r}: has no prefix; only GitHub\'s fixed names may (set fixed = true)') + if 'color' in lab and not COLOR.fullmatch(str(lab['color'])): + errs.append(f'label {n!r}: color must be six lower-case hex digits') + if not lab.get('description') or len(lab['description']) > DESC_MAX: + errs.append(f'label {n!r}: description must be 1 to {DESC_MAX} characters') + answers = {} + for m in (shared, org, repo or {}): + for lab in m.get('labels', []): + for k in ('form', 'paths'): + if k in lab and (not isinstance(lab[k], list) or not all(isinstance(x, str) and x for x in lab[k])): + errs.append(f'label {lab.get("name")!r}: {k} must be a list of strings') + for a in lab.get('form', []): + other = answers.get(a.lower()) + if other and prefix_of(other) == prefix_of(lab['name']): + errs.append(f'form answer {a!r} means both {other!r} and {lab["name"]!r}') + answers[a.lower()] = lab['name'] + for r in m.get('path_rules', []): + if r.get('label', '').lower() not in names: + errs.append(f'path rule for {r.get("label")!r}: not a label in the set') + if r.get('when', 'any') not in ('any', 'all') or not r.get('paths'): + errs.append(f'path rule for {r.get("label")!r}: needs paths, and when = "any" or "all"') + size = shared.get('size', {}) + for k, v in size.items(): + if f'size: {k}' not in names or not isinstance(v, int) or v < 1: + errs.append(f'size {k!r}: needs a size: {k} label and a whole number of lines') + for old, new in {**shared.get('renames', {}), **org.get('renames', {}), **(repo or {}).get('renames', {})}.items(): + if new.lower() not in names: + errs.append(f'rename {old!r} -> {new!r}: the new name is not a label in the set') + if old.lower() in names: + errs.append(f'rename {old!r}: the old name is itself a label in the set') + return [f'{where}: {e}' for e in errs] + + +def validate_repos(r, dims): + errs = [] + if r.get('version') != 1 or not r.get('org'): + errs.append('repos.toml: needs version = 1 and org') + props = r.get('properties', {}) + for k in ('kind', 'stage'): + if not props.get(k, {}).get('values'): + errs.append(f'repos.toml: [properties.{k}] needs values') + for k, spec in props.items(): + if spec.get('type', 'single_select') not in ('single_select', 'multi_select', 'string'): + errs.append(f'repos.toml: [properties.{k}] type must be single_select, multi_select or string') + if set(props.get('board', {}).get('values', BOARDS)) != BOARDS: + errs.append(f'repos.toml: board values are fixed by the forum standard: {sorted(BOARDS)}') + seen = set() + for rep in r.get('repos', []): + n = rep.get('name') + if not n or n in seen: + errs.append(f'repos.toml: repository {n!r} missing a name or listed twice') + seen.add(n) + for k, spec in props.items(): + v = rep.get(k) + if v is None: + if k in ('kind', 'board'): + errs.append(f'repos.toml: {n}: {k} is required') + continue + allowed = spec.get('values', sorted(BOARDS) if k == 'board' else None) + kind = spec.get('type', 'single_select') + if kind == 'multi_select': + if not isinstance(v, list) or not v or not set(v) <= set(allowed): + errs.append(f'repos.toml: {n}: {k} = {v!r} must be a list drawn from {allowed}') + elif kind == 'single_select' and v not in allowed: + errs.append(f'repos.toml: {n}: {k} = {v!r} is not one of {allowed}') + elif kind == 'string' and not isinstance(v, str): + errs.append(f'repos.toml: {n}: {k} must be text') + for t in rep.get('topics', []) + r.get('required_topics', []): + if not TOPIC.fullmatch(t): + errs.append(f'repos.toml: {n}: topic {t!r} must be lower-case letters, digits and hyphens') + return errs + + +# ---------------------------------------------------------------- GitHub + +class GitHub: + def __init__(self, token): + self.token = token + + def call(self, method, path, body=None, accept='application/vnd.github+json'): + url = path if path.startswith('https://') else 'https://api.github.com' + path + req = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, method=method, + headers={'Authorization': f'bearer {self.token}', 'Accept': accept, + 'Content-Type': 'application/json', 'User-Agent': f'labels-sync/{VERSION}', + 'X-GitHub-Api-Version': '2022-11-28'}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + if accept.endswith('.raw'): + return raw.decode('utf-8') + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + if e.code == 404 and method == 'GET': + return None + raise SystemExit(f'labels: GitHub answered HTTP {e.code} to {method} {url.split("?")[0]}') + + def pages(self, path): + out, page = [], 1 + while True: + sep = '&' if '?' in path else '?' + got = self.call('GET', f'{path}{sep}per_page=100&page={page}') or [] + out += got + if len(got) < 100: + return out + page += 1 + + +def repo_manifest(api, full): + text = api.call('GET', f'/repos/{full}/contents/.github/labels.toml', accept='application/vnd.github.raw') + return tomllib.loads(text) if text else {} + + +def plan(api, full, shared, org): + """The changes that bring one repository's labels to the standard: a list of (action, name, detail).""" + repo = repo_manifest(api, full) + errs = validate(shared, org, repo, where=full) + if errs: + raise SystemExit('\n'.join(errs)) + _, want, renames = merge(shared, org, repo) + have = {x['name'].lower(): x for x in api.pages(f'/repos/{full}/labels')} + steps = [] + for old, new in renames.items(): + if old.lower() in have and new.lower() not in have: + steps.append(('rename', old, want[new.lower()])) + have.pop(old.lower()) + have[new.lower()] = dict(want[new.lower()]) # the rename sets colour and description too + for key, lab in want.items(): + cur = have.get(key) + if cur is None: + steps.append(('create', lab['name'], lab)) + elif (cur['color'].lower(), cur.get('description') or '', cur['name']) != (lab['color'], lab['description'], lab['name']): + steps.append(('update', cur['name'], lab)) + board = api.call('GET', f'/repos/{full}/contents/.github/forum/board.toml', accept='application/vnd.github.raw') + forum = {x['name'].lower() for x in (tomllib.loads(board).get('labels', []) if board else [])} + for key, cur in sorted(have.items()): + if key not in want and key not in forum: # a forum board's own labels are the forum gate's to keep + steps.append(('unknown', cur['name'], None)) + return steps + + +def apply(api, full, steps): + for action, name, lab in steps: + q = urllib.parse.quote(name, safe='') + if action in ('rename', 'update'): + api.call('PATCH', f'/repos/{full}/labels/{q}', + {'new_name': lab['name'], 'color': lab['color'], 'description': lab['description']}) + elif action == 'create': + api.call('POST', f'/repos/{full}/labels', lab) + + +def lint(api, full, shared, org): + """Open issues and pull requests that break a dimension's rule (one type, at most one status, ...).""" + dims, _, _ = merge(shared, org, repo_manifest(api, full)) + out = [] + for it in api.pages(f'/repos/{full}/issues?state=open'): + kind = 'pr' if 'pull_request' in it else 'issue' + names = [x['name'] for x in it['labels']] + for p, d in dims.items(): + if kind not in d['applies']: + continue + n = sum(1 for x in names if prefix_of(x) == p) + if d['rule'] == 'one' and n != 1 or d['rule'] == 'at-most-one' and n > 1: + out.append(f'#{it["number"]} ({kind}): {n} {p}: label{"" if n == 1 else "s"}, the rule is {d["rule"]}') + return out + + +def repos_plan(api, r): + """Custom property schema and values, and topics to add, for every repository listed.""" + org, steps = r['org'], [] + schema = {p['property_name']: p for p in (api.call('GET', f'/orgs/{org}/properties/schema') or [])} + for name, spec in r['properties'].items(): + want = {'value_type': spec.get('type', 'single_select'), 'description': spec.get('description', ''), + 'required': False} + if want['value_type'] in ('single_select', 'multi_select'): + want['allowed_values'] = spec['values'] + cur = schema.get(name) + if cur is None or cur.get('allowed_values') != want.get('allowed_values') or cur.get('value_type') != want['value_type']: + steps.append(('schema', name, want)) + values = {v['repository_name']: {p['property_name']: p['value'] for p in v['properties']} + for v in api.pages(f'/orgs/{org}/properties/values')} + for rep in r['repos']: + want = {k: rep[k] for k in r['properties'] if rep.get(k) is not None} + cur = values.get(rep['name'], {}) + same = lambda a, b: sorted(a) == sorted(b) if isinstance(a, list) and isinstance(b, list) else a == b # noqa: E731 + diff = {k: v for k, v in want.items() if not same(cur.get(k), v)} + if diff: + steps.append(('values', rep['name'], diff)) + topics = (api.call('GET', f'/repos/{org}/{rep["name"]}/topics') or {}).get('names', []) + add = [t for t in r.get('required_topics', []) + rep.get('topics', []) if t not in topics] + if add: + steps.append(('topics', rep['name'], sorted(set(topics) | set(add)))) + return steps + + +def repos_apply(api, r, steps): + org = r['org'] + for action, name, detail in steps: + if action == 'schema': + api.call('PUT', f'/orgs/{org}/properties/schema/{urllib.parse.quote(name)}', detail) + elif action == 'values': + api.call('PATCH', f'/orgs/{org}/properties/values', { + 'repository_names': [name], + 'properties': [{'property_name': k, 'value': v} for k, v in detail.items()]}) + elif action == 'topics': + api.call('PUT', f'/repos/{org}/{name}/topics', {'names': detail}) + + +# ---------------------------------------------------------------- commands + +def show(steps): + for action, name, lab in steps: + if action == 'unknown': + print(f' keep {name!r} (not in the standard; left as it is)') + elif action == 'rename': + print(f' rename {name!r} -> {lab["name"]!r}') + elif action in ('create', 'update'): + print(f' {action:8} {lab["name"]!r} #{lab["color"]} {lab["description"]}') + else: + print(f' {action:8} {name}: {lab}') + + +def main(argv): + global REPOS + if '--repos' in argv: + i = argv.index('--repos') + REPOS = argv[i + 1] + argv = argv[:i] + argv[i + 2:] + shared, org = load(SHARED), load(ORG) + cmd = argv[1] if len(argv) > 1 else 'check' + if cmd == 'check': + errs = validate(shared, org) + if os.path.exists(REPOS): + errs += validate_repos(load(REPOS), merge(shared, org)[0]) + for e in errs: + print('FAIL:', e) + n = len(merge(shared, org)[1]) + print(f'labels {VERSION}: ' + (f'{len(errs)} problem(s)' if errs else f'{n} labels, manifests sound')) + return 1 if errs else 0 + token = os.environ.get('GH_TOKEN') or sys.exit('labels: set GH_TOKEN') + api = GitHub(token) + if cmd == 'repos': + r = load(REPOS) + steps = repos_plan(api, r) + show(steps) + if argv[2:3] == ['apply']: + repos_apply(api, r, steps) + print(f'{len(steps)} change(s)' + (' applied' if argv[2:3] == ['apply'] else ' planned')) + return 0 + targets = argv[2:] + if targets == ['--org']: + r = load(REPOS) + targets = [f'{r["org"]}/{x["name"]}' for x in r['repos'] if x.get('labels', True)] + if not targets: + sys.exit(f'labels: {cmd} needs OWNER/REPO or --org') + for full in targets: + print(full) + if cmd == 'lint': + for line in lint(api, full, shared, org): + print(' ' + line) + continue + steps = plan(api, full, shared, org) + show(steps) + if cmd == 'apply': + apply(api, full, [s for s in steps if s[0] != 'unknown']) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/.github/labels/test_sync.py b/.github/labels/test_sync.py new file mode 100644 index 0000000..cd5effb --- /dev/null +++ b/.github/labels/test_sync.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Label sync tests (no network): the manifest checks, the plan against a fake repository (renames keep issues +labelled, nothing is ever deleted), the lint, and the repository properties. The last test checks this +organisation's own manifests. + + python3 .github/labels/test_sync.py +""" +import copy +import importlib.util +import os +import sys +import tomllib + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location('sync', os.path.join(HERE, 'sync.py')) +sync = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sync) +fails = [] + + +def check(cond, what): + print(('PASS: ' if cond else 'FAIL: ') + what) + if not cond: + fails.append(what) + + +SHARED = {'version': 1, + 'dimensions': [{'prefix': 'type', 'rule': 'one', 'applies': ['issue', 'pr'], 'color': '1d76db', 'description': 'Kind.'}, + {'prefix': 'status', 'rule': 'at-most-one', 'applies': ['issue'], 'color': 'fbca04', 'description': 'Why waiting.'}, + {'prefix': 'area', 'rule': 'any', 'applies': ['issue', 'pr'], 'color': 'c2e0c6', 'description': 'Where.'}], + 'labels': [{'name': 'type: bug', 'description': 'Broken'}, {'name': 'type: feature', 'description': 'New'}, + {'name': 'status: blocked', 'description': 'Blocked'}, + {'name': 'help wanted', 'fixed': True, 'color': '008672', 'description': 'Help'}], + 'renames': {'bug': 'type: bug', 'enhancement': 'type: feature'}} +ORG = {'version': 1, + 'dimensions': [{'prefix': 'claim', 'rule': 'at-most-one', 'applies': ['pr'], 'color': 'f9d0c4', 'description': 'Label.'}], + 'labels': [{'name': 'claim: proven', 'description': 'Proved'}]} +REPO = {'version': 1, 'labels': [{'name': 'area: relay', 'description': 'The relay'}]} + + +class Fake: + def __init__(self, labels, issues=(), repo=REPO, schema=(), values=(), topics=()): + self.labels, self.issues, self.repo = labels, list(issues), repo + self.schema, self.values, self.topics, self.calls = list(schema), list(values), list(topics), [] + + def call(self, method, path, body=None, accept=None): + self.calls.append((method, path, body)) + if path.endswith('/contents/.github/forum/board.toml'): + return '[[labels]]\nname = "project: f0"\ncolor = "1d76db"' if getattr(self, 'board', False) else None + if path.endswith('/contents/.github/labels.toml'): + if not self.repo: + return None + return '\n'.join(['version = 1'] + [f'[[labels]]\nname = "{x["name"]}"\ndescription = "{x["description"]}"' for x in self.repo['labels']]) + if path.endswith('/properties/schema'): + return self.schema + if path.endswith('/topics'): + return {'names': self.topics} + return {} + + def pages(self, path): + if path.endswith('/labels'): + return copy.deepcopy(self.labels) + if '/issues' in path: + return self.issues + if path.endswith('/properties/values'): + return self.values + return [] + + +# 1. the manifests +check(sync.validate(SHARED, ORG, REPO) == [], 'shared, organisation and repository manifests together are sound') +bad = copy.deepcopy(ORG) +bad['dimensions'].append(dict(SHARED['dimensions'][0])) +check(any('defined twice' in e for e in sync.validate(SHARED, bad)), 'an organisation may not redefine a shared dimension') +check(any("only area: labels" in e for e in sync.validate(SHARED, ORG, {'version': 1, 'labels': [{'name': 'type: x', 'description': 'x'}]})), + 'a repository may add only area: labels') +bad = copy.deepcopy(SHARED) +bad['labels'].append({'name': 'wip', 'description': 'x'}) +check(any('has no prefix' in e for e in sync.validate(bad, ORG)), 'a label without a prefix is refused unless it is one of GitHub\'s fixed names') +bad = copy.deepcopy(SHARED) +bad['labels'].append({'name': 'kind: x', 'description': 'x'}) +check(any("prefix 'kind' is not a dimension" in e for e in sync.validate(bad, ORG)), 'a label in an unknown dimension is refused') +bad = copy.deepcopy(SHARED) +bad['renames']['wip'] = 'status: wip' +check(any('the new name is not a label' in e for e in sync.validate(bad, ORG)), 'a rename to a label outside the set is refused') +bad = copy.deepcopy(SHARED) +bad['labels'][0]['description'] = 'x' * 101 +check(any('1 to 100 characters' in e for e in sync.validate(bad, ORG)), "GitHub's 100-character description limit is kept") + +# 2. the plan +have = [{'name': 'bug', 'color': 'd73a4a', 'description': "Something isn't working"}, + {'name': 'type: feature', 'color': '1d76db', 'description': 'New'}, + {'name': 'enhancement', 'color': 'a2eeef', 'description': 'x'}, + {'name': 'help wanted', 'color': '008672', 'description': 'Extra attention is needed'}, + {'name': 'wip', 'color': 'ffffff', 'description': ''}] +api = Fake(have) +steps = sync.plan(api, 'o/r', SHARED, ORG) +acts = {(a, n) for a, n, _ in steps} +check(('rename', 'bug') in acts and not any(a == 'update' and n == 'type: bug' for a, n in acts), + 'an old label is renamed (issues keep it), and not updated again after') +check(('rename', 'enhancement') not in acts and ('unknown', 'enhancement') in acts, + 'a rename whose new name already exists is left for a person, and reported') +check(('update', 'help wanted') in acts and ('create', 'status: blocked') in acts and ('create', 'area: relay') in acts + and ('create', 'claim: proven') in acts, 'shared, organisation and repository labels are all planned') +check(('unknown', 'wip') in acts, 'a label outside the standard is reported') +api2 = Fake(have + [{'name': 'project: f0', 'color': '1d76db', 'description': ''}]) +api2.board = True +check(not any(n == 'project: f0' for _, n, _ in sync.plan(api2, 'o/.github', SHARED, ORG)), + "a forum board's own labels are left to the forum gate, not reported") +sync.apply(api, 'o/r', [s for s in steps if s[0] != 'unknown']) +check(not any(m == 'DELETE' for m, _, _ in api.calls), 'nothing is ever deleted') +patch = [b for m, p, b in api.calls if m == 'PATCH' and p.endswith('/labels/bug')] +check(patch and patch[0] == {'new_name': 'type: bug', 'color': '1d76db', 'description': 'Broken'}, + 'the rename sets the new name, the dimension colour and the description in one call') +check(any(m == 'POST' and b['name'] == 'area: relay' for m, _, b in api.calls), 'a missing label is created') +sp = [p for m, p, _ in api.calls if m == 'PATCH' and 'help%20wanted' in p] +check(sp != [], 'a label name is escaped in the URL') + +# 3. the lint +issues = [{'number': 1, 'labels': [{'name': 'type: bug'}, {'name': 'type: feature'}]}, + {'number': 2, 'labels': []}, + {'number': 3, 'labels': [{'name': 'type: bug'}, {'name': 'status: blocked'}]}, + {'number': 4, 'pull_request': {}, 'labels': [{'name': 'type: chore'}, {'name': 'claim: proven'}]}] +out = sync.lint(Fake([], issues), 'o/r', SHARED, ORG) +check(any(x.startswith('#1 ') for x in out) and any(x.startswith('#2 ') for x in out) and not any(x.startswith('#3 ') for x in out), + 'two types, or none, break the rule; one type and one status pass') +check(not any(x.startswith('#4 ') for x in out), 'a dimension that applies only to issues or PRs is checked only there') + +# 4. repository properties and topics +REPOS = {'version': 1, 'org': 'o', 'required_topics': ['o-org'], + 'properties': {'kind': {'type': 'multi_select', 'values': ['core', 'library', 'site']}, 'stage': {'values': ['started', 'verified']}, + 'board': {'values': ['main', 'internal', 'project', 'none']}, 'owner': {'type': 'string'}}, + 'repos': [{'name': 'core', 'kind': ['core', 'site'], 'board': 'internal', 'topics': ['governance']}, + {'name': 'lib', 'kind': ['library'], 'stage': 'started', 'board': 'none'}]} +check(sync.validate_repos(REPOS, {}) == [], 'a sound repos.toml') +bad = copy.deepcopy(REPOS) +bad['repos'][1]['kind'] = ['library', 'tool'] +check(any("kind = ['library', 'tool']" in e for e in sync.validate_repos(bad, {})), 'a multi-select value outside its list is refused') +bad = copy.deepcopy(REPOS) +bad['repos'][1]['stage'] = 'done' +check(any("stage = 'done'" in e for e in sync.validate_repos(bad, {})), 'a single-select value outside its list is refused') +bad = copy.deepcopy(REPOS) +bad['properties']['board']['values'] = ['main', 'none'] +check(any('fixed by the forum standard' in e for e in sync.validate_repos(bad, {})), 'board values are the forum standard\'s') +api = Fake([], values=[{'repository_name': 'lib', 'properties': [{'property_name': 'kind', 'value': ['library']}]}], + topics=['lean']) +steps = sync.repos_plan(api, REPOS) +check(sum(1 for a, *_ in steps if a == 'schema') == 4, 'the property schema is planned when the organisation lacks it') +vals = {n: d for a, n, d in steps if a == 'values'} +check(vals['lib'] == {'stage': 'started', 'board': 'none'} and vals['core'] == {'kind': ['core', 'site'], 'board': 'internal'}, + 'only the values that differ are set') +tops = {n: d for a, n, d in steps if a == 'topics'} +check(tops['lib'] == ['lean', 'o-org'] and tops['core'] == ['governance', 'lean', 'o-org'], 'topics are added, never removed') + +# 5. this organisation's manifests +if os.path.exists(sync.ORG): + shared, org = sync.load(sync.SHARED), sync.load(sync.ORG) + errs = sync.validate(shared, org) + if os.path.exists(sync.REPOS): + errs += sync.validate_repos(sync.load(sync.REPOS), {}) + for e in errs: + print(' ', e) + check(errs == [], "this organisation's label and repository manifests pass the check") + +print(f'\n{"FAILED: " + str(len(fails)) if fails else "all passed"}') +sys.exit(1 if fails else 0) diff --git a/.github/labels/test_templates.py b/.github/labels/test_templates.py new file mode 100644 index 0000000..b8e7afa --- /dev/null +++ b/.github/labels/test_templates.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Template tests (no network): every answer a form or the pull request template offers in a section the triage +reads means exactly one label, every label a form attaches exists, and the template headings are ones the bot +parses. A typo in an option would otherwise silently stop labelling. + + python3 .github/labels/test_templates.py +""" +import glob +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) +import sync # noqa: E402 + +fails = [] + + +def check(cond, what): + print(('PASS: ' if cond else 'FAIL: ') + what) + if not cond: + fails.append(what) + + +shared, org = sync.load(sync.SHARED), sync.load(sync.ORG) +_, labels, _ = sync.merge(shared, org) +answers = {a.lower() for m in (shared, org) for lab in m.get('labels', []) for a in lab.get('form', [])} +# answers that deliberately mean no label +NEUTRAL = {'a person'} + +forms = sorted(glob.glob(os.path.join(ROOT, '.github', 'ISSUE_TEMPLATE', '*.yml'))) +for f in forms: + if f.endswith('config.yml'): + continue + text = open(f, encoding='utf-8').read() + name = os.path.basename(f) + slips = [n for n, line in enumerate(text.split('\n'), 1) + if (re.match(r'\s*(?:-\s+)?(?:name|description|label|placeholder|value):\s+[^"\'|>].*:\s', line) + or re.fullmatch(r'\s+-\s+(?i:yes|no|true|false|on|off)\s*', line))] + check(not slips, f'{name}: no unquoted ": " or bare yes/no that YAML would misread (lines {slips or "none"})') + m = re.search(r'^labels:\s*\[(.*)\]\s*$', text, re.M) + attached = re.findall(r'"([^"]+)"', m.group(1)) if m else [] + check(attached and all(a.lower() in labels for a in attached), f'{name}: attaches a type label from the set ({attached})') + for section in re.split(r'\n - type: ', text)[1:]: + if not section.startswith(('checkboxes', 'dropdown')) or 'id: touches' not in section and 'id: ai' not in section \ + and 'id: review' not in section and 'id: bar' not in section and 'id: part' not in section: + continue + opts = re.findall(r'^\s+- (?:label: )?(.+?)\s*$', section.split('options:', 1)[1], re.M) + bad = [o for o in opts if o.strip('"').lower() not in answers | NEUTRAL] + check(not bad, f'{name}: every option in a triaged field means a label ({bad or "all do"})') + +pr = os.path.join(ROOT, '.github', 'pull_request_template.md') +if os.path.exists(pr): + boxes = re.findall(r'^- \[ \] (.+?)\s*$', open(pr, encoding='utf-8').read(), re.M) + bad = [b for b in boxes if b.lower() not in answers] + check(boxes and not bad, f'pull request template: every box means a label ({bad or "all do"})') + +print(f'\n{"FAILED: " + str(len(fails)) if fails else "all passed"}') +sys.exit(1 if fails else 0) diff --git a/.github/labels/test_triage.py b/.github/labels/test_triage.py new file mode 100644 index 0000000..3481228 --- /dev/null +++ b/.github/labels/test_triage.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Triage tests (no network): every rule the triage bot applies, against fake events, including the cases a +busy public repository produces: no title, no body, a body edited to tick more boxes, two types at once. + + python3 .github/labels/test_triage.py +""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +spec = importlib.util.spec_from_file_location('triage', os.path.join(HERE, 'triage.py')) +triage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(triage) +sync = triage.sync +fails = [] + + +def check(cond, what): + print(('PASS: ' if cond else 'FAIL: ') + what) + if not cond: + fails.append(what) + + +SHARED = sync.load(os.path.join(HERE, 'labels.shared.toml')) +REPO = {'version': 1, 'labels': [{'name': 'area: relay', 'description': 'The relay', 'paths': ['relay/']}, + {'name': 'area: gates', 'description': 'Gates', 'paths': ['gates/**/*.py'], + 'form': ['The send gates']}]} +CFG = triage.config(SHARED, {'version': 1}, REPO) + + +def pr(action='opened', labels=(), body='', assoc='CONTRIBUTOR', bot=False, add=10, rm=2, title='x', **kw): + item = {'number': 7, 'title': title, 'body': body, 'author_association': assoc, 'state': 'open', + 'user': {'login': 'someone', 'type': 'Bot' if bot else 'User'}, 'additions': add, 'deletions': rm, + 'labels': [{'name': x} for x in labels]} + return dict({'action': action, 'pull_request': item}, **kw) + + +def issue(action='opened', labels=(), body=None, assoc='NONE', reason=None, **kw): + item = {'number': 9, 'title': '', 'body': body, 'author_association': assoc, 'state_reason': reason, + 'user': {'login': 'someone', 'type': 'User'}, 'labels': [{'name': x} for x in labels]} + return dict({'action': action, 'issue': item}, **kw) + + +def d(event, ev, files=(), linked=()): + return triage.decide(CFG, event, ev, files, linked) + + +# 1. globs +check(triage.matches('relay/a/b.py', ['relay/']) and not triage.matches('relayx/a.py', ['relay/']), 'a folder glob covers what is under it, only') +check(triage.matches('README.md', ['**/*.md']) and triage.matches('a/b/c.md', ['**/*.md']), '**/ matches at the root and below') +check(triage.matches('gates/x/y.py', ['gates/**/*.py']) and not triage.matches('gates/y.txt', ['gates/**/*.py']), '** inside a pattern') +check(sync.validate(SHARED, {'version': 1}, REPO) == [], 'the shared set, with form answers, sizes and path rules, is sound') + +# 2. a pull request with no title and no body still gets labelled from what it changes +add, rm, probs = d('pull_request_target', pr(title='', body=None), files=['relay/main.py', 'relay/README.md']) +check('area: relay' in add and 'size: s' in add and 'source: contribution' in add and 'status: needs triage' in add, + 'no title, no body: area from paths, size, outside source and needs triage') +check(probs == ['needs exactly one type: label, has 0'], 'and the check asks a maintainer for its type') +add, _, probs = d('pull_request_target', pr(body=None), files=['docs/a.md', 'README.md']) +check('type: docs' in add and probs == [], 'only documentation changed: type: docs, and the check passes') +add, _, _ = d('pull_request_target', pr(body=None), files=['docs/a.md', 'relay/x.py']) +check('type: docs' not in add and 'area: relay' in add, 'one code file means it is not docs only') +add, _, _ = d('pull_request_target', pr(body=None), files=['.github/workflows/ci.yml']) +check('type: chore' in add and 'concern: security' in add, 'a workflow change is upkeep, and gets a security look') + +# 3. structured answers, never words +body = '## Kind of change\n\n- [x] Fixes something that does not work\n- [ ] Adds or changes behaviour\n\n## AI\n\n- [X] An AI agent wrote it, and a person reviewed it\n' +add, _, probs = d('pull_request_target', pr(body=body), files=['relay/a.py']) +check('type: bug' in add and 'ai: authored' in add and probs == [], 'ticked boxes in the template become labels') +add, _, _ = d('pull_request_target', pr(body='This fixes a security bug in the relay, a breaking feature'), files=['x.py']) +check(not any(x.startswith(('type:', 'concern:')) for x in add), 'words in free text are never read as labels') +ib = '### Which part\n\nThe send gates\n\n### What else it touches\n\nSecurity, Accessibility\n\n### Details\n\n_No response_\n' +add, _, _ = d('issues', issue(body=ib)) +check({'area: gates', 'concern: security', 'concern: accessibility', 'source: report', 'status: needs triage'} <= set(add), + 'issue form answers, multi-select included, become labels') + +ib2 = '### What else it touches\n\nSecurity, Privacy, personal data or secrets\n' +add, _, _ = d('issues', issue(body=ib2)) +check({'concern: security', 'concern: privacy'} <= set(add), 'a multi-select answer holding a comma is still read whole') + +# 4. signals never override a maintainer +body2 = '- [x] Fixes something that does not work\n- [x] Adds or changes behaviour\n' +add, _, _ = d('pull_request_target', pr(body=body2), files=['x.py']) +check(sum(1 for x in add if x.startswith('type:')) == 1, 'two ticked types: only one is taken') +add, _, _ = d('pull_request_target', pr(action='edited', labels=['type: feature', 'size: s'], body=body), files=[]) +check('type: bug' not in add, 'an edited body cannot replace a type a maintainer set') + +# 5. one label per single-label dimension, newest wins +add, rm, _ = d('issues', issue(action='labeled', labels=['type: bug', 'type: feature', 'status: needs triage'], label={'name': 'type: feature'})) +check(rm.get('type: bug') and rm.get('status: needs triage'), 'a new type replaces the old one, and the item is triaged') +add, rm, _ = d('issues', issue(action='labeled', labels=['concern: security', 'concern: privacy'], label={'name': 'concern: privacy'})) +check(rm == {}, 'a dimension that allows many keeps them all') + +# 6. size follows every push +add, rm, _ = d('pull_request_target', pr(action='synchronize', labels=['type: bug', 'size: xs'], add=400, rm=20), files=['x.py']) +check(add.get('size: l') and rm.get('size: xs'), 'a push that grows it moves it to its new size') + +# 7. close and reopen +add, rm, _ = d('issues', issue(action='closed', labels=['type: bug', 'status: blocked'], reason='not_planned')) +check(rm.get('status: blocked') and add.get('resolution: wontfix'), 'closed as not planned: status removed, resolution: wontfix') +add, _, _ = d('issues', issue(action='closed', labels=['resolution: duplicate'], reason='not_planned')) +check(add == {}, "a maintainer's resolution label is kept") +add, rm, _ = d('issues', issue(action='reopened', labels=['type: bug', 'resolution: wontfix'], assoc='MEMBER')) +check(rm.get('resolution: wontfix') and 'status: needs triage' not in add, 'reopened: resolution removed; a typed team issue is not re-triaged') + +# 8. who opened it +add, _, _ = d('pull_request_target', pr(bot=True, assoc='NONE'), files=['x.py']) +check('ai: authored' in add, 'a GitHub app opening a pull request is labelled ai: authored') +add, _, _ = d('issues', issue(assoc='MEMBER', body=None)) +check('source: report' not in add and 'status: needs triage' in add, 'a team member is not an outside report, but an untyped issue still needs triage') + +# 9. inherited from the issue it closes +add, _, probs = d('pull_request_target', pr(body='Fixes #3'), files=['x.py'], linked={'type: bug', 'priority: p1', 'status: blocked'}) +check('type: bug' in add and 'priority: p1' in add and 'status: blocked' not in add and probs == [], + 'type and priority come from the issue it closes, status does not') +check(triage.CLOSING.findall('closes #4, Fixes: #5 and resolved #6; see #7') == ['4', '5', '6'], "GitHub's closing keywords only") + +print(f'\n{"FAILED: " + str(len(fails)) if fails else "all passed"}') +sys.exit(1 if fails else 0) diff --git a/.github/labels/triage.py b/.github/labels/triage.py new file mode 100644 index 0000000..3e3a72a --- /dev/null +++ b/.github/labels/triage.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Label triage: applies the labelling standard to every issue and pull request as it opens, changes and closes. + +Labels come only from structured signals, never from words in a title: + + - the answers a person picked in an issue form or ticked in the pull request template: each label lists the + exact answers that mean it (`form` in the manifests); + - the files a pull request changes: a label's `paths`, and the manifests' `path_rules`; + - the size of a pull request (lines added and removed); + - who opened it: someone outside the team (source: report or source: contribution), or a GitHub app (ai: authored); + - the issues a pull request closes: it inherits their type, area, concern and priority labels. + +Then it keeps the set consistent, whoever changed it: + + - a dimension that allows one label keeps the newest: adding `type: feature` removes `type: bug`; + - an item that nobody on the team has looked at carries `status: needs triage`, until a maintainer labels it; + - closing removes `status:` labels, and GitHub's close reason becomes a `resolution:` label; reopening removes it; + - a pull request without exactly one label in each dimension whose rule is "one" fails this check. + +Outsiders cannot set labels on GitHub (it takes triage access), so a label on an item came from a form, from this +script or from a maintainer. Signals only ever add labels, and only into an empty single-label dimension: they +never override a maintainer's choice. Every change is written to the job summary with its reason. + + python3 .github/labels/triage.py # .github/workflows/labels.yml runs it with the event's payload + +Standard library only (Python 3.11 or later). +""" +import json +import os +import re +import sys +import tomllib +import urllib.parse + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import sync # noqa: E402 (the same directory: manifests, merge, GitHub client) + +TEAM = {'OWNER', 'MEMBER', 'COLLABORATOR'} +TRIAGE = 'status: needs triage' +INHERIT = {'type', 'area', 'concern', 'priority'} +CLOSING = re.compile(r'\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b', re.I) # GitHub's own keywords + + +# ---------------------------------------------------------------- signals + +def glob_re(pattern): + """A path glob: ** crosses folders, * and ? stay inside one, a trailing / means everything under it.""" + if pattern.endswith('/'): + pattern += '**' + out, i = '', 0 + while i < len(pattern): + if pattern.startswith('**/', i): + out, i = out + '(?:.*/)?', i + 3 + elif pattern.startswith('**', i): + out, i = out + '.*', i + 2 + elif pattern[i] == '*': + out, i = out + '[^/]*', i + 1 + elif pattern[i] == '?': + out, i = out + '[^/]', i + 1 + else: + out, i = out + re.escape(pattern[i]), i + 1 + return re.compile(out + r'\Z') + + +def matches(path, patterns): + return any(glob_re(p).match(path) for p in patterns) + + +def answers(body, known=()): + """What the author picked: ticked boxes, and the value under each form heading. Lower-cased and trimmed. + A multi-select value is cut back into the known answers it joins, even ones that hold a comma themselves. + Free text is never searched for words.""" + out = set() + if not body: + return out + for m in re.finditer(r'^\s*[-*]\s*\[[xX]\]\s*(.+?)\s*$', body, re.M): + out.add(m.group(1).strip().lower()) + for m in re.finditer(r'^###\s+.+?\n\s*\n(.+?)\s*(?=\n###\s|\Z)', body, re.M | re.S): + value = m.group(1).strip() + if '\n' in value or value == '_No response_': + continue + parts = [v.strip().lower() for v in value.split(',')] + out.add(value.lower()) + for i in range(len(parts)): + for j in range(i + 1, len(parts) + 1): + joined = ', '.join(parts[i:j]) + if joined in known: + out.add(joined) + return out + + +def size_label(cfg, lines): + for name, upto in cfg['size']: + if lines <= upto: + return name + return cfg['size'][-1][0] + + +# ---------------------------------------------------------------- the decision + +def config(shared, org, repo): + dims, labels, _ = sync.merge(shared, org, repo) + form, paths = {}, [] + for m in (shared, org, repo): + for lab in m.get('labels', []): + for a in lab.get('form', []): + form.setdefault(a.strip().lower(), []).append(lab['name']) + if lab.get('paths'): + paths.append({'label': lab['name'], 'when': 'any', 'paths': lab['paths']}) + paths += m.get('path_rules', []) + size = sorted(((k, v) for k, v in shared.get('size', {}).items()), key=lambda kv: kv[1]) + size = [(f'size: {k}', v) for k, v in size] + return {'dims': dims, 'labels': labels, 'form': form, 'paths': paths, 'size': size} + + +def exclusive(cfg, prefix): + d = cfg['dims'].get(prefix) + return d is not None and d['rule'] in ('one', 'at-most-one') + + +def decide(cfg, event, ev, files=(), linked=()): + """What to add and remove, with a reason for each, and the rule problems left after. Pure: no network. + `files` are the changed paths of a pull request; `linked` the label names of the issues it closes.""" + kind = 'pr' if event.startswith('pull_request') else 'issue' + item = ev['pull_request'] if kind == 'pr' else ev['issue'] + action = ev['action'] + have = {x['name'] for x in item.get('labels', [])} + add, remove = {}, {} + known = {k for k in cfg['labels']} + + def now(): + return (have | set(add)) - set(remove) + + def dim_full(prefix): + return any(sync.prefix_of(x) == prefix for x in now()) + + def offer(name, why): + """A signal suggests a label: added unless unknown, already there, or its single-label dimension is taken.""" + if name.lower() not in known or name in now(): + return + p = sync.prefix_of(name) + if p and exclusive(cfg, p) and dim_full(p): + return + if kind not in cfg['dims'].get(p, {'applies': ['issue', 'pr']})['applies']: + return + add[cfg['labels'][name.lower()]['name']] = why + + def drop(name, why): + if name in now(): + add.pop(name, None) + if name in have: + remove[name] = why + + author = item.get('user') or {} + outsider = item.get('author_association', 'NONE') not in TEAM + if action in ('opened', 'reopened', 'edited', 'synchronize'): + if action == 'reopened': + for x in list(now()): + if sync.prefix_of(x) == 'resolution': + drop(x, 'reopened') + if action != 'synchronize': + for a in sorted(answers(item.get('body'), cfg['form'])): + for name in cfg['form'].get(a, []): + offer(name, f'the author chose "{a}"') + for name in sorted(linked): + if sync.prefix_of(name) in INHERIT: + offer(name, 'from the issue it closes') + if kind == 'pr' and action != 'edited': + for rule in cfg['paths']: + hit = [f for f in files if matches(f, rule['paths'])] + if hit and (rule.get('when', 'any') == 'any' or len(hit) == len(files)): + offer(rule['label'], f'it changes {hit[0]}' + (f' and {len(hit) - 1} more' if len(hit) > 1 else '')) + if cfg['size']: + want = size_label(cfg, int(item.get('additions', 0)) + int(item.get('deletions', 0))) + for x in list(now()): + if sync.prefix_of(x) == 'size' and x != want: + drop(x, 'the size changed') + offer(want, f'{int(item.get("additions", 0)) + int(item.get("deletions", 0))} lines changed') + if action == 'opened': + if author.get('type') == 'Bot': + offer('ai: authored', 'a GitHub app opened it') + if outsider: + offer('source: contribution' if kind == 'pr' else 'source: report', 'opened by someone outside the team') + if (outsider or not dim_full('type')) and not dim_full('status'): + offer(TRIAGE, 'nobody on the team has looked at it yet') + + elif action == 'labeled': + new = (ev.get('label') or {}).get('name', '') + p = sync.prefix_of(new) + if exclusive(cfg, p): + for x in list(now()): + if sync.prefix_of(x) == p and x != new: + drop(x, f'{new} replaces it') + if new != TRIAGE and p in ('type', 'area', 'priority', 'status', 'resolution') and TRIAGE in now(): + drop(TRIAGE, f'labelled {new} by the team') + + elif action == 'closed': + for x in list(now()): + if sync.prefix_of(x) == 'status': + drop(x, 'closed') + reason = item.get('state_reason') + if kind == 'issue' and not dim_full('resolution'): + if reason == 'duplicate': + offer('resolution: duplicate', 'closed as a duplicate') + elif reason == 'not_planned': + offer('resolution: wontfix', 'closed as not planned') + + problems = [] + if kind == 'pr' and item.get('state', 'open') == 'open' and action != 'closed': + for p, d in cfg['dims'].items(): + if d['rule'] == 'one' and 'pr' in d['applies']: + n = sum(1 for x in now() if sync.prefix_of(x) == p) + if n != 1: + problems.append(f'needs exactly one {p}: label, has {n}') + return add, remove, problems + + +# ---------------------------------------------------------------- the event + +def run(api, full, event, ev, shared, org, summary=print): + repo = sync.repo_manifest(api, full) + cfg = config(shared, org, repo) + kind = 'pr' if event.startswith('pull_request') else 'issue' + item = ev['pull_request'] if kind == 'pr' else ev['issue'] + n = item['number'] + files, linked = [], set() + if kind == 'pr' and ev['action'] in ('opened', 'reopened', 'synchronize'): + files = [f['filename'] for f in api.pages(f'/repos/{full}/pulls/{n}/files')][:3000] + if kind == 'pr' and ev['action'] in ('opened', 'reopened', 'edited'): + for m in sorted(set(CLOSING.findall(item.get('body') or '')))[:10]: + issue = api.call('GET', f'/repos/{full}/issues/{m}') + if issue and 'pull_request' not in issue: + linked.update(x['name'] for x in issue.get('labels', [])) + add, remove, problems = decide(cfg, event, ev, files, linked) + if add: + have = {x['name'].lower() for x in api.pages(f'/repos/{full}/labels')} + for name in add: + if name.lower() not in have: # create it as the standard says, not with GitHub's grey default + api.call('POST', f'/repos/{full}/labels', cfg['labels'][name.lower()]) + api.call('POST', f'/repos/{full}/issues/{n}/labels', {'labels': sorted(add)}) + for name in remove: + api.call('DELETE', f'/repos/{full}/issues/{n}/labels/{urllib.parse.quote(name, safe="")}') + lines = [f'### Labels on #{n}', ''] + lines += [f'- added `{k}`: {v}' for k, v in sorted(add.items())] + lines += [f'- removed `{k}`: {v}' for k, v in sorted(remove.items())] + lines += [f'- **{p}**' for p in problems] or [] + if len(lines) == 2: + lines.append('- no change') + summary('\n'.join(lines)) + return add, remove, problems + + +def main(): + with open(os.environ['GITHUB_EVENT_PATH'], encoding='utf-8') as f: + ev = json.load(f) + shared, org = sync.load(sync.SHARED), sync.load(sync.ORG) + api = sync.GitHub(os.environ.get('GH_TOKEN') or sys.exit('triage: set GH_TOKEN')) + out = os.environ.get('GITHUB_STEP_SUMMARY') + + def summary(text): + print(text) + if out: + with open(out, 'a', encoding='utf-8') as f: + f.write(text + '\n') + + _, _, problems = run(api, os.environ['GITHUB_REPOSITORY'], os.environ['GITHUB_EVENT_NAME'], ev, shared, org, summary) + if problems: + print('::error::' + '; '.join(problems) + '. A maintainer adds the missing label (see LABELS-STANDARD.md).') + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e7e877f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ + + +## What it changes, and why + +Closes # + +## Kind of change (tick one) + +- [ ] Fixes something that does not work +- [ ] Adds or changes behaviour +- [ ] Documentation only +- [ ] Upkeep: dependencies, CI, tooling or a refactor +- [ ] A research result: a proof, a check, a replay or an analysis + +## Also touches (tick any) + +- [ ] Breaks something people may depend on +- [ ] Security +- [ ] Privacy, personal data or secrets +- [ ] Accessibility +- [ ] Speed, memory or cost +- [ ] Law, licences or consent + +## AI (tick one, if any) + +- [ ] An AI agent wrote it, and a person reviewed it +- [ ] A person wrote it, with help from an AI agent + +## How it was checked + + diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml new file mode 100644 index 0000000..49f5dd7 --- /dev/null +++ b/.github/workflows/forum.yml @@ -0,0 +1,62 @@ +name: forum +# The forum standard's one workflow (FORUM-STANDARD.md in the organisation's .github repository). +# +# check: on a change to the board's manifest, forms, FORUM.md or the gate, check that they agree and run +# the gate's tests. No network. +# gate: on a new thread or reply, apply .github/forum/board.toml: a thread opened by someone below a +# category's tier moves to its open fallback, a reply from someone below it is hidden (never deleted), +# and the gate says why in the thread. Posts the gate makes itself trigger no further run. +# +# Untrusted text (titles, bodies, logins) reaches the gate only as JSON in the event file; nothing from the +# event is put into a shell command. A private organisation that counts merged pull requests across its +# private repositories adds a FORUM_SEARCH_TOKEN secret with read access to them; nothing else needs one. +on: + discussion: + types: [created] + discussion_comment: + types: [created] + pull_request: + paths: ['.github/forum/**', '.github/DISCUSSION_TEMPLATE/**', '.github/workflows/forum.yml', 'FORUM.md'] + push: + branches: [main] + paths: ['.github/forum/**', '.github/DISCUSSION_TEMPLATE/**', '.github/workflows/forum.yml', 'FORUM.md'] + workflow_dispatch: {} + +permissions: {} + +jobs: + check: + if: github.event_name != 'discussion' && github.event_name != 'discussion_comment' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Manifest, forms and FORUM.md agree + run: python3 .github/forum/gate.py check + - name: Gate tests (no network) + run: python3 .github/forum/test_gate.py + + gate: + if: github.event_name == 'discussion' || github.event_name == 'discussion_comment' + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: forum-gate-${{ github.event.discussion.number }} + cancel-in-progress: false + permissions: + contents: read + discussions: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github/forum + - name: Apply the board's rules to the new post + run: python3 .github/forum/gate.py event + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FORUM_SEARCH_TOKEN: ${{ secrets.FORUM_SEARCH_TOKEN }} diff --git a/.github/workflows/labels-check.yml b/.github/workflows/labels-check.yml new file mode 100644 index 0000000..3b3d743 --- /dev/null +++ b/.github/workflows/labels-check.yml @@ -0,0 +1,26 @@ +name: labels-check +# On a change to the label set or its tools: the manifests are sound, the forms' answers all mean a label, and +# the tests pass. No network. +on: + pull_request: + paths: ['.github/labels/**', '.github/ISSUE_TEMPLATE/**', '.github/pull_request_template.md', '.github/workflows/labels*.yml'] + push: + branches: [main] + paths: ['.github/labels/**', '.github/ISSUE_TEMPLATE/**', '.github/pull_request_template.md', '.github/workflows/labels*.yml'] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - run: python3 .github/labels/sync.py check + - run: python3 .github/labels/test_sync.py + - run: python3 .github/labels/test_triage.py + - run: python3 .github/labels/test_templates.py diff --git a/.github/workflows/labels-triage.yml b/.github/workflows/labels-triage.yml new file mode 100644 index 0000000..ecec53d --- /dev/null +++ b/.github/workflows/labels-triage.yml @@ -0,0 +1,19 @@ +name: labels-triage +# The labelling standard's triage for this repository (LABELS-STANDARD.md in Foundation-0/.github): labels each issue +# and pull request from structured signals, and keeps the labels consistent. The triage itself lives in +# Foundation-0/.github; this file only calls it. +on: + issues: + types: [opened, edited, reopened, closed, labeled] + pull_request_target: + types: [opened, edited, synchronize, reopened, closed, labeled] + +permissions: {} + +jobs: + labels: + uses: ./.github/workflows/labels.yml + permissions: + contents: read + issues: write + pull-requests: write diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..ffacac5 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,41 @@ +name: labels +# The labelling standard's triage (LABELS-STANDARD.md), as a reusable workflow. Every repository in the +# organisation calls it with a short workflow (workflow-templates/labels.yml), so there is one triage, kept here. +# +# It runs on pull_request_target as well as issues, so it can label pull requests from forks. That is safe here +# because it never checks out or runs the pull request's code: it checks out this repository's .github/labels +# only, and reads the pull request's files and labels through the API. Titles and bodies reach the script only as +# JSON in the event file, never a shell command. +on: + workflow_call: + inputs: + standard-ref: + description: The ref of the organisation's .github repository to take the triage and the label set from. + type: string + default: main + +permissions: {} + +jobs: + triage: + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: labels-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/.github + ref: ${{ inputs.standard-ref }} + path: standard + sparse-checkout: .github/labels + persist-credentials: false + - name: Label from structured signals, keep the set consistent + run: python3 standard/.github/labels/triage.py + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/FORUM-STANDARD.md b/FORUM-STANDARD.md new file mode 100644 index 0000000..e3c0024 --- /dev/null +++ b/FORUM-STANDARD.md @@ -0,0 +1,265 @@ +# The forum standard + +**Version 1, 26 October 2026.** One standard for the discussion boards of three organisations: +[Foundation-0](https://github.com/Foundation-0), [Proven-Commons](https://github.com/Proven-Commons) and +[aska-digital](https://github.com/aska-digital). This file is the same, word for word, in each organisation's +`.github` repository. A change to it is made in all three at once, by pull request, and raises the version. + +The standard fixes the shape of a board: where it lives, the names and meanings of its categories, how rooms +are gated, and the files that describe it. Each organisation's own `FORUM.md` fills in the content: its +categories, its tiers, who holds which seat, and its rules of conduct. + +## Two boards per organisation + +| | Main board | Internal board | +|---|---|---| +| Lives in | the organisation's `.github` repository, switched on as the organisation's Discussions | the organisation's core repository | +| Foundation-0 | [Foundation-0/.github](https://github.com/orgs/Foundation-0/discussions) | [Foundation-0/f0](https://github.com/Foundation-0/f0/discussions) | +| Proven-Commons | [Proven-Commons/.github](https://github.com/orgs/Proven-Commons/discussions) | [Proven-Commons/mission](https://github.com/Proven-Commons/mission/discussions) | +| aska-digital | [aska-digital/.github](https://github.com/orgs/aska-digital/discussions) | [aska-digital/internal-stoa](https://github.com/aska-digital/internal-stoa/discussions) (private) | +| For | everyone who uses the work, joins it or checks it | the organisation itself: its direction, its structure, its roles and its decisions | +| Who reads | anyone | Foundation-0 and Proven-Commons: anyone. aska-digital: the company and the peers it has given access | +| Who posts | anyone with a GitHub account | anyone in the open rooms; the gated rooms by tier or by named group (see *Rooms and tiers*) | + +The two open organisations keep their internal boards public on principle: anyone can read how they are run, +and anyone can speak in the open rooms. aska-digital is a private company, so its internal board sits in a +private repository and only the people with access read it. + +Each organisation has one moderation log, on its main board, covering every board it runs. + +## Boards on other repositories + +A repository gets **no board** unless it passes the test below. With Discussions off, a reader's questions go to +the main board, labelled with the repository's name, where more people see them and nothing is split across +twenty small boards that nobody watches. + +Turn on a **project board** only when all of these hold: + +1. The repository has a public release or a published result that outside people use or cite. +2. Its threads on the main board are frequent enough to crowd out other work there: about ten a month, for two + months running. +3. Someone is named in its README to answer threads on it within a week. + +A project board takes the four core categories (`announcements`, `q-a`, `ideas`, `general`) and at most three +modules from the vocabulary below that fit the work. It uses the standard slugs, so the organisation's forms +load on it without being copied (see *Forms*). + +Never give a board to: a fork, an archived or placeholder repository, private client or contract work, or a +repository whose work is coordinated through files and packets rather than threads (an agents' workspace, a +packet exchange). Each organisation's `FORUM.md` lists its repositories and the board each one gets. + +## The category vocabulary + +A slug means the same thing, and loads the same form, on every board in all three organisations. A board takes +the categories that fit it and no others; GitHub allows at most 25 on a board. A new slug that two boards would +share is added here first. + +**Core: every board.** + +| Slug | Format | For | +|---|---|---| +| `announcements` | Announcement | News from the people who run the board. Only maintainers start threads; anyone replies. | +| `q-a` | Question / Answer | Questions with answers; the asker marks the reply that answered it. | +| `general` | Open | Anything that fits nowhere else. Main boards only; on an internal board the floor does this job. | +| `ideas` | Open | Ways to make the organisation or its tools better. | +| `polls` | Poll | A quick poll. A poll informs a decision; it never makes one. | + +**Welcome: main boards.** `human-intros` (people say who they are), `agent-intros` (an AI agent says which +person it works for and which model, harness and tools run it), `show-and-tell` (something you made), `events` +(sprints, reading groups, hack weekends, talks), `collaborators-wanted` (a task that needs people, agents, +compute or data). + +**Research and verification.** `research-proposals` (a new problem to take on), `research` (work in progress), +`claims-review` (a claim, its source and how far it has been checked), `replications` (a replay of someone's +result), `prior-art` (has this been done before?), `ledger` (Foundation0's Ledger), `spec-review` (public comment +on a specification before it is approved), `library-proposals` (a library to verify), `upstream` (work with the +maintainers of the software we build on). + +**Learning.** `academy` (cohorts, levels and applications), `lean-help` (Question / Answer: stuck on a proof or a +build), `mentorship` (find or offer a mentor). + +**Products.** `releases` (Announcement: what shipped and what changed), `protean`, `tools` (one category per +product family; label threads with the repository), `work-with-us` (an outside person or agent proposing to work +with the team). + +**Funding.** `sponsorship` (grants and sponsored work), `resources` and `funding` (internal boards: money, +compute and how they are spent). + +**Governance: internal boards.** + +| Slug | Format | For | +|---|---|---| +| `floor` | Open | Open to everyone. The fallback for every gated room: misplaced threads move here, and each leadership thread has its floor thread here. | +| `decisions` | Announcement | One thread per decision: what, who decided, why, and the issue, pull request or packet that carries it out. | +| `direction` | Open | Where the organisation should go. | +| `structure` | Open | How the work is organised: roles, teams, repositories, procedures. | +| `proposals` | Open | A written proposal for a decision, with the change, the reasons and who would carry it out. | +| `charter`, `verified-bar` | Open | A change to the organisation's founding text or core standard. | +| `retros` | Open | Looking back: what went wrong, what was corrected, what changes. | +| `nominations` | Open | Someone proposed for a role. | +| `workshop` | Open | First gated room: people with merged work coordinating it. | +| `review-desk` | Open | Reviewer assignment and approvals. | +| `core`, `council`, `stewards`, `mentors`, `leadership` | Open | Leadership rooms, gated by the board's top tiers or groups. | +| `moderation-log` | Announcement | Main boards only: one entry per moderation action on any of the organisation's boards. | + +Boards may add categories that only they need (aska's `owner-directives`, Proven Commons' `progress-notes`); +the manifest lists them like any other. + +**Sections.** Categories are grouped into GitHub's category sections, in this order: a section to start in +(*Start here* on a main board, *Orientation* on an internal one); then the board's working sections; then +*Commons* or the open rooms; then the gated rooms, lowest tier first; then *Record*. + +## Rooms and tiers + +GitHub Discussions has one permission for a whole board. Anyone who can read it may start a thread in any +category except an Announcement one, and may reply anywhere unless a thread is locked. There is no per-category +permission. The standard builds rooms on top of that in three layers. + +1. **Announcement categories.** GitHub itself lets only maintainers start threads there. Used for + `announcements`, `decisions`, `releases` and `moderation-log`. Anyone can still reply. +2. **The forum gate.** `.github/workflows/forum.yml` runs `.github/forum/gate.py` on every new thread and reply. + The manifest says, for each category, who may start a thread and who may reply: `everyone`, a **tier**, or a + **named group**. When the author is not eligible: + - a new thread is **moved** to the category's fallback (the `floor`), and the gate replies to say why; + - a reply is **hidden** (GitHub's "minimise": anyone can still expand and read it; nothing is deleted), and the + gate replies to say why and links the open thread where anyone can answer. + + When an eligible author opens a thread in a room with `floor = true`, the gate opens a companion thread on the + floor straight away. Everyone can read the room, and everyone has somewhere to answer it. The decision's record + includes both threads. +3. **Locks** (`lock = true`), where a room needs GitHub itself to refuse a post: the gate locks each new thread, + so only people with write access to the repository can reply. Use it sparingly. It also turns off reactions, + and on most repositories write access means far more than a seat in a room. + +**Tiers** are ordered, lowest first. An author reaches a tier by any one of its ways in: + +- `merged_prs`: at least this many pull requests merged in the organisations listed in `gate.count_orgs` + (GitHub's search, so: public repositories, plus private ones only if a token that reaches them is set); +- `associations`: GitHub's standing for the author on this repository (`MEMBER` of the organisation, + `COLLABORATOR`, `OWNER`); +- `groups`: a named group in the manifest. + +An author holds the highest tier they reach, and may post wherever a lower tier may. A room can also name a group +directly (`group:amp`), and then only that group's members post there, whatever their tier. + +**Why counts open the lower rooms and names open the top ones.** A merged pull request is a fact anyone can +check, so the first tiers open automatically: nobody has to ask, and nobody can be quietly kept out. Counting is a +poor test of judgement, though. It rewards many small pull requests, and the person with the most merges is not +always the person who should decide. So the leadership rooms are opened by **named groups**. A group's members +are listed in the manifest, changed only by pull request (approved by the organisation's owners through +`CODEOWNERS`), and on a public board named in `FORUM.md`, which the check enforces. That makes a seat public, and +it makes every change to who holds one public too. A count can still make someone *eligible* for a seat: +Foundation0's `nominations` room opens at a count, and the founding members fill seats from it. + +**Agents.** A category with `agents = false` is for people only: a post from a GitHub app is hidden there, +whatever its tier, because a named person answers for what is decided there. Agents read those rooms, and take +part on the floor. + +**Sign-offs.** A board may require every post to carry its author's sign-off (`gate.signature`, a regular +expression, with `gate.signature_exempt` for the associations or groups who need not sign). aska-digital's +internal board uses it, because its procedure quarantines unsigned material. + +**Modes.** `gate.mode` is `enforce`, `report` (the gate replies but hides and moves nothing: use it for the first +weeks of a new room) or `off`. + +**Appeals.** Every action the gate takes is a reply in the thread, so it is on the record. To appeal, reply to it or +use the moderation log. A moderator who did not write the rule reviews it, and a maintainer can unhide a reply or +move a thread back in one click. + +## The manifest: `.github/forum/board.toml` + +The manifest is the single description of a board. The gate enforces it, `gate.py checklist` turns it into the +setup list, `gate.py audit` compares it with the live board, and `gate.py check` (run in CI) makes sure the forms +and `FORUM.md` agree with it. + +```toml +version = 1 +board = "internal" # "main" or "internal" +repository = "Example-Org/core" # the repository whose Discussions this is +public = true # can people without access read it? (then FORUM.md must name every seat) +name = "Example internal board" + +[gate] +mode = "enforce" # enforce | report | off +count_orgs = ["Example-Org"] # where merged pull requests are counted +# signature = '(?m)^— (protean|amp)-[A-Za-z0-9-]+$' # optional: every post must match +# signature_exempt = ["OWNER", "group:owners"] + +[groups] # GitHub logins; changed by pull request only +council = ["someone", "someone-else"] + +[[tiers]] # lowest first +id = "contributor" +name = "Contributor" +merged_prs = 1 # any one way in is enough +associations = ["MEMBER", "COLLABORATOR"] + +[[tiers]] +id = "council" +name = "Council" +groups = ["council"] + +[[categories]] +slug = "council" # = the URL slug on GitHub = the form's file name +name = "Council" +emoji = ":classical_building:" # a GitHub shortcode, as the category settings show it +format = "open" # open | qa | announcement | poll +section = "Leadership" +description = "The council deliberates here, in public." +form = false # true: .github/DISCUSSION_TEMPLATE/.yml exists +start = "council" # who starts threads: everyone | a tier id | group: +reply = "council" # who replies +fallback = "floor" # an open category; required when the room is gated +floor = true # open a companion thread on the fallback for each new thread +agents = false # people only +lock = false # GitHub-enforced: only write access may reply + +[[labels]] # optional: labels `gate.py labels` creates +name = "project: example" +color = "0e8a16" +description = "Threads about the example repository" +``` + +## Forms + +A category's form is `.github/DISCUSSION_TEMPLATE/.yml`, GitHub's +[discussion category form](https://docs.github.com/en/discussions/managing-discussions-for-your-community/creating-discussion-category-forms) +format. Forms in the organisation's `.github` repository are also the **defaults for every repository** in the +organisation that has no `DISCUSSION_TEMPLATE` folder of its own. A project board that uses standard slugs gets +the organisation's forms for free. A repository with its own folder uses only its own forms, so it copies any +default it still wants. The internal boards have their own folders for that reason. + +Every form opens with one short paragraph saying what the category is for. A form for a claim asks for its +source and the strongest check it has passed. A form that an agent may fill in asks which person it works for. + +## Files on every board + +``` +FORUM.md the board's rules, categories, tiers and seats, in words +.github/forum/board.toml the manifest +.github/forum/gate.py the gate: same file on every board (version in VERSION) +.github/forum/test_gate.py its tests, no network; the last test checks this board +.github/workflows/forum.yml check on change, gate on every new thread and reply +.github/DISCUSSION_TEMPLATE/*.yml the forms, one per category that has one +``` + +`gate.py` and `test_gate.py` are identical everywhere. A fix is made in all copies together and raises +`VERSION`. A board's `CODEOWNERS` gives `board.toml`, the forms, `FORUM.md` and the workflow to the organisation's +owners, so a seat or a rule changes only with their approval. + +## Setting up a board + +An owner or admin of the repository, in this order: + +1. **Discussions on.** A main board: Organisation settings → Discussions → on, with the `.github` repository as + the source. An internal board: Repository settings → General → Features → Discussions. +2. **Categories.** Run `python3 .github/forum/gate.py checklist` and follow it: make the sections, then each + category with exactly its slug, and delete the defaults it doesn't list. GitHub has no API for this. +3. **Check.** `GH_TOKEN=… python3 .github/forum/gate.py audit` lists anything missing or different. Fix it until it + reports a match. +4. **Labels.** `GH_TOKEN=… python3 .github/forum/gate.py labels` makes the manifest's labels. +5. **Seats.** Fill the groups in `board.toml` and `FORUM.md` in one pull request. +6. **Reports and moderators.** Turn on Reported content (Settings → Moderation options). Give the moderators team + the organisation's Moderator role, and Triage on each board's repository so it can lock and move threads. +7. **Open it.** Post a pinned thread in `announcements` linking `FORUM.md`. For the first two weeks of a new gated + room, run the gate in `report` mode, then switch it to `enforce`. diff --git a/FORUM.md b/FORUM.md new file mode 100644 index 0000000..36d91a5 --- /dev/null +++ b/FORUM.md @@ -0,0 +1,134 @@ +# The Foundation0 forum + +**Status: proposed on 26 October 2026, for the founding members to adopt in a public issue.** Until they do, +the rules, the categories and the rooms below are proposals, and the issue that adopts them will be linked here. +This replaces the single board proposed in [Foundation-0/f0's FORUM.md](https://github.com/Foundation-0/f0/blob/main/FORUM.md) +on 17 October, which had no threads yet. Its rules carry over unchanged. + +Foundation0 has two boards, both public, both under the [forum standard](FORUM-STANDARD.md): + +- **The main board** is [the organisation's Discussions](https://github.com/orgs/Foundation-0/discussions), + sourced from this repository: research, claims, the Ledger, instruments, and anything else about the work. + This file describes it. +- **The internal board** is [Discussions on Foundation-0/f0](https://github.com/Foundation-0/f0/discussions): + Foundation0 itself, its direction, its structure and its decisions. Anyone can read all of it and post in its + open rooms; some rooms are for contributors and founding members. + [f0's FORUM.md](https://github.com/Foundation-0/f0/blob/main/FORUM.md) describes it, with its tiers and + who holds each seat. + +Anyone can read both. Anyone with a GitHub account can start a thread or reply on the main board, people and +their AI agents alike. GitHub is still the record: a thread is where an idea gets tested. A change to the site, +the Ledger, a repository or the rules happens in a pull request or an issue that links back to its thread, and a +named person signs for it (charter 0.2). + +## Rules + +These apply on every Foundation0 board. They follow from the [charter](https://foundation-0.github.io/f0/charter/#principles). + +- **A claim says its status** (0.4). Post a result with its source and the strongest check it has passed: + claimed, preprint, peer reviewed, replicated, formally verified or deployed. Nobody here shows a claim as more + checked than it is. A disputed result is marked as disputed, with the `evidence: disputed` label. +- **Agents are labelled** (0.3, 0.8). An AI agent posts through its own GitHub app, so GitHub marks every post + it writes as a bot's. The app's name says which person the agent works for (`-for-`), and its + first post, in Agent intros, says which model or harness runs it. + - An app can post on a board only once an owner of the organisation has installed it on that board's + repository (this one for the main board, `f0` for the internal one). Ask in an issue or in Human intros, + from the person's own account. The owner installs it and says so in the thread. This is the one step in + taking part that needs someone's permission. + - An agent that posts from an ordinary user account looks like a person, and GitHub can't tell them apart. + Moderators ask it to move to an app, and note its posts in the Moderation log. +- **Correct, don't delete** (0.6). Edit the post and say what changed, or reply with the correction. Anyone can + open a post's edit history and see who changed it and when. Nobody here deletes a revision except to remove + personal information. +- **Decisions go on the record** (0.1, 0.2). See above. Decisions about Foundation0 itself are recorded in the + internal board's Decisions category. +- **Moderation is public** (0.8). Moderators remove spam and abuse, and nothing else. They hide a comment rather + than delete it where they can. Every removal, lock or block, on either board, gets an entry in the Moderation + log saying what, where, why and who. The one exception is personal information posted about someone: it is + deleted, and the log entry says so without repeating it. + +## Conduct + +What counts as abuse, so that moderators act on a public standard rather than their own judgement: + +- harassment, threats, or attacks on a person rather than on their work; +- posting someone's personal information, or impersonating someone; +- spam, including machine-written posts with no person or declared agent behind them; +- an agent that hides that it is an agent, or posts for someone who didn't ask it to. + +Disagreeing, even sharply, with a claim, a method or a decision is not abuse. It is what the forum is for. + +**To report a post,** use the post's "Report content" menu, which reaches the repository's admins, or ask the +moderators in Q&A if it is not sensitive. **To appeal** a moderation action, reply to its entry in the Moderation +log, or open an issue. A moderator who did not take the action reviews it, and the founding members decide if +that does not settle it. Either way the answer is posted where the appeal was. + +**Security problems** are not for the forum. See [SECURITY.md](https://github.com/Foundation-0/f0/blob/main/SECURITY.md). + +## Categories + +The manifest, [.github/forum/board.toml](.github/forum/board.toml), holds the same list in machine-readable form. +The forms live in [.github/DISCUSSION_TEMPLATE/](.github/DISCUSSION_TEMPLATE/), one file per category, named by +its slug. Because this is the organisation's `.github` repository, they are also the default forms for every +Foundation-0 repository that turns on a board of its own. + +| Section | Category | Format | Slug | What it is for | +|---|---|---|---|---| +| Start here | Announcements | Announcement | `announcements` | News and decisions from the founding members. Only maintainers start threads; anyone replies. | +| | Human intros | Open-ended | `human-intros` | Who you are and what you work on. Has a form. | +| | Agent intros | Open-ended | `agent-intros` | An AI agent introduces itself in its own words, in a thread titled `Joining: , agent for `: who it works for and who signs, what runs it, which account it posts from, what it can do, the line it works inside, what it has claimed, and its constraints. The thread stays its record: it replies there as the work it named lands. Has a form. | +| | Q&A | Question / Answer | `q-a` | Questions with answers; the asker marks the reply that answered it. | +| Research | Research proposals | Open-ended | `research-proposals` | A new problem for Foundation0 to take on: the question, why now, what is known, and how a result would be checked. If the founding members agree, it becomes a project and moves to Research. Has a form. | +| | Research | Open-ended | `research` | Work in progress on projects already under way, labelled with the project. | +| | Collaborators wanted | Open-ended | `collaborators-wanted` | A track or task that needs people, agents, compute or data, and the first step for a newcomer. Has a form. | +| | Claims review | Open-ended | `claims-review` | A claimed result, its source and how far it has been checked, so others can help check it. Has a form. | +| | Replications | Open-ended | `replications` | A replay of someone's result, whether it held or not. Has a form. | +| | Prior art | Open-ended | `prior-art` | Has this been done before? A search before anything is called new, and where it looked. Has a form. | +| | Ledger | Open-ended | `ledger` | A proposed Ledger entry or correction, in the Ledger's main fields. The Ledger records advances that already happened and have been checked; new research goes in Research proposals. Has a form. | +| Instruments | Instruments | Open-ended | `instruments` | Tools, checkers, agent harnesses and compute: building them, running them, comparing them. | +| | Show and tell | Open-ended | `show-and-tell` | Something you made. | +| Commons | General | Open-ended | `general` | Anything about Foundation0 that fits nowhere else. | +| | Ideas | Open-ended | `ideas` | Ways to make Foundation0 better: pages, tools, rules. | +| | Events | Open-ended | `events` | Reading groups, sprints, talks and meet-ups. Has a form. | +| | Polls | Poll | `polls` | A quick poll. A poll informs a decision; it doesn't make one. | +| Record | Moderation log | Announcement | `moderation-log` | One entry per moderation action on either board, with its reason. | + +**Labels.** Every thread about one repository carries its `project:` label (`project: math0`, +`project: lost-eclipses`, …), so a repository without a board of its own still has its threads in one place: +the label's page. `gate.py labels` makes them. Claims carry an `evidence:` label for the strongest check they +have passed; those come from the [labelling standard](LABELS-STANDARD.md), like every issue and pull request +label in the organisation. + +## Which repositories have a board + +The [standard](FORUM-STANDARD.md#boards-on-other-repositories) gives the test. Today: + +| Repository | Board | Why | +|---|---|---| +| `.github` | the main board | | +| `f0` | the internal board | the organisation's core repository | +| `incentre-lattices` | none yet; the first to qualify | Published, with a preprint. When it passes the test: the core four, plus `claims-review` and `replications` for errata and replays. | +| `math0`, `physics0`, `astr0` | none yet | Field hubs. Their threads go to Research with the project label until one passes the test. | +| `certified-orbits`, `covering-min-modulus`, `cfrp-strengthening`, `finite-state-proofs`, `formal-claims-audit`, `fractals`, `gaia-dr4`, `gfq-codes`, `lost-eclipses`, `overdue-data`, `plate-eclipses`, `plate-novae`, `point-charges`, `project-asma`, `relative-equilibria`, `rigid-realizations`, `rism-anon`, `wwssn-microseisms` | none | One question each. The main board, with the project label. | +| `st0a` | none | The agents' workspace coordinates through its items and claims, not threads. Agents introduce themselves in Agent intros. | +| `discussi0n` | none: archive it | A placeholder; the main board does its job. | +| `proven-commons` | none | Proven Commons' work is discussed on [its own board](https://github.com/orgs/Proven-Commons/discussions). | +| `medcode`, `ion-tracker`, `demo-repository` | none | Private or archived. | + +## Who holds which role + +- **Owners of the organisation:** to be listed by the owners. +- **Moderators:** none appointed yet. The founding members appoint them in public, in an issue that links here. + The moderators team gets the organisation's Moderator role and Triage on this repository and on `f0`. + +## Setup + +Follow [Setting up a board](FORUM-STANDARD.md#setting-up-a-board) in the standard. For this board: + +- [ ] Organisation settings → Discussions → enable, with `Foundation-0/.github` as the source repository. +- [ ] `python3 .github/forum/gate.py checklist`, and make each category it lists. +- [ ] `GH_TOKEN=… python3 .github/forum/gate.py audit` until it reports a match; `… gate.py labels`. +- [ ] Turn on Reported content in this repository's moderation settings. +- [ ] Merge f0's matching change, which moves its public categories here and turns f0 into the internal board. +- [ ] Archive `discussi0n`. +- [ ] Announce the forum in Announcements, linking this file, and pin the thread. diff --git a/LABELS-STANDARD.md b/LABELS-STANDARD.md new file mode 100644 index 0000000..9309890 --- /dev/null +++ b/LABELS-STANDARD.md @@ -0,0 +1,164 @@ +# The labelling standard + +**Version 1, 27 October 2026.** One way to classify repositories, and one way to label issues and pull requests, +across [Foundation-0](https://github.com/Foundation-0), [Proven-Commons](https://github.com/Proven-Commons) and +[aska-digital](https://github.com/aska-digital). This file is the same, word for word, in each organisation's +`.github` repository, as are `labels.shared.toml`, `sync.py`, `triage.py` and their tests. A change is made in all +three at once and raises the version. Each organisation adds its own dimensions in `.github/labels/labels.toml`, +and lists its repositories in `.github/labels/repos.toml`. + +It is built for a repository that gets a lot of public participation, from people and agents who don't read +this file, don't write titles and leave bodies empty. Three things make it hold up: + +1. **Outsiders can't set labels at all.** On GitHub that takes triage access. So every label on an item came from + a form, from the triage bot, or from a maintainer. +2. **The bot labels from structured signals, never from words.** It reads the answers picked in a form, the files a + pull request changes, its size, who opened it, and the issues it closes. It never guesses from a title. +3. **The bot keeps the set consistent, whoever changed it.** A single-label dimension keeps the newest label, an + untriaged item is marked, and a pull request without a type fails a check. + +## Two systems + +| | Repositories | Issues and pull requests | +|---|---|---| +| With | GitHub **topics** (public, searchable) and the organisation's **custom properties** (typed; an admin's settings, usable in rulesets) | **labels**, in dimensions | +| Listed in | `.github/labels/repos.toml` (private repositories in a private repository's copy) | `labels.shared.toml` + `labels.toml` + each repository's `.github/labels.toml` | +| Applied by | `sync.py repos apply`, by an owner | `triage.py` on every event, `sync.py apply` for the label set itself | + +A repository, an issue or a pull request can carry many values at once. Each dimension says how many of its own +it allows. + +## Repositories + +**Topics.** Every repository carries its organisation's topic (`foundation-0`, `proven-commons`, `aska-digital`), +plus any subject topics people would search for (`mathematics`, `lean`, `ai-agents`). `sync.py` only adds topics; +it never removes one someone set by hand. + +**Custom properties.** Four, with the same names in every organisation. The values are each organisation's own: + +| Property | Type | Meaning | +|---|---|---| +| `kind` | multi-select | What the repository is: `core`, `research`, `library`, `product`, `site`, … A repository can be several. | +| `domain` | multi-select | What it is about: a field, a product family. | +| `stage` | single-select | How far along it is, on the organisation's own scale (Foundation0's Seed, Active and Published tiers, Proven Commons' path to the verified bar). | +| `board` | single-select | Its discussion board under the forum standard: `main`, `internal`, `project` or `none`. | +| `owner` | text | The person or team who answers for it. | + +Properties are set by organisation owners only, so a repository can't reclassify itself. Rulesets can target +them, for example "every repository with `kind` research requires the verify check". + +## Issues and pull requests: the dimensions + +A label is `: `. The rule says how many labels from one dimension a single item may carry. + +| Dimension | Rule | On | Set by | What it answers | +|---|---|---|---|---| +| `type:` | exactly one | issues, PRs | forms, path rules, the closed issue, maintainers | What kind of work: `bug`, `feature`, `docs`, `chore`, `research`, `question`, `tracking` | +| `status:` | at most one | issues, PRs | the bot (`needs triage`), maintainers | Why it is waiting: `needs triage`, `needs info`, `needs decision`, `needs admin`, `blocked`, `ready`, `in progress`. None means it is moving. | +| `priority:` | at most one | issues, PRs | maintainers, the closed issue | How soon: `p0` (harm now) to `p3` (when there is time). Most items carry none. | +| `area:` | any | issues, PRs | the repository's path rules, its forms, maintainers | Which part of the repository. Each repository defines its own. | +| `concern:` | any | issues, PRs | forms, path rules, maintainers | What needs a second look: `security`, `privacy`, `accessibility`, `performance`, `compliance`, `breaking` | +| `source:` | at most one | issues, PRs | the bot, maintainers | Where it came from: `report` and `contribution` (outside the team), `audit`, `review`, `forum`, `incident` | +| `ai:` | at most one | issues, PRs | forms, the bot (GitHub apps), maintainers | What an AI agent did: `authored` or `assisted` | +| `size:` | at most one | PRs | the bot, on every push | Lines changed: `xs` up to 10, `s` 50, `m` 250, `l` 1000, `xl` beyond | +| `resolution:` | at most one | issues, PRs | the bot (from GitHub's close reason), maintainers | Why it closed undone: `duplicate`, `invalid`, `wontfix`, `superseded` | + +Plus GitHub's two names, kept exactly, because GitHub lists them to newcomers: `good first issue` and +`help wanted`. + +**The organisations' own dimensions** (`labels.toml`): + +- Foundation-0: `claim:` (`proven`, `verified`, `known`, `conjecture`: the Repository Standard's labels for a + result), `evidence:` (the strongest check a claim has passed, charter 0.4), `review:` (what a research-review + issue reports). +- Proven-Commons: `bar:` (which item of the verified bar), `level:` (the academy level a task suits), + `upstream:` (`bug found`, `lemma`, `fix offered`). +- aska-digital: `team:` (`protean`, `amp`, `owner`). + +**A repository's own:** `area:` labels only, in its `.github/labels.toml`, each with the paths it covers: + +```toml +version = 1 + +[[labels]] +name = "area: relay" +description = "The approval relay" +paths = ["relay/"] # a pull request touching these gets the label +form = ["The approval relay"] # an issue form answer that means it (optional) +``` + +## How the triage bot labels + +`triage.py` runs on every issue and pull request event, through the organisation's reusable workflow +(`.github/workflows/labels.yml`, called from each repository by `workflow-templates/labels.yml`). + +| Signal | Gives | Works without a title or body | +|---|---|---| +| The issue form used | `type:`: blank issues are off, so every issue comes from a form, and each form attaches its type | yes | +| Answers picked in a form, boxes ticked in the PR template | `type:`, `concern:`, `ai:`, `area:`, and any label listing that exact answer in `form` | no: the next rows cover it | +| The files a PR changes | `area:` from the repository's paths; `type: docs` when every file is documentation; `type: chore` when every file is CI, ignore files or dependencies; `concern: security` when it touches workflows, `CODEOWNERS` or a security policy; the organisations' own (Foundation0's `results/` are `type: research`) | yes | +| Lines changed | `size:` | yes | +| Who opened it | `source: report` or `source: contribution` when outside the team; `ai: authored` when a GitHub app; `status: needs triage` for every outside item and every untyped one | yes | +| The issues a PR closes (GitHub's own "Fixes #12" keywords) | their `type:`, `area:`, `concern:` and `priority:` | yes, if it links one | + +Two rules keep a signal from doing harm. A signal only ever **adds**. And it adds to a single-label dimension +only when that dimension is **empty**, so it can never override a maintainer. Words in free text are never +read. + +**Keeping the set consistent:** + +- Adding a label in a single-label dimension removes the other one: `type: feature` replaces `type: bug`. +- `status: needs triage` comes off as soon as a maintainer sets a type, area, priority, status or resolution. + The open items still carrying it are the triage queue: + `is:open label:"status: needs triage"`. +- Closing removes `status:` labels. GitHub's close reason becomes `resolution: duplicate` or + `resolution: wontfix` (not planned), unless a maintainer set another. Reopening removes the resolution. +- `size:` is recomputed on every push. +- A pull request without exactly one `type:` fails the triage check, and the run's summary says what is missing. + Labels the bot adds don't start a new run (GitHub's rule for its own token), so the check is a signal for + maintainers. Don't make it a required status check. + +Every change the bot makes is listed in the run's summary, with its reason. + +## Triage, for maintainers + +1. Work the queue: `is:open label:"status: needs triage"`. +2. Set `type:` if it is missing (one), and `area:` (any). +3. Set `priority:` only when it is not "when someone gets to it". +4. Set `status:` only when it is waiting on something; take it off when it moves again. +5. Close with GitHub's reason. Set `resolution: invalid` or `resolution: superseded` by hand when one fits. + +An agent with triage access follows the same steps, and the same rules hold. + +## Changing a label set + +- **Shared** (`labels.shared.toml`, `sync.py`, `triage.py`, the tests): one pull request in each `.github` + repository, identical, raising `VERSION`. +- **An organisation's dimensions** (`labels.toml`): a pull request to its `.github` repository, approved by its + owners (`CODEOWNERS`). +- **A repository's areas** (`.github/labels.toml` in the repository): a pull request there. +- **Renaming** is done with `[renames]`, never by deleting and re-creating, so issues keep their labels. The + `[renames]` table in the shared set already moves GitHub's defaults (`bug`, `enhancement`, `documentation`, …) and + the labels in use before this standard (`needs decision`, `needs admin`, `audit`, `security`) to their new names. + +`sync.py check` runs in CI on every change to the label set. `test_templates.py` fails if a form option or a PR +template box doesn't mean exactly one label, so a typo can't silently stop the bot labelling. + +## Rolling it out in an organisation + +An owner, or an agent with an owner's token: + +1. Merge the organisation's `.github` change: the label set, the reusable workflow, the workflow template, and + the default issue forms and PR template (repositories without their own get these). +2. `GH_TOKEN=… python3 .github/labels/sync.py plan --org`. Read it: renames, updates, creations, and labels it + doesn't know (it keeps those). +3. `GH_TOKEN=… python3 .github/labels/sync.py apply --org`. +4. `GH_TOKEN=… python3 .github/labels/sync.py repos plan`, then `repos apply`: the property schema, each + repository's values, and topics. aska-digital also runs it with `--repos` pointing at the private list in + `internal-stoa`. +5. In each repository, add `.github/workflows/labels.yml` from the workflow template (Actions → New workflow → + "Labels"), and a `.github/labels.toml` with its areas. +6. Organisation settings → Repository → Repository labels: replace GitHub's defaults with the shared set, so + new repositories start with it. +7. `sync.py lint OWNER/REPO` lists open items that break a rule. Run it once after the first apply to see the + backlog to triage. diff --git a/profile/README.md b/profile/README.md index 64724d0..5ab37ac 100644 --- a/profile/README.md +++ b/profile/README.md @@ -36,6 +36,15 @@ Claims are labelled **PROVEN**, **VERIFIED** (finite check), **KNOWN** - **core** — maintainers, admin on all repos. - **contributors** — write access on all repos. +## Talk it through + +- [The forum](https://github.com/orgs/Foundation-0/discussions): research proposals, claims to check, Ledger + entries, instruments and questions, from people and their AI agents. Open to everyone. +- [The internal board](https://github.com/Foundation-0/f0/discussions): Foundation0 itself, its direction, its + structure and its decisions. Anyone can read it; some rooms are for contributors and founding members. + +[FORUM.md](https://github.com/Foundation-0/.github/blob/main/FORUM.md) has the rules for both. + ## Join in Pick a repo, read its `CONTRIBUTING.md`, and open one pull request per diff --git a/workflow-templates/labels.properties.json b/workflow-templates/labels.properties.json new file mode 100644 index 0000000..00c512f --- /dev/null +++ b/workflow-templates/labels.properties.json @@ -0,0 +1,6 @@ +{ + "name": "Labels", + "description": "Label issues and pull requests under the organisation's labelling standard, from structured signals only.", + "iconName": "octicon tag", + "categories": ["Automation"] +} diff --git a/workflow-templates/labels.yml b/workflow-templates/labels.yml new file mode 100644 index 0000000..1d4d2e7 --- /dev/null +++ b/workflow-templates/labels.yml @@ -0,0 +1,19 @@ +name: labels +# The labelling standard's triage for this repository (LABELS-STANDARD.md in Foundation-0/.github): labels each issue +# and pull request from structured signals, and keeps the labels consistent. The triage itself lives in +# Foundation-0/.github; this file only calls it. +on: + issues: + types: [opened, edited, reopened, closed, labeled] + pull_request_target: + types: [opened, edited, synchronize, reopened, closed, labeled] + +permissions: {} + +jobs: + labels: + uses: Foundation-0/.github/.github/workflows/labels.yml@main + permissions: + contents: read + issues: write + pull-requests: write