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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,19 @@ printf '{"suite": "%s", "scenario": %s, "agent": "%s", "spektacular_version": "%
> $(1)/build.json
endef

.PHONY: build test lint clean install install-local cross harbor-install harbor-report harbor-test harbor-test-plan harbor-test-spec harbor-test-spec-claude harbor-test-spec-codex _harbor-test-spec harbor-test-implement harbor-test-repo harbor-test-repo-one-at-a-time harbor-test-repo-delegated _harbor-test-repo harbor-test-epic harbor-test-epic-claude harbor-test-epic-codex harbor-test-epic-bob _harbor-test-epic harbor-test-plan-claude harbor-test-plan-codex harbor-test-plan-bob _harbor-test-plan harbor-test-implement-epic harbor-test-implement-epic-claude harbor-test-implement-epic-codex harbor-test-implement-epic-bob harbor-test-implement-epic-bob-unlimited _harbor-test-implement-epic harbor-test-standing-rules harbor-test-standing-rules-claude harbor-test-standing-rules-codex harbor-test-standing-rules-bob _harbor-test-standing-rules harbor-test-goal-check harbor-test-goal-check-claude harbor-test-goal-check-codex harbor-test-goal-check-bob _harbor-test-goal-check harbor-test-background-child _harbor-test-background-child
.PHONY: build test e2e lint clean install install-local cross harbor-install harbor-report harbor-test harbor-test-plan harbor-test-spec harbor-test-spec-claude harbor-test-spec-codex _harbor-test-spec harbor-test-implement harbor-test-repo harbor-test-repo-one-at-a-time harbor-test-repo-delegated _harbor-test-repo harbor-test-epic harbor-test-epic-claude harbor-test-epic-codex harbor-test-epic-bob _harbor-test-epic harbor-test-plan-claude harbor-test-plan-codex harbor-test-plan-bob _harbor-test-plan harbor-test-implement-epic harbor-test-implement-epic-claude harbor-test-implement-epic-codex harbor-test-implement-epic-bob harbor-test-implement-epic-bob-unlimited _harbor-test-implement-epic harbor-test-standing-rules harbor-test-standing-rules-claude harbor-test-standing-rules-codex harbor-test-standing-rules-bob _harbor-test-standing-rules harbor-test-goal-check harbor-test-goal-check-claude harbor-test-goal-check-codex harbor-test-goal-check-bob _harbor-test-goal-check harbor-test-background-child _harbor-test-background-child

build:
go build -ldflags "-X github.com/hivecommons/spektacular/cmd.version=$(VERSION)" -o ./bin/$(BINARY) .

test:
go test -shuffle=on ./...

# Builds the binary and runs `spektacular publish` against fake GitHub, Jira
# and Linear servers (tests/e2e). Needs no credentials; also part of `test`.
e2e:
go test -count=1 -v ./tests/e2e/...

