Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Issues and requests are frontmatter, not JSON

307cbc2aa624ca9b76b79849e8ce8926414be435 · Keenan Nemetz · 17d ago · parent 427c4761

Issues and requests are frontmatter, not JSON

issues/<ulid>/issue.md and requests/<ulid>/request.md replace meta.json plus
description.md, and approvals/<account>.md replaces approvals/<account>.json.
Every file on refs/heads/vole is now frontmatter markdown read by one package, so
there is one reader and one writer for issues, requests, comments and approvals
instead of a JSON path and a hand-rolled key/value path.

The diff is the interface. A JSON array rewrites its neighbour to add a comma, so
one label cost two changed lines and removing one cost three; a block sequence
costs one. docs/spikes/issue-frontmatter measured the same six edits at 25 changed
lines as meta.json and 10 as frontmatter, and one file per entity means one path
to diff, blame and log.

Three rules make YAML safe to hand-edit rather than something to police:

- the writer always double-quotes free text, so no indicator, reserved word or
  " # " can change what a title means
- a label is quoted only when the parser would not read it back bare ("null"),
  which leaves ordinary labels as they were
- a reader refuses an unquoted title that the parser read differently: "title:
  fix #12 crash" would otherwise be stored as "fix" and nobody would find out

Control characters are refused on both sides, so a tree the server accepts is a
tree it can always rewrite.

`updated` is gone from issues, requests and the index's issues table. It cost a
changed line on every edit — 11 of the 21 frontmatter lines in the spike's
sequence — and the commit that changed the file already carries the time.

Verified: TestRenderParseRoundTrip puts 13 titles that break naive YAML (hash,
colon, leading dash, null, quotes, backslash, unicode, 190 bytes) through the
writer and the reader; the server tests push a rendered issue over SSH and render
it, and the approval-ownership tests withdraw approvals/<account>.md. Then
scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).

internal/server/integration_test.go +85−52

