Read acceptance criteria from one recognized issue section
Gitea ingest set title, description and capability but never acceptance, so every Gitea-sourced task rendered "Acceptance: Not stated." however carefully its body was written. F2 restored the body; it did not wire the field. The convention is deliberately tiny. A markdown heading spelled "acceptance" or "acceptance criteria" opens the section, bullet and checklist items until the next heading become the criteria in order, checkbox markers are stripped, empty items are dropped, and the whole section leaves the description so a criterion is never also read as instruction prose. Nothing else is recognized: inferring acceptance from arbitrary prose would eventually invent a requirement, and a fabricated criterion outranks every human decision beneath it in the authority order. An absent section yields no acceptance, which is not an ingestion failure. The task renders "Not stated." and the frame phase resolves it through the decision-request path, where a human answers instead of the parser guessing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -277,3 +277,32 @@ func assertBefore(t *testing.T, ctx, first, second string) {
|
||||
t.Fatalf("%q must precede %q:\n%s", first, second, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingested acceptance criteria must reach the rendered task in order, and an
|
||||
// absent one must say so rather than be silently omitted.
|
||||
func TestAcceptanceCriteriaRenderInOrder(t *testing.T) {
|
||||
in := input()
|
||||
in.Task.Acceptance = []string{"labelled score does not regress", "targeted cases improve"}
|
||||
in.Intent.Task = in.Task
|
||||
got, err := Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := strings.Index(got.Task, "- labelled score does not regress")
|
||||
second := strings.Index(got.Task, "- targeted cases improve")
|
||||
if first < 0 || second < 0 {
|
||||
t.Fatalf("acceptance criteria missing from rendered task:\n%s", got.Task)
|
||||
}
|
||||
if first > second {
|
||||
t.Fatal("acceptance criteria rendered out of order")
|
||||
}
|
||||
in.Task.Acceptance = nil
|
||||
in.Intent.Task = in.Task
|
||||
got, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got.Task, "## Acceptance\n\nNot stated.") {
|
||||
t.Fatalf("absent acceptance did not render Not stated:\n%s", got.Task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -297,6 +298,57 @@ func (g Gitea) client() *http.Client {
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// acceptanceHeading opens the one recognized acceptance section. The
|
||||
// convention is deliberately tiny: two spellings, markdown heading only. An
|
||||
// ingestion that infers acceptance from arbitrary prose eventually invents
|
||||
// requirements, and a fabricated acceptance criterion outranks every human
|
||||
// decision below it in the authority order.
|
||||
var acceptanceHeading = regexp.MustCompile(`(?i)^#{1,6}[ \t]*acceptance([ \t]+criteria)?[ \t]*:?[ \t]*$`)
|
||||
|
||||
// acceptanceItem matches a bullet or checklist item and captures its text.
|
||||
var acceptanceItem = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(?:\[[ xX]\][ \t]*)?(.*)$`)
|
||||
|
||||
var markdownHeading = regexp.MustCompile(`^#{1,6}[ \t]+`)
|
||||
|
||||
// splitAcceptance separates an issue body into the description and the
|
||||
// acceptance criteria the issue stated for itself. Everything from the
|
||||
// recognized heading to the next heading leaves the description, so a criterion
|
||||
// is never also read as instruction prose. An empty result is not an ingestion
|
||||
// failure: a task with no stated acceptance renders "Not stated." and the frame
|
||||
// phase is where that gets resolved, through the decision-request path.
|
||||
func splitAcceptance(body string) (string, []string) {
|
||||
lines := strings.Split(body, "\n")
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
if acceptanceHeading.MatchString(strings.TrimRight(l, " \t\r")) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return body, nil
|
||||
}
|
||||
var acceptance []string
|
||||
end := len(lines)
|
||||
for i := start + 1; i < len(lines); i++ {
|
||||
line := strings.TrimRight(lines[i], " \t\r")
|
||||
if markdownHeading.MatchString(line) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
m := acceptanceItem.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if item := strings.TrimSpace(m[1]); item != "" {
|
||||
acceptance = append(acceptance, item)
|
||||
}
|
||||
}
|
||||
kept := append(append([]string{}, lines[:start]...), lines[end:]...)
|
||||
return strings.Trim(strings.Join(kept, "\n"), "\n"), acceptance
|
||||
}
|
||||
|
||||
func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
||||
caps := []string{}
|
||||
for _, l := range issue.Labels {
|
||||
@@ -306,7 +358,11 @@ func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
||||
// authority in every rendered context. Dropping it here made every
|
||||
// Gitea-sourced task run on its title alone, with "Acceptance: Not stated."
|
||||
// Found on the first burn-in task, 2026-08-26.
|
||||
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": issue.Body, "capability": caps}
|
||||
description, acceptance := splitAcceptance(issue.Body)
|
||||
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": description, "capability": caps}
|
||||
if len(acceptance) > 0 {
|
||||
p["acceptance"] = acceptance
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"orchestra/internal/domain"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -22,3 +23,81 @@ func TestJSONLRejectsMalformedLine(t *testing.T) {
|
||||
t.Fatalf("n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Gitea issue states its own acceptance under one recognized heading. Before
|
||||
// this, every Gitea-sourced task rendered "Acceptance: Not stated." no matter
|
||||
// what the body said, because nothing on that path ever set the field.
|
||||
func TestGiteaIssueAcceptanceSection(t *testing.T) {
|
||||
body := "improve ambiguous attribution.\n\n## acceptance\n- labelled score does not regress\n- targeted cases improve\n"
|
||||
e := Gitea{}.event(giteaIssue{Number: 7, Title: "fix attribution", Body: body}, "gitea", "p")
|
||||
var p struct {
|
||||
Description string `json:"description"`
|
||||
Acceptance []string `json:"acceptance"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Description != "improve ambiguous attribution." {
|
||||
t.Fatalf("description=%q, want the prose without the acceptance section", p.Description)
|
||||
}
|
||||
want := []string{"labelled score does not regress", "targeted cases improve"}
|
||||
if len(p.Acceptance) != len(want) {
|
||||
t.Fatalf("acceptance=%q, want %q", p.Acceptance, want)
|
||||
}
|
||||
for i := range want {
|
||||
if p.Acceptance[i] != want[i] {
|
||||
t.Fatalf("acceptance[%d]=%q, want %q (order is preserved)", i, p.Acceptance[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A body with no recognized section yields no acceptance, and that is correct
|
||||
// rather than an ingestion failure: the task renders "Not stated." and the
|
||||
// frame phase resolves it through the decision-request path. Inferring
|
||||
// criteria from arbitrary prose would invent requirements that outrank every
|
||||
// human decision beneath them.
|
||||
func TestGiteaIssueWithoutAcceptanceSectionStaysEmpty(t *testing.T) {
|
||||
body := "just do the thing.\n\n## notes\n- not an acceptance criterion\n"
|
||||
e := Gitea{}.event(giteaIssue{Number: 8, Title: "t", Body: body}, "gitea", "p")
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := p["acceptance"]; ok {
|
||||
t.Fatalf("acceptance=%v, want none", p["acceptance"])
|
||||
}
|
||||
if p["description"] != body {
|
||||
t.Fatalf("description=%q, want the body unchanged", p["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAcceptanceConventions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
desc string
|
||||
want []string
|
||||
}{
|
||||
{"checkboxes", "do it.\n\n## Acceptance\n- [ ] thing a works\n- [x] behaviour b unchanged\n", "do it.", []string{"thing a works", "behaviour b unchanged"}},
|
||||
{"acceptance criteria spelling", "do it.\n\n### Acceptance Criteria\n* one\n", "do it.", []string{"one"}},
|
||||
{"section ends at the next heading", "do it.\n\n## acceptance\n- one\n\n## notes\n- not acceptance\n", "do it.\n\n## notes\n- not acceptance", []string{"one"}},
|
||||
{"empty items ignored", "do it.\n\n## acceptance\n-\n- [ ]\n- real\n", "do it.", []string{"real"}},
|
||||
{"prose inside the section is not an item", "do it.\n\n## acceptance\nthese are the criteria:\n- one\n", "do it.", []string{"one"}},
|
||||
{"heading only", "do it.\n\n## acceptance\n", "do it.", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, got := splitAcceptance(tc.body)
|
||||
if desc != tc.desc {
|
||||
t.Fatalf("description=%q, want %q", desc, tc.desc)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("acceptance=%q, want %q", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("acceptance[%d]=%q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user