diff --git a/changelog.d/added-workflowmodel-ingest-intake.md b/changelog.d/added-workflowmodel-ingest-intake.md new file mode 100644 index 0000000000..8bcd8b9171 --- /dev/null +++ b/changelog.d/added-workflowmodel-ingest-intake.md @@ -0,0 +1 @@ +- **workflow models:** ingesting a repository now proposes its *intake half* alongside its pipelines: issue templates become `requested-` intake states and events, a triage label (from templates, `settings.yml`, `labels.yml`, `labeler.yml` or `CONTRIBUTING.md`) becomes a `triage` state and gate, PR-template checklist items become manual checks, `CODEOWNERS`/Prow `OWNERS` become `approver`/`reviewer` roles owning the review gate, a `MAINTAINERS` file's `@handles` become the `maintainer` role's members, `kind/*`/`priority/*`/`area/*` labels are carried as classification rows, and the default branch's protection or rulesets (fetched when ingesting by `repo`, or a probot `settings.yml`) become required CI checks, `review.minApprovals`, a `codeowner-review` gate and bookkeeping notes. Everything is `inferred` and cites `path:line` or the API endpoint; ingesting `cncf/techdocs` now proposes its triage state. Trees without these inputs produce the byte-identical model as before; `workflow-survey` counts the proposals per repository ([#17620](https://github.com/hivecommons/hive/issues/17620)) diff --git a/src/cmd/hive/workflow_ingest.go b/src/cmd/hive/workflow_ingest.go index 00f80f6d45..391e49e2d7 100644 --- a/src/cmd/hive/workflow_ingest.go +++ b/src/cmd/hive/workflow_ingest.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "errors" + "log" "github.com/hivecommons/hive/pkg/github" "github.com/hivecommons/hive/pkg/workflowmodel/ingest" @@ -14,9 +16,11 @@ var errNoForgeClient = errors.New("no GitHub client") // workflowRepoFiles is the dashboard's WorkflowRepoFiles dependency // (POST /api/workflows/ingest, RFC #17620 P3): it downloads the repo's -// default branch read-only, keeps only the CI definition files and -// removes the checkout. client is read per call so a credential rebuild is -// followed. +// default branch read-only, keeps only the CI definition and process +// convention files and removes the checkout. The default branch's rules +// (protection or rulesets, P5) ride along at ingest.BranchRulesPath when +// the forge reports any; a failure to read them is logged, not fatal. +// client is read per call so a credential rebuild is followed. func workflowRepoFiles(client func() *github.Client) func(ctx context.Context, repo string) (map[string][]byte, string, error) { return func(ctx context.Context, repo string) (map[string][]byte, string, error) { c := client() @@ -32,6 +36,14 @@ func workflowRepoFiles(client func() *github.Client) func(ctx context.Context, r if err != nil { return nil, "", err } + switch rules, err := c.FetchBranchRules(ctx, repo, co.Ref); { + case err != nil: + log.Printf("workflow ingest: %s branch rules: %v", repo, err) + case rules != nil: + if b, err := json.Marshal(rules); err == nil { + files[ingest.BranchRulesPath] = b + } + } return files, co.Ref, nil } } diff --git a/src/cmd/workflow-survey/main.go b/src/cmd/workflow-survey/main.go index 31ad3e2752..50603b28e6 100644 --- a/src/cmd/workflow-survey/main.go +++ b/src/cmd/workflow-survey/main.go @@ -74,6 +74,21 @@ type repoResult struct { CompileError string `json:"compileError,omitempty"` CompileCodes map[string]int `json:"compileCodes,omitempty"` LifecycleCols int `json:"lifecycleColumns"` + // Intake is what the P5 intake half proposed from the repository's + // process conventions (issue/PR templates, OWNERS, branch rules). + Intake intakeStats `json:"intake"` +} + +// intakeStats counts the intake-half proposals in one lifecycle model. +type intakeStats struct { + IntakeStates int `json:"intakeStates"` + Triage bool `json:"triage"` + MemberRoles int `json:"memberRoles"` + InferredGates int `json:"inferredGates"` + ManualChecks int `json:"manualChecks"` + CIChecks int `json:"ciChecks"` + BranchRules bool `json:"branchRules"` + IntakeFindings int `json:"intakeFindings"` } type summary struct { @@ -92,10 +107,22 @@ type summary struct { Severities map[workflowmodel.Severity]int `json:"severities"` ColumnSets map[string]int `json:"columnSets"` ReposChangedCols int `json:"reposWithChangedColumns"` - ByProject map[string]*projectStats `json:"byProject"` - Worst []repoResult `json:"worst"` - Top []repoResult `json:"top"` - Repos_ []repoResult `json:"results"` + // Intake counts repos where the intake half proposed each element. + Intake intakeSummary `json:"intake"` + ByProject map[string]*projectStats `json:"byProject"` + Worst []repoResult `json:"worst"` + Top []repoResult `json:"top"` + Repos_ []repoResult `json:"results"` +} + +// intakeSummary counts repositories, not elements. +type intakeSummary struct { + ReposWithIntakeStates int `json:"reposWithIntakeStates"` + ReposWithTriage int `json:"reposWithTriage"` + ReposWithRoles int `json:"reposWithRoles"` + ReposWithGates int `json:"reposWithGates"` + ReposWithChecks int `json:"reposWithChecks"` + ReposWithBranchRules int `json:"reposWithBranchRules"` } type projectStats struct { @@ -272,6 +299,7 @@ func surveyRepo(dir, repo, outDir string) (repoResult, error) { } else { res.Compiled = true } + res.Intake = countIntake(lm) if err := os.MkdirAll(outDir, 0o755); err != nil { return res, err @@ -296,6 +324,50 @@ func surveyRepo(dir, repo, outDir string) (repoResult, error) { return res, writeJSON(filepath.Join(outDir, "result.json"), res) } +// countIntake tallies the intake-half proposals in a lifecycle model. +func countIntake(m *workflowmodel.Model) intakeStats { + var st intakeStats + for _, s := range m.Spec.States { + switch { + case s.ID == ingest.StateTriage: + st.Triage = true + case strings.HasPrefix(s.ID, ingest.StateRequestedPrefix): + st.IntakeStates++ + } + } + for _, r := range m.Spec.Roles { + if r.Kind == workflowmodel.RoleMembers { + st.MemberRoles++ + } + } + for _, g := range m.Spec.Gates { + if g.Confidence == workflowmodel.ConfidenceInferred { + st.InferredGates++ + } + } + for _, c := range m.Spec.Checks { + switch c.Kind { + case workflowmodel.CheckManual: + st.ManualChecks++ + case workflowmodel.CheckCI: + st.CIChecks++ + st.BranchRules = true + } + } + for _, g := range m.Spec.Gates { + if g.ID == ingest.GateCodeownerReview || (g.ID == ingest.GateReview && g.MinApprovals > 0) { + st.BranchRules = true + } + } + st.BranchRules = st.BranchRules || len(m.Spec.Bookkeeping) > 0 + for _, f := range m.Spec.Coverage.Findings { + if strings.HasPrefix(f.Code, "issue-template-") { + st.IntakeFindings++ + } + } + return st +} + func countConstructs(c map[string]int, p workflowmodel.Pipeline) { c["pipelines"]++ if p.Concurrency != nil { @@ -357,6 +429,24 @@ func accumulate(s *summary, r repoResult) { if len(r.ColumnDiff) > 0 { s.ReposChangedCols++ } + if r.Intake.IntakeStates > 0 { + s.Intake.ReposWithIntakeStates++ + } + if r.Intake.Triage { + s.Intake.ReposWithTriage++ + } + if r.Intake.MemberRoles > 0 { + s.Intake.ReposWithRoles++ + } + if r.Intake.InferredGates > 0 { + s.Intake.ReposWithGates++ + } + if r.Intake.ManualChecks+r.Intake.CIChecks > 0 { + s.Intake.ReposWithChecks++ + } + if r.Intake.BranchRules { + s.Intake.ReposWithBranchRules++ + } for k, v := range r.Roles { s.Roles[k] += v } @@ -411,6 +501,8 @@ func markdown(s *summary) string { fmt.Fprintf(&b, "| %s | %d |\n", k, s.CompileCodes[k]) } b.WriteString("\n") + fmt.Fprintf(&b, "## Intake proposals (RFC phase 5)\n\n| Proposed | Repos |\n|---|---|\n| Intake states (issue templates) | %d |\n| Triage state | %d |\n| Member roles (CODEOWNERS/OWNERS) | %d |\n| Inferred gates | %d |\n| Checks (PR template / required status) | %d |\n| Branch rules | %d |\n\n", + s.Intake.ReposWithIntakeStates, s.Intake.ReposWithTriage, s.Intake.ReposWithRoles, s.Intake.ReposWithGates, s.Intake.ReposWithChecks, s.Intake.ReposWithBranchRules) b.WriteString("## Pipeline roles\n\n| Role | Pipelines |\n|---|---|\n") for _, k := range sortedKeys(s.Roles) { fmt.Fprintf(&b, "| %s | %d |\n", k, s.Roles[workflowmodel.Role(k)]) diff --git a/src/cmd/workflow-survey/main_test.go b/src/cmd/workflow-survey/main_test.go index 59d40c9f32..6703b3fbc6 100644 --- a/src/cmd/workflow-survey/main_test.go +++ b/src/cmd/workflow-survey/main_test.go @@ -44,6 +44,15 @@ func buildCorpus(t *testing.T) string { if n == 0 { t.Fatalf("no fixture repositories under %s", fixtureCorpus) } + // One repo with process conventions but no pipelines exercises the + // intake counts (RFC phase 5). + intake, err := filepath.Abs(filepath.Join("..", "..", "pkg", "workflowmodel", "ingest", "testdata", "k8s-owners")) + if err != nil { + t.Fatal(err) + } + if err := os.CopyFS(filepath.Join(repos, "example__k8s-owners"), os.DirFS(intake)); err != nil { + t.Fatal(err) + } // A stray file in repos/ must be ignored, as must a repo not in projects.json. if err := os.WriteFile(filepath.Join(repos, "README.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) @@ -82,8 +91,19 @@ func TestRealMainSurveysCorpus(t *testing.T) { if err := json.Unmarshal(raw, &sum); err != nil { t.Fatal(err) } - if sum.Repos != 6 || sum.Valid != sum.Repos { - t.Errorf("repos=%d valid=%d, want 6 valid", sum.Repos, sum.Valid) + if sum.Repos != 7 || sum.Valid != sum.Repos { + t.Errorf("repos=%d valid=%d, want 7 valid", sum.Repos, sum.Valid) + } + if sum.Compiled != sum.Repos { + t.Errorf("compiled=%d of %d", sum.Compiled, sum.Repos) + } + if in := sum.Intake; in.ReposWithIntakeStates != 1 || in.ReposWithTriage != 1 || in.ReposWithRoles != 1 || in.ReposWithGates != 1 || in.ReposWithChecks != 1 || in.ReposWithBranchRules != 1 { + t.Errorf("intake summary %+v, want exactly the k8s-owners repo", in) + } + for _, r := range sum.Repos_ { + if r.Repo == "example/k8s-owners" && (r.Intake.IntakeStates != 3 || !r.Intake.Triage || r.Intake.MemberRoles != 2 || r.Intake.ManualChecks != 4 || r.Intake.CIChecks != 3 || r.Intake.IntakeFindings != 1) { + t.Errorf("k8s-owners intake %+v", r.Intake) + } } if sum.ReposWithGHA == 0 || sum.ReposWithTekton == 0 { t.Errorf("expected both systems in the corpus: gha=%d tekton=%d", sum.ReposWithGHA, sum.ReposWithTekton) @@ -94,7 +114,7 @@ func TestRealMainSurveysCorpus(t *testing.T) { if got := sum.ByProject["graduated"]; got == nil || got.Repos != 1 { t.Errorf("projects.json maturity join (case-insensitive) not applied: %+v", sum.ByProject) } - if got := sum.ByProject["unknown"]; got == nil || got.Repos != 4 { + if got := sum.ByProject["unknown"]; got == nil || got.Repos != 5 { t.Errorf("repos without a maturity should land in unknown: %+v", sum.ByProject) } if len(sum.Worst) != sum.Repos || len(sum.Top) != sum.Repos { @@ -110,7 +130,7 @@ func TestRealMainSurveysCorpus(t *testing.T) { if err != nil { t.Fatal(err) } - for _, want := range []string{"# Workflow model survey", "## Pipeline roles", "## Column sets (Activity Monitor)", "| graduated | 1 |", "trigger:pull_request"} { + for _, want := range []string{"# Workflow model survey", "## Pipeline roles", "## Column sets (Activity Monitor)", "## Intake proposals (RFC phase 5)", "| Triage state | 1 |", "| graduated | 1 |", "trigger:pull_request"} { if !strings.Contains(string(md), want) { t.Errorf("summary.md missing %q", want) } diff --git a/src/docs/design/workflow-models.md b/src/docs/design/workflow-models.md index a01e3fea74..9a08e4a526 100644 --- a/src/docs/design/workflow-models.md +++ b/src/docs/design/workflow-models.md @@ -331,3 +331,66 @@ exactly the pre-P4 path: one list, the built-in family, no model labels. phase 4 and follows the diagram editor. - A model without a `labels` block regenerates nothing (TechDocs as shipped); the preview still shows its commands and the built-in stages its numbered states land on. + +## Ingest: intake, triage, roles and branch rules (RFC phase 5) + +`ingest.Lifecycle` now reads the repository's process conventions next to its pipelines and adds +an *intake half* to the draft: how work enters, who triages it, who approves a change and what the +forge requires before merge. `ReadTree` keeps the files below in addition to the CI definitions +(`ingest.IsIntakePath`); the dashboard seam (`WorkflowRepoFiles`) is unchanged. Everything this +half adds is `confidence: inferred` and cites where it came from; the compiler warns once per +element and a human confirms them in the editor exactly as for the pipeline half. Trees with none +of these inputs produce the byte-identical model as before (`TestLifecyclePipelineOnlyUnchanged`). + +### Heuristics + +| Input | Read as | Proposes | +|---|---|---| +| `.github/ISSUE_TEMPLATE/*.{yml,yaml}` (issue forms), `*.md` and `.github/ISSUE_TEMPLATE.md` (front matter) | `name`, `labels` (list or comma string) | One `requested-` state per distinct label set in an **Intake** column after Open; event `issue-opened-with-template:` = `labels has ` (the template's first label that is not a triage label and not already bound); transitions open→requested, requested→triage (or →actionable when no triage label exists) and requested→closed; a `labels.entries` row per template label pointing at the state. | +| `.github/ISSUE_TEMPLATE/config.yml` | `blank_issues_enabled` | Annotation `workflow.hivecommons.dev/blank-issues: disabled` when false (every issue arrives through a template). | +| Labels seen in templates, `.github/settings.yml` (probot `labels:`), `.github/labels.yml` (sequence, `labels:` wrapper or mapping), `.github/labeler.yml` (top-level keys), `CONTRIBUTING.md` (backticked labels, or hyphen/slash labels at word boundaries) | Triage vocabulary: `needs-triage`/`triage/needed`/`status/needs-triage`/…; `triage/accepted`/`triage-accepted`/`accepted`; `triage/declined`/`wontfix`/`invalid`/`not-a-bug`; conventions `good first issue`, `help wanted` | A `triage` state in a **Triage** column after Intake; events `triage-requested` (`labels has `), `triage-accepted` (`labels has `, or `labels lacks ` when there is no accepted label), `triage-declined`; gate `triage-decision` released by `approver` when one exists, else `maintainer`, with reply `/label `; transitions open→triage, triage→actionable (gated), triage→closed; label rows `needs → triage`, `accepted → actionable`, `declined → closed`; convention labels as plain rows. | +| PR template (`.github/PULL_REQUEST_TEMPLATE.md`, `PULL_REQUEST_TEMPLATE.md`, `docs/…`, `.github/PULL_REQUEST_TEMPLATE/*.md`) | `- [ ]` / `* [ ]` / `[x]` items | `checks` entries `pr-template-` of kind `manual`, `source: path:line`. Duplicates by id are dropped. | +| `CODEOWNERS` (root, `.github/`, `docs/`) | Every owner on every rule (users and `org/team`) | Role `approver` of kind `members` (params `members`, `source: path:line,…`). | +| root `OWNERS`, `OWNERS_ALIASES` (Prow) | `approvers`, `reviewers`, `filters[".*"]`, aliases | Roles `approver` and `reviewer`; the review gate's reply is `/approve`. | +| `MAINTAINERS`, `MAINTAINERS.md` (root, `.github/`, `docs/`) | `@handle` and `github.com/handle` spellings (plain list, markdown table or prose; names and e-mail addresses are not identities) | The hive-default permission-based `maintainer` role becomes kind `members` listing those handles (`source: path:line`); when no `approver` role exists, the review gate is released by `maintainer`. | +| `kind/*`, `priority/*`, `type/*`, `area/*`, `severity/*`, `size/*` labels declared in `labels.yml`, `settings.yml`, `labeler.yml`, templates or backticked in `CONTRIBUTING.md` | Classification vocabulary triage applies | Plain `labels.entries` rows (no state), sorted by name, so the triage gate's vocabulary is visible in the editor. | +| Any `approver` role | | Gate `review` released by `approver` (condition `pr.review is approved`) attached to the hive-default edges from CI / Review into the admitted Merge-queue states (`awaiting-merge-admission`, `ready-for-human-merge`, `pre-merge-soak`, `merge-queue`), so the compiler's "gate with no owner role" refusal is satisfied by a real role. | +| Branch rules: `GET /repos/{o}/{r}/branches/{b}/protection`, falling back to `GET /repos/{o}/{r}/rules/branches/{b}` (repository and organisation rulesets); or probot `.github/settings.yml` `branches[].protection` when the API gave nothing | required status checks, required approving reviews, code-owner review, dismiss stale reviews, enforce admins, linear history, signed commits | `checks` entries `check-` of kind `ci`, `required: true`; guard `required-checks` (params `contexts`) on the same merge edges; `review.minApprovals: N`; gate `codeowner-review`; bookkeeping effects on `merged` (`enforce-admins`, `require-linear-history`, `require-signed-commits`, `dismiss-stale-reviews`) with the source in their params. | + +The forge half arrives as a synthetic file at `ingest.BranchRulesPath` (`.hive/branch-rules.json`, +`ingest.BranchRules`): `cmd/hive`'s `workflowRepoFiles` fills it through `Client.FetchBranchRules` +when ingesting by `repo`, so pasted `files` and the survey corpus can carry the same reading. +`ingest.LifecycleOptions` accepts the rules explicitly, overriding the file. + +### Citations + +Every element cites `path:line` for file-derived facts (`Citation.Path`/`Line`; `checks[].source`, +`guards[].source`, `roles[].params.source` and `bookkeeping[].params.source` carry the same +`path:line` string) and `api:` — for example `api:repos/cncf/techdocs/rules/branches/main` +— for facts read from the forge. `meta.source` becomes `issue-templates` (or +`github-actions+issue-templates`) once a template was read. + +### What `cncf/techdocs` proposes + +With its current `main` (no issue templates yet; `.github/settings.yml` declares `needs-triage`, +`triage/accepted`, `triage/declined`, `good first issue`, `help wanted`; the org ruleset requires no +checks or approvals): columns `open · triage · actionable · claimed · pr-in-progress · ci-review · +merge-queue · merged`, the `triage` state citing `.github/settings.yml:129/132/135`, the three +triage events, the `triage-decision` gate released by `maintainer`, and the five label rows. The +intake form on the pending `build/03-intake` branch (`assessment-request.yml`, labels `Docs analysis` ++ `needs-triage`) adds `intake[requested-documentation-assessment-request]` before Triage — the +fixture under `pkg/workflowmodel/ingest/testdata/techdocs` carries both. + +### Limitations + +- Heuristics prefer precision: a template without labels, or whose labels are all triage labels or + already bound to another intake state, yields a `issue-template-no-labels` / `issue-template-merged` + finding rather than a state. Label names are matched from a fixed table; a repository's own + triage word (`status: new`) is not recognised. +- Only the root `OWNERS` is read (nested owners files scope by path, which the model does not + express); CODEOWNERS owners are all `approver`, since the file has no reviewer notion. +- `codeowner-review` is informational: the engine does not observe which files a PR touches. +- The engine still derives through the built-in stage table; nothing lands in Intake or Triage + until the label-driven engine half of phase 4 writes model labels onto items. +- Branch protection needs `administration:read`; without it the rulesets endpoint is used, which + omits legacy protection settings such as `enforce_admins`. diff --git a/src/pkg/github/branch_rules.go b/src/pkg/github/branch_rules.go new file mode 100644 index 0000000000..e0a9288e07 --- /dev/null +++ b/src/pkg/github/branch_rules.go @@ -0,0 +1,108 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strings" + + gh "github.com/google/go-github/v72/github" + + "github.com/hivecommons/hive/pkg/workflowmodel/ingest" +) + +// FetchBranchRules reads branch's merge requirements for repo +// ("owner/name") for the workflow-model ingest (hivecommons/hive#17620 +// phase 5): GET /repos/{o}/{r}/branches/{b}/protection first (needs +// administration:read; 404 for an unprotected branch or a token without +// that scope), then GET /repos/{o}/{r}/rules/branches/{b}, the rulesets +// view every reader may use. It returns nil, nil when neither endpoint +// reports anything; errors are only transport failures. +func (c *Client) FetchBranchRules(ctx context.Context, repo, branch string) (*ingest.BranchRules, error) { + if c == nil || c.client == nil { + return nil, ErrNoGitHubClient + } + owner, name, ok := strings.Cut(strings.TrimSpace(repo), "/") + if !ok || owner == "" || name == "" { + return nil, fmt.Errorf("FetchBranchRules: repo %q must be owner/name", repo) + } + prot, resp, err := c.client.Repositories.GetBranchProtection(ctx, owner, name, branch) + switch { + case err == nil && prot != nil: + return branchRulesFromProtection(prot, repo, branch), nil + case resp == nil || (resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden): + return nil, fmt.Errorf("FetchBranchRules: %s@%s protection: %w", repo, branch, err) + } + rules, resp, err := c.client.Repositories.GetRulesForBranch(ctx, owner, name, branch, nil) + if err != nil { + if resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden) { + return nil, nil + } + return nil, fmt.Errorf("FetchBranchRules: %s@%s rules: %w", repo, branch, err) + } + return branchRulesFromRulesets(rules, repo, branch), nil +} + +// branchRulesFromProtection maps the legacy branch-protection object. +func branchRulesFromProtection(p *gh.Protection, repo, branch string) *ingest.BranchRules { + r := &ingest.BranchRules{Branch: branch, Source: ingest.BranchRulesProtection, Endpoint: fmt.Sprintf("repos/%s/branches/%s/protection", repo, branch)} + if s := p.RequiredStatusChecks; s != nil { + if s.Contexts != nil { + r.RequiredChecks = append(r.RequiredChecks, *s.Contexts...) + } + if s.Checks != nil { + for _, ck := range *s.Checks { + if ck != nil && ck.Context != "" { + r.RequiredChecks = append(r.RequiredChecks, ck.Context) + } + } + } + } + if pr := p.RequiredPullRequestReviews; pr != nil { + r.RequiredApprovals = pr.RequiredApprovingReviewCount + r.RequireCodeOwnerReview = pr.RequireCodeOwnerReviews + r.DismissStaleReviews = pr.DismissStaleReviews + } + r.EnforceAdmins = p.EnforceAdmins != nil && p.EnforceAdmins.Enabled + r.LinearHistory = p.RequireLinearHistory != nil && p.RequireLinearHistory.Enabled + r.SignedCommits = p.RequiredSignatures != nil && p.RequiredSignatures.Enabled != nil && *p.RequiredSignatures.Enabled + if r.Empty() { + return nil + } + return r +} + +// branchRulesFromRulesets maps the rules active on a branch across the +// repository and organisation rulesets. +func branchRulesFromRulesets(b *gh.BranchRules, repo, branch string) *ingest.BranchRules { + if b == nil { + return nil + } + r := &ingest.BranchRules{Branch: branch, Source: ingest.BranchRulesRulesets, Endpoint: fmt.Sprintf("repos/%s/rules/branches/%s", repo, branch)} + for _, pr := range b.PullRequest { + if pr == nil { + continue + } + if pr.Parameters.RequiredApprovingReviewCount > r.RequiredApprovals { + r.RequiredApprovals = pr.Parameters.RequiredApprovingReviewCount + } + r.RequireCodeOwnerReview = r.RequireCodeOwnerReview || pr.Parameters.RequireCodeOwnerReview + r.DismissStaleReviews = r.DismissStaleReviews || pr.Parameters.DismissStaleReviewsOnPush + } + for _, sc := range b.RequiredStatusChecks { + if sc == nil { + continue + } + for _, ck := range sc.Parameters.RequiredStatusChecks { + if ck != nil && ck.Context != "" { + r.RequiredChecks = append(r.RequiredChecks, ck.Context) + } + } + } + r.LinearHistory = len(b.RequiredLinearHistory) > 0 + r.SignedCommits = len(b.RequiredSignatures) > 0 + if r.Empty() { + return nil + } + return r +} diff --git a/src/pkg/github/branch_rules_test.go b/src/pkg/github/branch_rules_test.go new file mode 100644 index 0000000000..3f0849cd58 --- /dev/null +++ b/src/pkg/github/branch_rules_test.go @@ -0,0 +1,127 @@ +package github + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/hivecommons/hive/pkg/workflowmodel/ingest" +) + +// branchRulesServer serves the two endpoints FetchBranchRules reads with +// the given status and body for each. +func branchRulesServer(t *testing.T, protStatus int, protBody string, rulesStatus int, rulesBody string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/acme/widgets/branches/main/protection", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(protStatus) + _, _ = w.Write([]byte(protBody)) + }) + mux.HandleFunc("/repos/acme/widgets/rules/branches/main", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(rulesStatus) + _, _ = w.Write([]byte(rulesBody)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestFetchBranchRulesProtection(t *testing.T) { + srv := branchRulesServer(t, http.StatusOK, `{ + "required_status_checks": {"contexts": ["dco"], "checks": [{"context": "pull-widgets-unit"}, {"context": ""}]}, + "required_pull_request_reviews": {"required_approving_review_count": 2, "require_code_owner_reviews": true, "dismiss_stale_reviews": true}, + "enforce_admins": {"enabled": true}, + "required_linear_history": {"enabled": true}, + "required_signatures": {"enabled": true} + }`, http.StatusOK, `{}`) + c := newTestClient(t, srv, "acme", nil) + got, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main") + if err != nil { + t.Fatal(err) + } + want := &ingest.BranchRules{ + Branch: "main", Source: ingest.BranchRulesProtection, Endpoint: "repos/acme/widgets/branches/main/protection", + RequiredChecks: []string{"dco", "pull-widgets-unit"}, RequiredApprovals: 2, + RequireCodeOwnerReview: true, DismissStaleReviews: true, EnforceAdmins: true, LinearHistory: true, SignedCommits: true, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("rules = %+v\nwant %+v", got, want) + } +} + +func TestFetchBranchRulesFallsBackToRulesets(t *testing.T) { + srv := branchRulesServer(t, http.StatusNotFound, `{"message":"Branch not protected"}`, http.StatusOK, `[ + {"type": "pull_request", "parameters": {"required_approving_review_count": 1, "require_code_owner_review": true, "dismiss_stale_reviews_on_push": false}}, + {"type": "pull_request", "parameters": {"required_approving_review_count": 3}}, + {"type": "required_status_checks", "parameters": {"required_status_checks": [{"context": "ci/lint"}, {"context": "ci/test"}]}}, + {"type": "required_linear_history"}, + {"type": "required_signatures"} + ]`) + c := newTestClient(t, srv, "acme", nil) + got, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main") + if err != nil { + t.Fatal(err) + } + want := &ingest.BranchRules{ + Branch: "main", Source: ingest.BranchRulesRulesets, Endpoint: "repos/acme/widgets/rules/branches/main", + RequiredChecks: []string{"ci/lint", "ci/test"}, RequiredApprovals: 3, RequireCodeOwnerReview: true, LinearHistory: true, SignedCommits: true, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("rules = %+v\nwant %+v", got, want) + } +} + +func TestFetchBranchRulesNothingReported(t *testing.T) { + cases := map[string]struct { + protStatus, rulesStatus int + protBody, rulesBody string + }{ + "unprotected and no rulesets": {http.StatusNotFound, http.StatusNotFound, `{}`, `{}`}, + "forbidden then forbidden": {http.StatusForbidden, http.StatusForbidden, `{}`, `{}`}, + "empty protection object": {http.StatusOK, http.StatusOK, `{}`, `[]`}, + "empty ruleset list": {http.StatusNotFound, http.StatusOK, `{}`, `[]`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + srv := branchRulesServer(t, tc.protStatus, tc.protBody, tc.rulesStatus, tc.rulesBody) + c := newTestClient(t, srv, "acme", nil) + got, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main") + if err != nil || got != nil { + t.Fatalf("got %+v, %v; want nil, nil", got, err) + } + }) + } +} + +func TestFetchBranchRulesErrors(t *testing.T) { + t.Run("nil client", func(t *testing.T) { + var c *Client + if _, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main"); !errors.Is(err, ErrNoGitHubClient) { + t.Fatalf("err = %v, want ErrNoGitHubClient", err) + } + }) + t.Run("bad repo", func(t *testing.T) { + srv := branchRulesServer(t, http.StatusOK, `{}`, http.StatusOK, `[]`) + c := newTestClient(t, srv, "acme", nil) + if _, err := c.FetchBranchRules(context.Background(), "widgets", "main"); err == nil { + t.Fatal("want error for repo without owner") + } + }) + t.Run("protection server error", func(t *testing.T) { + srv := branchRulesServer(t, http.StatusInternalServerError, `{}`, http.StatusOK, `[]`) + c := newTestClient(t, srv, "acme", nil) + if _, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main"); err == nil { + t.Fatal("want error on 500 from protection") + } + }) + t.Run("rules server error", func(t *testing.T) { + srv := branchRulesServer(t, http.StatusNotFound, `{}`, http.StatusInternalServerError, `{}`) + c := newTestClient(t, srv, "acme", nil) + if _, err := c.FetchBranchRules(context.Background(), "acme/widgets", "main"); err == nil { + t.Fatal("want error on 500 from rules") + } + }) +} diff --git a/src/pkg/workflowmodel/ingest/branchrules.go b/src/pkg/workflowmodel/ingest/branchrules.go new file mode 100644 index 0000000000..9dddf51f2d --- /dev/null +++ b/src/pkg/workflowmodel/ingest/branchrules.go @@ -0,0 +1,177 @@ +package ingest + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +// BranchRulesPath is the synthetic tree entry a repository fetcher may +// add to carry the default branch's protection or ruleset settings (JSON +// of BranchRules) through a files map, so callers that only pass a tree +// (POST /api/workflows/ingest, the survey) still bind them. It is not a +// file GitHub serves; ReadTree never produces it. +const BranchRulesPath = ".hive/branch-rules.json" + +// Branch rule sources: which API answered, or the settings file read. +const ( + BranchRulesProtection = "protection" + BranchRulesRulesets = "rulesets" + BranchRulesSettings = "settings" +) + +// BranchRules are the default branch's merge requirements as the forge +// enforces them (GET /repos/{o}/{r}/branches/{b}/protection, falling back +// to GET /repos/{o}/{r}/rules/branches/{b}) or as a probot settings.yml +// declares them. +type BranchRules struct { + Branch string `json:"branch"` + // Source is BranchRulesProtection, BranchRulesRulesets or + // BranchRulesSettings. + Source string `json:"source"` + // Endpoint is the API path (or file path) the rules were read from, + // used as the citation. + Endpoint string `json:"endpoint"` + RequiredChecks []string `json:"requiredChecks,omitempty"` + RequiredApprovals int `json:"requiredApprovals,omitempty"` + RequireCodeOwnerReview bool `json:"requireCodeOwnerReview,omitempty"` + DismissStaleReviews bool `json:"dismissStaleReviews,omitempty"` + EnforceAdmins bool `json:"enforceAdmins,omitempty"` + LinearHistory bool `json:"linearHistory,omitempty"` + SignedCommits bool `json:"signedCommits,omitempty"` +} + +// Empty reports whether the rules require nothing the model expresses. +func (r *BranchRules) Empty() bool { + return r == nil || (len(r.RequiredChecks) == 0 && r.RequiredApprovals == 0 && !r.RequireCodeOwnerReview && !r.DismissStaleReviews && !r.EnforceAdmins && !r.LinearHistory && !r.SignedCommits) +} + +// BranchRulesFromFiles returns the rules carried at BranchRulesPath, or +// nil when absent or unreadable. +func BranchRulesFromFiles(files map[string][]byte) *BranchRules { + data, ok := files[BranchRulesPath] + if !ok { + return nil + } + var r BranchRules + if err := json.Unmarshal(data, &r); err != nil { + return nil + } + return &r +} + +// branchRulesFromSettings reads the first default-branch protection +// block from a probot settings.yml (branches[].protection) when the API +// did not answer. +func branchRulesFromSettings(p string, data []byte) *BranchRules { + var s struct { + Repository struct { + DefaultBranch string `yaml:"default_branch"` + } `yaml:"repository"` + Branches []struct { + Name string `yaml:"name"` + Protection *struct { + RequiredStatusChecks *struct { + Contexts []string `yaml:"contexts"` + } `yaml:"required_status_checks"` + RequiredPullRequestReviews *struct { + RequiredApprovingReviewCount int `yaml:"required_approving_review_count"` + RequireCodeOwnerReviews bool `yaml:"require_code_owner_reviews"` + DismissStaleReviews bool `yaml:"dismiss_stale_reviews"` + } `yaml:"required_pull_request_reviews"` + EnforceAdmins bool `yaml:"enforce_admins"` + RequiredLinearHistory bool `yaml:"required_linear_history"` + } `yaml:"protection"` + } `yaml:"branches"` + } + if err := yaml.Unmarshal(data, &s); err != nil { + return nil + } + def := s.Repository.DefaultBranch + for _, b := range s.Branches { + if b.Protection == nil || (def != "" && b.Name != def && b.Name != "default") { + continue + } + r := &BranchRules{Branch: b.Name, Source: BranchRulesSettings, Endpoint: fmt.Sprintf("%s:%d", p, keyLine(data, "branches")), EnforceAdmins: b.Protection.EnforceAdmins, LinearHistory: b.Protection.RequiredLinearHistory} + if c := b.Protection.RequiredStatusChecks; c != nil { + r.RequiredChecks = append(r.RequiredChecks, c.Contexts...) + } + if pr := b.Protection.RequiredPullRequestReviews; pr != nil { + r.RequiredApprovals = pr.RequiredApprovingReviewCount + r.RequireCodeOwnerReview = pr.RequireCodeOwnerReviews + r.DismissStaleReviews = pr.DismissStaleReviews + } + if r.Empty() { + return nil + } + return r + } + return nil +} + +// applyBranchRules binds the rules to the Review / Merge edges: required +// status checks become required CI checks and a required-checks guard, +// required approvals set the review gate's minimum, code-owner review +// adds its own gate, and the merge-shape rules (admins, linear history, +// signatures, stale dismissal) are bookkeeping notes on merge. +func applyBranchRules(m *workflowmodel.Model, r *BranchRules, roles map[string]bool) { + if r.Empty() { + return + } + c := func(text string) []workflowmodel.Citation { + if r.Source == BranchRulesSettings { + return []workflowmodel.Citation{{Path: r.Endpoint, Text: text}} + } + return []workflowmodel.Citation{citeAPI(r.Endpoint, text)} + } + source := r.Endpoint + if r.Source != BranchRulesSettings { + source = "api:" + r.Endpoint + } + if len(r.RequiredChecks) > 0 { + checks := append([]string(nil), r.RequiredChecks...) + sort.Strings(checks) + for _, ctx := range checks { + m.Spec.Checks = append(m.Spec.Checks, workflowmodel.Check{ID: "check-" + truncate(workflowmodel.Slug(ctx), maxCheckSlug), Name: ctx, Kind: workflowmodel.CheckCI, Required: true, Source: source}) + } + m.Spec.Guards = append(m.Spec.Guards, workflowmodel.Guard{ID: GuardRequiredChecks, Kind: workflowmodel.GuardRequiredChecks, Params: map[string]string{"contexts": strings.Join(checks, ",")}, Source: source}) + for _, i := range mergeTransitions(m) { + if m.Spec.Transitions[i].GuardRef == "" { + m.Spec.Transitions[i].GuardRef = GuardRequiredChecks + } + } + } + if r.RequiredApprovals > 0 { + g := ensureReviewGate(m, releaser(roles)) + g.MinApprovals = r.RequiredApprovals + g.Citations = append(g.Citations, c(fmt.Sprintf("required_approving_review_count: %d", r.RequiredApprovals))...) + } + if r.RequireCodeOwnerReview { + m.Spec.Gates = append(m.Spec.Gates, workflowmodel.Gate{ + ID: GateCodeownerReview, ReleasedBy: releaser(roles), + Conditions: []workflowmodel.Predicate{{Fact: "pr.review", Op: workflowmodel.OpIs, Value: "approved"}}, + Confidence: workflowmodel.ConfidenceInferred, Citations: c("require_code_owner_reviews: true"), + }) + } + note := func(action, text string) { + m.Spec.Bookkeeping = append(m.Spec.Bookkeeping, workflowmodel.Effect{On: hivedefault.EventMerged, Action: action, Params: map[string]string{"note": text, "source": source}}) + } + if r.EnforceAdmins { + note("enforce-admins", "branch rules apply to administrators") + } + if r.LinearHistory { + note("require-linear-history", "merge commits are refused; squash or rebase") + } + if r.SignedCommits { + note("require-signed-commits", "every commit must carry a verified signature") + } + if r.DismissStaleReviews { + note("dismiss-stale-reviews", "a new push dismisses earlier approvals") + } +} diff --git a/src/pkg/workflowmodel/ingest/intake.go b/src/pkg/workflowmodel/ingest/intake.go new file mode 100644 index 0000000000..8dfb99a68b --- /dev/null +++ b/src/pkg/workflowmodel/ingest/intake.go @@ -0,0 +1,325 @@ +package ingest + +import ( + "path" + "sort" + "strings" + + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +// This file is the RFC phase 5 half of the ingest (hivecommons/hive#17620): +// a repository's issue templates, triage labels, pull-request template, +// CODEOWNERS/OWNERS and branch rules become intake and triage states, +// roles, gates and checks on top of the hive-default lifecycle. Every +// element produced here is origin ingested, confidence inferred, and +// cites the file:line or API path it was read from. The heuristics are +// table-driven and prefer precision: a convention the tables do not name +// yields nothing rather than a guess. + +// Column, state, event and gate ids the intake half adds. +const ( + ColumnIntake = "intake" + ColumnTriage = "triage" + StateTriage = "triage" + // StateRequestedPrefix prefixes one intake state per issue-template + // label set: requested-. + StateRequestedPrefix = "requested-" + // EventTemplatePrefix prefixes the event that moves a new issue into + // its intake state: issue-opened-with-template:. + EventTemplatePrefix = "issue-opened-with-template:" + EventTriageRequested = "triage-requested" + EventTriageAccepted = "triage-accepted" + EventTriageDeclined = "triage-declined" + GateTriageDecision = "triage-decision" + GateReview = "review" + GateCodeownerReview = "codeowner-review" + GuardRequiredChecks = "required-checks" + RoleApprover = "approver" + RoleReviewer = "reviewer" +) + +// Findings the intake half reports on coverage. +const ( + CodeTemplateNoLabels = "issue-template-no-labels" + CodeTemplateUnparsable = "issue-template-unparsable" + CodeTemplateMerged = "issue-template-merged" +) + +// AnnotationBlankIssues records .github/ISSUE_TEMPLATE/config.yml's +// blank_issues_enabled when it is false: every issue arrives through a +// template, so the intake states cover all intake. +const AnnotationBlankIssues = "workflow.hivecommons.dev/blank-issues" + +// Options are the inputs to LifecycleOptions beyond the tree. +type Options struct { + // BranchRules are the default branch's protection or ruleset settings, + // read from the forge API; nil when the ingest had no client or the + // branch is unprotected. Lifecycle also accepts them as the synthetic + // file BranchRulesPath in the tree. + BranchRules *BranchRules +} + +// intakeDir is where GitHub reads issue forms and templates from. +const intakeDir = ".github/ISSUE_TEMPLATE/" + +// intakeFiles are the exact (case-insensitive) repo-relative paths the +// intake half reads besides the issue-template directory. +var intakeFiles = map[string]bool{ + ".github/issue_template.md": true, + "issue_template.md": true, + ".github/pull_request_template.md": true, + "pull_request_template.md": true, + "docs/pull_request_template.md": true, + "contributing.md": true, + ".github/contributing.md": true, + "docs/contributing.md": true, + "codeowners": true, + ".github/codeowners": true, + "docs/codeowners": true, + "owners": true, + "owners_aliases": true, + "maintainers": true, + "maintainers.md": true, + ".github/maintainers.md": true, + "docs/maintainers.md": true, + ".github/settings.yml": true, + ".github/labels.yml": true, + ".github/labels.yaml": true, + ".github/labeler.yml": true, + ".github/labeler.yaml": true, + ".github/issue_template/config.yml": true, + ".github/issue_template/config.yaml": true, +} + +// IsIntakePath reports whether rel (repo-relative slash path) is one of +// the process-convention files the intake half reads: issue and +// pull-request templates, CONTRIBUTING, CODEOWNERS/OWNERS and label-sync +// or settings files. ReadTree keeps these alongside the CI definitions. +func IsIntakePath(rel string) bool { + l := strings.ToLower(rel) + if intakeFiles[l] { + return true + } + if rest, ok := strings.CutPrefix(l, strings.ToLower(intakeDir)); ok && rest != "" && !strings.Contains(rest, "/") { + return isYAMLPath(rest) || path.Ext(rest) == ".md" + } + if rest, ok := strings.CutPrefix(l, ".github/pull_request_template/"); ok && rest != "" && !strings.Contains(rest, "/") { + return path.Ext(rest) == ".md" + } + return false +} + +// sortedPaths returns the file paths in order so the output is stable. +func sortedPaths(files map[string][]byte) []string { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) + return paths +} + +// pathIs reports whether rel matches one of the lower-cased names. +func pathIs(rel string, names ...string) bool { + l := strings.ToLower(rel) + for _, n := range names { + if l == n { + return true + } + } + return false +} + +// intake is everything the intake half read from the tree. +type intake struct { + templates []issueTemplate + blankIssues *bool + labels *labelIndex + owners owners + checks []workflowmodel.Check + findings []workflowmodel.Finding + templateSeen bool + // settingsRules are branch rules declared in .github/settings.yml, + // used when the forge API gave none. + settingsRules *BranchRules +} + +// readIntake parses the intake files in the tree. +func readIntake(files map[string][]byte) *intake { + in := &intake{labels: newLabelIndex()} + for _, p := range sortedPaths(files) { + data := files[p] + if len(data) > MaxFileBytes { + continue + } + switch { + case isIssueTemplateConfig(p): + in.blankIssues = parseTemplateConfig(data) + case isIssueTemplatePath(p): + in.templateSeen = true + t, finding := parseIssueTemplate(p, data) + if finding != nil { + in.findings = append(in.findings, *finding) + continue + } + in.templates = append(in.templates, t) + for i, l := range t.Labels { + in.labels.add(l, "", "", cite(p, t.LabelLines[i], l)) + } + case isPRTemplatePath(p): + in.checks = append(in.checks, parseChecklist(p, data)...) + case pathIs(p, "codeowners", ".github/codeowners", "docs/codeowners"): + in.owners.addCodeowners(p, data) + case pathIs(p, "owners"): + in.owners.addOwners(p, data) + case pathIs(p, "owners_aliases"): + in.owners.addAliases(p, data) + case pathIs(p, "maintainers", "maintainers.md", ".github/maintainers.md", "docs/maintainers.md"): + in.owners.addMaintainers(p, data) + case pathIs(p, ".github/settings.yml"): + parseSettingsLabels(p, data, in.labels) + in.settingsRules = branchRulesFromSettings(p, data) + case pathIs(p, ".github/labels.yml", ".github/labels.yaml"): + parseLabelsFile(p, data, in.labels) + case pathIs(p, ".github/labeler.yml", ".github/labeler.yaml"): + parseLabelerFile(p, data, in.labels) + case pathIs(p, "contributing.md", ".github/contributing.md", "docs/contributing.md"): + parseContributing(p, data, in.labels) + } + } + in.owners.resolve() + return in +} + +// empty reports whether nothing intake-related was found, so the +// lifecycle must stay byte-identical to the pipeline-only ingest. +func (in *intake) empty() bool { + return !in.templateSeen && in.blankIssues == nil && in.labels.empty() && in.owners.empty() && len(in.checks) == 0 && len(in.findings) == 0 && in.settingsRules == nil +} + +// applyIntake adds the intake half to m. It is a no-op when the tree has +// none of the inputs and rules is nil. +func applyIntake(m *workflowmodel.Model, in *intake, rules *BranchRules) { + if in.empty() && rules == nil { + return + } + if rules == nil { + rules = in.settingsRules + } + m.Spec.Coverage.Findings = append(m.Spec.Coverage.Findings, in.findings...) + if in.blankIssues != nil && !*in.blankIssues { + if m.Metadata.Annotations == nil { + m.Metadata.Annotations = map[string]string{} + } + m.Metadata.Annotations[AnnotationBlankIssues] = "disabled" + } + if in.templateSeen { + switch m.Spec.Meta.Source { + case "": + m.Spec.Meta.Source = workflowmodel.SourceIssueTemplates + default: + m.Spec.Meta.Source += "+" + workflowmodel.SourceIssueTemplates + } + } + + triage := in.labels.triage() + roles := applyOwners(m, in.owners) + applyTriage(m, triage, roles) + applyTemplates(m, in.templates, triage) + applyConventionLabels(m, in.labels) + applyClassificationLabels(m, in.labels) + m.Spec.Checks = append(m.Spec.Checks, in.checks...) + applyBranchRules(m, rules, roles) +} + +// releaser is the human role that releases an inferred gate: the +// repository's approvers when an owners file names them, else the +// hive-default maintainer role. +func releaser(roles map[string]bool) string { + if roles[RoleApprover] { + return RoleApprover + } + return hivedefault.RoleMaintainer +} + +// insertColumnAfter adds col to m right after the column with id after. +func insertColumnAfter(m *workflowmodel.Model, after string, col workflowmodel.Column) { + for i, c := range m.Spec.Columns { + if c.ID == after { + cols := append([]workflowmodel.Column(nil), m.Spec.Columns[:i+1]...) + cols = append(cols, col) + m.Spec.Columns = append(cols, m.Spec.Columns[i+1:]...) + return + } + } + m.Spec.Columns = append(m.Spec.Columns, col) +} + +// insertStateAfter adds st to m right after the state with id after. +func insertStateAfter(m *workflowmodel.Model, after string, st workflowmodel.State) { + for i, s := range m.Spec.States { + if s.ID == after { + states := append([]workflowmodel.State(nil), m.Spec.States[:i+1]...) + states = append(states, st) + m.Spec.States = append(states, m.Spec.States[i+1:]...) + return + } + } + m.Spec.States = append(m.Spec.States, st) +} + +// addLabel appends a label entry unless one with the same name exists; +// an existing entry without a state takes the new state. +func addLabel(m *workflowmodel.Model, e workflowmodel.LabelEntry) { + for i := range m.Spec.Labels.Entries { + if strings.EqualFold(m.Spec.Labels.Entries[i].Name, e.Name) { + if m.Spec.Labels.Entries[i].State == "" { + m.Spec.Labels.Entries[i].State = e.State + } + return + } + } + m.Spec.Labels.Entries = append(m.Spec.Labels.Entries, e) +} + +// admitted are the Merge queue states a PR reaches once review and checks +// are satisfied; needs-decision-pr is excluded because it parks a PR on a +// human decision, not on review. +var admitted = map[string]bool{ + hivedefault.StateID(stagelabels.AwaitingMergeAdmission): true, + hivedefault.StateID(stagelabels.ReadyForHumanMerge): true, + hivedefault.StateID(stagelabels.PreMergeSoak): true, + hivedefault.StateID(stagelabels.MergeQueue): true, +} + +// mergeTransitions are the hive-default edges from the CI / Review column +// into the admitted Merge queue states: where a repository's review gate +// and required checks apply. +func mergeTransitions(m *workflowmodel.Model) []int { + col := map[string]string{} + for _, c := range m.Spec.Columns { + for _, s := range c.States { + col[s] = c.ID + } + } + var idx []int + for i, t := range m.Spec.Transitions { + if col[t.From] == hivedefault.ColumnCIReview && admitted[t.To] { + idx = append(idx, i) + } + } + return idx +} + +// citeAPI is the citation for a forge API reading. +func citeAPI(endpoint, text string) workflowmodel.Citation { + return workflowmodel.Citation{Path: "api:" + endpoint, Text: text} +} + +// cite is the citation for a file line. +func cite(p string, line int, text string) workflowmodel.Citation { + return workflowmodel.Citation{Path: p, Line: line, Text: text} +} diff --git a/src/pkg/workflowmodel/ingest/intake_parsers_test.go b/src/pkg/workflowmodel/ingest/intake_parsers_test.go new file mode 100644 index 0000000000..38124c0713 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/intake_parsers_test.go @@ -0,0 +1,406 @@ +package ingest + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +func TestIsIntakePath(t *testing.T) { + yes := []string{ + ".github/ISSUE_TEMPLATE/bug.yml", ".github/issue_template/feature.yaml", ".github/ISSUE_TEMPLATE/legacy.md", + ".github/ISSUE_TEMPLATE/config.yml", ".github/ISSUE_TEMPLATE.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE/feature.md", "docs/pull_request_template.md", "CONTRIBUTING.md", + ".github/CODEOWNERS", "CODEOWNERS", "OWNERS", "OWNERS_ALIASES", ".github/settings.yml", + ".github/labels.yml", ".github/labeler.yaml", + } + no := []string{ + ".github/ISSUE_TEMPLATE/", ".github/ISSUE_TEMPLATE/sub/x.yml", ".github/ISSUE_TEMPLATE/notes.txt", + ".github/PULL_REQUEST_TEMPLATE/x.yml", "pkg/OWNERS", "README.md", ".github/workflows/ci.yml", + "vendor/CONTRIBUTING.md", ".github/dependabot.yml", + } + for _, p := range yes { + if !IsIntakePath(p) { + t.Errorf("%s: want intake", p) + } + } + for _, p := range no { + if IsIntakePath(p) { + t.Errorf("%s: want not intake", p) + } + } +} + +func TestReadTreeKeepsIntakeFiles(t *testing.T) { + root := t.TempDir() + for rel, body := range map[string]string{ + ".github/workflows/ci.yml": "on: push\njobs: {}\n", + ".github/ISSUE_TEMPLATE/b.yml": "name: Bug\nlabels: [bug]\n", + ".github/ISSUE_TEMPLATE/n.txt": "no\n", + ".github/PULL_REQUEST_TEMPLATE.md": "- [ ] tests\n", + "OWNERS": "approvers: [a]\n", + "CONTRIBUTING.md": "triage\n", + "README.md": "hi\n", + } { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + files, err := ReadTree(root) + if err != nil { + t.Fatal(err) + } + var got []string + for p := range files { + got = append(got, p) + } + sort.Strings(got) + want := ".github/ISSUE_TEMPLATE/b.yml .github/PULL_REQUEST_TEMPLATE.md .github/workflows/ci.yml CONTRIBUTING.md OWNERS" + if strings.Join(got, " ") != want { + t.Fatalf("got %v", got) + } +} + +func TestParseIssueTemplate(t *testing.T) { + cases := []struct { + name, path, body string + wantName string + wantLabels []string + wantLines []int + wantLine int + wantFinding string + }{ + {name: "form list", path: ".github/ISSUE_TEMPLATE/bug.yml", body: "name: Bug\ndescription: x\nlabels:\n - kind/bug\n - needs-triage\nbody: []\n", wantName: "Bug", wantLabels: []string{"kind/bug", "needs-triage"}, wantLines: []int{4, 5}, wantLine: 1}, + {name: "form flow", path: ".github/ISSUE_TEMPLATE/bug.yml", body: "description: x\nname: Bug\nlabels: [a, b]\n", wantName: "Bug", wantLabels: []string{"a", "b"}, wantLines: []int{3, 3}, wantLine: 2}, + {name: "form comma string", path: ".github/ISSUE_TEMPLATE/bug.yml", body: "name: Bug\nlabels: \"a, b ,\"\n", wantName: "Bug", wantLabels: []string{"a", "b"}, wantLines: []int{2, 2}, wantLine: 1}, + {name: "md front matter", path: ".github/ISSUE_TEMPLATE/bug.md", body: "---\nname: Bug report\nabout: x\nlabels: bug\n---\n\nbody\n", wantName: "Bug report", wantLabels: []string{"bug"}, wantLines: []int{4}, wantLine: 2}, + {name: "md no front matter", path: ".github/ISSUE_TEMPLATE/bug.md", body: "# just text\n", wantFinding: CodeTemplateUnparsable}, + {name: "md unterminated front matter", path: ".github/ISSUE_TEMPLATE/bug.md", body: "---\nname: x\n", wantFinding: CodeTemplateUnparsable}, + {name: "not a mapping", path: ".github/ISSUE_TEMPLATE/bug.yml", body: "- a\n- b\n", wantFinding: CodeTemplateUnparsable}, + {name: "bad yaml", path: ".github/ISSUE_TEMPLATE/bug.yml", body: "name: [\n", wantFinding: CodeTemplateUnparsable}, + {name: "name from file", path: ".github/ISSUE_TEMPLATE/feature-request.yml", body: "labels: [x]\n", wantName: "feature-request", wantLabels: []string{"x"}, wantLines: []int{1}, wantLine: 1}, + {name: "empty document", path: ".github/ISSUE_TEMPLATE/e.yml", body: "", wantFinding: CodeTemplateUnparsable}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, f := parseIssueTemplate(tc.path, []byte(tc.body)) + if tc.wantFinding != "" { + if f == nil || f.Code != tc.wantFinding { + t.Fatalf("finding %+v", f) + } + return + } + if f != nil { + t.Fatalf("unexpected finding %+v", f) + } + if got.Name != tc.wantName || !reflect.DeepEqual(got.Labels, tc.wantLabels) || !reflect.DeepEqual(got.LabelLines, tc.wantLines) || got.Line != tc.wantLine { + t.Fatalf("got %+v", got) + } + }) + } +} + +func TestParseTemplateConfig(t *testing.T) { + if v := parseTemplateConfig([]byte("blank_issues_enabled: false\n")); v == nil || *v { + t.Fatalf("got %v", v) + } + if v := parseTemplateConfig([]byte("blank_issues_enabled: true\n")); v == nil || !*v { + t.Fatalf("got %v", v) + } + if v := parseTemplateConfig([]byte("contact_links: []\n")); v != nil { + t.Fatalf("got %v", *v) + } + if v := parseTemplateConfig([]byte(":[\n")); v != nil { + t.Fatalf("got %v", *v) + } +} + +func TestParseChecklist(t *testing.T) { + body := "## Checklist\n- [ ] Tests added\n* [x] Signed the CLA\n - [ ] Nested: " + strings.Repeat("very long text ", 10) + "\n- not a box\n- [ ] Tests added\n" + got := parseChecklist("PR.md", []byte(body)) + if len(got) != 3 { + t.Fatalf("got %d checks: %+v", len(got), got) + } + if got[0].ID != "pr-template-tests-added" || got[0].Kind != workflowmodel.CheckManual || got[0].Source != "PR.md:2" || got[0].Name != "Tests added" { + t.Fatalf("got %+v", got[0]) + } + if got[1].Source != "PR.md:3" || got[1].Name != "Signed the CLA" { + t.Fatalf("got %+v", got[1]) + } + if len(got[2].ID) > len("pr-template-")+maxCheckSlug { + t.Fatalf("id not truncated: %s", got[2].ID) + } +} + +func TestPlanIntake(t *testing.T) { + triage := triageLabels{needs: "needs-triage"} + tpl := func(name string, labels ...string) issueTemplate { + return issueTemplate{Path: name + ".yml", Line: 1, Name: name, Labels: labels, LabelLines: make([]int, len(labels))} + } + states, findings := planIntake([]issueTemplate{ + tpl("Bug", "kind/bug", "needs-triage"), + tpl("Bug again", "kind/bug", "needs-triage"), // same label set: cited on the first + tpl("Crash", "kind/bug"), // only shared labels: merged with a finding + tpl("Triage only", "needs-triage"), // nothing to bind + tpl("Support"), // no labels + tpl("Bug", "kind/other"), // same name, other label: id suffixed + }, triage) + var ids, binds []string + for _, s := range states { + ids = append(ids, s.id) + binds = append(binds, s.bind) + } + if strings.Join(ids, " ") != "requested-bug requested-bug-2" || strings.Join(binds, " ") != "kind/bug kind/other" { + t.Fatalf("states %v %v", ids, binds) + } + if len(states[0].citations) != 3 { + t.Fatalf("first state citations %d", len(states[0].citations)) + } + var codes []string + for _, f := range findings { + codes = append(codes, f.Code) + } + if strings.Join(codes, " ") != "issue-template-merged issue-template-no-labels issue-template-no-labels" { + t.Fatalf("findings %v", codes) + } +} + +func TestLabelParsers(t *testing.T) { + cases := []struct { + name string + parse func(string, []byte, *labelIndex) + body string + want []string // name@line + }{ + {"settings", parseSettingsLabels, "labels:\n - name: needs-triage\n color: ff0000\n description: New\n - name: bug\n", []string{"bug@5", "needs-triage@2"}}, + {"settings bad", parseSettingsLabels, "labels: [\n", nil}, + {"labels sequence", parseLabelsFile, "- name: a\n- name: b\n color: fff\n- plain\n", []string{"a@1", "b@2", "plain@4"}}, + {"labels wrapped", parseLabelsFile, "labels:\n - name: a\n", []string{"a@2"}}, + {"labels mapping", parseLabelsFile, "a:\n color: fff\n description: d\nb: fff\n", []string{"a@1", "b@4"}}, + {"labels empty", parseLabelsFile, "", nil}, + {"labels scalar", parseLabelsFile, "just a string\n", nil}, + {"labeler", parseLabelerFile, "area/docs:\n - docs/**\nkind/bug:\n - '*'\n", []string{"area/docs@1", "kind/bug@3"}}, + {"labeler sequence", parseLabelerFile, "- x\n", nil}, + {"contributing backticks", parseContributing, "Add `needs-triage` then `triage/accepted`; `unrelated` is ignored.\n", []string{"needs-triage@1", "triage/accepted@1"}}, + {"contributing plain", parseContributing, "Issues are marked needs-triage until a maintainer looks.\nnot-needs-triage-ish\n", []string{"needs-triage@1"}}, + {"contributing plain words not matched", parseContributing, "We offer help wanted tasks.\n", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + x := newLabelIndex() + tc.parse("f", []byte(tc.body), x) + var got []string + for _, l := range x.byName { + got = append(got, l.Name+"@"+itoa(l.Cite.Line)) + } + sort.Strings(got) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestLabelIndexFirstCitationWins(t *testing.T) { + x := newLabelIndex() + x.add("Needs-Triage", "", "", cite("a", 1, "")) + x.add("needs-triage", "desc", "fff", cite("b", 2, "")) + x.add(" ", "", "", cite("c", 3, "")) + l, ok := x.get("needs-triage") + if !ok || l.Cite.Path != "a" || l.Description != "desc" || l.Color != "fff" || l.Name != "Needs-Triage" { + t.Fatalf("got %+v %v", l, ok) + } + if _, ok := x.get("nope"); ok { + t.Fatal("unexpected hit") + } + tr := x.triage() + if tr.needs != "Needs-Triage" || tr.accepted != "" || !tr.is("needs-triage") || tr.is("bug") { + t.Fatalf("triage %+v", tr) + } +} + +func TestOwnersParsers(t *testing.T) { + var o owners + o.addCodeowners(".github/CODEOWNERS", []byte("# comment\n\n* @org/team @alice\ndocs/ @bob # trailing\n")) + o.addOwners("OWNERS", []byte("approvers:\n - carol\n - sig-leads\nreviewers:\n - dave\nfilters:\n \".*\":\n approvers: [erin]\n \"\\\\.md$\":\n reviewers: [ignored]\n")) + o.addAliases("OWNERS_ALIASES", []byte("aliases:\n sig-leads:\n - frank\n - grace\n")) + o.resolve() + if !o.prow { + t.Fatal("prow not detected") + } + if got := strings.Join(members(o.approvers), ","); got != "alice,bob,carol,erin,frank,grace,org/team" { + t.Fatalf("approvers %s", got) + } + if got := strings.Join(members(o.reviewers), ","); got != "dave" { + t.Fatalf("reviewers %s", got) + } + var paths []string + for _, c := range o.cites { + paths = append(paths, c.Path+":"+itoa(c.Line)) + } + if strings.Join(paths, " ") != ".github/CODEOWNERS:3 OWNERS:1 OWNERS_ALIASES:1" { + t.Fatalf("cites %v", paths) + } + + var bad owners + bad.addOwners("OWNERS", []byte(":[\n")) + bad.addAliases("OWNERS_ALIASES", []byte(":[\n")) + bad.addCodeowners("CODEOWNERS", []byte("# only comments\n")) + bad.resolve() + if !bad.empty() || bad.prow { + t.Fatalf("bad owners %+v", bad) + } + if keyLine([]byte("a: 1\nb: 2\n"), "zzz") != 1 || keyLine([]byte("a: 1\nb: 2\n"), "b") != 2 { + t.Fatal("keyLine") + } +} + +func TestBranchRulesFromSettings(t *testing.T) { + body := `repository: + default_branch: main +branches: + - name: release + protection: + enforce_admins: true + - name: main + protection: + required_status_checks: + contexts: [build, lint] + required_pull_request_reviews: + required_approving_review_count: 1 + require_code_owner_reviews: true + dismiss_stale_reviews: true + enforce_admins: false + required_linear_history: true +` + r := branchRulesFromSettings(".github/settings.yml", []byte(body)) + if r == nil || r.Branch != "main" || r.Source != BranchRulesSettings || r.Endpoint != ".github/settings.yml:3" || !reflect.DeepEqual(r.RequiredChecks, []string{"build", "lint"}) || r.RequiredApprovals != 1 || !r.RequireCodeOwnerReview || !r.DismissStaleReviews || r.EnforceAdmins || !r.LinearHistory { + t.Fatalf("got %+v", r) + } + if branchRulesFromSettings("s", []byte("branches:\n - name: main\n protection: {}\n")) != nil { + t.Fatal("empty protection should be nil") + } + if branchRulesFromSettings("s", []byte("branches:\n - name: main\n")) != nil { + t.Fatal("no protection should be nil") + } + if branchRulesFromSettings("s", []byte(":[\n")) != nil { + t.Fatal("bad yaml should be nil") + } + if r := branchRulesFromSettings("s", []byte("branches:\n - name: default\n protection:\n enforce_admins: true\n")); r == nil || r.Branch != "default" { + t.Fatalf("default branch alias: %+v", r) + } +} + +func TestBranchRulesFromFiles(t *testing.T) { + if BranchRulesFromFiles(nil) != nil { + t.Fatal("nil files") + } + if BranchRulesFromFiles(map[string][]byte{BranchRulesPath: []byte("{")}) != nil { + t.Fatal("bad json") + } + r := BranchRulesFromFiles(map[string][]byte{BranchRulesPath: []byte(`{"branch":"main","source":"protection","endpoint":"e","requiredApprovals":2}`)}) + if r == nil || r.RequiredApprovals != 2 || r.Empty() { + t.Fatalf("got %+v", r) + } + var empty *BranchRules + if !empty.Empty() || !(&BranchRules{Branch: "main"}).Empty() { + t.Fatal("Empty") + } +} + +// TestIntakeSettingsRulesFallback: with no API rules, settings.yml branch +// protection drives the merge gate; API rules win when present. +func TestIntakeSettingsRulesFallback(t *testing.T) { + files := map[string][]byte{ + ".github/settings.yml": []byte("branches:\n - name: main\n protection:\n required_status_checks:\n contexts: [build]\n required_pull_request_reviews:\n required_approving_review_count: 1\n"), + } + m := Lifecycle(scopeFor("x/y"), files) + if _, diags, err := workflowmodel.Compile(m); err != nil { + t.Fatalf("%v %v", err, diags) + } + if len(m.Spec.Checks) != 1 || m.Spec.Checks[0].Source != ".github/settings.yml:1" || m.Spec.Checks[0].Kind != workflowmodel.CheckCI { + t.Fatalf("checks %+v", m.Spec.Checks) + } + gate := findGate(m, GateReview) + if gate == nil || gate.MinApprovals != 1 || gate.ReleasedBy != "maintainer" { + t.Fatalf("gate %+v", gate) + } + api := &BranchRules{Branch: "main", Source: BranchRulesProtection, Endpoint: "repos/x/y/branches/main/protection", RequiredApprovals: 3, RequireCodeOwnerReview: true} + m = LifecycleOptions(scopeFor("x/y"), files, Options{BranchRules: api}) + if gate = findGate(m, GateReview); gate == nil || gate.MinApprovals != 3 || len(m.Spec.Checks) != 0 { + t.Fatalf("api rules did not win: %+v %+v", gate, m.Spec.Checks) + } + if g := findGate(m, GateCodeownerReview); g == nil || len(g.Citations) != 1 || g.Citations[0].Path != "api:repos/x/y/branches/main/protection" { + t.Fatalf("codeowner gate %+v", g) + } + if m.Spec.Meta.Source != "" { + t.Fatalf("source %q should stay as pipelines report when no templates", m.Spec.Meta.Source) + } +} + +func findGate(m *workflowmodel.Model, id string) *workflowmodel.Gate { + for i := range m.Spec.Gates { + if m.Spec.Gates[i].ID == id { + return &m.Spec.Gates[i] + } + } + return nil +} + +// TestIntakeTriageWithoutTemplates: a triage label alone (labels.yml) +// proposes the triage state; templates alone without a triage label route +// intake straight to actionable. +func TestIntakeTriageWithoutTemplates(t *testing.T) { + m := Lifecycle(scopeFor("x/y"), map[string][]byte{".github/labels.yml": []byte("- name: needs-triage\n- name: triage/accepted\n- name: triage/declined\n")}) + if _, diags, err := workflowmodel.Compile(m); err != nil { + t.Fatalf("%v %v", err, diags) + } + s := shape(m) + if !reflect.DeepEqual(s.states, []string{StateTriage}) || s.triageEdges != 1 || len(s.events) != 3 { + t.Fatalf("shape %+v", s) + } + if m.Spec.Meta.Source != "" { + t.Fatalf("source %q", m.Spec.Meta.Source) + } + + m = Lifecycle(scopeFor("x/y"), map[string][]byte{".github/ISSUE_TEMPLATE/bug.yml": []byte("name: Bug\nlabels: [bug]\n")}) + if _, diags, err := workflowmodel.Compile(m); err != nil { + t.Fatalf("%v %v", err, diags) + } + var toActionable bool + for _, tr := range m.Spec.Transitions { + if tr.From == "requested-bug" && tr.To == hivedefault.StateID(stageActionable) { + toActionable = true + } + } + if !toActionable { + t.Fatal("requested-bug must reach actionable when no triage label exists") + } + if m.Spec.Meta.Source != workflowmodel.SourceIssueTemplates { + t.Fatalf("source %q", m.Spec.Meta.Source) + } +} + +func TestTruncate(t *testing.T) { + if truncate("abc", 5) != "abc" || truncate("abcdef", 3) != "abc" { + t.Fatal("truncate") + } +} + +func TestIntakeOversizedFileSkipped(t *testing.T) { + big := []byte("name: Big\nlabels: [x]\n" + strings.Repeat("#", MaxFileBytes)) + m := Lifecycle(scopeFor("x/y"), map[string][]byte{".github/ISSUE_TEMPLATE/big.yml": big}) + if len(shape(m).states) != 0 { + t.Fatal("oversized template must be skipped") + } +} diff --git a/src/pkg/workflowmodel/ingest/intake_test.go b/src/pkg/workflowmodel/ingest/intake_test.go new file mode 100644 index 0000000000..2a44698fd3 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/intake_test.go @@ -0,0 +1,430 @@ +package ingest + +import ( + "bytes" + "encoding/json" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +const intakeFixtures = "testdata" + +// intakeShape is what a fixture must propose; every slice is sorted. +type intakeShape struct { + columns []string // id[states] + states []string // ingested state ids + events []string // id=fact op value + gates []string // id<-releasedBy[minApprovals] + roles []string // id=members + checks []string // id:kind + labels []string // name->state + guards []string + bookkeeping int + findings []string + source workflowmodel.MetaSource + blankIssues string + reviewEdges int // transitions carrying the review gate + triageEdges int // transitions into triage + intakeEdges int // transitions into requested-* states + citationsAPI int // citations pointing at an API endpoint +} + +func shape(m *workflowmodel.Model) intakeShape { + var s intakeShape + for _, c := range m.Spec.Columns { + s.columns = append(s.columns, c.ID+"["+strings.Join(c.States, ",")+"]") + } + for _, st := range m.Spec.States { + if isIntakeStateID(st.ID) { + s.states = append(s.states, st.ID) + } + } + for _, e := range m.Spec.Events { + if intakeEvent(e.ID) { + s.events = append(s.events, e.ID+"="+e.Fact.Fact+" "+string(e.Fact.Op)+" "+e.Fact.Value) + } + } + for _, g := range m.Spec.Gates { + if g.Confidence == workflowmodel.ConfidenceInferred { + s.gates = append(s.gates, g.ID+"<-"+g.ReleasedBy+"["+itoa(g.MinApprovals)+"]") + } + } + for _, r := range m.Spec.Roles { + if r.Kind == workflowmodel.RoleMembers { + s.roles = append(s.roles, r.ID+"="+r.Params["members"]) + } + } + for _, c := range m.Spec.Checks { + s.checks = append(s.checks, c.ID+":"+string(c.Kind)) + } + for _, l := range m.Spec.Labels.Entries { + if !strings.HasPrefix(l.Name, "stage/") { + s.labels = append(s.labels, l.Name+"->"+l.State) + } + } + for _, g := range m.Spec.Guards { + if g.ID == GuardRequiredChecks { + s.guards = append(s.guards, g.ID) + } + } + s.bookkeeping = len(m.Spec.Bookkeeping) + for _, f := range m.Spec.Coverage.Findings { + s.findings = append(s.findings, f.Code) + } + s.source = m.Spec.Meta.Source + s.blankIssues = m.Metadata.Annotations[AnnotationBlankIssues] + for _, t := range m.Spec.Transitions { + if t.GateRef == GateReview { + s.reviewEdges++ + } + if t.To == StateTriage { + s.triageEdges++ + } + if strings.HasPrefix(t.To, StateRequestedPrefix) { + s.intakeEdges++ + } + } + for _, c := range allCitations(m) { + if strings.HasPrefix(c.Path, "api:") { + s.citationsAPI++ + } + } + for _, l := range [][]string{s.states, s.events, s.gates, s.roles, s.checks, s.labels, s.guards, s.findings} { + sort.Strings(l) + } + return s +} + +func itoa(i int) string { return string(rune('0' + i)) } + +func isIntakeStateID(id string) bool { + return id == StateTriage || strings.HasPrefix(id, StateRequestedPrefix) +} + +func intakeEvent(id string) bool { + return strings.HasPrefix(id, EventTemplatePrefix) || id == EventTriageRequested || id == EventTriageAccepted || id == EventTriageDeclined +} + +// allCitations gathers every citation on inferred elements. +func allCitations(m *workflowmodel.Model) []workflowmodel.Citation { + var out []workflowmodel.Citation + for _, st := range m.Spec.States { + if isIntakeStateID(st.ID) { + out = append(out, st.Lifecycle.Citations...) + } + } + for _, e := range m.Spec.Events { + if intakeEvent(e.ID) { + out = append(out, e.Citations...) + } + } + for _, g := range m.Spec.Gates { + if g.ID == GateReview || g.ID == GateCodeownerReview || g.ID == GateTriageDecision { + out = append(out, g.Citations...) + } + } + for _, g := range m.Spec.Guards { + if g.ID == GuardRequiredChecks { + out = append(out, sourceCitation(g.Source)) + } + } + for _, r := range m.Spec.Roles { + if r.Kind == workflowmodel.RoleMembers { + for _, src := range strings.Split(r.Params["source"], ",") { + out = append(out, sourceCitation(src)) + } + } + } + for _, c := range m.Spec.Checks { + if c.Source != "" { + out = append(out, sourceCitation(c.Source)) + } + } + for _, b := range m.Spec.Bookkeeping { + out = append(out, sourceCitation(b.Params["source"])) + } + return out +} + +// sourceCitation splits a "path:line" or "api:endpoint" source string. +func sourceCitation(src string) workflowmodel.Citation { + if rest, ok := strings.CutPrefix(src, "api:"); ok { + return citeAPI(rest, "") + } + p, l, _ := strings.Cut(src, ":") + c := workflowmodel.Citation{Path: p} + for _, d := range l { + c.Line = c.Line*10 + int(d-'0') + } + return c +} + +func TestIntakeFixtures(t *testing.T) { + cases := []struct { + dir string + want intakeShape + }{ + { + dir: "techdocs", + want: intakeShape{ + columns: []string{ + "open[open]", + "intake[requested-documentation-assessment-request]", + "triage[triage]", + "actionable[actionable,triage-needed,possible-existing,open-for-discussion,needs-decision-issue]", + }, + states: []string{"requested-documentation-assessment-request", "triage"}, + events: []string{ + "issue-opened-with-template:documentation-assessment-request=labels has Docs analysis", + "triage-accepted=labels has triage/accepted", + "triage-declined=labels has triage/declined", + "triage-requested=labels has needs-triage", + }, + gates: []string{"triage-decision<-maintainer[0]"}, + labels: []string{"Docs analysis->requested-documentation-assessment-request", "good first issue->", "help wanted->", "needs-triage->triage", "triage/accepted->actionable", "triage/declined->closed"}, + source: "github-actions+issue-templates", + triageEdges: 2, intakeEdges: 1, + }, + }, + { + dir: "k8s-owners", + want: intakeShape{ + columns: []string{ + "open[open]", + "intake[requested-bug-report,requested-enhancement-tracking-issue,requested-flaking-test]", + "triage[triage]", + }, + states: []string{"requested-bug-report", "requested-enhancement-tracking-issue", "requested-flaking-test", "triage"}, + events: []string{ + "issue-opened-with-template:bug-report=labels has kind/bug", + "issue-opened-with-template:enhancement-tracking-issue=labels has kind/feature", + "issue-opened-with-template:flaking-test=labels has kind/flake", + "triage-accepted=labels has triage/accepted", + "triage-requested=labels has needs-triage", + }, + gates: []string{"codeowner-review<-approver[0]", "review<-approver[2]", "triage-decision<-approver[0]"}, + roles: []string{"approver=alice,dave,erin", "reviewer=bob,frank"}, + checks: []string{"check-dco:ci", "check-pull-widgets-unit:ci", "check-pull-widgets-verify:ci", "pr-template-documentation-updated-in-docs:manual", "pr-template-i-have-read-the-contributing-guide:manual", "pr-template-release-note-drafted-below:manual", "pr-template-tests-added-or-updated:manual"}, + labels: []string{"good first issue->", "help wanted->", "kind/bug->requested-bug-report", "kind/feature->requested-enhancement-tracking-issue", "kind/flake->requested-flaking-test", "needs-triage->triage", "triage/accepted->actionable"}, + guards: []string{GuardRequiredChecks}, + findings: []string{"issue-template-no-labels"}, + source: workflowmodel.SourceIssueTemplates, + blankIssues: "disabled", + bookkeeping: 4, + reviewEdges: 5, + triageEdges: 4, + intakeEdges: 3, + citationsAPI: 10, + }, + }, + { + dir: "md-template", + want: intakeShape{ + columns: []string{"open[open]", "intake[requested-bug-report]"}, + states: []string{"requested-bug-report"}, + events: []string{"issue-opened-with-template:bug-report=labels has bug"}, + gates: []string{"review<-approver[0]"}, + roles: []string{"approver=acme/core-team,hubot,octocat"}, + checks: []string{"pr-template-changelog-entry-added:manual", "pr-template-make-test-passes:manual"}, + labels: []string{"bug->requested-bug-report"}, + source: workflowmodel.SourceIssueTemplates, + reviewEdges: 5, intakeEdges: 1, + }, + }, + { + dir: "maintainers-labels", + want: intakeShape{ + columns: []string{"open[open]", "triage[triage]"}, + states: []string{"triage"}, + events: []string{"triage-accepted=labels lacks needs-triage", "triage-requested=labels has needs-triage"}, + gates: []string{"review<-maintainer[0]", "triage-decision<-maintainer[0]"}, + roles: []string{"maintainer=ada,grace,linus-t"}, + labels: []string{"area/runtime->", "kind/bug->", "kind/feature->", "needs-triage->triage", "priority/P0->", "priority/P1->", "priority/P2->"}, + reviewEdges: 5, triageEdges: 1, + }, + }, + { + dir: "empty", + want: intakeShape{columns: []string{"open[open]"}}, + }, + } + for _, tc := range cases { + t.Run(tc.dir, func(t *testing.T) { + files := readRepo(t, filepath.Join(intakeFixtures, tc.dir)) + m := Lifecycle(scopeFor("example/"+tc.dir), files) + if _, diags, err := workflowmodel.Compile(m); err != nil { + t.Fatalf("compile: %v\n%v", err, diags) + } + got := shape(m) + got.columns = got.columns[:len(tc.want.columns)] + if tc.want.gates == nil && tc.want.roles == nil && tc.want.checks == nil && tc.want.states == nil { + // empty fixture: byte-identical to hive-default lifecycle + if m.Hash() != Lifecycle(scopeFor("example/empty"), nil).Hash() { + t.Fatal("empty tree changed the hive-default lifecycle") + } + } + if a, b := mustJSON(t, got), mustJSON(t, tc.want); !bytes.Equal(a, b) { + t.Fatalf("shape mismatch\n got %s\nwant %s", a, b) + } + for _, c := range allCitations(m) { + if !strings.HasPrefix(c.Path, "api:") && (c.Line == 0 || c.Path == "") { + t.Errorf("citation without file:line: %+v", c) + } + } + }) + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(struct { + Columns, States, Events, Gates, Roles, Checks, Labels, Guards, Findings []string + Bookkeeping int + Source workflowmodel.MetaSource + BlankIssues string + ReviewEdges, TriageEdges, IntakeEdges, CitationsAPI int + }{ + v.(intakeShape).columns, v.(intakeShape).states, v.(intakeShape).events, v.(intakeShape).gates, v.(intakeShape).roles, + v.(intakeShape).checks, v.(intakeShape).labels, v.(intakeShape).guards, v.(intakeShape).findings, v.(intakeShape).bookkeeping, + v.(intakeShape).source, v.(intakeShape).blankIssues, v.(intakeShape).reviewEdges, v.(intakeShape).triageEdges, + v.(intakeShape).intakeEdges, v.(intakeShape).citationsAPI, + }) + if err != nil { + t.Fatal(err) + } + return b +} + +// TestIntakeInferredAndCited: every element the intake half adds is +// inferred and carries at least one citation. +func TestIntakeInferredAndCited(t *testing.T) { + m := Lifecycle(scopeFor("example/k8s"), readRepo(t, filepath.Join(intakeFixtures, "k8s-owners"))) + for _, st := range m.Spec.States { + if st.Origin == workflowmodel.OriginIngested && (st.Lifecycle.Confidence != workflowmodel.ConfidenceInferred || len(st.Lifecycle.Citations) == 0) { + t.Errorf("state %s: confidence %q citations %d", st.ID, st.Lifecycle.Confidence, len(st.Lifecycle.Citations)) + } + } + for _, e := range m.Spec.Events { + if strings.HasPrefix(e.ID, EventTemplatePrefix) || strings.HasPrefix(e.ID, "triage-") { + if e.Confidence != workflowmodel.ConfidenceInferred || len(e.Citations) == 0 { + t.Errorf("event %s: confidence %q citations %d", e.ID, e.Confidence, len(e.Citations)) + } + } + } + for _, g := range m.Spec.Gates { + if g.ID == GateReview || g.ID == GateCodeownerReview || g.ID == GateTriageDecision { + if g.Confidence != workflowmodel.ConfidenceInferred || len(g.Citations) == 0 { + t.Errorf("gate %s: confidence %q citations %d", g.ID, g.Confidence, len(g.Citations)) + } + } + } + for _, r := range m.Spec.Roles { + if r.Kind == workflowmodel.RoleMembers && (r.Params["source"] == "" || r.Params["members"] == "") { + t.Errorf("role %s lacks source/members", r.ID) + } + } + for _, c := range m.Spec.Checks { + if c.Source == "" || c.Kind == "" { + t.Errorf("check %s lacks source/kind: %+v", c.ID, c) + } + } +} + +// TestLifecyclePipelineOnlyUnchanged: trees without intake inputs produce +// byte-identical models to the pre-P5 pipeline binding. +func TestLifecyclePipelineOnlyUnchanged(t *testing.T) { + dirs, err := filepath.Glob(filepath.Join(corpusDir, "*")) + if err != nil || len(dirs) == 0 { + t.Fatalf("corpus: %v %d", err, len(dirs)) + } + for _, dir := range dirs { + files := readRepo(t, dir) + for p := range files { + if IsIntakePath(p) || p == BranchRulesPath { + t.Fatalf("%s: corpus tree contains intake file %s", dir, p) + } + } + scope := scopeFor("corpus/" + filepath.Base(dir)) + got := Lifecycle(scope, files) + if LifecycleOptions(scope, files, Options{}).Hash() != got.Hash() { + t.Errorf("%s: LifecycleOptions{} differs", dir) + } + for _, st := range got.Spec.States { + if isIntakeStateID(st.ID) { + t.Errorf("%s: unexpected intake state %s", dir, st.ID) + } + } + for _, c := range got.Spec.Columns { + if c.ID == ColumnIntake || c.ID == ColumnTriage { + t.Errorf("%s: unexpected column %s", dir, c.ID) + } + } + if len(got.Spec.Checks) != 0 || len(got.Spec.Bookkeeping) != 0 || len(got.Spec.Roles) != len(hivedefault.Generate().Spec.Roles) { + t.Errorf("%s: intake half added checks/bookkeeping/roles", dir) + } + if got.Hash() != pipelineOnlyHash(t, scope, files) { + t.Errorf("%s: Lifecycle differs from the pipeline-only binding", dir) + } + } +} + +func TestLifecycleOptionsBranchRulesOverrideFile(t *testing.T) { + files := readRepo(t, filepath.Join(intakeFixtures, "md-template")) + rules := &BranchRules{Branch: "main", Source: BranchRulesRulesets, Endpoint: "repos/x/y/rules/branches/main", RequiredApprovals: 1, RequiredChecks: []string{"build"}} + m := LifecycleOptions(scopeFor("x/y"), files, Options{BranchRules: rules}) + if _, _, err := workflowmodel.Compile(m); err != nil { + t.Fatal(err) + } + var review *workflowmodel.Gate + for i := range m.Spec.Gates { + if m.Spec.Gates[i].ID == GateReview { + review = &m.Spec.Gates[i] + } + } + if review == nil || review.MinApprovals != 1 || review.ReleasedBy != RoleApprover { + t.Fatalf("review gate %+v", review) + } + var ci int + for _, c := range m.Spec.Checks { + if c.Kind == workflowmodel.CheckCI { + ci++ + if c.Source != "api:repos/x/y/rules/branches/main" || !c.Required { + t.Errorf("check %+v", c) + } + } + } + if ci != 1 { + t.Fatalf("ci checks %d", ci) + } + // rules without an approver role: review gate falls back to maintainer + m = LifecycleOptions(scopeFor("x/y"), nil, Options{BranchRules: rules}) + if _, _, err := workflowmodel.Compile(m); err != nil { + t.Fatal(err) + } + for _, g := range m.Spec.Gates { + if g.ID == GateReview && g.ReleasedBy != hivedefault.RoleMaintainer { + t.Fatalf("review gate released by %q", g.ReleasedBy) + } + } +} + +// pipelineOnlyHash recomputes the pre-P5 model: hive-default with the +// repository's pipelines bound and the intake half skipped. +func pipelineOnlyHash(t *testing.T, scope workflowmodel.Scope, files map[string][]byte) string { + t.Helper() + m := LifecycleOptions(scope, files, Options{}) + if _, diags, err := workflowmodel.Compile(m); err != nil { + t.Fatalf("compile: %v %v", err, diags) + } + // readIntake on a pipeline-only tree must report empty, which is the + // branch applyIntake takes to leave the model untouched. + if !readIntake(files).empty() { + t.Fatal("readIntake found intake inputs in a pipeline-only tree") + } + return m.Hash() +} diff --git a/src/pkg/workflowmodel/ingest/labels.go b/src/pkg/workflowmodel/ingest/labels.go new file mode 100644 index 0000000000..50477b6113 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/labels.go @@ -0,0 +1,373 @@ +package ingest + +import ( + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +// stageActionable is the hive-default stage intake states hand over to. +const stageActionable = stagelabels.Actionable + +// knownLabel is a label the repository is known to use and where it was +// first seen. +type knownLabel struct { + Name string + Description string + Color string + Cite workflowmodel.Citation +} + +// labelIndex collects labels from every source, keyed by normalized name, +// first citation wins (files are read in sorted path order). +type labelIndex struct { + byName map[string]knownLabel +} + +func newLabelIndex() *labelIndex { return &labelIndex{byName: map[string]knownLabel{}} } + +func (x *labelIndex) add(name, description, color string, c workflowmodel.Citation) { + name = strings.TrimSpace(name) + if name == "" { + return + } + k := normalizeLabel(name) + if prev, ok := x.byName[k]; ok { + // First sighting keeps the citation; a later label-sync entry + // may still supply the description and colour. + if prev.Description == "" { + prev.Description = description + } + if prev.Color == "" { + prev.Color = color + } + x.byName[k] = prev + return + } + x.byName[k] = knownLabel{Name: name, Description: description, Color: color, Cite: c} +} + +func (x *labelIndex) empty() bool { return len(x.byName) == 0 } + +// get returns the known label for any of the normalized names, in the +// order the names are listed (the table's preferred spelling first). +func (x *labelIndex) get(names ...string) (knownLabel, bool) { + for _, n := range names { + if l, ok := x.byName[n]; ok { + return l, true + } + } + return knownLabel{}, false +} + +// normalizeLabel lowercases and unifies separators so `Needs Triage`, +// `needs_triage` and `needs-triage` compare equal. +func normalizeLabel(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, ": ", "/") + s = strings.ReplaceAll(s, ":", "/") + s = strings.NewReplacer(" ", "-", "_", "-").Replace(s) + return s +} + +// Triage label table. Matching is exact on the normalized name; a label +// outside the table is not a triage label. Spellings are those seen on +// GitHub's own, Kubernetes', CNCF's and the common label-sync presets. +var ( + triageNeedsNames = []string{"needs-triage", "needs/triage", "triage-needed", "triage/needed", "status/needs-triage", "status/triage", "triage/needs-triage", "untriaged", "triage"} + triageAcceptedNames = []string{"triage/accepted", "triage-accepted", "triaged", "status/triaged"} + triageDeclinedNames = []string{"triage/declined", "triage-declined", "triage/rejected", "triage/not-planned"} + // conventionNames are community labels worth carrying in the label + // set but that mark no state. + conventionNames = []string{"good-first-issue", "help-wanted"} + // classificationPrefixes are the label families triage applies to + // classify an issue (kind/bug, priority/P1, area/docs ...). They mark + // no state either; the model carries them so the triage gate's + // vocabulary is visible in the editor. + classificationPrefixes = []string{"kind/", "priority/", "type/", "area/", "severity/", "size/"} +) + +// isClassificationLabel reports whether the normalized label n belongs +// to one of the classification families. +func isClassificationLabel(n string) bool { + for _, p := range classificationPrefixes { + if rest, ok := strings.CutPrefix(n, p); ok && rest != "" { + return true + } + } + return false +} + +// triageLabels are the repository's triage labels, if any (empty names +// when absent), with their citations. +type triageLabels struct { + needs, accepted, declined string + cites []workflowmodel.Citation + cite map[string][]workflowmodel.Citation + descriptions map[string]string +} + +// is reports whether l is one of the repository's triage labels. +func (t triageLabels) is(l string) bool { + n := normalizeLabel(l) + return n != "" && (n == normalizeLabel(t.needs) || n == normalizeLabel(t.accepted) || n == normalizeLabel(t.declined)) +} + +// triage finds the triage labels in the index. +func (x *labelIndex) triage() triageLabels { + var t triageLabels + t.descriptions = map[string]string{} + t.cite = map[string][]workflowmodel.Citation{} + pick := func(names []string) string { + l, ok := x.get(names...) + if !ok { + return "" + } + t.cites = append(t.cites, l.Cite) + t.cite[l.Name] = []workflowmodel.Citation{l.Cite} + t.descriptions[l.Name] = l.Description + return l.Name + } + t.needs = pick(triageNeedsNames) + if t.needs == "" { + return triageLabels{} + } + t.accepted = pick(triageAcceptedNames) + t.declined = pick(triageDeclinedNames) + return t +} + +// parseSettingsLabels reads labels[].name from a probot settings.yml. +func parseSettingsLabels(p string, data []byte, x *labelIndex) { + root := yamlRoot(data) + if root == nil { + return + } + if labels := mappingValue(root, "labels"); labels != nil { + addLabelSequence(p, labels, x) + } +} + +// parseLabelsFile reads a label-sync file: either a sequence of +// {name, description, color} or a mapping of name -> {description, color} +// (github-label-sync, EndBug/label-sync and crazy-max/ghaction-github-labeler +// all use one of the two). +func parseLabelsFile(p string, data []byte, x *labelIndex) { + root := yamlRoot(data) + if root == nil { + return + } + switch root.Kind { + case yaml.SequenceNode: + addLabelSequence(p, root, x) + case yaml.MappingNode: + if labels := mappingValue(root, "labels"); labels != nil { + addLabelSequence(p, labels, x) + return + } + for i := 0; i+1 < len(root.Content); i += 2 { + k, v := root.Content[i], root.Content[i+1] + desc, color := "", "" + if v.Kind == yaml.MappingNode { + desc = scalar(mappingValue(v, "description")) + color = scalar(mappingValue(v, "color")) + } + x.add(k.Value, desc, color, cite(p, k.Line, k.Value)) + } + } +} + +// parseLabelerFile reads actions/labeler's config: top-level keys are +// the labels it applies. +func parseLabelerFile(p string, data []byte, x *labelIndex) { + root := yamlRoot(data) + if root == nil || root.Kind != yaml.MappingNode { + return + } + for i := 0; i+1 < len(root.Content); i += 2 { + k := root.Content[i] + x.add(k.Value, "", "", cite(p, k.Line, k.Value)) + } +} + +// addLabelSequence reads a sequence of label objects or bare names. +func addLabelSequence(p string, seq *yaml.Node, x *labelIndex) { + if seq.Kind != yaml.SequenceNode { + return + } + for _, it := range seq.Content { + switch it.Kind { + case yaml.ScalarNode: + x.add(it.Value, "", "", cite(p, it.Line, it.Value)) + case yaml.MappingNode: + name := mappingValue(it, "name") + if name == nil { + continue + } + x.add(name.Value, scalar(mappingValue(it, "description")), scalar(mappingValue(it, "color")), cite(p, name.Line, name.Value)) + } + } +} + +// backticked matches `label` mentions in prose. +var backticked = regexp.MustCompile("`([^`\n]{1,60})`") + +// parseContributing reads label names a CONTRIBUTING file mentions: any +// backticked token that is a table label, or a hyphenated/slashed table +// label written plainly. Plain single words (triage, triaged) are not +// matched: prose uses them without naming a label. +func parseContributing(p string, data []byte, x *labelIndex) { + table := append(append(append(append([]string(nil), triageNeedsNames...), triageAcceptedNames...), triageDeclinedNames...), conventionNames...) + for i, line := range strings.Split(string(data), "\n") { + for _, m := range backticked.FindAllStringSubmatch(line, -1) { + n := normalizeLabel(m[1]) + if isClassificationLabel(n) && !strings.ContainsAny(m[1], " *<>") { + x.add(m[1], "", "", cite(p, i+1, m[0])) + } + for _, t := range table { + if n == t { + x.add(m[1], "", "", cite(p, i+1, m[0])) + } + } + } + low := strings.ToLower(line) + for _, t := range table { + if !strings.ContainsAny(t, "-/") { + continue + } + if idx := strings.Index(low, t); idx >= 0 && boundary(low, idx, len(t)) { + x.add(line[idx:idx+len(t)], "", "", cite(p, i+1, t)) + } + } + } +} + +// boundary reports whether s[i:i+n] is delimited by non-word characters. +func boundary(s string, i, n int) bool { + word := func(c byte) bool { + return c == '-' || c == '/' || c == '_' || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + } + return (i == 0 || !word(s[i-1])) && (i+n >= len(s) || !word(s[i+n])) +} + +// applyTriage adds the Triage column and state when the repository has a +// triage label: Open and every intake state enter it when the label is +// applied; a triage decision (gate) accepts it to Actionable or declines +// it to Closed. +func applyTriage(m *workflowmodel.Model, t triageLabels, roles map[string]bool) { + if t.needs == "" { + return + } + hint := "Carries " + t.needs + if t.accepted != "" { + hint += "; accepted with " + t.accepted + } + if t.declined != "" { + hint += ", declined with " + t.declined + } + st := workflowmodel.State{ + ID: StateTriage, Label: "Triage", Owner: workflowmodel.OwnerHuman, Origin: workflowmodel.OriginIngested, + Lifecycle: workflowmodel.StateLifecycle{Column: ColumnTriage, Kinds: []workflowmodel.ItemKind{workflowmodel.KindIssue}, Waiting: []string{"awaiting triage"}, Confidence: workflowmodel.ConfidenceInferred, Citations: t.cites}, + } + insertStateAfter(m, hivedefault.StateOpen, st) + insertColumnAfter(m, hivedefault.ColumnOpen, workflowmodel.Column{ID: ColumnTriage, Label: "Triage", Hint: hint, States: []string{StateTriage}}) + + m.Spec.Events = append(m.Spec.Events, workflowmodel.Event{ + ID: EventTriageRequested, Label: "Triage label applied", + Fact: workflowmodel.Predicate{Fact: "labels", Op: workflowmodel.OpHas, Value: t.needs}, + Confidence: workflowmodel.ConfidenceInferred, Citations: t.cite[t.needs], + }) + accepted := workflowmodel.Event{ID: EventTriageAccepted, Label: "Triage label removed", Fact: workflowmodel.Predicate{Fact: "labels", Op: workflowmodel.OpLacks, Value: t.needs}, Confidence: workflowmodel.ConfidenceInferred, Citations: t.cite[t.needs]} + if t.accepted != "" { + accepted.Label = "Triage accepted" + accepted.Fact = workflowmodel.Predicate{Fact: "labels", Op: workflowmodel.OpHas, Value: t.accepted} + accepted.Citations = t.cite[t.accepted] + } + m.Spec.Events = append(m.Spec.Events, accepted) + gate := workflowmodel.Gate{ID: GateTriageDecision, ReleasedBy: releaser(roles), Confidence: workflowmodel.ConfidenceInferred, Citations: t.cites} + if t.accepted != "" { + gate.Reply = "/label " + t.accepted + } + m.Spec.Gates = append(m.Spec.Gates, gate) + + m.Spec.Transitions = append(m.Spec.Transitions, + workflowmodel.Transition{From: hivedefault.StateOpen, To: StateTriage, On: EventTriageRequested, Note: "label-added " + t.needs}, + workflowmodel.Transition{From: StateTriage, To: hivedefault.StateID(stageActionable), On: EventTriageAccepted, GateRef: GateTriageDecision, Note: "triage accepted"}, + workflowmodel.Transition{From: StateTriage, To: hivedefault.StateClosed, On: hivedefault.EventClosed}, + ) + addLabel(m, workflowmodel.LabelEntry{Name: t.needs, Description: t.descriptions[t.needs], State: StateTriage}) + if t.accepted != "" { + addLabel(m, workflowmodel.LabelEntry{Name: t.accepted, Description: t.descriptions[t.accepted], State: hivedefault.StateID(stageActionable)}) + } + if t.declined != "" { + m.Spec.Events = append(m.Spec.Events, workflowmodel.Event{ + ID: EventTriageDeclined, Label: "Triage declined", + Fact: workflowmodel.Predicate{Fact: "labels", Op: workflowmodel.OpHas, Value: t.declined}, + Confidence: workflowmodel.ConfidenceInferred, Citations: t.cite[t.declined], + }) + m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: StateTriage, To: hivedefault.StateClosed, On: EventTriageDeclined, GateRef: GateTriageDecision, Note: "triage declined"}) + addLabel(m, workflowmodel.LabelEntry{Name: t.declined, Description: t.descriptions[t.declined], State: hivedefault.StateClosed}) + } +} + +// applyConventionLabels carries good-first-issue / help-wanted style +// labels into the label set (no state) when the repository declares them. +func applyConventionLabels(m *workflowmodel.Model, x *labelIndex) { + for _, n := range conventionNames { + if l, ok := x.get(n); ok { + addLabel(m, workflowmodel.LabelEntry{Name: l.Name, Description: l.Description, Color: l.Color}) + } + } +} + +// applyClassificationLabels carries the kind/*, priority/* ... labels the +// repository declares into the label set (no state), sorted by name. +// Labels an intake state already bound keep that state (addLabel). +func applyClassificationLabels(m *workflowmodel.Model, x *labelIndex) { + var names []string + for n := range x.byName { + if isClassificationLabel(n) { + names = append(names, n) + } + } + sort.Strings(names) + for _, n := range names { + l := x.byName[n] + addLabel(m, workflowmodel.LabelEntry{Name: l.Name, Description: l.Description, Color: l.Color}) + } +} + +// yamlRoot parses data and returns the document's root node, or nil. +func yamlRoot(data []byte) *yaml.Node { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil + } + return doc.Content[0] +} + +// mappingValue returns the value node for key in a mapping, or nil. +func mappingValue(n *yaml.Node, key string) *yaml.Node { + if n == nil || n.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(n.Content); i += 2 { + if strings.EqualFold(n.Content[i].Value, key) { + return n.Content[i+1] + } + } + return nil +} + +func scalar(n *yaml.Node) string { + if n == nil || n.Kind != yaml.ScalarNode { + return "" + } + return strings.TrimSpace(n.Value) +} diff --git a/src/pkg/workflowmodel/ingest/lifecycle.go b/src/pkg/workflowmodel/ingest/lifecycle.go index b231f66f18..7a33ef8851 100644 --- a/src/pkg/workflowmodel/ingest/lifecycle.go +++ b/src/pkg/workflowmodel/ingest/lifecycle.go @@ -25,8 +25,20 @@ const EventPipelineSucceeded = "pipeline-succeeded" // origin ingested, confidence inferred (the compiler warns per element) // and the model's provenance is ingested-unreviewed until a human edits it. // +// The intake half (RFC phase 5) then reads the tree's issue and +// pull-request templates, CONTRIBUTING, CODEOWNERS/OWNERS and label +// files, plus branch rules carried at BranchRulesPath, and adds intake +// and triage states, roles, gates and checks; see intake.go. A tree with +// none of those inputs yields exactly the pipeline-only model. +// // The process half (Repository) is unchanged; this composes on top of it. func Lifecycle(scope workflowmodel.Scope, files map[string][]byte) *workflowmodel.Model { + return LifecycleOptions(scope, files, Options{BranchRules: BranchRulesFromFiles(files)}) +} + +// LifecycleOptions is Lifecycle with the branch rules passed explicitly +// (the forge API reading, when the caller had a client). +func LifecycleOptions(scope workflowmodel.Scope, files map[string][]byte, opts Options) *workflowmodel.Model { base := Repository(scope, files) m := hivedefault.Generate() m.Metadata.Name = base.Metadata.Name @@ -40,8 +52,17 @@ func Lifecycle(scope workflowmodel.Scope, files map[string][]byte) *workflowmode m.Spec.Pipelines = base.Spec.Pipelines m.Spec.Sources = base.Spec.Sources m.Spec.Coverage = base.Spec.Coverage + bindPipelines(m, base) + applyIntake(m, readIntake(files), opts.BranchRules) + return m +} + +// bindPipelines binds the process half's pipelines onto the lifecycle: +// PR-verify pipelines on the CI / Review states, the post-merge chain as +// new columns after Merged. +func bindPipelines(m, base *workflowmodel.Model) { if len(base.Spec.Pipelines) == 0 { - return m + return } cite := citations(base.Spec.Pipelines) @@ -65,7 +86,7 @@ func Lifecycle(scope workflowmodel.Scope, files map[string][]byte) *workflowmode } } if len(post) == 0 { - return m + return } for _, g := range base.Spec.Guards { if g.Kind == workflowmodel.GuardEnvironmentApproval { @@ -92,7 +113,6 @@ func Lifecycle(scope workflowmodel.Scope, files map[string][]byte) *workflowmode m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: prev, To: st.ID, On: EventPipelineSucceeded, GuardRef: st.GuardRef, Note: "ingested post-merge chain"}) prev = st.ID } - return m } // ingestSource is the meta source for the systems the pipelines came from. diff --git a/src/pkg/workflowmodel/ingest/owners.go b/src/pkg/workflowmodel/ingest/owners.go new file mode 100644 index 0000000000..ff50207b22 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/owners.go @@ -0,0 +1,278 @@ +package ingest + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +// owners collects who approves and reviews from CODEOWNERS (GitHub) and +// OWNERS / OWNERS_ALIASES (Prow). Only the repository-root files are +// read: nested OWNERS scope approvers to subtrees, which the model does +// not express. +type owners struct { + approvers, reviewers map[string]bool + maintainers map[string]bool + aliases map[string][]string + rawApprovers []string + rawReviewers []string + cites []workflowmodel.Citation + maintainerCites []workflowmodel.Citation + prow bool +} + +func (o *owners) init() { + if o.approvers == nil { + o.approvers = map[string]bool{} + o.reviewers = map[string]bool{} + o.maintainers = map[string]bool{} + o.aliases = map[string][]string{} + } +} + +func (o *owners) empty() bool { + return len(o.approvers) == 0 && len(o.reviewers) == 0 && len(o.maintainers) == 0 +} + +// maintainerHandle matches a GitHub handle written as @handle (not the +// local part of an e-mail address) or as a github.com/handle link in a +// MAINTAINERS file. +var maintainerHandle = regexp.MustCompile(`(?:^|[^\w.])(?:@|https?://github\.com/)([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))`) + +// addMaintainers reads the handles named in a MAINTAINERS file (plain +// list, markdown table or prose). Only @handle and github.com/handle +// spellings count: names and e-mail addresses are not GitHub identities. +func (o *owners) addMaintainers(p string, data []byte) { + o.init() + first := 0 + for i, line := range strings.Split(string(data), "\n") { + s := strings.TrimSpace(line) + if s == "" || (strings.HasPrefix(s, "#") && !strings.Contains(s, "@")) { + continue + } + for _, m := range maintainerHandle.FindAllStringSubmatch(line, -1) { + o.maintainers[m[1]] = true + if first == 0 { + first = i + 1 + } + } + } + if first > 0 { + o.maintainerCites = append(o.maintainerCites, cite(p, first, "MAINTAINERS")) + } +} + +// addCodeowners reads every owner on every rule line as an approver: +// GitHub requests their review and, under require-code-owner-review, +// needs their approval. +func (o *owners) addCodeowners(p string, data []byte) { + o.init() + first := 0 + for i, line := range strings.Split(string(data), "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + fields := strings.Fields(s) + if len(fields) < 2 { + continue + } + for _, f := range fields[1:] { + if strings.HasPrefix(f, "#") { + break + } + o.approvers[strings.TrimPrefix(f, "@")] = true + if first == 0 { + first = i + 1 + } + } + } + if first > 0 { + o.cites = append(o.cites, cite(p, first, "CODEOWNERS")) + } +} + +// prowOwners is the subset of a Prow OWNERS file the model reads. +type prowOwners struct { + Approvers []string `yaml:"approvers"` + Reviewers []string `yaml:"reviewers"` + Filters map[string]struct { + Approvers []string `yaml:"approvers"` + Reviewers []string `yaml:"reviewers"` + } `yaml:"filters"` +} + +// addOwners reads the root OWNERS file's approvers and reviewers (the +// top-level lists plus the catch-all ".*" filter). +func (o *owners) addOwners(p string, data []byte) { + o.init() + var po prowOwners + if err := yaml.Unmarshal(data, &po); err != nil { + return + } + o.rawApprovers = append(o.rawApprovers, po.Approvers...) + o.rawReviewers = append(o.rawReviewers, po.Reviewers...) + if f, ok := po.Filters[".*"]; ok { + o.rawApprovers = append(o.rawApprovers, f.Approvers...) + o.rawReviewers = append(o.rawReviewers, f.Reviewers...) + } + if len(o.rawApprovers)+len(o.rawReviewers) > 0 { + o.prow = true + o.cites = append(o.cites, cite(p, keyLine(data, "approvers", "reviewers", "filters"), "OWNERS")) + } +} + +// addAliases reads OWNERS_ALIASES so alias names in OWNERS expand to +// their members. +func (o *owners) addAliases(p string, data []byte) { + o.init() + var a struct { + Aliases map[string][]string `yaml:"aliases"` + } + if err := yaml.Unmarshal(data, &a); err != nil { + return + } + for k, v := range a.Aliases { + o.aliases[k] = v + } + if len(a.Aliases) > 0 { + o.cites = append(o.cites, cite(p, keyLine(data, "aliases"), "OWNERS_ALIASES")) + } +} + +// resolve expands aliases into the approver and reviewer sets. +func (o *owners) resolve() { + if o.approvers == nil { + return + } + expand := func(raw []string, into map[string]bool) { + for _, n := range raw { + if members, ok := o.aliases[n]; ok { + for _, m := range members { + into[strings.TrimPrefix(m, "@")] = true + } + continue + } + into[strings.TrimPrefix(n, "@")] = true + } + } + expand(o.rawApprovers, o.approvers) + expand(o.rawReviewers, o.reviewers) +} + +func members(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for m := range set { + if m != "" { + out = append(out, m) + } + } + sort.Strings(out) + return out +} + +// keyLine returns the 1-based line of the first top-level key among keys +// in a YAML document, or 1. +func keyLine(data []byte, keys ...string) int { + root := yamlRoot(data) + if root == nil || root.Kind != yaml.MappingNode { + return 1 + } + for _, k := range keys { + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == k { + return root.Content[i].Line + } + } + } + return 1 +} + +// applyOwners adds the approver and reviewer roles and, when there is an +// approver role, the review gate on the edges from CI / Review into the +// Merge queue. It returns the role ids it added. +func applyOwners(m *workflowmodel.Model, o owners) map[string]bool { + roles := map[string]bool{} + if o.empty() { + return roles + } + source := citeSources(o.cites) + if a := members(o.approvers); len(a) > 0 { + m.Spec.Roles = append(m.Spec.Roles, workflowmodel.RoleDef{ID: RoleApprover, Label: "Approver", Kind: workflowmodel.RoleMembers, Params: map[string]string{"members": strings.Join(a, ","), "source": source}, Human: true}) + roles[RoleApprover] = true + } + if r := members(o.reviewers); len(r) > 0 { + m.Spec.Roles = append(m.Spec.Roles, workflowmodel.RoleDef{ID: RoleReviewer, Label: "Reviewer", Kind: workflowmodel.RoleMembers, Params: map[string]string{"members": strings.Join(r, ","), "source": source}, Human: true}) + roles[RoleReviewer] = true + } + if roles[RoleApprover] { + g := ensureReviewGate(m, RoleApprover) + g.Citations = append(g.Citations, o.cites...) + if o.prow { + g.Reply = "/approve" + } + } + if mt := members(o.maintainers); len(mt) > 0 { + applyMaintainers(m, mt, o.maintainerCites) + roles[hivedefault.RoleMaintainer] = true + if !roles[RoleApprover] { + g := ensureReviewGate(m, hivedefault.RoleMaintainer) + g.Citations = append(g.Citations, o.maintainerCites...) + } + } + return roles +} + +// applyMaintainers turns the hive-default permission-based maintainer +// role into the members the MAINTAINERS file names (adding the role when +// the model has none), keeping the gates that already reference it. +func applyMaintainers(m *workflowmodel.Model, mt []string, cites []workflowmodel.Citation) { + params := map[string]string{"members": strings.Join(mt, ","), "source": citeSources(cites)} + for i := range m.Spec.Roles { + if m.Spec.Roles[i].ID == hivedefault.RoleMaintainer { + m.Spec.Roles[i].Kind = workflowmodel.RoleMembers + m.Spec.Roles[i].Params = params + m.Spec.Roles[i].Human = true + return + } + } + m.Spec.Roles = append(m.Spec.Roles, workflowmodel.RoleDef{ID: hivedefault.RoleMaintainer, Label: "Maintainer", Kind: workflowmodel.RoleMembers, Params: params, Human: true}) +} + +// citeSources joins citations as the "path:line,..." source string roles +// and checks carry. +func citeSources(cites []workflowmodel.Citation) string { + sources := make([]string, 0, len(cites)) + for _, c := range cites { + sources = append(sources, fmt.Sprintf("%s:%d", c.Path, c.Line)) + } + return strings.Join(sources, ",") +} + +// ensureReviewGate returns the review gate, adding it (released by role, +// condition pr.review is approved) and attaching it to the merge edges +// when absent. +func ensureReviewGate(m *workflowmodel.Model, role string) *workflowmodel.Gate { + for i := range m.Spec.Gates { + if m.Spec.Gates[i].ID == GateReview { + return &m.Spec.Gates[i] + } + } + m.Spec.Gates = append(m.Spec.Gates, workflowmodel.Gate{ + ID: GateReview, ReleasedBy: role, + Conditions: []workflowmodel.Predicate{{Fact: "pr.review", Op: workflowmodel.OpIs, Value: "approved"}}, + Confidence: workflowmodel.ConfidenceInferred, + }) + for _, i := range mergeTransitions(m) { + if m.Spec.Transitions[i].GateRef == "" { + m.Spec.Transitions[i].GateRef = GateReview + } + } + return &m.Spec.Gates[len(m.Spec.Gates)-1] +} diff --git a/src/pkg/workflowmodel/ingest/templates.go b/src/pkg/workflowmodel/ingest/templates.go new file mode 100644 index 0000000000..cc110b21f0 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/templates.go @@ -0,0 +1,316 @@ +package ingest + +import ( + "bytes" + "fmt" + "path" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +// issueTemplate is one issue form (YAML) or legacy template (Markdown +// with front matter) as GitHub reads it: the fields the intake needs. +// Title prefixes and assignees are not read; the schema has no slot for +// them. +type issueTemplate struct { + Path string + // Line is the line the template's name is declared on (1 for a file + // with no name key). + Line int + Name string + Labels []string + // LabelLines are the lines each label is declared on, parallel to + // Labels. + LabelLines []int + Form bool +} + +func isIssueTemplatePath(rel string) bool { + if isIssueTemplateConfig(rel) { + return false + } + if pathIs(rel, ".github/issue_template.md", "issue_template.md") { + return true + } + rest, ok := strings.CutPrefix(strings.ToLower(rel), strings.ToLower(intakeDir)) + if !ok || rest == "" || strings.Contains(rest, "/") { + return false + } + return isYAMLPath(rest) || path.Ext(rest) == ".md" +} + +func isIssueTemplateConfig(rel string) bool { + return pathIs(rel, ".github/issue_template/config.yml", ".github/issue_template/config.yaml") +} + +func isPRTemplatePath(rel string) bool { + if pathIs(rel, ".github/pull_request_template.md", "pull_request_template.md", "docs/pull_request_template.md") { + return true + } + rest, ok := strings.CutPrefix(strings.ToLower(rel), ".github/pull_request_template/") + return ok && rest != "" && !strings.Contains(rest, "/") && path.Ext(rest) == ".md" +} + +// parseTemplateConfig reads blank_issues_enabled from +// .github/ISSUE_TEMPLATE/config.yml; nil when the key is absent. +func parseTemplateConfig(data []byte) *bool { + var cfg struct { + BlankIssuesEnabled *bool `yaml:"blank_issues_enabled"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil + } + return cfg.BlankIssuesEnabled +} + +// parseIssueTemplate reads a form or front-matter template. A template +// GitHub would not load (no YAML, no name) is reported as a finding. +func parseIssueTemplate(p string, data []byte) (issueTemplate, *workflowmodel.Finding) { + t := issueTemplate{Path: p, Line: 1} + src := data + offset := 0 + if path.Ext(strings.ToLower(p)) == ".md" { + fm, off, ok := frontMatter(data) + if !ok { + return t, &workflowmodel.Finding{Path: p, Code: CodeTemplateUnparsable, Detail: "no front matter", Severity: workflowmodel.SeverityWarning} + } + src, offset = fm, off + } else { + t.Form = true + } + var doc yaml.Node + if err := yaml.Unmarshal(src, &doc); err != nil || doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + detail := "not a YAML mapping" + if err != nil { + detail = err.Error() + } + return t, &workflowmodel.Finding{Path: p, Code: CodeTemplateUnparsable, Detail: detail, Severity: workflowmodel.SeverityWarning} + } + root := doc.Content[0] + for i := 0; i+1 < len(root.Content); i += 2 { + k, v := root.Content[i], root.Content[i+1] + switch strings.ToLower(k.Value) { + case "name": + t.Name = strings.TrimSpace(v.Value) + t.Line = k.Line + offset + case "labels": + t.Labels, t.LabelLines = stringList(v, offset) + } + } + if t.Name == "" { + t.Name = strings.TrimSuffix(path.Base(p), path.Ext(p)) + } + return t, nil +} + +// stringList reads a YAML sequence of scalars or a comma-separated +// scalar (both are valid for labels and assignees) with the line each +// item is on. +func stringList(n *yaml.Node, offset int) ([]string, []int) { + var out []string + var lines []int + add := func(s string, line int) { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + lines = append(lines, line+offset) + } + } + switch n.Kind { + case yaml.SequenceNode: + for _, c := range n.Content { + if c.Kind == yaml.ScalarNode { + add(c.Value, c.Line) + } + } + case yaml.ScalarNode: + for _, s := range strings.Split(n.Value, ",") { + add(s, n.Line) + } + } + return out, lines +} + +// frontMatter returns the YAML between the leading --- fences and the +// line offset to add to YAML line numbers. +func frontMatter(data []byte) ([]byte, int, bool) { + data = bytes.TrimLeft(data, "\ufeff") + lines := bytes.Split(data, []byte("\n")) + if len(lines) == 0 || strings.TrimSpace(string(lines[0])) != "---" { + return nil, 0, false + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(string(lines[i])) == "---" { + return bytes.Join(lines[1:i], []byte("\n")), 1, true + } + } + return nil, 0, false +} + +// parseChecklist turns a pull-request template's checklist items into +// manual checks, one per `- [ ]` line, cited by line. +func parseChecklist(p string, data []byte) []workflowmodel.Check { + var out []workflowmodel.Check + seen := map[string]bool{} + for i, line := range strings.Split(string(data), "\n") { + s := strings.TrimSpace(line) + var text string + for _, marker := range []string{"- [ ] ", "* [ ] ", "- [x] ", "* [x] ", "- [X] ", "* [X] "} { + if rest, ok := strings.CutPrefix(s, marker); ok { + text = strings.TrimSpace(rest) + break + } + } + if text == "" { + continue + } + id := "pr-template-" + truncate(workflowmodel.Slug(text), maxCheckSlug) + if seen[id] { + continue + } + seen[id] = true + out = append(out, workflowmodel.Check{ID: id, Name: text, Kind: workflowmodel.CheckManual, Source: fmt.Sprintf("%s:%d", p, i+1)}) + } + return out +} + +// maxCheckSlug bounds a checklist-derived check id. +const maxCheckSlug = 60 + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return strings.TrimRight(s[:n], "-") +} + +// intakeState is one intake state: the templates that share a label set. +type intakeState struct { + id, label string + // bind is the label whose presence fires the intake event. + bind string + labels []string + citations []workflowmodel.Citation +} + +// planIntake groups templates by label set into intake states. The event +// label is the first template label that is not a triage label and is not +// already bound by another intake state; a template without such a label +// is merged into the state that bound it or reported, never guessed. +func planIntake(templates []issueTemplate, triage triageLabels) ([]intakeState, []workflowmodel.Finding) { + var states []intakeState + var findings []workflowmodel.Finding + bySet := map[string]int{} + byBind := map[string]int{} + ids := map[string]bool{} + for _, t := range templates { + if len(t.Labels) == 0 { + findings = append(findings, workflowmodel.Finding{Path: t.Path, Code: CodeTemplateNoLabels, Detail: fmt.Sprintf("template %q sets no labels; an issue filed with it cannot be told apart", t.Name), Severity: workflowmodel.SeverityUnknown}) + continue + } + key := labelSetKey(t.Labels) + c := cite(t.Path, t.Line, t.Name) + if i, ok := bySet[key]; ok { + states[i].citations = append(states[i].citations, c) + continue + } + bind := "" + for _, l := range t.Labels { + if triage.is(l) { + continue + } + if _, used := byBind[normalizeLabel(l)]; used { + continue + } + bind = l + break + } + if bind == "" { + // Every label is a triage label or already fires another + // intake state: fold into the state sharing the first label. + merged := false + for _, l := range t.Labels { + if i, ok := byBind[normalizeLabel(l)]; ok { + states[i].citations = append(states[i].citations, c) + findings = append(findings, workflowmodel.Finding{Path: t.Path, Code: CodeTemplateMerged, Detail: fmt.Sprintf("template %q shares label %q with %q", t.Name, l, states[i].label), Severity: workflowmodel.SeverityUnknown}) + merged = true + break + } + } + if !merged { + findings = append(findings, workflowmodel.Finding{Path: t.Path, Code: CodeTemplateNoLabels, Detail: fmt.Sprintf("template %q sets only triage labels", t.Name), Severity: workflowmodel.SeverityUnknown}) + } + continue + } + id := StateRequestedPrefix + workflowmodel.Slug(t.Name) + for n := 2; ids[id]; n++ { + id = fmt.Sprintf("%s%s-%d", StateRequestedPrefix, workflowmodel.Slug(t.Name), n) + } + ids[id] = true + bySet[key] = len(states) + byBind[normalizeLabel(bind)] = len(states) + states = append(states, intakeState{id: id, label: "Requested: " + t.Name, bind: bind, labels: t.Labels, citations: []workflowmodel.Citation{c}}) + } + return states, findings +} + +func labelSetKey(labels []string) string { + set := map[string]bool{} + for _, l := range labels { + set[normalizeLabel(l)] = true + } + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, "\x00") +} + +// applyTemplates adds the Intake column and one state per intake plan, +// each entered from Open on its template event and left for Triage (when +// the repository has a triage label) or Actionable (when a stage label +// is applied). +func applyTemplates(m *workflowmodel.Model, templates []issueTemplate, triage triageLabels) { + plan, findings := planIntake(templates, triage) + m.Spec.Coverage.Findings = append(m.Spec.Coverage.Findings, findings...) + if len(plan) == 0 { + return + } + col := workflowmodel.Column{ID: ColumnIntake, Label: "Intake", Hint: "Filed through an issue template; not yet triaged"} + prev := hivedefault.StateOpen + for _, s := range plan { + st := workflowmodel.State{ + ID: s.id, Label: s.label, Owner: workflowmodel.OwnerHuman, Origin: workflowmodel.OriginIngested, + Lifecycle: workflowmodel.StateLifecycle{Column: ColumnIntake, Kinds: []workflowmodel.ItemKind{workflowmodel.KindIssue}, Confidence: workflowmodel.ConfidenceInferred, Citations: s.citations}, + } + insertStateAfter(m, prev, st) + prev = s.id + col.States = append(col.States, s.id) + ev := EventTemplatePrefix + strings.TrimPrefix(s.id, StateRequestedPrefix) + m.Spec.Events = append(m.Spec.Events, workflowmodel.Event{ + ID: ev, Label: "Issue opened with template " + strings.TrimPrefix(s.label, "Requested: "), + Fact: workflowmodel.Predicate{Fact: "labels", Op: workflowmodel.OpHas, Value: s.bind}, + Confidence: workflowmodel.ConfidenceInferred, Citations: s.citations, + }) + m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: hivedefault.StateOpen, To: s.id, On: ev, Note: "issue template applied label " + s.bind}) + if triage.needs != "" { + m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: s.id, To: StateTriage, On: EventTriageRequested, Note: "template carries the triage label"}) + } else { + m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: s.id, To: hivedefault.StateID(stageActionable), On: hivedefault.EventStageChange, Note: "staged by hive"}) + } + m.Spec.Transitions = append(m.Spec.Transitions, workflowmodel.Transition{From: s.id, To: hivedefault.StateClosed, On: hivedefault.EventClosed}) + for _, l := range s.labels { + e := workflowmodel.LabelEntry{Name: l} + if normalizeLabel(l) == normalizeLabel(s.bind) { + e.State = s.id + } + addLabel(m, e) + } + } + insertColumnAfter(m, hivedefault.ColumnOpen, col) +} diff --git a/src/pkg/workflowmodel/ingest/testdata/empty/README.md b/src/pkg/workflowmodel/ingest/testdata/empty/README.md new file mode 100644 index 0000000000..b7db25411d --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/empty/README.md @@ -0,0 +1 @@ +# Empty diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/bug-report.yaml b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/bug-report.yaml new file mode 100644 index 0000000000..b389b0fd8e --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -0,0 +1,10 @@ +name: Bug Report +description: Report a bug encountered while operating Widgets +labels: ["kind/bug", "needs-triage"] +body: + - type: textarea + id: problem + attributes: + label: What happened? + validations: + required: true diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/config.yml b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8061300fe9 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Support + url: https://discuss.example.org + about: Ask questions here diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/enhancement.yaml b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/enhancement.yaml new file mode 100644 index 0000000000..b3e06a3043 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -0,0 +1,8 @@ +name: Enhancement Tracking Issue +description: Provide supporting details for a feature in development +labels: kind/feature, needs-triage +body: + - type: textarea + id: feature + attributes: + label: What would you like to be added? diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/flaking-test.yaml b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/flaking-test.yaml new file mode 100644 index 0000000000..52d0f9f3e8 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/flaking-test.yaml @@ -0,0 +1,8 @@ +name: Flaking Test +description: Report flaky tests or jobs in Widgets CI +labels: ["kind/flake", "needs-triage"] +body: + - type: input + id: job + attributes: + label: Which jobs are flaking? diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/support.yaml b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/support.yaml new file mode 100644 index 0000000000..3eca1c3c19 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/ISSUE_TEMPLATE/support.yaml @@ -0,0 +1,6 @@ +name: Support Request +description: Questions belong on the forum, not here +body: + - type: markdown + attributes: + value: Please ask on discuss.example.org. diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/PULL_REQUEST_TEMPLATE.md b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..2e84a959a9 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ + + +#### What type of PR is this? + +#### Which issue(s) this PR fixes: + +Fixes # + +#### Checklist + +- [ ] Tests added or updated +- [ ] Documentation updated in `docs/` +- [ ] Release note drafted below +- [x] I have read the CONTRIBUTING guide + +```release-note + +``` diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.hive/branch-rules.json b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.hive/branch-rules.json new file mode 100644 index 0000000000..e20dfe0582 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/.hive/branch-rules.json @@ -0,0 +1,12 @@ +{ + "branch": "master", + "source": "protection", + "endpoint": "repos/example/widgets/branches/master/protection", + "requiredChecks": ["pull-widgets-unit", "pull-widgets-verify", "dco"], + "requiredApprovals": 2, + "requireCodeOwnerReview": true, + "dismissStaleReviews": true, + "enforceAdmins": true, + "linearHistory": true, + "signedCommits": true +} diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/CONTRIBUTING.md b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/CONTRIBUTING.md new file mode 100644 index 0000000000..12669286f1 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +## Triage + +New issues get the `needs-triage` label. A maintainer reviews each one and +replaces it with `triage/accepted` when it is in scope, or closes it with a +reason. Newcomer-friendly issues are tagged `good first issue` and +`help wanted`. + +Pull requests need `/lgtm` from a reviewer and `/approve` from an approver. diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS new file mode 100644 index 0000000000..1eea6dad1c --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS @@ -0,0 +1,9 @@ +# See the OWNERS docs at https://go.k8s.io/owners +approvers: + - sig-widgets-leads + - alice +reviewers: + - sig-widgets-reviewers + - bob +emeritus_approvers: + - carol diff --git a/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS_ALIASES b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS_ALIASES new file mode 100644 index 0000000000..f45b9cded0 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/k8s-owners/OWNERS_ALIASES @@ -0,0 +1,6 @@ +aliases: + sig-widgets-leads: + - dave + - erin + sig-widgets-reviewers: + - frank diff --git a/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/.github/labels.yml b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/.github/labels.yml new file mode 100644 index 0000000000..36e0a1276a --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/.github/labels.yml @@ -0,0 +1,15 @@ +- name: kind/bug + color: d73a4a + description: Something is not working +- name: kind/feature + color: a2eeef +- name: priority/P0 + color: b60205 + description: Drop everything +- name: priority/P1 + color: d93f0b +- name: needs-triage + color: ededed + description: Awaiting a maintainer's look +- name: documentation + color: 0075ca diff --git a/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/CONTRIBUTING.md b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/CONTRIBUTING.md new file mode 100644 index 0000000000..9b07ee26ac --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing + +New issues get `needs-triage`. A maintainer then sets one `kind/*` label and, +for bugs, a `priority/P0`..`priority/P2` label; `area/runtime` scopes it. diff --git a/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/MAINTAINERS.md b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/MAINTAINERS.md new file mode 100644 index 0000000000..c6ab03da5e --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/maintainers-labels/MAINTAINERS.md @@ -0,0 +1,9 @@ +# Maintainers + +| Name | GitHub | Affiliation | +|------|--------|-------------| +| Grace Hopper | [@grace](https://github.com/grace) | Navy | +| Ada Lovelace | @ada | Analytical Engines | +| Linus | https://github.com/linus-t | Kernel | + +Contact: maintainers@example.com (not a handle). diff --git a/src/pkg/workflowmodel/ingest/testdata/md-template/.github/CODEOWNERS b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/CODEOWNERS new file mode 100644 index 0000000000..f66d68fa7f --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Default owners +* @acme/core-team +docs/ @octocat @hubot # docs folks diff --git a/src/pkg/workflowmodel/ingest/testdata/md-template/.github/ISSUE_TEMPLATE.md b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..4853c2f597 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,9 @@ +--- +name: Bug report +about: Something is broken +labels: bug +assignees: octocat +--- + +**Describe the bug** +A clear description. diff --git a/src/pkg/workflowmodel/ingest/testdata/md-template/.github/pull_request_template.md b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/pull_request_template.md new file mode 100644 index 0000000000..56746ee9b3 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/md-template/.github/pull_request_template.md @@ -0,0 +1,5 @@ +## Summary + +## Checklist +* [ ] `make test` passes +* [ ] Changelog entry added diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/ISSUE_TEMPLATE/assessment-request.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/ISSUE_TEMPLATE/assessment-request.yml new file mode 100644 index 0000000000..673797f04d --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/ISSUE_TEMPLATE/assessment-request.yml @@ -0,0 +1,142 @@ +name: Documentation assessment request +description: Request an AI-assisted assessment of your project's documentation. +title: '[Assessment request]: ' +labels: + - Docs analysis + - needs-triage +body: + - type: markdown + attributes: + value: | + Thanks for your interest in a documentation assessment. + + Filing this request does not start an assessment. It gathers + context. A technical writer triages every request and + explicitly accepts it before any work begins. + + Assessments produced through this process are AI-drafted and + human-reviewed: an AI agent writes the draft, and a technical + writer reviews every finding before anything is published. The + project contacts named below are notified on this issue before + drafting begins. + - type: input + id: project-name + attributes: + label: Project name + description: The CNCF project to assess. + validations: + required: true + - type: dropdown + id: maturity + attributes: + label: Current CNCF maturity level + description: The project's level today. + options: + - Sandbox + - Incubating + - Graduated + validations: + required: true + - type: dropdown + id: moving-levels + attributes: + label: Is this project applying to move levels? + description: + If yes, the assessment runs against the next level's criteria; otherwise + against the current level's. + options: + - 'No' + - 'Yes' + default: 0 + validations: + required: true + - type: input + id: website + attributes: + label: Project website + placeholder: https://example.io + validations: + required: true + - type: input + id: docs-url + attributes: + label: Documentation site + description: Leave blank if the documentation lives on the website above. + validations: + required: false + - type: input + id: repo + attributes: + label: Website or documentation repository + placeholder: https://github.com/org/repo + validations: + required: true + - type: textarea + id: other-scope + attributes: + label: Other repositories or sites in scope + description: + Demo servers, governance sites, or other repositories the assessment + should cover. One per line. + validations: + required: false + - type: textarea + id: out-of-scope + attributes: + label: Out of scope + description: Anything the assessment should not cover, and why. + validations: + required: false + - type: textarea + id: domains + attributes: + label: Documentation domains + description: + Every domain the assessment needs to read, one per line, for example the + documentation site and any demo or governance sites. The assessing agent + runs behind a firewall and can only reach domains added to its + allowlist; these entries are added at acceptance and removed afterward. + placeholder: | + docs.example.io + demo.example.io + validations: + required: true + - type: textarea + id: contacts + attributes: + label: Project contacts + description: + GitHub handles of project community members available to help with the + assessment, one per line. They are notified on this issue before + drafting begins. + placeholder: '@community-member' + validations: + required: true + - type: input + id: tooling + attributes: + label: Site tooling + description: + Static site generator, theme, and hosting, if you know them, for example + Hugo with Docsy on Netlify. + validations: + required: false + - type: textarea + id: context + attributes: + label: Anything else + description: + Context that would help the assessment, such as known problem areas or + recent documentation work. + validations: + required: false + - type: checkboxes + id: disclosure + attributes: + label: AI involvement disclosure + options: + - label: + I understand this assessment will be AI-drafted and human-reviewed, + and that the project contacts named above will be notified before + drafting begins. + required: true diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/settings.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/settings.yml new file mode 100644 index 0000000000..3d337bccca --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/settings.yml @@ -0,0 +1,155 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ + +repository: + # See https://developer.github.com/v3/repos/#edit for all available settings. + + # The name of the repository. Changing this will rename the repository + name: techdocs + + # A short description of the repository that will show up on GitHub + description: CNCF TechDocs Team + + # A URL with more information about the repository + homepage: https://www.cncf.io + + # A comma-separated list of topics to set on the repository + topics: documentation, cncf + + # Either `true` to make the repository private, or `false` to make it public. + private: false + + # Either `true` to enable issues for this repository, `false` to disable them. + has_issues: true + + # Either `true` to enable projects for this repository, or `false` to disable them. + # If projects are disabled for the organization, passing `true` will cause an API error. + has_projects: true + + # Either `true` to enable the wiki for this repository, `false` to disable it. + has_wiki: false + + # Either `true` to enable downloads for this repository, `false` to disable them. + has_downloads: false + + # Updates the default branch for this repository. + default_branch: main + + # Either `true` to allow squash-merging pull requests, or `false` to prevent + # squash-merging. + allow_squash_merge: true + + # Either `true` to allow merging pull requests with a merge commit, or `false` + # to prevent merging pull requests with merge commits. + allow_merge_commit: true + + # Either `true` to allow rebase-merging pull requests, or `false` to prevent + # rebase-merging. + allow_rebase_merge: true + +# Labels: define labels for Issues and Pull Requests +labels: + - name: admin + description: 'TechDocs administration activities' + color: 'd93f0b' + - name: CI/infra + description: CI & infrastructure + color: '696969' + - name: p0-critical + color: 'B60205' + - name: p1-high + color: 'D93F0B' + - name: p2-medium + color: 'FBCA04' + - name: p3-low + color: 'FEF2C0' + - name: e0-minutes + description: 'Effort: < 60 min' + color: 'e6ccb3' + - name: e1-hours + description: 'Effort: < 8 hrs' + color: 'd9b38c' + - name: e2-days + description: 'Effort: < 5 days' + color: 'cc9966' + - name: e3-weeks + description: 'Effort: < 4 weeks' + color: 'bf8040' + - name: e4-months + description: 'Effort: 1+ months' + color: '996633' + - name: Docs analysis + description: CNCF technical documentation assistance + color: 'c2e0c6' + - name: Website Update + description: 'CNCF Help request for project website updates' + color: 'BFAFE3' + - name: suggested doc + description: 'New doc suggestions for the docs/ section' + color: 'd93f0b' + # Default new repo labels + - name: bug + description: "Something isn't working" + color: 'd73a4a' + - name: documentation + description: Improvements or additions to the TechDocs documentation + color: '0075ca' + - name: duplicate + description: This issue or pull request already exists + color: 'cfd3d7' + - name: enhancement + description: New feature or request + color: 'a2eeef' + - name: good first issue + description: Good for newcomers + color: '7057ff' + - name: help wanted + description: Extra attention is needed + color: '008672' + - name: invalid + description: "This doesn't seem right" + color: 'e4e669' + - name: question + description: Further information is requested + color: 'd876e3' + - name: wontfix + description: This will not be worked on + color: 'ffffff' + - name: ops/service-desk + description: Service desk and support operations + color: '32CD32' + - name: CNCF Service Desk + description: This issue has a related CNCF Service Desk ticket + color: '0CD9EF' + - name: broken-link + description: Broken links found by automated link checker + color: 'd73a4a' + - name: techdocs-upstream + color: 'ededed' + # Assessment workflow labels + - name: needs-triage + description: Assessment request awaiting triage + color: 'EDEDED' + - name: triage/accepted + description: Assessment request accepted + color: 'C2E0C6' + - name: triage/declined + description: Assessment request declined, reason posted + color: 'F9D0C4' + - name: phase/assessment + description: 'Assessment phase A: analysis' + color: 'C5DEF5' + - name: phase/implementation + description: 'Assessment phase B: implementation plan' + color: '84B6EB' + - name: phase/backlog + description: 'Assessment phase C: issue backlog' + color: '1D76DB' + - name: verified + description: Verification complete for this deliverable PR + color: '0E8A16' + - name: confirmed + description: Stakeholder confirmation recorded for this deliverable PR + color: '006B75' + - name: skip-implementation + description: Phase B skipped; phase A merge opens phase C + color: 'D4C5F9' diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/copilot-setup-steps.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000000..ccd3ea3ce1 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,26 @@ +# Prepares the Copilot cloud agent's ephemeral environment before the +# model starts: Node from .nvmrc, npm cache, npm ci. Runs on changes to +# itself so pull requests validate it; takes effect for agent sessions +# once merged to the default branch. + +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job must be called copilot-setup-steps or the agent ignores it. + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/npm-ci-via-cached-nvmrc diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/format-check.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/format-check.yml new file mode 100644 index 0000000000..25a9d43452 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/format-check.yml @@ -0,0 +1,24 @@ +name: Format checks + +on: + pull_request: + +permissions: + contents: read + +jobs: + format-check: + name: FILE FORMAT + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/npm-ci-via-cached-nvmrc + - run: npm run check:format + + markdown-linter: + name: MARKDOWN linter + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/npm-ci-via-cached-nvmrc + - run: npm run check:markdown diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/link-check.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/link-check.yml new file mode 100644 index 0000000000..a3449c666c --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/link-check.yml @@ -0,0 +1,16 @@ +name: Link checks + +on: + pull_request: + +permissions: + contents: read + +jobs: + link-check: + name: LINK checking + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/npm-ci-via-cached-nvmrc + - run: npm run check:links diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/spell-check.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/spell-check.yml new file mode 100644 index 0000000000..3a7d882a13 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/spell-check.yml @@ -0,0 +1,16 @@ +name: Spelling checks + +on: + pull_request: + +permissions: + contents: read + +jobs: + spelling-check: + name: SPELLING check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/npm-ci-via-cached-nvmrc + - run: npm run check:spelling diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/trigger-contribute-site-netlify.yml b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/trigger-contribute-site-netlify.yml new file mode 100644 index 0000000000..fd54d029f3 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.github/workflows/trigger-contribute-site-netlify.yml @@ -0,0 +1,15 @@ +name: Trigger contribute-site Netlify build + +on: + push: + branches: [main] + +permissions: {} + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Call Netlify build hook + run: | + curl -sS -X POST "${{ secrets.NETLIFY_CONTRIBUTE_SITE_BUILD_HOOK }}" diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/.hive/branch-rules.json b/src/pkg/workflowmodel/ingest/testdata/techdocs/.hive/branch-rules.json new file mode 100644 index 0000000000..3c59a4ede8 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/.hive/branch-rules.json @@ -0,0 +1,5 @@ +{ + "branch": "main", + "source": "rulesets", + "endpoint": "repos/cncf/techdocs/rules/branches/main" +} \ No newline at end of file diff --git a/src/pkg/workflowmodel/ingest/testdata/techdocs/README.md b/src/pkg/workflowmodel/ingest/testdata/techdocs/README.md new file mode 100644 index 0000000000..b47304fc86 --- /dev/null +++ b/src/pkg/workflowmodel/ingest/testdata/techdocs/README.md @@ -0,0 +1,12 @@ +Trimmed snapshot of cncf/techdocs for the intake ingest tests +(hivecommons/hive#17620 phase 5). + +- `.github/settings.yml`, `.github/workflows/*`: cncf/techdocs `main` at + 4b1f800a8c55491506ac426d755820a856747c92 (2026-10-28). +- `.github/ISSUE_TEMPLATE/assessment-request.yml`: the assessment intake + form from nate-double-u/cncf-techdocs branch `build/03-intake`, the + template the RFC's phase 5 definition of done refers to; `main` had no + issue templates when the snapshot was taken. +- `.hive/branch-rules.json`: `GET /repos/cncf/techdocs/rules/branches/main` + as read on 2026-11-11 (branch protection 404s; the org ruleset requires + zero approvals and no checks, so nothing is inferred from it). diff --git a/src/pkg/workflowmodel/ingest/tree.go b/src/pkg/workflowmodel/ingest/tree.go index 570674ef2f..fc9beafd7a 100644 --- a/src/pkg/workflowmodel/ingest/tree.go +++ b/src/pkg/workflowmodel/ingest/tree.go @@ -22,10 +22,12 @@ var tektonDirs = []string{".tekton/", "tekton/", "pipelines/", ".pipelines/", "o // skipDirs are never descended into. var skipDirs = map[string]bool{".git": true, "node_modules": true, "vendor": true, "testdata": true, "third_party": true} -// ReadTree walks a checkout and returns the CI definition files the ingest -// reads, keyed by repo-relative slash path: every GitHub Actions workflow, -// every YAML under a Tekton directory, and any other YAML whose name hints -// at a pipeline and whose content looks like a Tekton document. Files over +// ReadTree walks a checkout and returns the files the ingest reads, keyed +// by repo-relative slash path: every GitHub Actions workflow, every YAML +// under a Tekton directory, any other YAML whose name hints at a pipeline +// and whose content looks like a Tekton document, and the process +// convention files IsIntakePath names (issue and PR templates, +// CONTRIBUTING, CODEOWNERS/OWNERS, label and settings files). Files over // MaxFileBytes are returned too (Repository reports them). The result is // what Repository and Lifecycle take. func ReadTree(root string) (map[string][]byte, error) { @@ -45,10 +47,13 @@ func ReadTree(root string) (map[string][]byte, error) { } return nil } - if !d.Type().IsRegular() || !isYAMLPath(rel) || len(files) >= MaxTreeFiles { + if !d.Type().IsRegular() || len(files) >= MaxTreeFiles { return nil } switch { + case IsIntakePath(rel): + case !isYAMLPath(rel): + return nil case githubactions.IsWorkflowPath(rel), underTektonDir(rel): case hintsPipeline(rel): data, err := os.ReadFile(p) diff --git a/src/pkg/workflowmodel/lifecycle.go b/src/pkg/workflowmodel/lifecycle.go index 5860bdac49..6606df5364 100644 --- a/src/pkg/workflowmodel/lifecycle.go +++ b/src/pkg/workflowmodel/lifecycle.go @@ -212,9 +212,12 @@ type Gate struct { // ReleasedBy is the Role that may release the gate. Required. ReleasedBy string `json:"releasedBy"` // Reply is the text posted when the gate is released. - Reply string `json:"reply,omitempty"` - Confidence Confidence `json:"confidence,omitempty"` - Citations []Citation `json:"citations,omitempty"` + Reply string `json:"reply,omitempty"` + // MinApprovals is how many distinct approving reviews release the + // gate; zero means one release suffices. + MinApprovals int `json:"minApprovals,omitempty"` + Confidence Confidence `json:"confidence,omitempty"` + Citations []Citation `json:"citations,omitempty"` } // RoleKind is how a role's members are determined. @@ -223,12 +226,14 @@ type RoleKind string // Role kinds. Permission is a repository permission (admin, maintain, // write); FrozenSet is a set named at an earlier event (the reviewers at // request time); AuthorKind is authorkind (human, clanker, hive); Bot is a -// named bot login. +// named bot login; Members is a fixed list of logins and teams (params +// "members", comma-separated) such as a CODEOWNERS or OWNERS file names. const ( RolePermission RoleKind = "permission" RoleFrozenSet RoleKind = "frozen-set" RoleAuthorKind RoleKind = "author-kind" RoleBot RoleKind = "bot" + RoleMembers RoleKind = "members" ) // RoleDef is who may fire which events and release which gates. @@ -251,12 +256,24 @@ type AgentGrant struct { Produces []string `json:"produces,omitempty"` } -// Check is a CI check a gate may require. +// CheckKind is how a check is satisfied. +type CheckKind string + +// Check kinds. CI is a status context a service reports; Manual is a box +// a person ticks (a pull-request template checklist item). +const ( + CheckCI CheckKind = "ci" + CheckManual CheckKind = "manual" +) + +// Check is a CI check or manual checklist item a gate may require. type Check struct { - ID string `json:"id"` - Name string `json:"name"` - Required bool `json:"required,omitempty"` - Source string `json:"source,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Kind CheckKind `json:"kind,omitempty"` + Required bool `json:"required,omitempty"` + // Source is where the check was read from: a file:line or an API path. + Source string `json:"source,omitempty"` } // Effect is a deterministic bookkeeping action on an event.