@@ -26,6 +26,7 @@ import (
26 26
27 27 "github.com/rodent-software/vole/internal/acl"
28 28 "github.com/rodent-software/vole/internal/config"
29 "github.com/rodent-software/vole/internal/frontmatter"
29 30 "github.com/rodent-software/vole/internal/identity"
30 31 "github.com/rodent-software/vole/internal/index"
31 32 "github.com/rodent-software/vole/internal/repo"
@@ -516,9 +517,7 @@ func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string {
516 517
517 518 func issueFiles(id, title, desc string) map[string]string {
518 519 return map[string]string{
519 "issues/" + id + "/meta.json": fmt.Sprintf(
520 `{"title":%q,"state":"open","author":"keenan","created":"2026-10-22T21:00:00Z","labels":["bug"]}`, title),
521 "issues/" + id + "/description.md": desc,
520 "issues/" + id + "/issue.md": issueFile(title, "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, desc),
522 521 }
523 522 }
524 523
@@ -806,14 +805,14 @@ func TestRequestCLIResolvesNamesOverSSH(t *testing.T) {
806 805 // What landed on the server names the account id, so the name was resolved
807 806 // from the roster rather than passed through.
808 807 raw, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
809 "show", "refs/heads/vole:requests/"+id+"/meta.json")
808 "show", "refs/heads/vole:requests/"+id+"/request.md")
810 809 if err != nil {
811 810 t.Fatalf("read request from the server: %v\n%s", err, raw)
812 811 }
813 if !strings.Contains(raw, `"author": "keenan"`) {
812 if !strings.Contains(raw, `author: keenan`) {
814 813 t.Fatalf("request lost the author name:\n%s", raw)
815 814 }
816 if !strings.Contains(raw, `"author_id": "`+f.ownerID+`"`) {
815 if !strings.Contains(raw, `author_id: `+f.ownerID) {
817 816 t.Fatalf("request author_id is not the account id %s:\n%s", f.ownerID, raw)
818 817 }
819 818 }
@@ -830,15 +829,12 @@ func TestIssueFiltersAndSearch(t *testing.T) {
830 829 f := newFixture(t)
831 830 f.publish(f.repo)
832 831 files := map[string]string{}
833 add := func(id, title, state, body string, labels string) {
834 files["issues/"+id+"/meta.json"] = fmt.Sprintf(
835 `{"title":%q,"state":%q,"author":"keenan","created":"2026-10-22T21:00:00Z","labels":%s}`,
836 title, state, labels)
837 files["issues/"+id+"/description.md"] = body
832 add := func(id, title, state, body string, labels []string) {
833 files["issues/"+id+"/issue.md"] = issueFile(title, "keenan", state, "2026-10-22T21:00:00Z", labels, body)
838 834 }
839 add("01M5JR2JK29KKMAA1T9MDNTPB1", "Segfault on startup", "open", "crash in parser", `["bug"]`)
840 add("01M5JR2JK29KKMAA1T9MDNTPB2", "Docs typo", "closed", "spelling", `["docs"]`)
841 add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", `["bug","perf"]`)
835 add("01M5JR2JK29KKMAA1T9MDNTPB1", "Segfault on startup", "open", "crash in parser", []string{"bug"})
836 add("01M5JR2JK29KKMAA1T9MDNTPB2", "Docs typo", "closed", "spelling", []string{"docs"})
837 add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", []string{"bug", "perf"})
842 838
843 839 work := f.issuesRepo(t, files)
844 840 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
@@ -1099,11 +1095,8 @@ func writeIssueFile(t *testing.T, dir, id, title string) {
1099 1095 if err := os.MkdirAll(p, 0o755); err != nil {
1100 1096 t.Fatal(err)
1101 1097 }
1102 meta := fmt.Sprintf(`{"title":%q,"state":"open","author":"keenan","created":"2026-10-22T21:00:00Z"}`, title)
1103 if err := os.WriteFile(filepath.Join(p, "meta.json"), []byte(meta), 0o644); err != nil {
1104 t.Fatal(err)
1105 }
1106 if err := os.WriteFile(filepath.Join(p, "description.md"), []byte("body\n"), 0o644); err != nil {
1098 raw := issueFile(title, "keenan", "open", "2026-10-22T21:00:00Z", nil, "body\n")
1099 if err := os.WriteFile(filepath.Join(p, "issue.md"), []byte(raw), 0o644); err != nil {
1107 1100 t.Fatal(err)
1108 1101 }
1109 1102 }
@@ -1216,13 +1209,54 @@ func (f *fixture) cloneWithChange(t *testing.T, files map[string]string, msg str
1216 1209
1217 1210 const reqID = "01M5JR2JK29KKMAA1T9MDNTR01"
1218 1211
1212 // requestFile renders requests/<id>/request.md. The body is the request's
1213 // description, so a fixture cannot drift from the format the reader expects.
1214 func requestFile(base, head, author, authorID, body string) string {
1215 d := frontmatter.New()
1216 d.Text("title", "Land the feature")
1217 d.Word("state", "open")
1218 d.Word("author", author)
1219 d.Word("author_id", authorID)
1220 d.Word("base", base)
1221 d.Word("head", head)
1222 d.Word("created", "2026-10-22T22:00:00Z")
1223 out, err := d.Bytes(body)
1224 if err != nil {
1225 panic(err)
1226 }
1227 return string(out)
1228 }
1229
1230 // requestMeta is requestFile with no description: the common fixture shape.
1219 1231 func requestMeta(base, head, author, authorID string) string {
1220 return fmt.Sprintf(`{"title":"Land the feature","author":%q,"author_id":%q,"base":%q,"head":%q,"state":"open","created":"2026-10-22T22:00:00Z"}`+"\n",
1221 author, authorID, base, head)
1232 return requestFile(base, head, author, authorID, "")
1222 1233 }
1223 1234
1224 1235 func approvalFile(base, head string) string {
1225 return fmt.Sprintf(`{"base":%q,"head":%q,"created":"2026-10-22T22:10:00Z"}`+"\n", base, head)
1236 d := frontmatter.New()
1237 d.Word("base", base)
1238 d.Word("head", head)
1239 d.Word("created", "2026-10-22T22:10:00Z")
1240 out, err := d.Bytes("")
1241 if err != nil {
1242 panic(err)
1243 }
1244 return string(out)
1245 }
1246
1247 // issueFile renders issues/<id>/issue.md for fixtures.
1248 func issueFile(title, author, state, created string, labels []string, body string) string {
1249 d := frontmatter.New()
1250 d.Text("title", title)
1251 d.Word("state", state)
1252 d.Word("author", author)
1253 d.Word("created", created)
1254 d.List("labels", labels)
1255 out, err := d.Bytes(body)
1256 if err != nil {
1257 panic(err)
1258 }
1259 return string(out)
1226 1260 }
1227 1261
1228 1262 // TestProtectedBranchRequiresApprovedRequest is the whole Phase 3 contract: a
@@ -1265,8 +1299,7 @@ func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
1265 1299 // 2. Request open, no approvals: still refused.
1266 1300 meta := f.newMetaRepo(t)
1267 1301 if err := meta.apply(map[string]string{
1268 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1269 "requests/" + reqID + "/description.md": "please\n",
1302 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1270 1303 }, "request"); err != nil {
1271 1304 t.Fatalf("request push: %v", err)
1272 1305 }
@@ -1279,7 +1312,7 @@ func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
1279 1312 // 3. Approval by another principal: allowed.
1280 1313 approver := f.newMetaRepo(t)
1281 1314 if err := approver.apply(map[string]string{
1282 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1315 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1283 1316 }, "approve"); err != nil {
1284 1317 t.Fatalf("approval push: %v", err)
1285 1318 }
@@ -1317,13 +1350,13 @@ func TestApprovalIsBoundToTheCommit(t *testing.T) {
1317 1350
1318 1351 meta := f.newMetaRepo(t)
1319 1352 if err := meta.apply(map[string]string{
1320 "requests/" + reqID + "/meta.json": requestMeta("main", approved, "wilbur", f.accountID("wilbur")),
1353 "requests/" + reqID + "/request.md": requestMeta("main", approved, "wilbur", f.accountID("wilbur")),
1321 1354 }, "request"); err != nil {
1322 1355 t.Fatalf("request: %v", err)
1323 1356 }
1324 1357 approver := f.newMetaRepo(t)
1325 1358 if err := approver.apply(map[string]string{
1326 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", approved),
1359 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", approved),
1327 1360 }, "approve"); err != nil {
1328 1361 t.Fatalf("approve: %v", err)
1329 1362 }
@@ -1351,8 +1384,8 @@ func TestSelfApprovalRejected(t *testing.T) {
1351 1384
1352 1385 meta := f.newMetaRepo(t)
1353 1386 err := meta.apply(map[string]string{
1354 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1355 "requests/" + reqID + "/approvals/" + f.accountID("wilbur") + ".json": approvalFile("main", head),
1387 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1388 "requests/" + reqID + "/approvals/" + f.accountID("wilbur") + ".md": approvalFile("main", head),
1356 1389 }, "self approve")
1357 1390 if err == nil {
1358 1391 t.Fatal("a self-approval was accepted")
@@ -1387,13 +1420,13 @@ func TestMergeCommitShapeAllowed(t *testing.T) {
1387 1420 }
1388 1421 meta := f.newMetaRepo(t)
1389 1422 if err := meta.apply(map[string]string{
1390 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1423 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1391 1424 }, "request"); err != nil {
1392 1425 t.Fatalf("request: %v", err)
1393 1426 }
1394 1427 approver := f.newMetaRepo(t)
1395 1428 if err := approver.apply(map[string]string{
1396 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1429 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1397 1430 }, "approve"); err != nil {
1398 1431 t.Fatalf("approve: %v", err)
1399 1432 }
@@ -1431,7 +1464,7 @@ func TestImpersonatedApprovalRejected(t *testing.T) {
1431 1464
1432 1465 meta := f.newMetaRepo(t)
1433 1466 if err := meta.apply(map[string]string{
1434 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1467 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1435 1468 }, "request"); err != nil {
1436 1469 t.Fatalf("request: %v", err)
1437 1470 }
@@ -1439,7 +1472,7 @@ func TestImpersonatedApprovalRejected(t *testing.T) {
1439 1472 impostor := f.newMetaRepo(t)
1440 1473 impostor.key = writerKey
1441 1474 err := impostor.apply(map[string]string{
1442 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1475 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1443 1476 }, "approve as someone else")
1444 1477 if err == nil {
1445 1478 t.Fatal("a writer approved in another principal's name")
@@ -1473,13 +1506,13 @@ func TestApprovalIsBoundToTheBase(t *testing.T) {
1473 1506 feature, head := f.cloneWithChange(t, map[string]string{"a.md": "v2\n"}, "feature")
1474 1507 meta := f.newMetaRepo(t)
1475 1508 if err := meta.apply(map[string]string{
1476 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1509 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1477 1510 }, "request"); err != nil {
1478 1511 t.Fatalf("request: %v", err)
1479 1512 }
1480 1513 approver := f.newMetaRepo(t)
1481 1514 if err := approver.apply(map[string]string{
1482 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1515 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1483 1516 }, "approve"); err != nil {
1484 1517 t.Fatalf("approve: %v", err)
1485 1518 }
@@ -1487,7 +1520,7 @@ func TestApprovalIsBoundToTheBase(t *testing.T) {
1487 1520 // Repoint the request at release. The approval named main.
1488 1521 repoint := f.newMetaRepo(t)
1489 1522 if err := repoint.apply(map[string]string{
1490 "requests/" + reqID + "/meta.json": requestMeta("release", head, "wilbur", f.accountID("wilbur")),
1523 "requests/" + reqID + "/request.md": requestMeta("release", head, "wilbur", f.accountID("wilbur")),
1491 1524 }, "repoint"); err != nil {
1492 1525 t.Fatalf("repoint: %v", err)
1493 1526 }
@@ -1513,8 +1546,8 @@ func TestFirstPushCannotSeedForeignApprovals(t *testing.T) {
1513 1546 meta := f.newMetaRepo(t)
1514 1547 meta.key = writerKey
1515 1548 err := meta.apply(map[string]string{
1516 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1517 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1549 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1550 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1518 1551 }, "seed")
1519 1552 if err == nil {
1520 1553 t.Fatal("the first push seeded an approval in another principal's name")
@@ -1548,13 +1581,13 @@ func TestModifiedApprovalCannotBeForged(t *testing.T) {
1548 1581 }
1549 1582 meta := f.newMetaRepo(t)
1550 1583 if err := meta.apply(map[string]string{
1551 "requests/" + reqID + "/meta.json": requestMeta("main", approvedHead, "wilbur", f.accountID("wilbur")),
1584 "requests/" + reqID + "/request.md": requestMeta("main", approvedHead, "wilbur", f.accountID("wilbur")),
1552 1585 }, "request"); err != nil {
1553 1586 t.Fatalf("request: %v", err)
1554 1587 }
1555 1588 approver := f.newMetaRepo(t)
1556 1589 if err := approver.apply(map[string]string{
1557 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", approvedHead),
1590 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", approvedHead),
1558 1591 }, "approve"); err != nil {
1559 1592 t.Fatalf("approve: %v", err)
1560 1593 }
@@ -1570,8 +1603,8 @@ func TestModifiedApprovalCannotBeForged(t *testing.T) {
1570 1603 forger := f.newMetaRepo(t)
1571 1604 forger.key = writerKey
1572 1605 err := forger.apply(map[string]string{
1573 "requests/" + reqID + "/meta.json": requestMeta("main", sneaky, "wilbur", f.accountID("wilbur")),
1574 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", sneaky),
1606 "requests/" + reqID + "/request.md": requestMeta("main", sneaky, "wilbur", f.accountID("wilbur")),
1607 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", sneaky),
1575 1608 }, "repoint approval")
1576 1609 if err == nil {
1577 1610 t.Fatal("a writer rewrote another principal's approval and the push was accepted")
@@ -1597,13 +1630,13 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1597 1630 meta := f.newMetaRepo(t)
1598 1631 meta.key = writerKey
1599 1632 if err := meta.apply(map[string]string{
1600 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1633 "requests/" + reqID + "/request.md": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1601 1634 }, "request"); err != nil {
1602 1635 t.Fatalf("setup: %v", err)
1603 1636 }
1604 1637 approver := f.newMetaRepo(t)
1605 1638 if err := approver.apply(map[string]string{
1606 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1639 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".md": approvalFile("main", head),
1607 1640 }, "approve"); err != nil {
1608 1641 t.Fatalf("approve: %v", err)
1609 1642 }
@@ -1612,7 +1645,7 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1612 1645 deleter := f.newMetaRepo(t)
1613 1646 deleter.key = writerKey
1614 1647 deleter.sync()
1615 if err := os.Remove(filepath.Join(deleter.dir, "requests", reqID, "approvals", f.accountID("keenan")+".json")); err != nil {
1648 if err := os.Remove(filepath.Join(deleter.dir, "requests", reqID, "approvals", f.accountID("keenan")+".md")); err != nil {
1616 1649 t.Fatal(err)
1617 1650 }
1618 1651 if _, errs, err := runGit(t, deleter.dir, "add", "-A"); err != nil {
@@ -1632,7 +1665,7 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1632 1665 // keenan withdraws his own.
1633 1666 own := f.newMetaRepo(t)
1634 1667 own.sync()
1635 if err := os.Remove(filepath.Join(own.dir, "requests", reqID, "approvals", f.accountID("keenan")+".json")); err != nil {
1668 if err := os.Remove(filepath.Join(own.dir, "requests", reqID, "approvals", f.accountID("keenan")+".md")); err != nil {
1636 1669 t.Fatal(err)
1637 1670 }
1638 1671 if _, errs, cerr := runGit(t, own.dir, "add", "-A"); cerr != nil {
@@ -1663,14 +1696,14 @@ func TestRenameKeepsAccessAndApprovalOwnership(t *testing.T) {
1663 1696 // keenan authors a request; wilbur (the writer) approves it.
1664 1697 meta := f.newMetaRepo(t)
1665 1698 if err := meta.apply(map[string]string{
1666 "requests/" + reqID + "/meta.json": requestMeta("main", head, "keenan", f.ownerID),
1699 "requests/" + reqID + "/request.md": requestMeta("main", head, "keenan", f.ownerID),
1667 1700 }, "request"); err != nil {
1668 1701 t.Fatalf("request: %v", err)
1669 1702 }
1670 1703 approver := f.newMetaRepo(t)
1671 1704 approver.key = writerKey
1672 1705 if err := approver.apply(map[string]string{
1673 "requests/" + reqID + "/approvals/" + f.writerID + ".json": approvalFile("main", head),
1706 "requests/" + reqID + "/approvals/" + f.writerID + ".md": approvalFile("main", head),
1674 1707 }, "approve"); err != nil {
1675 1708 t.Fatalf("approve: %v", err)
1676 1709 }
@@ -1687,7 +1720,7 @@ func TestRenameKeepsAccessAndApprovalOwnership(t *testing.T) {
1687 1720 issue := f.newMetaRepo(t)
1688 1721 issue.key = writerKey
1689 1722 if err := issue.apply(map[string]string{
1690 "issues/01M5JR2JK29KKMAA1T9MDNTZ01/meta.json": `{"title":"after rename","state":"open","author":"robert","created":"2026-10-22T22:00:00Z"}` + "\n",
1723 "issues/01M5JR2JK29KKMAA1T9MDNTZ01/issue.md": issueFile("after rename", "robert", "open", "2026-10-22T22:00:00Z", nil, ""),
1691 1724 }, "issue after rename"); err != nil {
1692 1725 t.Fatalf("the renamed account lost its access: %v", err)
1693 1726 }
@@ -1697,7 +1730,7 @@ func TestRenameKeepsAccessAndApprovalOwnership(t *testing.T) {
1697 1730 withdraw := f.newMetaRepo(t)
1698 1731 withdraw.key = writerKey
1699 1732 withdraw.sync()
1700 if err := os.Remove(filepath.Join(withdraw.dir, "requests", reqID, "approvals", f.writerID+".json")); err != nil {
1733 if err := os.Remove(filepath.Join(withdraw.dir, "requests", reqID, "approvals", f.writerID+".md")); err != nil {
1701 1734 t.Fatal(err)
1702 1735 }
1703 1736 if _, errs, err := runGit(t, withdraw.dir, "add", "-A"); err != nil {
@@ -1987,13 +2020,13 @@ func (f *fixture) openAndApprove(t *testing.T, head, author string) {
1987 2020 t.Helper()
1988 2021 meta := f.newMetaRepo(t)
1989 2022 if err := meta.apply(map[string]string{
1990 "requests/" + reqID + "/meta.json": requestMeta("main", head, author, f.accountID(author)),
2023 "requests/" + reqID + "/request.md": requestMeta("main", head, author, f.accountID(author)),
1991 2024 }, "request"); err != nil {
1992 2025 t.Fatalf("request: %v", err)
1993 2026 }
1994 2027 approver := f.newMetaRepo(t)
1995 2028 if err := approver.apply(map[string]string{
1996 "requests/" + reqID + "/approvals/" + f.ownerID + ".json": approvalFile("main", head),
2029 "requests/" + reqID + "/approvals/" + f.ownerID + ".md": approvalFile("main", head),
1997 2030 }, "approve"); err != nil {
1998 2031 t.Fatalf("approve: %v", err)
1999 2032 }