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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,18 @@ Every form takes the same selection (`--name`, `--kind`, `--agent`, and a repeat

**Epics.** When a piece of work is split into an epic, ask your agent to *plan this epic*, review the plans, then *implement this epic*. Independent specs are worked on in parallel, each spec is built in its own worktree and merged before the specs that depend on it, and repeating either request picks up where it stopped. Naming an epic in `status` adds a `run` view that says, for planning and for implementing, what each spec still needs and what blocks the epic. See [Epics](https://spektacular.dev/epics/).

**Publishing.** A final spec or epic can be handed to a work source — a GitHub repository or a Jira project — as one RFC issue plus a child issue per unit of work, so an agent hive can pick the work up:
**Publishing.** A final spec or epic can be handed to a work source — a GitHub repository, a Jira project or a Linear team — as one RFC issue plus a child issue per unit of work, so an agent hive can pick the work up:

```bash
spektacular publish <name> --dry-run # render the RFC and children; no network calls
spektacular publish <name> [--repo owner/repo] # file them on GitHub (the default target)
spektacular publish <name> --target jira # file them as an Epic + Stories in publish.jira.project_key
spektacular publish <name> --target linear # file them as an issue + sub-issues in team publish.linear.team
spektacular publish <name> --update # re-publish, updating the filed items in place
spektacular publish <name> --format json # the result as structured data
```

A spec's children are its plan's tasks when it has a task-format plan, else its `## Requirements` entries with the acceptance criteria traced to each; an epic's children are its specs, in epic order, with `depends_on` carried over. Each child is a native sub-issue of the RFC and each dependency a "blocked by" relation. The `epic` label (or `publish.github.parent_label`) goes on the RFC only when the repository already has it; publish never creates labels. Every filed item carries a hidden marker, `<!-- spek:publish name=<name> artifact_id=<id> target=github -->` on the RFC and the same with `task=<child-id> rfc=<url>` on each child, so a re-run finds what it filed: an unchanged publication is a no-op, one that would change filed items is refused with `publish_conflict` and a list of the changes unless `--update` is given. The result is written back to the artifact's frontmatter as `published: [{target, rfc_url, children: [{id, url}]}]`, which `status` reports. On Jira the RFC is an Epic and each child a Story under it (`publish.jira.parent_issue_type` / `child_issue_type`), dependencies are `Blocks` links, labels get their whitespace replaced with `-`, the marker is also kept in the `spek.publish` issue property (set in the same request that creates the issue) and the description carries a `spekpub<hash>` search token that a re-run's text JQL finds, with the recorded `published:` URLs read directly when the search index has not caught up; a re-run also puts every child back under the Epic and refiles any missing `Blocks` link, reporting them as `linked`; Jira Cloud (REST v3, `JIRA_EMAIL` + `JIRA_API_TOKEN`) and Data Center (`deployment: datacenter`, REST v2, `JIRA_PAT`, `JIRA_CA_BUNDLE`) are both supported, with the site in `JIRA_BASE_URL`. A non-final artifact is refused with `publish_not_final` (`--allow-draft` files it as `RFC: [DRAFT] …`); a target that is not github or jira with `publish_target_unconfigured`, as is a jira run with no `publish.jira.project_key` or site; a github run with no repository to file in with `publish_repo_unknown`, and one with no credential (`GITHUB_TOKEN`, `GH_TOKEN` or `gh auth token`; `JIRA_EMAIL`/`JIRA_API_TOKEN` or `JIRA_PAT`) with `publish_auth_missing`; a body a hive's clarity gate would park — under 80 characters, an unsubstituted `<placeholder>`, an unchosen `high/medium/low` list — with `publish_body_hygiene`. See [docs/publish-hive-handshake.md](docs/publish-hive-handshake.md) for the contract a hive reads.
A spec's children are its plan's tasks when it has a task-format plan, else its `## Requirements` entries with the acceptance criteria traced to each; an epic's children are its specs, in epic order, with `depends_on` carried over. Each child is a native sub-issue of the RFC and each dependency a "blocked by" relation. The `epic` label (or `publish.github.parent_label`) goes on the RFC only when the repository already has it; publish never creates labels. Every filed item carries a hidden marker, `<!-- spek:publish name=<name> artifact_id=<id> target=github -->` on the RFC and the same with `task=<child-id> rfc=<url>` on each child, so a re-run finds what it filed: an unchanged publication is a no-op, one that would change filed items is refused with `publish_conflict` and a list of the changes unless `--update` is given. The result is written back to the artifact's frontmatter as `published: [{target, rfc_url, children: [{id, url}]}]`, which `status` reports. On Jira the RFC is an Epic and each child a Story under it (`publish.jira.parent_issue_type` / `child_issue_type`), dependencies are `Blocks` links, labels get their whitespace replaced with `-`, the marker is also kept in the `spek.publish` issue property (set in the same request that creates the issue) and the description carries a `spekpub<hash>` search token that a re-run's text JQL finds, with the recorded `published:` URLs read directly when the search index has not caught up; a re-run also puts every child back under the Epic and refiles any missing `Blocks` link, reporting them as `linked`; Jira Cloud (REST v3, `JIRA_EMAIL` + `JIRA_API_TOKEN`) and Data Center (`deployment: datacenter`, REST v2, `JIRA_PAT`, `JIRA_CA_BUNDLE`) are both supported, with the site in `JIRA_BASE_URL`. On Linear the RFC is an issue in `publish.linear.team` and each child a sub-issue of it, created in Todo (or `publish.linear.state`) and assigned to `publish.linear.assignee` when set; dependencies are `blocks` relations, put back on every run like the parent; labels are applied only when the team or workspace has them; the description carries a body digest before the marker so re-runs compare digests, archived issues are searched too, and the API key is `LINEAR_API_KEY`. A non-final artifact is refused with `publish_not_final` (`--allow-draft` files it as `RFC: [DRAFT] …`); an unknown target with `publish_target_unconfigured`, as is a jira run with no `publish.jira.project_key` or site and a linear run with no `publish.linear.team`; a github run with no repository to file in with `publish_repo_unknown`, and one with no credential (`GITHUB_TOKEN`, `GH_TOKEN` or `gh auth token`; `JIRA_EMAIL`/`JIRA_API_TOKEN` or `JIRA_PAT`; `LINEAR_API_KEY`) with `publish_auth_missing`; a body a hive's clarity gate would park — under 80 characters, an unsubstituted `<placeholder>`, an unchosen `high/medium/low` list — with `publish_body_hygiene`. See [docs/publish-hive-handshake.md](docs/publish-hive-handshake.md) for the contract a hive reads.

### Addressing specs, plans and changelog records

Expand Down Expand Up @@ -385,7 +386,7 @@ design:
config:
location: ../design/api # relative to the folder holding config.yaml; may sit outside the project
publish:
default_target: github # optional; the work source `publish` files into: github or jira
default_target: github # optional; the work source `publish` files into: github, jira or linear
github:
repo: org/my-project # optional; defaults to `source`, then the current directory's origin
parent_label: epic # optional; applied to the RFC only when the repository has the label
Expand All @@ -400,6 +401,12 @@ publish:
epic_link_field: customfield_10014 # optional; Data Center links Stories by Epic Link, not parent; discovered from the site when unset
insecure_http: false # optional; allow a plain http:// base URL (sends the credential in clear); loopback never needs it
acceptance_criteria_field: customfield_10100 # optional; also receives each child's acceptance criteria
linear:
team: ENG # required for --target linear; the team key the hive reads. Credential: LINEAR_API_KEY
state: Todo # optional; the state new issues are created in: Todo when the team has it, else its first unstarted state, else the team default
assignee: dev@acme.test # optional; email, display name or user name every filed issue is assigned to
parent_label: epic # optional; applied to the RFC only when the team has the label
labels: [spek] # optional; applied to every filed issue that the team has labels for
```

Each repo entry needs a slug-safe unique `name` and a `location`: the folder holding that repo's `repo.yaml` (`local` is still accepted and means the same thing). A relative location is resolved from the folder holding `config.yaml`, and nothing is appended to it, so the project's own footprint is `.` and a repo folder in the project is `../repos/<name>`. A repo is normally added through a guided flow: you are asked which repo to add, and its name, description, role and tags are each proposed for you from what the repo says about itself, one question at a time, with a plain-language confirmation before anything is written. Spektacular's files go inside the repo being added unless it cannot take them or you say otherwise, in which case they live in a folder under the project and the repo is left with only its code. An add can be started and finished while a spec or plan is already in progress. A caller that already knows every detail can still register a repo in a single command with `repo add`. Where the code lives is declared in the repo's own `repo.yaml` as `source`; the old `address` key is no longer read, and a config that still carries it fails to load with an error saying where the value now goes. `description`, `role`, and `tags` are optional metadata, also in `repo.yaml`, that cross-repo planning uses to attribute requirements to the right repo. Add to the registry with `spektacular repo new`, or `spektacular repo add` when every detail is already known, and inspect it with `spektacular repo list`; removal is a manual config edit. Cloned repos are never fetched or pulled automatically; a stale clone produces a warning only.
Expand Down
76 changes: 58 additions & 18 deletions cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/hivecommons/spektacular/internal/publish"
"github.com/hivecommons/spektacular/internal/publish/github"
"github.com/hivecommons/spektacular/internal/publish/jira"
"github.com/hivecommons/spektacular/internal/publish/linear"
"github.com/hivecommons/spektacular/internal/store"
)

Expand All @@ -46,12 +47,15 @@ publication is recorded in the artifact's frontmatter under published.

--target jira files the RFC as an Epic in publish.jira.project_key and each
child as a Story under it, with "Blocks" links for dependencies, on Jira Cloud
or Data Center.
or Data Center. --target linear files the RFC as an issue in team
publish.linear.team and each child as a sub-issue of it, with "blocks"
relations for dependencies.

--dry-run renders what would be filed and makes no network calls. Credentials
come from the environment: GITHUB_TOKEN, GH_TOKEN, or ` + "`gh auth token`" + ` for
github; JIRA_BASE_URL with JIRA_EMAIL and JIRA_API_TOKEN (Cloud) or JIRA_PAT
(Data Center) for jira. Nothing is written to config.`,
(Data Center) for jira; LINEAR_API_KEY for linear. Nothing is written to
config.`,
Args: cobra.MaximumNArgs(1),
RunE: runPublish,
}
Expand All @@ -73,14 +77,14 @@ var publishOutputSchema = &schemaObj{
"name": {Type: "string"},
"kind": {Type: "string", Enum: []string{publish.KindSpec, publish.KindEpic}},
"artifact_id": {Type: "string", Description: "<project>/<kind>/<name>; the marker's artifact_id"},
"target": {Type: "string", Enum: []string{config.PublishTargetGitHub, config.PublishTargetJira}},
"repo": {Type: "string", Description: "where the items were filed: owner/repo on github, the project key on jira"},
"target": {Type: "string", Enum: []string{config.PublishTargetGitHub, config.PublishTargetJira, config.PublishTargetLinear}},
"repo": {Type: "string", Description: "where the items were filed: owner/repo on github, the project key on jira, the team key on linear"},
"dry_run": {Type: "boolean"},
"rfc_url": {Type: "string"},
"rfc_number": {Type: "integer", Description: "github only"},
"rfc_key": {Type: "string", Description: "the RFC's issue key on jira"},
"rfc_key": {Type: "string", Description: "the RFC's issue key on jira, its identifier on linear"},
"children": {Type: "array", Items: &schemaProp{Type: "object", Properties: map[string]*schemaProp{
"id": {Type: "string"}, "url": {Type: "string"}, "number": {Type: "integer", Description: "github only"}, "key": {Type: "string", Description: "the issue key on jira"},
"id": {Type: "string"}, "url": {Type: "string"}, "number": {Type: "integer", Description: "github only"}, "key": {Type: "string", Description: "the issue key on jira, the identifier on linear"},
}}},
"created": {Type: "array", Items: &schemaProp{Type: "string"}, Description: "rfc and child ids filed by this run"},
"updated": {Type: "array", Items: &schemaProp{Type: "string"}},
Expand All @@ -102,7 +106,7 @@ func runPublish(cmd *cobra.Command, args []string) error {
return output.Write(cmd.OutOrStdout(), commandSchema{
Output: publishOutputSchema,
Flags: map[string]*schemaProp{
"target": {Type: "string", Enum: []string{config.PublishTargetGitHub, config.PublishTargetJira, config.PublishTargetLinear}, Description: "work source; publish.default_target in config, else github. linear refuses publish_target_unconfigured until implemented"},
"target": {Type: "string", Enum: []string{config.PublishTargetGitHub, config.PublishTargetJira, config.PublishTargetLinear}, Description: "work source; publish.default_target in config, else github"},
"repo": {Type: "string", Description: "github: owner/repo to file in; else publish.github.repo, else the project's source, else the cwd's origin"},
"dry-run": {Type: "boolean", Description: "render what would be filed; no network calls"},
"update": {Type: "boolean", Description: "update items already filed for this artifact in place instead of refusing"},
Expand Down Expand Up @@ -137,7 +141,9 @@ func runPublish(cmd *cobra.Command, args []string) error {
if targetName == "" {
targetName = cfg.Publish.Target()
}
if targetName != config.PublishTargetGitHub && targetName != config.PublishTargetJira {
switch targetName {
case config.PublishTargetGitHub, config.PublishTargetJira, config.PublishTargetLinear:
default:
return publishTargetUnconfigured(targetName)
}

Expand Down Expand Up @@ -261,17 +267,10 @@ func renderPublishPretty(cmd *cobra.Command, res publishResult) error {
}

func publishTargetUnconfigured(name string) error {
switch name {
case config.PublishTargetLinear:
return output.NewError(publish.ErrTargetUnconfigured,
fmt.Sprintf("the %s target is not available yet in this release", name)).
WithResource(name).
WithNextAction("re-run with --target github or --target jira, or set publish.default_target in .spektacular/config.yaml")
}
return output.NewError(publish.ErrTargetUnconfigured,
fmt.Sprintf("unknown publish target %q; supported targets: %s, %s", name, config.PublishTargetGitHub, config.PublishTargetJira)).
fmt.Sprintf("unknown publish target %q; supported targets: %s, %s, %s", name, config.PublishTargetGitHub, config.PublishTargetJira, config.PublishTargetLinear)).
WithResource(name).
WithNextAction("re-run with --target github or --target jira")
WithNextAction("re-run with --target github, --target jira or --target linear, or set publish.default_target in .spektacular/config.yaml")
}

// publishTargetFor builds the target named, its options, and where it
Expand All @@ -285,6 +284,13 @@ func publishTargetFor(cfg config.Config, cmd *cobra.Command, name string) (publi
return nil, publish.PublishOptions{}, "", err
}
return t, publish.PublishOptions{Labels: cfg.Publish.Jira.Labels}, t.Slug(), nil
case config.PublishTargetLinear:
t, err := linearTarget(cfg.Publish.Linear)
if err != nil {
return nil, publish.PublishOptions{}, "", err
}
opts := publish.PublishOptions{ParentLabel: cfg.Publish.Linear.ParentLabelName(), Labels: cfg.Publish.Linear.Labels}
return t, opts, t.Slug(), nil
default:
owner, repoName, ok := publishRepo(cfg, cmd)
if !ok {
Expand All @@ -311,6 +317,9 @@ func publishDryRunWhere(cfg config.Config, cmd *cobra.Command, target string) st
if target == config.PublishTargetJira {
return strings.TrimSpace(cfg.Publish.Jira.ProjectKey)
}
if target == config.PublishTargetLinear {
return strings.TrimSpace(cfg.Publish.Linear.Team)
}
if repo, _ := cmd.Flags().GetString("repo"); repo != "" {
return repo
}
Expand Down Expand Up @@ -408,6 +417,37 @@ func jiraTarget(jc config.PublishJiraConfig) (*jira.Target, error) {
return t, nil
}

// Environment the Linear target reads. The API key never comes from
// config; LINEAR_API_URL points tests at a fake.
const (
envLinearAPIKey = "LINEAR_API_KEY"
envLinearAPIURL = "LINEAR_API_URL"
)

// linearTarget builds the Linear target from config and the environment.
func linearTarget(lc config.PublishLinearConfig) (*linear.Target, error) {
team := strings.TrimSpace(lc.Team)
if team == "" {
return nil, output.NewError(publish.ErrTargetUnconfigured,
"the linear target has no team to file into: publish.linear.team is not set").
WithResource(config.PublishTargetLinear).
WithNextAction("set publish.linear.team in .spektacular/config.yaml to the key of the team the hive reads (ENG in ENG-12)")
}
apiKey := strings.TrimSpace(os.Getenv(envLinearAPIKey))
if apiKey == "" {
return nil, output.NewError(publish.ErrAuthMissing,
fmt.Sprintf("no Linear credential: %s is unset", envLinearAPIKey)).
WithNextAction(fmt.Sprintf("export %s with a personal API key that can create issues in team %s", envLinearAPIKey, team))
}
t := linear.New(team, apiKey)
t.State = strings.TrimSpace(lc.State)
t.Assignee = strings.TrimSpace(lc.Assignee)
if api := strings.TrimSpace(os.Getenv(envLinearAPIURL)); api != "" {
t.Endpoint = api
}
return t, nil
}

// publishName parses the single <name> argument as a spec or epic name.
func publishName(command string, args []string) (string, error) {
addr, err := artifact.Parse(artifact.KindSpec, args)
Expand Down Expand Up @@ -741,7 +781,7 @@ func replacePublication(list []metadata.Publication, entry metadata.Publication)

func init() {
publishCmd.Flags().Bool("schema", false, "Print the input/output schema and exit")
publishCmd.Flags().String("target", "", "Work source to publish into: github (default), jira, linear")
publishCmd.Flags().String("target", "", "Work source to publish into: github (default), jira or linear")
publishCmd.Flags().String("repo", "", "GitHub owner/repo to file in; defaults to publish.github.repo, then the project's source, then the cwd's origin")
publishCmd.Flags().Bool("dry-run", false, "Render what would be filed and make no network calls")
publishCmd.Flags().Bool("update", false, "Update items already filed for this artifact in place")
Expand Down
Loading
Loading