Merge pull request #77 from cantacuzene/docs/24-e2e-validation
docs(e2e): end-to-end validation and fixes (#24)
This commit is contained in:
@@ -51,6 +51,18 @@ func TestDepsHandler(t *testing.T) {
|
||||
t.Errorf("labels created again (%d requests): want once per clone", got)
|
||||
}
|
||||
|
||||
// Labels changed in .may/config.yaml: created again (e2e #24).
|
||||
writeFile(t, filepath.Join(dir, ".may", "config.yaml"), "labels:\n in_progress: wip\n")
|
||||
deps()
|
||||
if got := len(srv.Requests("POST", testgh.Repo+"/labels")); got != 8 {
|
||||
t.Errorf("labels not re-created after a config change (%d requests)", got)
|
||||
}
|
||||
deps()
|
||||
if got := len(srv.Requests("POST", testgh.Repo+"/labels")); got != 8 {
|
||||
t.Errorf("labels created again without change (%d requests)", got)
|
||||
}
|
||||
os.Remove(filepath.Join(dir, ".may", "config.yaml"))
|
||||
|
||||
// Missing tool, with its install command.
|
||||
t.Setenv("PATH", pathWith(t, "gh"))
|
||||
if out := deps(); !strings.Contains(out, "`go` not found") || !strings.Contains(out, "brew install go") {
|
||||
|
||||
+5
-2
@@ -43,10 +43,13 @@ func handleDeps(_ []byte, stdout, _ io.Writer) int {
|
||||
}
|
||||
if authed && configOK {
|
||||
if state, err := repo.StateDir(); err == nil {
|
||||
// The marker holds the label configuration: labels are created
|
||||
// again when .may/config.yaml changes them.
|
||||
marker := filepath.Join(state, "labels-ok")
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
labels, _ := self("config", "-e", "labels")
|
||||
if done, err := os.ReadFile(marker); err != nil || string(done) != labels {
|
||||
if _, code := self("status", "ensure-labels"); code == 0 {
|
||||
_ = os.WriteFile(marker, nil, 0o644)
|
||||
_ = os.WriteFile(marker, []byte(labels), 0o644)
|
||||
} else {
|
||||
problems = append(problems, "could not create the status labels: may status ensure-labels")
|
||||
}
|
||||
|
||||
+19
-1
@@ -167,11 +167,15 @@ func marker(s string) string {
|
||||
// thread ITEM ID URL AUTHOR EXCERPT MARKER THREAD open|resolved
|
||||
// ci ITEM SHA STATE (PRs)
|
||||
// pr ITEM STATE (PRs)
|
||||
// issue ITEM STATE (issues)
|
||||
func observations(alias string, it ghItem) []string {
|
||||
row := func(cols ...string) string { return strings.Join(cols, "\t") }
|
||||
var out []string
|
||||
if strings.HasPrefix(alias, "i") {
|
||||
out = append(out, row("body", alias, base64.StdEncoding.EncodeToString([]byte(it.Title+"\n\n"+it.Body))))
|
||||
if it.State != "" {
|
||||
out = append(out, row("issue", alias, it.State))
|
||||
}
|
||||
} else {
|
||||
ci := "NONE"
|
||||
if len(it.Commits.Nodes) > 0 && it.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
||||
@@ -206,7 +210,7 @@ func observe(state *syncpkg.State, issues, prs []string) ([]string, error) {
|
||||
var fields, aliases []string
|
||||
for _, n := range issues {
|
||||
aliases = append(aliases, "i"+n)
|
||||
fields = append(fields, fmt.Sprintf("i%s: issue(number:%s){title body comments(last:50){nodes{id url body author{login}}}}", n, n))
|
||||
fields = append(fields, fmt.Sprintf("i%s: issue(number:%s){title body state comments(last:50){nodes{id url body author{login}}}}", n, n))
|
||||
}
|
||||
for _, n := range prs {
|
||||
aliases = append(aliases, "p"+n)
|
||||
@@ -320,6 +324,18 @@ type ghPull struct {
|
||||
}
|
||||
|
||||
// prEvent handles pr:N:MERGED|CLOSED and returns the lines for the agent.
|
||||
// issueEvent handles a watched issue closed by hand: its parents' checklists
|
||||
// are synced, which also suggests closing a parent whose children are all
|
||||
// closed. Not acked on failure.
|
||||
func issueEvent(id string) []string {
|
||||
n := strings.Split(id, ":")[1]
|
||||
if _, code := self("checklist", "sync", n); code != 0 {
|
||||
return []string{fmt.Sprintf("may: #%s closed but its parent checklists could not be updated; retry with may-github:sync.", n)}
|
||||
}
|
||||
self("sync", "ack", id)
|
||||
return []string{fmt.Sprintf("may: #%s closed: parent checklists updated.", n)}
|
||||
}
|
||||
|
||||
func prEvent(id string) []string {
|
||||
p := strings.Split(id, ":")
|
||||
pr, state := p[1], p[2]
|
||||
@@ -428,6 +444,8 @@ func syncContext(header string) string {
|
||||
}
|
||||
if id, _, _ := strings.Cut(e, "\t"); strings.HasPrefix(id, "pr:") {
|
||||
lines = append(lines, prEvent(id)...)
|
||||
} else if strings.HasPrefix(id, "issue:") {
|
||||
lines = append(lines, issueEvent(id)...)
|
||||
} else {
|
||||
others = append(others, e)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ func TestSyncContext(t *testing.T) {
|
||||
t.Errorf("retarget not reported:\n%s", out)
|
||||
}
|
||||
|
||||
// An issue closed by hand: parents' checklists synced (close suggestions), acked.
|
||||
set("issue:3:CLOSED\tissue\t#3\t\t\tissue closed")
|
||||
out, _, _ = mayRun("", "sync-context", "now", "HEADER")
|
||||
if got := readCalls(calls); !strings.Contains(got, "checklist sync 3") || !strings.Contains(got, "ack issue:3:CLOSED") {
|
||||
t.Errorf("issue close not handled: %q", got)
|
||||
}
|
||||
if !strings.Contains(out, "#3 closed") || strings.Contains(out, "REPORT issue:") {
|
||||
t.Errorf("issue close context:\n%s", out)
|
||||
}
|
||||
|
||||
// Nothing to say: no output at all.
|
||||
set()
|
||||
writeFile(t, events, "")
|
||||
|
||||
+11
-4
@@ -13,11 +13,12 @@ import (
|
||||
|
||||
const threadUsage = `usage: may thread reply PR COMMENT_ID BODY_FILE
|
||||
may thread resolve PR COMMENT_ID
|
||||
COMMENT_ID is the node id of any comment of the review thread (PRRC_…, as in sync events "thread:ID").`
|
||||
COMMENT_ID: any comment of the review thread, as a node id (PRRC_…), a sync event id (thread:PRRC_…)
|
||||
or the numeric id of its URL (#discussion_rNNN).`
|
||||
|
||||
const threadsQuery = `query($owner: String!, $name: String!, $n: Int!) {
|
||||
repository(owner: $owner, name: $name) { pullRequest(number: $n) {
|
||||
reviewThreads(first: 100) { nodes { id isResolved comments(first: 100) { nodes { id } } } } } } }`
|
||||
reviewThreads(first: 100) { nodes { id isResolved comments(first: 100) { nodes { id databaseId } } } } } } }`
|
||||
|
||||
const replyMutation = `mutation($thread: ID!, $body: String!) {
|
||||
addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: $thread, body: $body}) { comment { url } } }`
|
||||
@@ -75,13 +76,19 @@ func cmdThread(args []string, stdout, stderr io.Writer) int {
|
||||
|
||||
// threadOf returns the id of the review thread of PR pr containing comment.
|
||||
func threadOf(c *gh.Client, pr int, comment string) (string, error) {
|
||||
comment = strings.TrimPrefix(comment, "thread:")
|
||||
var out struct {
|
||||
Repository struct {
|
||||
PullRequest struct {
|
||||
ReviewThreads struct {
|
||||
Nodes []struct {
|
||||
ID string
|
||||
Comments struct{ Nodes []struct{ ID string } }
|
||||
Comments struct {
|
||||
Nodes []struct {
|
||||
ID string
|
||||
DatabaseID int64
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +99,7 @@ func threadOf(c *gh.Client, pr int, comment string) (string, error) {
|
||||
}
|
||||
for _, t := range out.Repository.PullRequest.ReviewThreads.Nodes {
|
||||
for _, cm := range t.Comments.Nodes {
|
||||
if cm.ID == comment {
|
||||
if cm.ID == comment || strconv.FormatInt(cm.DatabaseID, 10) == comment {
|
||||
return t.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const threadsResponse = `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[
|
||||
{"id":"PRRT_t1","isResolved":false,"comments":{"nodes":[{"id":"PRRC_carol3"}]}},
|
||||
{"id":"PRRT_t2","isResolved":false,"comments":{"nodes":[{"id":"PRRC_eve4"},{"id":"PRRC_reply"}]}}]}}}}}`
|
||||
{"id":"PRRT_t2","isResolved":false,"comments":{"nodes":[{"id":"PRRC_eve4","databaseId":4169175082},{"id":"PRRC_reply"}]}}]}}}}}`
|
||||
|
||||
func TestThread(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
@@ -45,6 +45,16 @@ func TestThread(t *testing.T) {
|
||||
t.Errorf("resolve variables = %v", vars)
|
||||
}
|
||||
|
||||
// the event id (thread:ID) and the numeric id (discussion_rN) work too
|
||||
for _, id := range []string{"thread:PRRC_eve4", "4169175082"} {
|
||||
if _, errOut, code := mayRun("", "thread", "resolve", "71", id); code != 0 {
|
||||
t.Fatalf("resolve %s: code %d, %q", id, code, errOut)
|
||||
}
|
||||
if vars := lastMutation(t, srv, "resolveReviewThread"); vars["thread"] != "PRRT_t2" {
|
||||
t.Errorf("resolve %s variables = %v", id, vars)
|
||||
}
|
||||
}
|
||||
|
||||
// unknown comment, usage
|
||||
if _, errOut, code := mayRun("", "thread", "resolve", "71", "PRRC_nope"); code != 1 || !strings.Contains(errOut, "PRRC_nope") {
|
||||
t.Errorf("unknown comment: code %d, %q", code, errOut)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# End-to-end checklist
|
||||
|
||||
Validates may-github in a real destination project: real Claude Code sessions
|
||||
(`claude -p`, plugin loaded from the submodule), real GitHub. Run it before a
|
||||
release and after any change to hooks or skills.
|
||||
|
||||
Each step lists the prompt given to Claude and what must be true afterwards
|
||||
(checked with `gh`, not by reading the agent's answer). Record the run at the
|
||||
bottom: date, may revision, ✅/❌ per step, issues filed.
|
||||
|
||||
## 0. Scratch project
|
||||
|
||||
```sh
|
||||
gh repo create OWNER/may-e2e --private --clone && cd may-e2e
|
||||
go mod init example.com/e2e # tiny Go module + `go test` workflow
|
||||
git submodule add git@github.com:cantacuzene/may.git .may/core
|
||||
# .claude/settings.json as in the README, commit, push to main
|
||||
claude plugin marketplace add ./.may/core --scope local
|
||||
claude plugin install may-github@may --scope local
|
||||
```
|
||||
|
||||
Sessions: `claude -p "<prompt>" --output-format json`, then `--resume <session_id>`
|
||||
to continue the same conversation.
|
||||
|
||||
| # | Step | Prompt (gist) | Must be true |
|
||||
|---|---|---|---|
|
||||
| 1 | Install | any first prompt | SessionStart: status labels created, no dependency warning |
|
||||
| 2 | Issue first | "add a `greet` command" (no issue) | agent asks for an issue; Edit/Write blocked until one exists; issue created from the brainstorm |
|
||||
| 3 | Roadmap | a feature needing 2 issues, one complex | roadmap issue (`type:roadmap`), milestone with the same name, 1 simple issue + 1 parent with 2 sub-issues, checklists linked, everything in the milestone |
|
||||
| 4 | Start leaf 1 | "start the first issue" | branch `{type}/{N}-{slug}` from `origin/main`, `status:in-progress`, assignee |
|
||||
| 5 | Guards | commit without `Refs:`; push to main | both blocked (exit 2) with the reason |
|
||||
| 6 | PR leaf 1 | "open the PR" | draft PR on `main`, `Closes #N`, milestone, `status:in-review`, parent box ticked (`check_on: pr_opened`), stack section filled |
|
||||
| 7 | Stack | leaves 2 and 3 → PRs | each PR based on the previous leaf's branch; `stack base` exit 3 when the previous leaf has no PR |
|
||||
| 8 | CI | leaf 2 pushes a failing test | ci skill reads the log, fixes, green, `gh pr ready` |
|
||||
| 9 | Review | comment on PR 2: one pertinent thread, one contradicting a Decision; one question on the issue | next session: sync context announces them; respond shows one table, waits; after "go": fix pushed, reply + resolve on the pertinent thread, reasoned reply on the other (open), answer posted, all with `<!-- may -->`, events acked |
|
||||
| 10 | Body edit | add a criterion to an issue body | reported as scope change; not implemented without the user |
|
||||
| 11 | Restack | fix lands on leaf 1 | restack moves leaves 2–3 with `--update-refs`, `--force-with-lease`, bases unchanged |
|
||||
| 12 | Merges | merge bottom-up (`gh pr merge --merge`) | next PR retargeted to `main`; issues closed; parent closed suggestion; roadmap close suggestion comment; milestone 100% |
|
||||
|
||||
## Overrides
|
||||
|
||||
Second clone or reset, with:
|
||||
|
||||
- `.may/config.yaml`: custom `labels` (e.g. `wip`, `review`), `checklist.check_on: merged`,
|
||||
`hooks.pre-tool-use.mode: replace`;
|
||||
- `.may/skills/pr.md`: e.g. "title without the `(#N)` suffix";
|
||||
- `.may/hooks/pre-tool-use` (executable): logs its stdin and exits 0.
|
||||
|
||||
| # | Must be true |
|
||||
|---|---|
|
||||
| O1 | custom labels created and used |
|
||||
| O2 | parent box ticked only after the merge |
|
||||
| O3 | the PR title follows `.may/skills/pr.md` |
|
||||
| O4 | built-in guards no longer run (push to main allowed by may), project hook ran |
|
||||
| O5 | `roadmap.milestone: false` → roadmap created without milestone |
|
||||
|
||||
## Runs
|
||||
|
||||
### 2026-10-02 — main @ 06e763a → docs/24-e2e-validation, Claude Code 2.1.287, private repo `may-e2e`
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| 1 Install | ✅ labels created, no warning |
|
||||
| 2 Issue first | ✅ asked for an issue, proposed one from the brainstorm, no edit |
|
||||
| 3 Roadmap | ✅ #1 + milestone, #2 simple, #3 → #4 #5, checklists linked · ❌ decisions stayed on the roadmap → fixed (leaf `## Decisions`) |
|
||||
| 4 Start | ✅ `feat/2-…` from `origin/main`, in-progress, assigned |
|
||||
| 5 Guards | ✅ commit without `Refs:` and push to main → exit 2 |
|
||||
| 6 PR | ✅ draft → ready, `Closes #2`, milestone, in-review, box ticked, stack section |
|
||||
| 7 Stack | ✅ #7 → #2's branch, #8 → #4's branch; `stack base 5` exit 3, parent exit 2 |
|
||||
| 8 CI | ✅ teammate regression reproduced, `fix:` commit, green · note: PR stayed ready while red → #76 |
|
||||
| 9 Review | ✅ one table, waited for go; fix + reply + resolve; YAML thread answered from the Decision, left open; question answered; marker everywhere; acked · ❌ numeric comment id rejected by `may thread` → fixed · ❌ acceptance-criteria edit not reported → fixed |
|
||||
| 10 Body edit | ✅ (after fix) reported as scope, implemented only once the user said so |
|
||||
| 11 Restack | ✅ `--update-refs`, `--force-with-lease`, bases checked |
|
||||
| 12 Merges | ✅ #7 retargeted to main, issues closed, #3 suggestion · ❌ no roadmap suggestion when #3 closed by hand → fixed · milestone 100 % (8/8) · note: status labels of parents/closed issues → #75 |
|
||||
| O1 labels | ✅ custom labels used · ❌ not created after the config change → fixed |
|
||||
| O2 check_on | ✅ box unticked while the PR is open |
|
||||
| O3 skill override | ✅ PR title without `(#N)` |
|
||||
| O4 hook replace | ✅ project hook ran, built-in guards off |
|
||||
| O5 no milestone | ✅ |
|
||||
|
||||
Migration note: bodies stored before the tasks fix are migrated silently, so a criterion edited before the update is not reported.
|
||||
+49
-6
@@ -48,7 +48,7 @@ type State struct{ dir string }
|
||||
// Open prepares <stateDir>/sync.
|
||||
func Open(stateDir string) (*State, error) {
|
||||
s := &State{dir: filepath.Join(stateDir, "sync")}
|
||||
for _, d := range []string{"bodies", "pending", "ci", "prs", "baseline"} {
|
||||
for _, d := range []string{"bodies", "pending", "ci", "prs", "issues", "baseline"} {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, d), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,20 +115,39 @@ func (s *State) ForgetPR(n string) {
|
||||
|
||||
var mayStart = regexp.MustCompile(`<!-- may:([\w-]+):start -->`)
|
||||
|
||||
// StripMaySections removes every <!-- may:NAME:start --> … <!-- may:NAME:end -->
|
||||
// block (markers included).
|
||||
func StripMaySections(text string) string {
|
||||
var tick = regexp.MustCompile(`(?m)^(\s*[-*] )\[[xX]\]`)
|
||||
|
||||
// StripMaySections removes the blocks may rewrites itself
|
||||
// (<!-- may:NAME:start --> … <!-- may:NAME:end -->, markers included), except
|
||||
// the tasks section: its items are the acceptance criteria, written by people,
|
||||
// so they are kept with their boxes unticked (ticking is not a change).
|
||||
func StripMaySections(text string) string { return stripSections(text, true) }
|
||||
|
||||
func stripSections(text string, keepTasks bool) string {
|
||||
from := 0
|
||||
for {
|
||||
loc := mayStart.FindStringSubmatchIndex(text)
|
||||
loc := mayStart.FindStringSubmatchIndex(text[from:])
|
||||
if loc == nil {
|
||||
return text
|
||||
}
|
||||
end := "<!-- may:" + text[loc[2]:loc[3]] + ":end -->"
|
||||
for i := range loc {
|
||||
loc[i] += from
|
||||
}
|
||||
name := text[loc[2]:loc[3]]
|
||||
end := "<!-- may:" + name + ":end -->"
|
||||
i := strings.Index(text[loc[1]:], end)
|
||||
if i < 0 {
|
||||
return text
|
||||
}
|
||||
inner := text[loc[1] : loc[1]+i]
|
||||
if name == "tasks" && keepTasks {
|
||||
kept := tick.ReplaceAllString(inner, "$1[ ]")
|
||||
text = text[:loc[0]] + kept + text[loc[1]+i+len(end):]
|
||||
from = loc[0] + len(kept)
|
||||
continue
|
||||
}
|
||||
text = text[:loc[0]] + text[loc[1]+i+len(end):]
|
||||
from = loc[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +214,12 @@ func (s *State) Compute(obs []string) []Event {
|
||||
if hash(lf(stored)) == h {
|
||||
continue
|
||||
}
|
||||
// Stored before the tasks section was kept: migrate silently.
|
||||
legacy := strings.TrimRight(stripSections(strings.TrimRight(lf(string(raw)), "\n"), false), "\n")
|
||||
if hash(lf(stored)) == hash(legacy) {
|
||||
s.write(text, "bodies", n)
|
||||
continue
|
||||
}
|
||||
s.write(text, "pending", "body-"+n+"-"+h)
|
||||
events = append(events, Event{"body:" + n + ":" + h, "body", label(item), "", "", "title/body changed"})
|
||||
|
||||
@@ -229,6 +254,22 @@ func (s *State) Compute(obs []string) []Event {
|
||||
}
|
||||
events = append(events, Event{"ci:" + n + ":" + sha + ":" + state, "ci", label(item), "", "", "CI " + state + " on " + short})
|
||||
|
||||
case "issue":
|
||||
state := c[2]
|
||||
stored, ok := s.read("issues", n)
|
||||
if !ok || isFirst {
|
||||
s.write(state+"\n", "issues", n)
|
||||
continue
|
||||
}
|
||||
if stored == state {
|
||||
continue
|
||||
}
|
||||
if state == "CLOSED" {
|
||||
events = append(events, Event{"issue:" + n + ":CLOSED", "issue", label(item), "", "", "issue closed"})
|
||||
} else {
|
||||
s.write(state+"\n", "issues", n)
|
||||
}
|
||||
|
||||
case "pr":
|
||||
state := c[2]
|
||||
stored, ok := s.read("prs", n)
|
||||
@@ -314,6 +355,8 @@ func (s *State) Ack(ids []string) []string {
|
||||
s.write(p[2]+" "+p[3]+"\n", "ci", p[1])
|
||||
case p[0] == "pr" && len(p) == 3:
|
||||
s.write(p[2]+"\n", "prs", p[1])
|
||||
case p[0] == "issue" && len(p) == 3:
|
||||
s.write(p[2]+"\n", "issues", p[1])
|
||||
default:
|
||||
unknown = append(unknown, id)
|
||||
}
|
||||
|
||||
@@ -26,3 +26,83 @@ func TestComputeIgnoresLineEndings(t *testing.T) {
|
||||
t.Errorf("real change not reported: %v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// e2e #24: acceptance criteria live in the tasks section. Editing them is a
|
||||
// change; ticking them, or may rewriting its own checklists, is not.
|
||||
func TestComputeBodySections(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := func(tasks, subs string) string {
|
||||
return "Title\n\n## Acceptance criteria\n<!-- may:tasks:start -->\n" + tasks +
|
||||
"<!-- may:tasks:end -->\n<!-- may:subissues:start -->\n" + subs + "<!-- may:subissues:end -->"
|
||||
}
|
||||
steps := []struct {
|
||||
name, tasks, subs string
|
||||
want int
|
||||
}{
|
||||
{"baseline", "- [ ] a\n", "- [ ] #4\n", 0},
|
||||
{"task ticked", "- [x] a\n", "- [ ] #4\n", 0},
|
||||
{"checklist rewritten by may", "- [x] a\n", "- [x] #4\n- [ ] #5\n", 0},
|
||||
{"criterion added", "- [x] a\n- [ ] b\n", "- [x] #4\n- [ ] #5\n", 1},
|
||||
{"criterion reworded", "- [x] a\n- [ ] b2\n", "- [x] #4\n- [ ] #5\n", 1},
|
||||
}
|
||||
for _, st := range steps {
|
||||
ev := s.Compute([]string{bodyObs("i5", body(st.tasks, st.subs))})
|
||||
if len(ev) != st.want {
|
||||
t.Errorf("%s: %d events, want %d: %v", st.name, len(ev), st.want, ev)
|
||||
}
|
||||
for _, e := range ev {
|
||||
s.Ack([]string{e.ID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bodies stored before tasks were kept (tasks stripped) are migrated silently.
|
||||
func TestComputeMigratesStrippedBodies(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.write("Title\n\n## Acceptance criteria\n", "bodies", "7")
|
||||
b := "Title\n\n## Acceptance criteria\n<!-- may:tasks:start -->\n- [x] a\n<!-- may:tasks:end -->"
|
||||
if ev := s.Compute([]string{bodyObs("i7", b)}); len(ev) != 0 {
|
||||
t.Errorf("legacy stored body reported as a change: %v", ev)
|
||||
}
|
||||
if ev := s.Compute([]string{bodyObs("i7", b+"\n- [ ] b\n")}); len(ev) != 1 {
|
||||
t.Errorf("change after migration not reported: %v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// e2e #24: an issue closed by hand is an event (its parents' checklists and
|
||||
// close suggestions depend on it); reopening is not.
|
||||
func TestComputeIssueState(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obs := func(state string) []string { return []string{"issue\ti3\t" + state} }
|
||||
if ev := s.Compute(obs("OPEN")); len(ev) != 0 {
|
||||
t.Fatalf("baseline: %v", ev)
|
||||
}
|
||||
ev := s.Compute(obs("CLOSED"))
|
||||
if len(ev) != 1 || ev[0].ID != "issue:3:CLOSED" || ev[0].Kind != "issue" {
|
||||
t.Fatalf("close: %v", ev)
|
||||
}
|
||||
if len(s.Compute(obs("CLOSED"))) != 1 {
|
||||
t.Error("unacked close not reported again")
|
||||
}
|
||||
if u := s.Ack([]string{"issue:3:CLOSED"}); len(u) != 0 {
|
||||
t.Fatalf("ack: unknown %v", u)
|
||||
}
|
||||
if ev := s.Compute(obs("CLOSED")); len(ev) != 0 {
|
||||
t.Errorf("acked close reported: %v", ev)
|
||||
}
|
||||
if ev := s.Compute(obs("OPEN")); len(ev) != 0 {
|
||||
t.Errorf("reopen reported: %v", ev)
|
||||
}
|
||||
if ev := s.Compute(obs("CLOSED")); len(ev) != 1 {
|
||||
t.Errorf("second close not reported: %v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ Before anything else, read `$CLAUDE_PROJECT_DIR/.may/skills/roadmap.md` if it ex
|
||||
<!-- may:tasks:start -->
|
||||
- [ ] step
|
||||
<!-- may:tasks:end -->
|
||||
## Decisions
|
||||
## Files likely touched
|
||||
```
|
||||
A complex issue's body is Goal + an empty `<!-- may:subissues:start -->` `<!-- may:subissues:end -->` section.
|
||||
Decisions: copy the roadmap decisions this leaf must respect — reviews are judged against the leaf, not the roadmap. A complex issue's body is Goal + Decisions + an empty `<!-- may:subissues:start -->` `<!-- may:subissues:end -->` section.
|
||||
5. **Report** the created tree with numbers, then hand over to **may-github:start** for the first leaf. Never create branches here.
|
||||
|
||||
## Changing a roadmap
|
||||
|
||||
@@ -23,7 +23,7 @@ Reporting is read-only. An event leaves the list only when it has been **handled
|
||||
- **question** — someone asks something;
|
||||
- **disagreement** — a suggestion that contradicts a recorded decision;
|
||||
- **scope** — the issue's title/body (criteria, decisions) changed;
|
||||
- **state** — PR merged/closed (sync already retargets and closes issues; say so).
|
||||
- **state** — PR merged/closed, issue closed (sync already closes issues, updates checklists and retargets; say so).
|
||||
3. **Offer may-github:respond** for everything that is not purely informative: "Want me to handle these? I'll prepare fixes and drafts and show you the batch before posting anything."
|
||||
4. Purely informative events (PR merged, a thank-you comment) may be acked right away: `sh "${CLAUDE_PLUGIN_ROOT}/bin/may" sync ack ID…` (ids from `sync events`, first column).
|
||||
5. The user asks to clear unhandled items → say once which ones would go unanswered. If they confirm, ack them: it is their call.
|
||||
|
||||
Reference in New Issue
Block a user