Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
## What this does

<!--
Describe the resulting behavior and scope. For each new API, show
representative calls. For each changed API, show representative calls before
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ traffic or cleanup, or bad input reaches hardware before it is rejected.
- Require documentation in the same commit as exported API, ownership,
lifecycle, safety, platform, composition, or validation-claim changes.
- Reject a pull request which adds an API without representative calls in
“What this does.” When an API changes, require representative calls before
and after the change. Verify that every example preserves the real
its opening description. When an API changes, require representative calls
before and after the change. Verify that every example preserves the real
ownership, cleanup, and safety rules and shows the actual migration.
- Reject pull-request prose paragraphs which are hard-wrapped in the Markdown
source. Let GitHub wrap paragraphs for display; use source line breaks for
Expand Down
13 changes: 8 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ architecture, behavioral coverage, and documentation claims. A completed
standard Codex review must match the current pull-request head. Codex must
reject a nontrivial commit whose message does not explain why the commit is
needed and what behavior it establishes at that boundary. Codex must also
reject a pull request which adds an API without representative calls in “What
this does,” or changes an API without representative calls before and after
reject a pull request which adds an API without representative calls in its
opening description, or changes an API without representative calls before and after
the change. It must also reject examples which hide ownership, cleanup, safety,
or migration details needed to understand ordinary use, and prose paragraphs
which are hard-wrapped in the Markdown source. On the final head, comment
Expand Down Expand Up @@ -170,9 +170,12 @@ hardware path.

## Pull-request descriptions

Use “What this does” for the resulting behavior and scope. For every new API,
show representative calls which make ordinary use concrete. For every changed
API, show representative calls before and after the change so that the
Begin with the resulting behavior and scope, without an opening heading.
Headings, separators, and empty blocks do not satisfy the description
requirement. Existing descriptions may retain “What this does” as the first
section heading.
For every new API, show representative calls which make ordinary use concrete.
For every changed API, show representative calls before and after the change so that the
migration is visible. The examples must preserve the same ownership, cleanup,
and safety rules as ordinary code.

Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module github.com/jon/ostiole

go 1.25.12

require (
github.com/yuin/goldmark v1.8.6
golang.org/x/net v0.58.0
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
172 changes: 155 additions & 17 deletions internal/ci/checkpr/metadata.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
package main

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"unicode"

"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
markdownhtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
xhtml "golang.org/x/net/html"
)

type pullRequestMetadata struct {
Title string
Body string
}

var htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
var disallowedHTML = regexp.MustCompile(`(?i)<(/?)(title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)([\t\n\f\r />])`)