lint:
go vet ./...

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ make cross
|---|---|
| `make build` | Build the `./bin/spektacular` binary |
| `make test` | Run `go test ./...` |
| `make e2e` | Build the binary and run `spektacular publish` against fake GitHub, Jira and Linear servers (`tests/e2e`; no credentials) |
| `make lint` | Run `go vet ./...` |
| `make clean` | Remove build artefacts |
| `make install-local` | Build and copy the binary to `/usr/local/bin` |
Expand Down
3 changes: 3 additions & 0 deletions cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ func runPublish(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if err := opts.CheckLabels("publish." + targetName); err != nil {
return publishRefusal(err, name)
}
res.Repo = where

ctx := cmd.Context()
Expand Down
137 changes: 16 additions & 121 deletions cmd/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,19 @@ package cmd

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"

"github.com/stretchr/testify/require"

"github.com/hivecommons/spektacular/internal/metadata"
"github.com/hivecommons/spektacular/internal/output"
"github.com/hivecommons/spektacular/internal/publish"
"github.com/hivecommons/spektacular/internal/publish/github/githubtest"
"github.com/hivecommons/spektacular/internal/publish/jira/jiratest"
"github.com/hivecommons/spektacular/internal/publish/linear/lineartest"
)
Expand Down Expand Up @@ -219,8 +216,9 @@ func TestPublish_Refusals(t *testing.T) {

func TestPublish_FilesThenRecordsThenRefusesThenUpdates(t *testing.T) {
root := publishProject(t)
gh := newPublishFake(t)
t.Setenv("SPEKTACULAR_GITHUB_API", gh.url)
gh := githubtest.New(t)
t.Setenv("GITHUB_TOKEN", githubtest.Token)
t.Setenv("SPEKTACULAR_GITHUB_API", gh.URL)

stdout, code := runPublishCmd(t, "nightly-digest", "--repo", "acme/demo", "--format", "json")
require.Equal(t, 0, code, stdout)
Expand All @@ -238,7 +236,7 @@ func TestPublish_FilesThenRecordsThenRefusesThenUpdates(t *testing.T) {
require.Equal(t, []string{"rfc", "R1", "R2"}, res.Created)
require.Len(t, res.Children, 2)
require.Nil(t, res.Pub, "the full model is dry-run output only")
require.Equal(t, []string{"epic"}, gh.issues[1].Labels, "the parent label exists so it is applied to the RFC")
require.Equal(t, []string{"epic"}, gh.Issues[1].Labels, "the parent label exists so it is applied to the RFC")

// Written back to the spec's frontmatter.
raw, err := os.ReadFile(filepath.Join(root, ".spektacular", "specs", "nightly-digest.md"))
Expand All @@ -252,7 +250,7 @@ func TestPublish_FilesThenRecordsThenRefusesThenUpdates(t *testing.T) {
require.Contains(t, string(body), "## Requirements", "the body is untouched")

// A re-run with nothing changed succeeds and creates nothing.
before := gh.created()
before := gh.Count("POST /repos/acme/demo/issues")
stdout, code = runPublishCmd(t, "nightly-digest", "--repo", "acme/demo", "--format", "json")
require.Equal(t, 0, code, stdout)
var rerun struct {
Expand All @@ -262,7 +260,7 @@ func TestPublish_FilesThenRecordsThenRefusesThenUpdates(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(stdout), &rerun))
require.Empty(t, rerun.Created)
require.Equal(t, []string{"R1", "R2", "rfc"}, rerun.Unchanged)
require.Equal(t, before, gh.created())
require.Equal(t, before, gh.Count("POST /repos/acme/demo/issues"))

// A change refuses without --update, naming what would change.
writeSpecFixture(t, root, "nightly-digest", strings.Replace(string(raw),
Expand All @@ -271,16 +269,16 @@ func TestPublish_FilesThenRecordsThenRefusesThenUpdates(t *testing.T) {
resp := requirePublishRefusal(t, stdout, code, publish.ErrConflict)
require.Contains(t, resp.Message, "R3 (create)")
require.Contains(t, resp.NextAction, "--update")
require.Equal(t, before, gh.created())
require.Equal(t, before, gh.Count("POST /repos/acme/demo/issues"))

stdout, code = runPublishCmd(t, "nightly-digest", "--repo", "acme/demo", "--update")
require.Equal(t, 0, code, stdout)
require.Contains(t, stdout, "created: R3")
require.Equal(t, before+1, gh.created())
require.Contains(t, gh.issues[1].Body, "- [ ] #4 — Opt-out")
require.Equal(t, before+1, gh.Count("POST /repos/acme/demo/issues"))
require.Contains(t, gh.Issues[1].Body, "- [ ] #4 — Opt-out")

// A closed RFC is a conflict however the run is flagged.
gh.issues[1].State = "closed"
gh.Issues[1].State = "closed"
stdout, code = runPublishCmd(t, "nightly-digest", "--repo", "acme/demo", "--update")
resp = requirePublishRefusal(t, stdout, code, publish.ErrConflict)
require.Contains(t, resp.Message, "closed")
Expand All @@ -292,8 +290,9 @@ func TestPublish_EpicPublishesSpecsAsChildren(t *testing.T) {
epicPath := filepath.Join(root, ".spektacular", "epics", "notify.md")
require.NoError(t, os.MkdirAll(filepath.Dir(epicPath), 0o755))
require.NoError(t, os.WriteFile(epicPath, []byte("---\ncreated_date: 2026-02-01\ndocument_status: final\nspecs:\n - name: nightly-digest\n depends_on: []\n - name: chat-alerts\n depends_on:\n - nightly-digest\n---\n\n# Epic: Operator notifications\n\n## Overview\n\nOperators hear about the hive's work through the channels they already read.\n\n## Specs\n\n- nightly-digest\n- chat-alerts\n"), 0o644))
gh := newPublishFake(t)
t.Setenv("SPEKTACULAR_GITHUB_API", gh.url)
gh := githubtest.New(t)
t.Setenv("GITHUB_TOKEN", githubtest.Token)
t.Setenv("SPEKTACULAR_GITHUB_API", gh.URL)

stdout, code := runPublishCmd(t, "notify", "--repo", "acme/demo", "--format", "json")
require.Equal(t, 0, code, stdout)
Expand All @@ -304,8 +303,8 @@ func TestPublish_EpicPublishesSpecsAsChildren(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(stdout), &res))
require.Equal(t, "epic", res.Kind)
require.Equal(t, []string{"rfc", "nightly-digest", "chat-alerts"}, res.Created)
require.Equal(t, []int64{1002}, gh.blocked[3], "chat-alerts is blocked by nightly-digest")
m, ok := publish.ParseMarker(gh.issues[3].Body)
require.Equal(t, []int64{1002}, gh.Blocked[3], "chat-alerts is blocked by nightly-digest")
m, ok := publish.ParseMarker(gh.Issues[3].Body)
require.True(t, ok)
require.Equal(t, "chat-alerts", m.Task)
require.Equal(t, "https://github.com/acme/demo/issues/1", m.RFC)
Expand Down Expand Up @@ -693,107 +692,3 @@ func TestParseGitHubRepo(t *testing.T) {
require.Equal(t, want, owner+"/"+repo, in)
}
}

// publishFake is the slice of GitHub's REST API the verb touches.
type publishFake struct {
mu sync.Mutex
url string
issues map[int]*publishFakeIssue
next int
blocked map[int][]int64
posts int
}

type publishFakeIssue struct {
ID int64
Number int
Title string
Body string
State string
Labels []string
}

func (i *publishFakeIssue) json() map[string]any {
labels := []map[string]string{}
for _, l := range i.Labels {
labels = append(labels, map[string]string{"name": l})
}
return map[string]any{"id": i.ID, "number": i.Number, "html_url": fmt.Sprintf("https://github.com/acme/demo/issues/%d", i.Number), "title": i.Title, "body": i.Body, "state": i.State, "labels": labels}
}

func newPublishFake(t *testing.T) *publishFake {
t.Helper()
f := &publishFake{issues: map[int]*publishFakeIssue{}, next: 1, blocked: map[int][]int64{}}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
f.url = srv.URL
return f
}

func (f *publishFake) created() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.posts
}

var (
fakeIssueRE = regexp.MustCompile(`^/repos/acme/demo/issues/(\d+)$`)
fakeSubRE = regexp.MustCompile(`^/repos/acme/demo/issues/(\d+)/sub_issues$`)
fakeBlockedRE = regexp.MustCompile(`^/repos/acme/demo/issues/(\d+)/dependencies/blocked_by$`)
fakeArtifact = regexp.MustCompile(`"artifact_id=([^"]+)"`)
)

func (f *publishFake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Header.Get("Authorization") != "Bearer test-token" {
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
write := func(status int, v any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
path := r.URL.Path
switch {
case path == "/repos/acme/demo/labels":
write(200, []map[string]string{{"name": "epic"}})
case path == "/repos/acme/demo/issues" && r.Method == http.MethodPost:
n := f.next
f.next++
f.posts++
is := &publishFakeIssue{ID: int64(1000 + n), Number: n, Title: body["title"].(string), Body: body["body"].(string), State: "open"}
if raw, ok := body["labels"].([]any); ok {
for _, l := range raw {
is.Labels = append(is.Labels, l.(string))
}
}
f.issues[n] = is
write(201, is.json())
case fakeIssueRE.MatchString(path) && r.Method == http.MethodPatch:
n, _ := strconv.Atoi(fakeIssueRE.FindStringSubmatch(path)[1])
is := f.issues[n]
is.Title = body["title"].(string)
is.Body = body["body"].(string)
write(200, is.json())
case fakeSubRE.MatchString(path):
write(201, map[string]any{})
case fakeBlockedRE.MatchString(path):
n, _ := strconv.Atoi(fakeBlockedRE.FindStringSubmatch(path)[1])
f.blocked[n] = append(f.blocked[n], int64(body["issue_id"].(float64)))
write(201, map[string]any{})
case path == "/search/issues":
m := fakeArtifact.FindStringSubmatch(r.URL.Query().Get("q"))
items := []map[string]any{}
for n := 1; n < f.next; n++ {
if m != nil && strings.Contains(f.issues[n].Body, "artifact_id="+m[1]+" ") {
items = append(items, f.issues[n].json())
}
}
write(200, map[string]any{"items": items})
default:
write(404, map[string]string{"message": "no route " + r.Method + " " + path})
}
}
35 changes: 34 additions & 1 deletion docs/publish-hive-handshake.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,44 @@ published:
`status <name> --format json` reports it under `epic.published` and
`specs[].published`.

## 6. Still owed under #114
## 6. What is verified, and what is still owed under #114

`make e2e` (`tests/e2e`, also run by `go test ./...` and so by CI) builds the
binary and runs `spektacular publish` against in-process fakes of GitHub,
Jira Cloud, Jira Data Center and Linear. On every target it asserts the
Spek side of this contract: the RFC title starts `RFC:`, its marker has no
`task=` and ends the body, every child carries a marker with `task=` and
the RFC URL, hangs off the RFC (sub-issue, Epic parent or Data Center Epic
Link, Linear parent), clears the clarity gate (≥ 80 chars once the hidden
marker and digest lines are stripped, no `<placeholders>`), carries the
configured label and none of hive's suppress labels and never the parent
label, each `depends_on` edge is filed as the target's blocked-by relation,
the RFC checklist has a line for every child, the publication and its URLs
land in the spec's frontmatter, git provenance is recorded, a dry run makes
no network call, and a configured suppress label is refused before any call
is made. Idempotence is checked on both sides: a re-run reports everything
unchanged and the fake holds the same issue and relation counts; a changed
spec is refused as a conflict until `--update`, which updates the RFC in
place and files nothing new; a closed (Done, completed-and-archived) RFC is
refused. The Jira fake runs with a lagging search index throughout, so the
re-run is shown to find its issues through the recorded URLs and not the
search.

The fakes are models of the APIs from their documentation, not recordings
of them, so what they prove is that Spek sends the calls the documentation
asks for; they cannot prove that a real Jira Data Center build accepts the
Epic Link field as modelled or that Linear keeps HTML comments in a
description verbatim. Those are listed as caveats on
[#117](https://github.com/hivecommons/spektacular/pull/117) and
[#118](https://github.com/hivecommons/spektacular/pull/118).

What the fakes cannot show is hive's side, still owed under
[spektacular#114](https://github.com/hivecommons/spektacular/issues/114):

- End-to-end on a test repo with a hive attached (`runs.triage` on): children
reach `stage/010-actionable`, one is claimed and kicked direct-fix, the RFC
stays unclaimed, the PR says `Fixes #<child>`.
- The same with `issue_filter.reporter_trust.enabled: true`, documenting what
an untrusted publishing identity sees.
- The hive and crux changes, cross-linked from #109.

Loading
Loading