var requiredPullRequestSections = []string{
"What this does",
"Why",
"Documentation",
}
Expand All @@ -44,9 +52,15 @@ func readPullRequestEvent(name string) (pullRequestMetadata, error) {

func checkPullRequest(metadata pullRequestMetadata) []finding {
findings := checkSubject("pull-request", strings.TrimSpace(metadata.Title))
sections := pullRequestSections(htmlComment.ReplaceAllString(metadata.Body, ""))
sections := pullRequestSections(metadata.Body)
if !sections[""] {
findings = append(findings, newFinding(
errorLevel, "pr-body", "pull-request",
"pull-request opening description is missing or empty",
))
}
for _, required := range requiredPullRequestSections {
if strings.TrimSpace(sections[required]) != "" {
if sections["#"+required] {
continue
}
findings = append(findings, newFinding(
Expand All @@ -57,19 +71,143 @@ func checkPullRequest(metadata pullRequestMetadata) []finding {
return findings
}

func pullRequestSections(body string) map[string]string {
sections := make(map[string]string)
current := ""
scanner := bufio.NewScanner(strings.NewReader(body))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "## ") {
current = strings.TrimSpace(strings.TrimPrefix(line, "## "))
continue
type pullRequestBody struct {
sections map[string]bool
current string
first bool
}

func pullRequestSections(body string) map[string]bool {
result := pullRequestBody{sections: make(map[string]bool), first: true}
markdown := goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Footnote),
Comment thread
jon marked this conversation as resolved.
Comment thread
jon marked this conversation as resolved.
goldmark.WithRendererOptions(markdownhtml.WithUnsafe()),
Comment thread
jon marked this conversation as resolved.
)
source := []byte(body)
document := markdown.Parser().Parse(text.NewReader(source))
removeDecorations(document, source)
var rendered bytes.Buffer
if err := markdown.Renderer().Render(&rendered, source, document); err != nil {
return result.sections
}
filtered := disallowedHTML.ReplaceAll(rendered.Bytes(), []byte("&lt;$1$2$3"))
htmlDocument, err := xhtml.Parse(bytes.NewReader(filtered))
if err != nil {
return result.sections
}
result.addHTMLNode(htmlDocument, true)
return result.sections
}

func (body *pullRequestBody) heading(level int, title string, sections bool) {
if sections {
body.addHeading(level, title)
} else if body.current == "" && !body.sections[""] {
body.current = "#"
body.first = false
}
}

func (body *pullRequestBody) addHeading(level int, title string) {
title = strings.TrimSpace(title)
if body.first && level == 2 && title == "What this does" {
body.current = ""
} else if body.current == "" && !body.sections[""] || level <= 2 {
body.current = "#" + title
}
body.first = false
}

func (body *pullRequestBody) addHTMLNode(node *xhtml.Node, sections bool) {
if node.Type == xhtml.ElementNode {
if len(node.Data) == 2 && node.Data[0] == 'h' && node.Data[1] >= '1' && node.Data[1] <= '6' {
body.heading(int(node.Data[1]-'0'), htmlText(node), sections)
Comment thread
jon marked this conversation as resolved.
return
}
switch node.Data {
case "html", "body", "div", "section", "article", "main":
default:
sections = false
}
}
if node.Type == xhtml.TextNode && visibleText(node.Data) {
body.sections[body.current] = true
body.first = false
}
for _, child := range visibleChildren(node) {
body.addHTMLNode(child, sections)
}
}

func htmlText(node *xhtml.Node) string {
if node.Type == xhtml.TextNode {
return node.Data
}
var result strings.Builder
for _, child := range visibleChildren(node) {
result.WriteString(htmlText(child))
}
return result.String()
}

func visibleText(value string) bool {
return strings.ContainsFunc(value, func(r rune) bool {
return r != '\u2800' && !unicode.IsSpace(r) && !unicode.IsControl(r) && !unicode.IsMark(r) &&
!unicode.Is(unicode.Cf, r) && !unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r)
})
}

func removeDecorations(node ast.Node, source []byte) {
if node.Kind() == ast.KindBlockquote {
removeAlertMarker(node.FirstChild(), source)
}
for child := node.FirstChild(); child != nil; {
next := child.NextSibling()
switch child.Kind() {
case extast.KindFootnoteList, extast.KindFootnoteLink:
node.RemoveChild(node, child)
default:
removeDecorations(child, source)
}
if current != "" {
sections[current] += line + "\n"
child = next
}
}

func removeAlertMarker(node ast.Node, source []byte) {
paragraph, ok := node.(*ast.Paragraph)
if !ok || paragraph.Lines().Len() == 0 || paragraph.FirstChild() == nil {
return
}
line := paragraph.Lines().At(0)
switch strings.TrimSpace(string(line.Value(source))) {
case "[!NOTE]", "[!TIP]", "[!IMPORTANT]", "[!WARNING]", "[!CAUTION]":
for child := paragraph.FirstChild(); child != nil && child.Pos() < line.Stop; child = paragraph.FirstChild() {
paragraph.RemoveChild(paragraph, child)
}
}
}

func collapsedDetails(node *xhtml.Node) bool {
if node.Type != xhtml.ElementNode || node.Data != "details" {
return false
}
for _, attr := range node.Attr {
if attr.Key == "open" {
return false
}
}
return true
}

func visibleChildren(node *xhtml.Node) []*xhtml.Node {
var children []*xhtml.Node
collapsed := collapsedDetails(node)
for child := node.FirstChild; child != nil; child = child.NextSibling {
if !collapsed {
children = append(children, child)
} else if child.Type == xhtml.ElementNode && child.Data == "summary" {
return []*xhtml.Node{child}
}
}
return sections
return children
}
Loading
Loading