From e9f461977ac9e7bba3e81a71c40d2be7b85763fe Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Sat, 7 Nov 2026 18:45:22 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20publish:=20Linear=20target=20(i?= =?UTF-8?q?ssue=20+=20sub-issues,=20blocks=20relations)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Linear work-source target behind the publish Target interface. The RFC is filed as an issue in publish.linear.team and each child as a sub-issue of it (parentId), which hive already reads as a tracker; depends_on edges become blocks relations from the blocker, best effort. Labels (and the parent label on the RFC) are applied only when the team has them, new issues go to the team default state or publish.linear.state, and an unknown team or state is refused before anything is filed. Linear stores Markdown, so bodies round-trip verbatim and a re-publish finds its items by the marker in the description. Auth is LINEAR_API_KEY, sent bare as hive does; LINEAR_API_URL points tests at a fake. Includes an in-process fake GraphQL API (lineartest) and unit/command tests covering create, idempotent re-run, --update, relation failures as warnings, closed-state detection, refusals and dry-run making no network call. Signed-off-by: Andrew Anderson --- README.md | 12 +- cmd/publish.go | 75 +++- cmd/publish_test.go | 121 ++++- docs/publish-hive-handshake.md | 9 +- internal/config/config.go | 26 +- internal/publish/linear/linear.go | 499 +++++++++++++++++++++ internal/publish/linear/linear_test.go | 191 ++++++++ internal/publish/linear/lineartest/fake.go | 245 ++++++++++ 8 files changed, 1146 insertions(+), 32 deletions(-) create mode 100644 internal/publish/linear/linear.go create mode 100644 internal/publish/linear/linear_test.go create mode 100644 internal/publish/linear/lineartest/fake.go diff --git a/README.md b/README.md index 3d98e8b..cbdbbc1 100644 --- a/README.md +++ b/README.md @@ -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 --dry-run # render the RFC and children; no network calls spektacular publish [--repo owner/repo] # file them on GitHub (the default target) spektacular publish --target jira # file them as an Epic + Stories in publish.jira.project_key +spektacular publish --target linear # file them as an issue + sub-issues in team publish.linear.team spektacular publish --update # re-publish, updating the filed items in place spektacular publish --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, `` on the RFC and the same with `task= rfc=` 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` 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 ``, 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, `` on the RFC and the same with `task= rfc=` 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` 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, dependencies are `blocks` relations, labels are applied only when the team has them, 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 ``, 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 @@ -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 @@ -400,6 +401,11 @@ 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 workflow state new issues are created in (team default when unset) + 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/`. 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. diff --git a/cmd/publish.go b/cmd/publish.go index cbdbd8d..ebde02e 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -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" ) @@ -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, } @@ -73,14 +77,14 @@ var publishOutputSchema = &schemaObj{ "name": {Type: "string"}, "kind": {Type: "string", Enum: []string{publish.KindSpec, publish.KindEpic}}, "artifact_id": {Type: "string", Description: "//; 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"}}, @@ -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"}, @@ -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) } @@ -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 @@ -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 { @@ -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 } @@ -408,6 +417,36 @@ 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) + if api := strings.TrimSpace(os.Getenv(envLinearAPIURL)); api != "" { + t.Endpoint = api + } + return t, nil +} + // publishName parses the single argument as a spec or epic name. func publishName(command string, args []string) (string, error) { addr, err := artifact.Parse(artifact.KindSpec, args) @@ -741,7 +780,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") diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 85fb77f..e664c01 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -19,6 +19,7 @@ import ( "github.com/hivecommons/spektacular/internal/output" "github.com/hivecommons/spektacular/internal/publish" "github.com/hivecommons/spektacular/internal/publish/jira/jiratest" + "github.com/hivecommons/spektacular/internal/publish/linear/lineartest" ) const publishSpecBody = `# Feature: Nightly digest email @@ -173,10 +174,9 @@ func TestPublish_Refusals(t *testing.T) { }) t.Run("target unconfigured", func(t *testing.T) { publishProject(t) - for _, target := range []string{"linear", "trello"} { - stdout, code := runPublishCmd(t, "nightly-digest", "--dry-run", "--target", target) - requirePublishRefusal(t, stdout, code, publish.ErrTargetUnconfigured) - } + stdout, code := runPublishCmd(t, "nightly-digest", "--dry-run", "--target", "trello") + resp := requirePublishRefusal(t, stdout, code, publish.ErrTargetUnconfigured) + require.Contains(t, resp.Message, "github, jira, linear") }) t.Run("repo unknown", func(t *testing.T) { publishProject(t) @@ -526,6 +526,119 @@ func TestPublish_JiraDataCenterUsesV2(t *testing.T) { require.IsType(t, "", srv.Issues["DEMO-1"].Description, "Data Center descriptions are wiki text") } +// publishLinearProject is publishProject configured for the linear target +// with the API key in the environment and the endpoint pointed at the fake. +func publishLinearProject(t *testing.T) (string, *lineartest.Server) { + t.Helper() + root := publishProject(t) + writeSpecCommandConfig(t, root, "publish:\n default_target: linear\n linear:\n team: ENG\n state: Todo\n labels: [spek]\n") + writeSpecFixture(t, root, "nightly-digest", "---\ncreated_date: 2026-02-01\ndocument_status: final\n---\n\n"+publishSpecBody) + srv := lineartest.New(t) + t.Setenv("LINEAR_API_URL", srv.URL) + t.Setenv("LINEAR_API_KEY", lineartest.APIKey) + return root, srv +} + +func TestPublish_LinearDryRunMakesNoNetworkCall(t *testing.T) { + _, srv := publishLinearProject(t) + stdout, code := runPublishCmd(t, "nightly-digest", "--dry-run", "--format", "json") + require.Equal(t, 0, code, stdout) + require.Contains(t, stdout, `"target": "linear"`) + require.Contains(t, stdout, `"repo": "ENG"`) + require.Empty(t, srv.Issues) + require.Zero(t, srv.Count("teams")) +} + +func TestPublish_LinearRefusalsNameTheMissingSetting(t *testing.T) { + t.Run("team", func(t *testing.T) { + root, _ := publishLinearProject(t) + writeSpecCommandConfig(t, root, "publish:\n default_target: linear\n") + stdout, code := runPublishCmd(t, "nightly-digest") + resp := requirePublishRefusal(t, stdout, code, publish.ErrTargetUnconfigured) + require.Contains(t, resp.Message, "publish.linear.team") + }) + t.Run("api key", func(t *testing.T) { + publishLinearProject(t) + t.Setenv("LINEAR_API_KEY", "") + stdout, code := runPublishCmd(t, "nightly-digest") + resp := requirePublishRefusal(t, stdout, code, publish.ErrAuthMissing) + require.Contains(t, resp.Message, "LINEAR_API_KEY") + require.Contains(t, resp.NextAction, "ENG") + }) + t.Run("unknown team is a publish failure before anything is filed", func(t *testing.T) { + root, srv := publishLinearProject(t) + writeSpecCommandConfig(t, root, "publish:\n default_target: linear\n linear:\n team: NOPE\n") + stdout, code := runPublishCmd(t, "nightly-digest") + resp := requirePublishRefusal(t, stdout, code, publish.ErrTargetFailed) + require.Contains(t, resp.Message, `team "NOPE" does not exist`) + require.Empty(t, srv.Issues) + }) +} + +func TestPublish_LinearFilesIssueAndSubIssuesThenUpdates(t *testing.T) { + root, srv := publishLinearProject(t) + + stdout, code := runPublishCmd(t, "nightly-digest", "--format", "json") + require.Equal(t, 0, code, stdout) + var res struct { + Target string `json:"target"` + Repo string `json:"repo"` + RFCURL string `json:"rfc_url"` + RFCKey string `json:"rfc_key"` + Children []publish.ResultChild `json:"children"` + Created []string `json:"created"` + Warnings []string `json:"warnings"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &res), stdout) + require.Equal(t, "linear", res.Target) + require.Equal(t, "ENG", res.Repo) + require.Equal(t, "ENG-1", res.RFCKey) + require.Equal(t, "https://linear.app/acme/issue/ENG-1", res.RFCURL) + require.Equal(t, []string{"rfc", "R1", "R2"}, res.Created) + require.Equal(t, []publish.ResultChild{ + {ID: "R1", URL: "https://linear.app/acme/issue/ENG-2", Key: "ENG-2"}, + {ID: "R2", URL: "https://linear.app/acme/issue/ENG-3", Key: "ENG-3"}, + }, res.Children) + require.Empty(t, res.Warnings) + rfc := srv.ByIdentifier("ENG-1") + require.Equal(t, []string{lineartest.LabelID("epic"), lineartest.LabelID("spek")}, rfc.LabelIDs, "default parent label on the RFC") + require.Equal(t, lineartest.StateID("Todo"), rfc.StateID) + child := srv.ByIdentifier("ENG-2") + require.Equal(t, rfc.ID, child.ParentID) + require.Equal(t, []string{lineartest.LabelID("spek")}, child.LabelIDs) + + raw, err := os.ReadFile(filepath.Join(root, ".spektacular", "specs", "nightly-digest.md")) + require.NoError(t, err) + fm, _, err := metadata.Split(raw) + require.NoError(t, err) + require.Equal(t, []metadata.Publication{{Target: "linear", RFCURL: "https://linear.app/acme/issue/ENG-1", Children: []metadata.PublishedChild{ + {ID: "R1", URL: "https://linear.app/acme/issue/ENG-2"}, {ID: "R2", URL: "https://linear.app/acme/issue/ENG-3"}, + }}}, fm.Published) + + stdout, code = runPublishCmd(t, "nightly-digest") + require.Equal(t, 0, code, stdout) + require.Contains(t, stdout, "unchanged: R1, R2, rfc") + require.Equal(t, 3, srv.Count("issueCreate")) + + writeSpecFixture(t, root, "nightly-digest", strings.Replace(string(raw), + "with links.\n", "with links.\n- [ ] **Opt-out**: an operator can turn the digest off.\n", 1)) + stdout, code = runPublishCmd(t, "nightly-digest") + resp := requirePublishRefusal(t, stdout, code, publish.ErrConflict) + require.Contains(t, resp.Message, "R3 (create)") + require.Equal(t, 3, srv.Count("issueCreate")) + + stdout, code = runPublishCmd(t, "nightly-digest", "--update") + require.Equal(t, 0, code, stdout) + require.Contains(t, stdout, "created: R3") + require.Equal(t, 4, srv.Count("issueCreate")) + require.Contains(t, srv.ByIdentifier("ENG-1").Description, "ENG-4 — Opt-out") + + srv.ByIdentifier("ENG-1").StateType = "completed" + stdout, code = runPublishCmd(t, "nightly-digest", "--update") + resp = requirePublishRefusal(t, stdout, code, publish.ErrConflict) + require.Contains(t, resp.Message, "closed") +} + func TestPublish_SchemaPublishesFlags(t *testing.T) { publishProject(t) stdout, code := runPublishCmd(t, "--schema") diff --git a/docs/publish-hive-handshake.md b/docs/publish-hive-handshake.md index fba0c75..5183e3a 100644 --- a/docs/publish-hive-handshake.md +++ b/docs/publish-hive-handshake.md @@ -1,9 +1,8 @@ # Publish ⇄ Hive handshake `spektacular publish` turns a final spec or epic into one **RFC** item plus a -**child** item per unit of work in a work source (GitHub and Jira today; -Linear drops in behind the same `Target` interface, see -[spektacular#113](https://github.com/hivecommons/spektacular/issues/113)). A hive +**child** item per unit of work in a work source (GitHub, Jira or Linear, +behind one `Target` interface). A hive attached to that work source must pick the children up, build each against the spek's acceptance criteria, leave the RFC alone, and never route a child into a fresh Spek `spec` run. This page is the contract both sides read. @@ -94,8 +93,8 @@ From `hivecommons/hive` at the time of writing; verify against current main. | Sub-issues / dependencies | `hive-open-issue --parent`, `--blocked-by` (`src/docs/hive-open-issue.md`) | Each child is added with `POST /repos/{o}/{r}/issues/{rfc}/sub_issues` (`sub_issue_id` = the child's database id); each `depends_on` edge with `POST /repos/{o}/{r}/issues/{child}/dependencies/blocked_by` (`issue_id` = the blocker's database id). Both are best effort: a failure is a warning in the result, and `--update` retries. | | PR linkage | closing keywords `Fixes/Closes/Resolves #N` | The child is the issue the kick receives, so its number is what the PR closes. | | Jira | `src/docs/work-sources.md` → `type: jira`: `project in () AND statusCategory != Done AND issuetype != Epic`, `hold_labels` | The RFC is an **Epic** (`publish.jira.parent_issue_type`) in `publish.jira.project_key`, so hive's default JQL never lists it; each child a **Story** (`publish.jira.child_issue_type`) with `parent` = the Epic on Cloud, or on Data Center, where `parent` is for sub-tasks only, the Epic Link custom field (`publish.jira.epic_link_field`, discovered from `GET field` when unset) = its key, created in the project's default status, whose category is not Done, with `publish.jira.labels` (whitespace → `-`) and never a hold label. Each `depends_on` edge is a `Blocks` issue link (blocker outward, dependant inward); a re-run reads `parent`/Epic Link and `issuelinks` back and refiles whatever is missing. The marker is the last line of the description **and** the issue property `spek.publish` (`{name, artifact_id, target, task, rfc, title, body_digest}`), written in the create request itself; the line before the marker is a search token `spekpub`, and a re-publish searches `project = AND description ~ ""` (the `issue.property[...]` JQL needs an app to index the property, so it is not used), then reads any issue the artifact's `published:` frontmatter recorded that the search did not return. Cloud uses REST v3 with ADF descriptions and `JIRA_EMAIL` + `JIRA_API_TOKEN`; Data Center (`publish.jira.deployment: datacenter`) REST v2 with wiki markup and `JIRA_PAT`, trusting `JIRA_CA_BUNDLE`. The site is `JIRA_BASE_URL`. | -| Linear | `src/docs/work-sources.md` → `type: linear` | Deferred to #113. | -| Crux proxy | `crux/proxy/linear_rules.go`; `crux/issueshape` on `POST /repos/*/issues` | Publishing from inside a hive needs `POST /repos/*/issues`, `PATCH /repos/*/issues/*`, `POST …/sub_issues`, `POST …/dependencies/blocked_by`, `GET /repos/*/labels` and `GET /search/issues` allowed at the publishing agent's mode. | +| Linear | `src/docs/work-sources.md` → `type: linear`: issues of the team in the configured states, `IsTracker` when the issue has children or its marker has no `task=`, `inverseRelations` of type `blocks` | The RFC is an issue in team `publish.linear.team`, each child a **sub-issue** of it (`parentId`), so hive sees the RFC as a tracker twice over and never claims it; the `epic` label (`publish.linear.parent_label`) goes on the RFC **only if the team has it**, `publish.linear.labels` on every issue that the team has labels for; publish never creates labels. Issues are created in the team's default state or `publish.linear.state`, which must exist. Each `depends_on` edge is `issueRelationCreate(issueId: blocker, relatedIssueId: dependant, type: blocks)`, so the dependant's `inverseRelations` carries the blocker, best effort. Linear stores Markdown, so the marker is the description's last line and a re-publish finds it with `issues(filter: {team: {key: {eq}}, description: {contains: "artifact_id="}})`; bodies compare verbatim. Auth is `LINEAR_API_KEY` sent bare in `Authorization`, as hive does. | +| Crux proxy | `crux/proxy/linear_rules.go`; `crux/issueshape` on `POST /repos/*/issues` | Publishing from inside a hive needs `POST /repos/*/issues`, `PATCH /repos/*/issues/*`, `POST …/sub_issues`, `POST …/dependencies/blocked_by`, `GET /repos/*/labels` and `GET /search/issues` allowed at the publishing agent's mode. On Linear it needs `issueCreate`, `issueUpdate` and `issueRelationCreate`, all allowed at `ModeIssuesOnly` today; queries pass. | ## 4. The "already specced" signal: option (b), the marker diff --git a/internal/config/config.go b/internal/config/config.go index 9b7cd9f..fa98380 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -404,8 +404,7 @@ func (k KnowledgeConfig) PruneMaxAgeDays() int { return k.Prune.MaxAgeDays } -// Publish targets. linear is reserved so a config naming it is refused with -// publish_target_unconfigured rather than an unknown-value error. +// Publish targets. const ( PublishTargetGitHub = "github" PublishTargetJira = "jira" @@ -433,6 +432,29 @@ type PublishConfig struct { DefaultTarget string `yaml:"default_target,omitempty"` GitHub PublishGitHubConfig `yaml:"github,omitempty"` Jira PublishJiraConfig `yaml:"jira,omitempty"` + Linear PublishLinearConfig `yaml:"linear,omitempty"` +} + +// PublishLinearConfig is the Linear target's settings. The API key comes +// from the environment (LINEAR_API_KEY). Labels are applied only when the +// team already has them; publish never creates a label. +type PublishLinearConfig struct { + // Team is the team key, the prefix of its identifiers (ENG in ENG-12). + Team string `yaml:"team,omitempty"` + // State is the workflow state new issues are created in; the team's + // default when unset. + State string `yaml:"state,omitempty"` + Labels []string `yaml:"labels,omitempty"` + ParentLabel string `yaml:"parent_label,omitempty"` +} + +// ParentLabelName is the label for the RFC, DefaultPublishParentLabel when +// unset. +func (p PublishLinearConfig) ParentLabelName() string { + if p.ParentLabel == "" { + return DefaultPublishParentLabel + } + return p.ParentLabel } // PublishJiraConfig is the Jira target's settings. The site, credentials and diff --git a/internal/publish/linear/linear.go b/internal/publish/linear/linear.go new file mode 100644 index 0000000..85b3702 --- /dev/null +++ b/internal/publish/linear/linear.go @@ -0,0 +1,499 @@ +// Package linear files a publication into a Linear team through the +// GraphQL API: the RFC as an issue, each child as a sub-issue of it, and +// each depends_on edge as a "blocks" relation. Linear stores Markdown, so +// bodies round-trip verbatim and the marker is the last line of every +// description, which is also how a re-publish finds what it filed. +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/hivecommons/spektacular/internal/publish" +) + +// DefaultEndpoint is Linear's GraphQL API. +const DefaultEndpoint = "https://api.linear.app/graphql" + +// RelationBlocks is the relation type filed for a depends_on edge, read +// from the blocker: the blocker "blocks" the dependant. +const RelationBlocks = "blocks" + +// pageSize bounds every list the target reads; a team with more labels or +// filed items than this is not a shape publish produces. +const pageSize = 250 + +// Target files into one Linear team. +type Target struct { + // Endpoint is the GraphQL URL; DefaultEndpoint when empty. + Endpoint string + // TeamKey is the team's key, the prefix of its identifiers: ENG in ENG-12. + TeamKey string + // APIKey is sent bare in the Authorization header, as Linear expects + // for personal API keys. + APIKey string + // State, when set, is the name of the workflow state new issues are + // created in; the team's default otherwise. + State string + // HTTPClient defaults to one with a 30s timeout. + HTTPClient *http.Client +} + +// New returns a target for teamKey. +func New(teamKey, apiKey string) *Target { + return &Target{Endpoint: DefaultEndpoint, TeamKey: teamKey, APIKey: apiKey, HTTPClient: &http.Client{Timeout: 30 * time.Second}} +} + +// Name implements publish.Target. +func (t *Target) Name() string { return "linear" } + +// Slug is the team key, what Result.Repo reports. +func (t *Target) Slug() string { return t.TeamKey } + +func (t *Target) endpoint() string { + if t.Endpoint == "" { + return DefaultEndpoint + } + return t.Endpoint +} + +type issueNode struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + State struct { + Type string `json:"type"` + } `json:"state"` +} + +const findQuery = `query($team: String!, $needle: String!, $first: Int!) { + issues(filter: { team: { key: { eq: $team } }, description: { contains: $needle } }, first: $first) { + nodes { id identifier url title description state { type } } + } +}` + +// Find implements publish.Target: the team's issues whose description +// carries the artifact id, each read back by its marker. +func (t *Target) Find(ctx context.Context, artifactID string) (*publish.Existing, error) { + var out struct { + Issues struct { + Nodes []issueNode `json:"nodes"` + } `json:"issues"` + } + vars := map[string]any{"team": t.TeamKey, "needle": "artifact_id=" + artifactID, "first": pageSize} + if err := t.query(ctx, findQuery, vars, &out); err != nil { + return nil, fmt.Errorf("searching team %s for published items: %w", t.TeamKey, err) + } + existing := &publish.Existing{Children: map[string]publish.Filed{}} + for _, n := range out.Issues.Nodes { + m, ok := publish.ParseMarker(n.Description) + if !ok || m.ArtifactID != artifactID { + continue + } + f := filed(n, m) + if m.IsRFC() { + if existing.RFC == nil || (existing.RFC.Closed && !f.Closed) { + rfc := f + existing.RFC = &rfc + } + continue + } + if prev, dup := existing.Children[m.Task]; !dup || (prev.Closed && !f.Closed) { + existing.Children[m.Task] = f + } + } + if existing.RFC == nil && len(existing.Children) == 0 { + return nil, nil + } + return existing, nil +} + +// filed maps an issue to a Filed: Key is Linear's UUID, what mutations +// take; Ref the identifier, what people and other bodies use. +func filed(n issueNode, m publish.Marker) publish.Filed { + return publish.Filed{ + Key: n.ID, + Ref: n.Identifier, + URL: n.URL, + Title: n.Title, + Body: n.Description, + Closed: n.State.Type == "completed" || n.State.Type == "canceled", + Marker: m, + } +} + +type team struct { + ID string `json:"id"` + Key string `json:"key"` + States struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"nodes"` + } `json:"states"` + Labels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` +} + +const teamQuery = `query($key: String!, $first: Int!) { + teams(filter: { key: { eq: $key } }, first: 1) { + nodes { + id key + states(first: $first) { nodes { id name type } } + labels(first: $first) { nodes { id name } } + } + } +}` + +// team reads the team's id, states and labels; an unknown key is an error +// before anything is filed. +func (t *Target) team(ctx context.Context) (team, error) { + var out struct { + Teams struct { + Nodes []team `json:"nodes"` + } `json:"teams"` + } + if err := t.query(ctx, teamQuery, map[string]any{"key": t.TeamKey, "first": pageSize}, &out); err != nil { + return team{}, fmt.Errorf("reading team %s: %w", t.TeamKey, err) + } + if len(out.Teams.Nodes) == 0 { + return team{}, fmt.Errorf("team %q does not exist or the API key cannot see it", t.TeamKey) + } + return out.Teams.Nodes[0], nil +} + +// stateID resolves the configured state name; "" means the team default. +func (t *Target) stateID(tm team) (string, error) { + if t.State == "" { + return "", nil + } + for _, s := range tm.States.Nodes { + if strings.EqualFold(s.Name, t.State) { + return s.ID, nil + } + } + return "", fmt.Errorf("team %s has no workflow state named %q", t.TeamKey, t.State) +} + +// pickLabels keeps the wanted labels that exist in the team, warning once +// per missing one. Labels are never created. +func (t *Target) pickLabels(tm team, res *publish.Result, parent string, wanted ...string) []string { + have := map[string]string{} + for _, l := range tm.Labels.Nodes { + have[strings.ToLower(l.Name)] = l.ID + } + var out []string + add := func(name, role string) { + if name == "" { + return + } + if id, ok := have[strings.ToLower(name)]; ok { + out = append(out, id) + return + } + w := fmt.Sprintf("%s label %q does not exist in team %s; not applied (publish never creates labels)", role, name, t.TeamKey) + if !contains(res.Warnings, w) { + res.Warnings = append(res.Warnings, w) + } + } + add(parent, "parent") + for _, l := range wanted { + add(l, "configured") + } + return out +} + +// Publish implements publish.Target in the same order as the GitHub +// target: the RFC first, then each child as its sub-issue, then the blocks +// relations, then the RFC again with the children's identifiers in its +// checklist. Relations are best effort: a failure is a warning. +func (t *Target) Publish(ctx context.Context, p publish.Publication, existing *publish.Existing, opts publish.PublishOptions) (publish.Result, error) { + res := publish.Result{Target: t.Name(), Repo: t.Slug(), Created: []string{}, Updated: []string{}, Unchanged: []string{}, Warnings: []string{}, Children: []publish.ResultChild{}} + if existing == nil { + existing = &publish.Existing{Children: map[string]publish.Filed{}} + } + tm, err := t.team(ctx) + if err != nil { + return res, err + } + stateID, err := t.stateID(tm) + if err != nil { + return res, err + } + rfcLabels := t.pickLabels(tm, &res, opts.ParentLabel, opts.Labels...) + childLabels := t.pickLabels(tm, &res, "", opts.Labels...) + + refs := map[string]publish.Ref{} + for id, f := range existing.Children { + refs[id] = publish.Ref{Ref: f.Ref, URL: f.URL} + } + + var rfc publish.Filed + if existing.RFC == nil { + created, err := t.createIssue(ctx, tm.ID, p.RFC.Title, p.RFCBody(refs), rfcLabels, stateID, "") + if err != nil { + return res, fmt.Errorf("creating the RFC issue: %w", err) + } + rfc = created + res.Created = append(res.Created, "rfc") + } else { + rfc = *existing.RFC + } + res.RFCURL = rfc.URL + res.RFCKey = rfc.Ref + + filedChildren := map[string]publish.Filed{} + for _, c := range p.Children { + body := c.Render(p, rfc.URL, refs) + if f, ok := existing.Children[c.ID]; ok { + if f.Title != c.Title || !f.SameAsFiled(body) { + if err := t.updateIssue(ctx, f.Key, c.Title, body); err != nil { + return res, fmt.Errorf("updating child %s (%s): %w", c.ID, f.Ref, err) + } + f.Body = body + res.Updated = append(res.Updated, c.ID) + } else { + res.Unchanged = append(res.Unchanged, c.ID) + } + filedChildren[c.ID] = f + } else { + created, err := t.createIssue(ctx, tm.ID, c.Title, body, childLabels, stateID, rfc.Key) + if err != nil { + return res, fmt.Errorf("creating child %s: %w", c.ID, err) + } + filedChildren[c.ID] = created + refs[c.ID] = publish.Ref{Ref: created.Ref, URL: created.URL} + res.Created = append(res.Created, c.ID) + } + f := filedChildren[c.ID] + res.Children = append(res.Children, publish.ResultChild{ID: c.ID, URL: f.URL, Key: f.Ref}) + } + + // Second pass: bodies that named children by ID before those existed + // get their identifiers, and the blocks relations are filed. + for _, c := range p.Children { + f := filedChildren[c.ID] + body := c.Render(p, rfc.URL, refs) + wasCreated := contains(res.Created, c.ID) + if !f.SameAsFiled(body) { + if err := t.updateIssue(ctx, f.Key, c.Title, body); err != nil { + return res, fmt.Errorf("updating child %s (%s): %w", c.ID, f.Ref, err) + } + if !wasCreated && !contains(res.Updated, c.ID) { + res.Updated = append(res.Updated, c.ID) + res.Unchanged = remove(res.Unchanged, c.ID) + } + } + for _, dep := range c.DependsOn { + blocker, ok := filedChildren[dep] + if !ok { + continue + } + if wasCreated || contains(res.Created, dep) { + t.blockedBy(ctx, f, blocker, &res) + } + } + } + + rfcBody := p.RFCBody(refs) + if existing.RFC == nil { + if err := t.updateIssue(ctx, rfc.Key, p.RFC.Title, rfcBody); err != nil { + return res, fmt.Errorf("updating the RFC checklist (%s): %w", rfc.Ref, err) + } + } else if rfc.Title != p.RFC.Title || !rfc.SameAsFiled(rfcBody) { + if err := t.updateIssue(ctx, rfc.Key, p.RFC.Title, rfcBody); err != nil { + return res, fmt.Errorf("updating the RFC (%s): %w", rfc.Ref, err) + } + res.Updated = append(res.Updated, "rfc") + } else { + res.Unchanged = append(res.Unchanged, "rfc") + } + return res, nil +} + +const createMutation = `mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { success issue { id identifier url title description state { type } } } +}` + +const updateMutation = `mutation($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { success } +}` + +const relationMutation = `mutation($input: IssueRelationCreateInput!) { + issueRelationCreate(input: $input) { success } +}` + +func (t *Target) createIssue(ctx context.Context, teamID, title, body string, labelIDs []string, stateID, parentID string) (publish.Filed, error) { + input := map[string]any{"teamId": teamID, "title": title, "description": body} + if len(labelIDs) > 0 { + input["labelIds"] = labelIDs + } + if stateID != "" { + input["stateId"] = stateID + } + if parentID != "" { + input["parentId"] = parentID + } + var out struct { + IssueCreate struct { + Success bool `json:"success"` + Issue issueNode `json:"issue"` + } `json:"issueCreate"` + } + if err := t.query(ctx, createMutation, map[string]any{"input": input}, &out); err != nil { + return publish.Filed{}, err + } + if !out.IssueCreate.Success || out.IssueCreate.Issue.ID == "" { + return publish.Filed{}, errors.New("issueCreate reported no issue") + } + m, _ := publish.ParseMarker(body) + return filed(out.IssueCreate.Issue, m), nil +} + +func (t *Target) updateIssue(ctx context.Context, id, title, body string) error { + var out struct { + IssueUpdate struct { + Success bool `json:"success"` + } `json:"issueUpdate"` + } + input := map[string]any{"title": title, "description": body} + if err := t.query(ctx, updateMutation, map[string]any{"id": id, "input": input}, &out); err != nil { + return err + } + if !out.IssueUpdate.Success { + return errors.New("issueUpdate reported failure") + } + return nil +} + +// blockedBy files blocker → issue as a blocks relation, best effort. +func (t *Target) blockedBy(ctx context.Context, issue, blocker publish.Filed, res *publish.Result) { + input := map[string]any{"issueId": blocker.Key, "relatedIssueId": issue.Key, "type": RelationBlocks} + var out struct { + IssueRelationCreate struct { + Success bool `json:"success"` + } `json:"issueRelationCreate"` + } + err := t.query(ctx, relationMutation, map[string]any{"input": input}, &out) + if err == nil && !out.IssueRelationCreate.Success { + err = errors.New("issueRelationCreate reported failure") + } + if err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("could not mark %s as blocked by %s: %v", issue.Ref, blocker.Ref, err)) + } +} + +// apiError is a non-2xx response or a GraphQL errors array. +type apiError struct { + Status int + Message string +} + +func (e *apiError) Error() string { + if e.Status != 0 { + return fmt.Sprintf("linear API %d: %s", e.Status, e.Message) + } + return "linear API: " + e.Message +} + +// query posts one GraphQL document and decodes data into out. +func (t *Target) query(ctx context.Context, doc string, vars map[string]any, out any) error { + payload, err := json.Marshal(map[string]any{"query": doc, "variables": vars}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.endpoint(), bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", t.APIKey) + client := t.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return &apiError{Status: resp.StatusCode, Message: errorMessage(raw)} + } + var env struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("decoding linear response: %w", err) + } + if len(env.Errors) > 0 { + msgs := make([]string, 0, len(env.Errors)) + for _, e := range env.Errors { + msgs = append(msgs, e.Message) + } + return &apiError{Message: strings.Join(msgs, "; ")} + } + if out != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, out) + } + return nil +} + +func errorMessage(raw []byte) string { + var env struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + Message string `json:"message"` + } + if json.Unmarshal(raw, &env) == nil { + if len(env.Errors) > 0 { + return env.Errors[0].Message + } + if env.Message != "" { + return env.Message + } + } + return strings.TrimSpace(string(raw)) +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func remove(list []string, s string) []string { + out := list[:0] + for _, v := range list { + if v != s { + out = append(out, v) + } + } + return out +} diff --git a/internal/publish/linear/linear_test.go b/internal/publish/linear/linear_test.go new file mode 100644 index 0000000..1e74dfb --- /dev/null +++ b/internal/publish/linear/linear_test.go @@ -0,0 +1,191 @@ +package linear_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hivecommons/spektacular/internal/publish" + "github.com/hivecommons/spektacular/internal/publish/linear/lineartest" +) + +func testPublication(t *testing.T) publish.Publication { + t.Helper() + body := []byte(`# Feature: Nightly digest + +## Overview + +Operators want one email per day summarising what the hive filed, merged and +parked, so they can skim the night's work without opening the dashboard. + +## Requirements + +- [ ] **Schedule**: send one digest per day at the configured hour, in the operator's time zone. +- [ ] **Content**: list filed, merged and parked items with links back to each. + +## Acceptance Criteria + +- [ ] **Schedule**: a digest arrives within five minutes of the hour. +- [ ] **Content**: every item of the last 24 hours appears exactly once. +`) + p, err := publish.Build(publish.Source{Kind: publish.KindSpec, Name: "nightly-digest", ArtifactID: "demo/spec/nightly-digest", Target: "linear", Body: body}) + require.NoError(t, err) + p.Children[1].DependsOn = []string{"R1"} + require.NoError(t, publish.Validate(p)) + return p +} + +func TestTarget_PublishCreatesIssueThenSubIssuesAndRelations(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.State = lineartest.Todo + p := testPublication(t) + ctx := context.Background() + + existing, err := target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.Nil(t, existing) + + res, err := target.Publish(ctx, p, nil, publish.PublishOptions{ParentLabel: "epic", Labels: []string{"spek", "nope"}}) + require.NoError(t, err) + require.Equal(t, []string{`configured label "nope" does not exist in team ENG; not applied (publish never creates labels)`}, res.Warnings) + require.Equal(t, "ENG", res.Repo) + require.Equal(t, "ENG-1", res.RFCKey) + require.Equal(t, "https://linear.app/acme/issue/ENG-1", res.RFCURL) + require.Equal(t, []string{"rfc", "R1", "R2"}, res.Created) + require.Equal(t, []publish.ResultChild{ + {ID: "R1", URL: "https://linear.app/acme/issue/ENG-2", Key: "ENG-2"}, + {ID: "R2", URL: "https://linear.app/acme/issue/ENG-3", Key: "ENG-3"}, + }, res.Children) + + rfc := f.ByIdentifier("ENG-1") + require.Equal(t, "RFC: Nightly digest", rfc.Title) + require.Equal(t, "", rfc.ParentID) + require.Equal(t, []string{lineartest.LabelID("epic"), lineartest.LabelID("spek")}, rfc.LabelIDs, "parent label on the RFC only, missing ones skipped") + require.Equal(t, lineartest.StateID(lineartest.Todo), rfc.StateID) + require.Contains(t, rfc.Description, "ENG-2 — Schedule") + require.Contains(t, rfc.Description, "ENG-3 — Content") + require.True(t, strings.HasSuffix(strings.TrimSpace(rfc.Description), "target=linear -->"), "marker is the last line") + + child := f.ByIdentifier("ENG-3") + require.Equal(t, rfc.ID, child.ParentID, "children are sub-issues of the RFC") + require.Equal(t, []string{lineartest.LabelID("spek")}, child.LabelIDs) + require.Contains(t, child.Description, "Blocked by ENG-2 — Schedule") + require.Contains(t, child.Description, "Part of RFC: https://linear.app/acme/issue/ENG-1") + m, ok := publish.ParseMarker(child.Description) + require.True(t, ok) + require.Equal(t, "R2", m.Task) + require.Equal(t, "https://linear.app/acme/issue/ENG-1", m.RFC) + + require.Equal(t, []string{"ENG-2 blocks ENG-3"}, f.Relations) + require.Equal(t, 3, f.Count("issueCreate")) + require.Equal(t, 1, f.Count("teams"), "the team is read once per publish") +} + +func TestTarget_FindThenRepublishIsIdempotent(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + p := testPublication(t) + ctx := context.Background() + _, err := target.Publish(ctx, p, nil, publish.PublishOptions{}) + require.NoError(t, err) + f.Add(&lineartest.Issue{Title: "other", Description: "x\n\n\n"}) + + existing, err := target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.NotNil(t, existing) + require.NotNil(t, existing.RFC) + require.Equal(t, "uuid-1", existing.RFC.Key, "Key is the UUID mutations take") + require.Equal(t, "ENG-1", existing.RFC.Ref) + require.Equal(t, []string{"R1", "R2"}, publish.SortedIDs(existing.Children)) + require.Empty(t, publish.Diff(p, existing), "Linear stores Markdown, so the body round-trips") + + res, err := target.Publish(ctx, p, existing, publish.PublishOptions{}) + require.NoError(t, err) + require.Empty(t, res.Created) + require.Empty(t, res.Updated) + require.Equal(t, []string{"R1", "R2", "rfc"}, res.Unchanged) + require.Equal(t, 3, f.Count("issueCreate"), "nothing is created twice") + require.Equal(t, 1, f.Count("issueUpdate"), "only the first run's RFC checklist rewrite") + + p.Children[0].Title = "Schedule the digest" + p.Children = append(p.Children, publish.Child{}) + p.Children[2] = p.Children[1] + p.Children[2].ID = "R3" + p.Children[2].Title = "Opt out" + p.Children[2].DependsOn = []string{"R2"} + p.Children[2].Marker = strings.Replace(p.Children[1].Marker, "task=R2", "task=R3", 1) + require.Equal(t, "rfc (body), R1 (title), R2 (body), R3 (create)", publish.DescribeChanges(publish.Diff(p, existing))) + + res, err = target.Publish(ctx, p, existing, publish.PublishOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"R3"}, res.Created) + require.ElementsMatch(t, []string{"R1", "R2", "rfc"}, res.Updated) + require.Empty(t, res.Unchanged) + require.Equal(t, "Schedule the digest", f.ByIdentifier("ENG-2").Title) + require.Contains(t, f.ByIdentifier("ENG-1").Description, "ENG-5 — Opt out") + require.Equal(t, "uuid-1", f.ByIdentifier("ENG-5").ParentID) + require.Equal(t, []string{"ENG-2 blocks ENG-3", "ENG-3 blocks ENG-5"}, f.Relations) + + existing, err = target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.Empty(t, publish.Diff(p, existing)) +} + +func TestTarget_ClosedItemsAreReported(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + p := testPublication(t) + ctx := context.Background() + _, err := target.Publish(ctx, p, nil, publish.PublishOptions{}) + require.NoError(t, err) + f.ByIdentifier("ENG-1").StateType = "completed" + f.ByIdentifier("ENG-2").StateType = "canceled" + + existing, err := target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.True(t, existing.RFC.Closed) + require.True(t, existing.Children["R1"].Closed) + require.False(t, existing.Children["R2"].Closed) +} + +func TestTarget_RelationFailuresAreWarnings(t *testing.T) { + f := lineartest.New(t) + f.FailRelations = true + res, err := f.Target().Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.NoError(t, err) + require.Len(t, res.Warnings, 1) + require.Contains(t, res.Warnings[0], "could not mark ENG-3 as blocked by ENG-2") + require.Contains(t, res.Warnings[0], "relation refused") + require.Equal(t, 3, f.Count("issueCreate"), "the items were still filed") +} + +func TestTarget_RefusesBeforeFilingWhenTeamOrStateIsUnknown(t *testing.T) { + t.Run("team", func(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.TeamKey = "NOPE" + _, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.ErrorContains(t, err, `team "NOPE" does not exist`) + require.Zero(t, f.Count("issueCreate")) + }) + t.Run("state", func(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.State = "Shipped" + _, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.ErrorContains(t, err, `no workflow state named "Shipped"`) + require.Zero(t, f.Count("issueCreate")) + }) +} + +func TestTarget_BadAPIKey(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.APIKey = "wrong" + _, err := target.Find(context.Background(), "demo/spec/nightly-digest") + require.ErrorContains(t, err, "linear API 401") + require.ErrorContains(t, err, "not authenticated") +} diff --git a/internal/publish/linear/lineartest/fake.go b/internal/publish/linear/lineartest/fake.go new file mode 100644 index 0000000..ebae85d --- /dev/null +++ b/internal/publish/linear/lineartest/fake.go @@ -0,0 +1,245 @@ +// Package lineartest is a fake Linear GraphQL API for tests of the publish +// linear target and of the publish command. +package lineartest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hivecommons/spektacular/internal/publish/linear" +) + +// What the fake knows: one team with two states and two labels. +const ( + APIKey = "lin_api_test" + Team = "ENG" + TeamID = "team-uuid-eng" + Backlog = "Backlog" + Todo = "Todo" + LabelOne = "spek" + LabelTwo = "epic" +) + +// Server is enough of the GraphQL API for the target: teams, issues by +// description, issueCreate, issueUpdate and issueRelationCreate, recording +// each operation. +type Server struct { + mu sync.Mutex + t *testing.T + URL string + next int + // Issues by UUID, in creation order via Order. + Issues map[string]*Issue + Order []string + // Relations records "BLOCKER blocks BLOCKED" by identifier. + Relations []string + FailRelations bool + calls []string +} + +// Issue is one issue the fake holds. +type Issue struct { + ID string + Identifier string + Title string + Description string + ParentID string + LabelIDs []string + StateID string + StateType string +} + +// New starts the fake and stops it with the test. +func New(t *testing.T) *Server { + t.Helper() + f := &Server{t: t, Issues: map[string]*Issue{}} + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + f.URL = srv.URL + return f +} + +// Target is a target pointed at the fake with its credentials. +func (f *Server) Target() *linear.Target { + t := linear.New(Team, APIKey) + t.Endpoint = f.URL + return t +} + +// LabelID and StateID are the ids the fake gives its labels and states. +func LabelID(name string) string { return "label-" + name } +func StateID(name string) string { return "state-" + name } + +func (f *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + write := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) + } + if r.Method != http.MethodPost { + write(http.StatusMethodNotAllowed, map[string]any{"errors": []map[string]string{{"message": "POST only"}}}) + return + } + if r.Header.Get("Authorization") != APIKey { + write(http.StatusUnauthorized, map[string]any{"errors": []map[string]string{{"message": "Authentication required, not authenticated"}}}) + return + } + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&req)) + data := func(v any) { write(http.StatusOK, map[string]any{"data": v}) } + gqlErr := func(msg string) { + write(http.StatusOK, map[string]any{"data": nil, "errors": []map[string]string{{"message": msg}}}) + } + input, _ := req.Variables["input"].(map[string]any) + switch { + case strings.Contains(req.Query, "teams("): + f.calls = append(f.calls, "teams") + if req.Variables["key"] != Team { + data(map[string]any{"teams": map[string]any{"nodes": []any{}}}) + return + } + data(map[string]any{"teams": map[string]any{"nodes": []any{map[string]any{ + "id": TeamID, "key": Team, + "states": map[string]any{"nodes": []any{ + map[string]any{"id": StateID(Backlog), "name": Backlog, "type": "backlog"}, + map[string]any{"id": StateID(Todo), "name": Todo, "type": "unstarted"}, + }}, + "labels": map[string]any{"nodes": []any{ + map[string]any{"id": LabelID(LabelOne), "name": LabelOne}, + map[string]any{"id": LabelID(LabelTwo), "name": LabelTwo}, + }}, + }}}}) + case strings.Contains(req.Query, "issues("): + f.calls = append(f.calls, "issues") + needle, _ := req.Variables["needle"].(string) + nodes := []any{} + for _, id := range f.Order { + if req.Variables["team"] != Team { + break + } + is := f.Issues[id] + if strings.Contains(is.Description, needle) { + nodes = append(nodes, is.node()) + } + } + data(map[string]any{"issues": map[string]any{"nodes": nodes}}) + case strings.Contains(req.Query, "issueCreate("): + f.calls = append(f.calls, "issueCreate") + require.Equal(f.t, TeamID, input["teamId"]) + f.next++ + is := &Issue{ + ID: fmt.Sprintf("uuid-%d", f.next), + Identifier: fmt.Sprintf("%s-%d", Team, f.next), + Title: input["title"].(string), + Description: input["description"].(string), + StateType: "backlog", + } + if p, ok := input["parentId"].(string); ok { + require.Contains(f.t, f.Issues, p, "parentId must name an existing issue") + is.ParentID = p + } + if s, ok := input["stateId"].(string); ok { + is.StateID = s + } + if ls, ok := input["labelIds"].([]any); ok { + for _, l := range ls { + is.LabelIDs = append(is.LabelIDs, l.(string)) + } + } + f.Issues[is.ID] = is + f.Order = append(f.Order, is.ID) + data(map[string]any{"issueCreate": map[string]any{"success": true, "issue": is.node()}}) + case strings.Contains(req.Query, "issueUpdate("): + f.calls = append(f.calls, "issueUpdate") + id, _ := req.Variables["id"].(string) + is, ok := f.Issues[id] + if !ok { + gqlErr("Entity not found: Issue") + return + } + if title, ok := input["title"].(string); ok { + is.Title = title + } + if d, ok := input["description"].(string); ok { + is.Description = d + } + data(map[string]any{"issueUpdate": map[string]any{"success": true}}) + case strings.Contains(req.Query, "issueRelationCreate("): + f.calls = append(f.calls, "issueRelationCreate") + if f.FailRelations { + gqlErr("relation refused") + return + } + require.Equal(f.t, linear.RelationBlocks, input["type"]) + blocker, ok1 := f.Issues[input["issueId"].(string)] + blocked, ok2 := f.Issues[input["relatedIssueId"].(string)] + require.True(f.t, ok1 && ok2, "relation between unknown issues") + f.Relations = append(f.Relations, blocker.Identifier+" blocks "+blocked.Identifier) + data(map[string]any{"issueRelationCreate": map[string]any{"success": true}}) + default: + gqlErr("unsupported operation: " + req.Query) + } +} + +func (is *Issue) node() map[string]any { + return map[string]any{ + "id": is.ID, "identifier": is.Identifier, "url": "https://linear.app/acme/issue/" + is.Identifier, + "title": is.Title, "description": is.Description, "state": map[string]any{"type": is.StateType}, + } +} + +// Add seeds an issue as if filed earlier. +func (f *Server) Add(is *Issue) { + f.mu.Lock() + defer f.mu.Unlock() + f.next++ + if is.ID == "" { + is.ID = fmt.Sprintf("uuid-%d", f.next) + } + if is.Identifier == "" { + is.Identifier = fmt.Sprintf("%s-%d", Team, f.next) + } + if is.StateType == "" { + is.StateType = "backlog" + } + f.Issues[is.ID] = is + f.Order = append(f.Order, is.ID) +} + +// Count is how many times an operation (teams, issues, issueCreate, +// issueUpdate, issueRelationCreate) was called. +func (f *Server) Count(op string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, c := range f.calls { + if c == op { + n++ + } + } + return n +} + +// ByIdentifier returns the issue with that identifier, or nil. +func (f *Server) ByIdentifier(identifier string) *Issue { + f.mu.Lock() + defer f.mu.Unlock() + for _, is := range f.Issues { + if is.Identifier == identifier { + return is + } + } + return nil +} From 0cf22c868d20dd275c8786862d6c28680a7b895c Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Sun, 8 Nov 2026 08:18:11 -0700 Subject: [PATCH 2/2] fix(publish/linear): reconcile relations, archived search, body digest, assignee, Todo default - parent and blocks relations are read back (parent { id }, inverseRelations) and refiled when missing on every run, reported as linked - Find uses includeArchived: true and pages with pageInfo/after, so an auto-archived completed RFC is found and refused as closed rather than duplicated; team key compared with eqIgnoreCase - a line before the marker carries the digest of the rendered body; Find reports BodyDigest and the comparison no longer assumes Linear returns Markdown verbatim (the fake now normalises whitespace on store) - publish.linear.assignee resolved through users (email, display name or name) to assigneeId on every filed issue - default state is the team's Todo, else its first unstarted state, else the team default; workspace labels count as existing - team, state, label and user lookups happen only when something is created, so a no-op run makes one issues query - fake routes on the document's first field, asserts instead of require on handler goroutines, Snapshot/Calls accessors, Archived and NoTodo options Signed-off-by: Andrew Anderson --- README.md | 5 +- cmd/publish.go | 1 + cmd/publish_test.go | 23 +- docs/publish-hive-handshake.md | 2 +- internal/config/config.go | 10 +- internal/publish/linear/linear.go | 325 +++++++++++++++++---- internal/publish/linear/linear_test.go | 159 +++++++++- internal/publish/linear/lineartest/fake.go | 275 ++++++++++++----- 8 files changed, 666 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index cbdbbc1..2738099 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ spektacular publish --update # re-publish, updating spektacular publish --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, `` on the RFC and the same with `task= rfc=` 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` 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, dependencies are `blocks` relations, labels are applied only when the team has them, 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 ``, 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, `` on the RFC and the same with `task= rfc=` 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` 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 ``, 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 @@ -403,7 +403,8 @@ publish: 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 workflow state new issues are created in (team default when unset) + 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 ``` diff --git a/cmd/publish.go b/cmd/publish.go index ebde02e..bc28ac2 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -441,6 +441,7 @@ func linearTarget(lc config.PublishLinearConfig) (*linear.Target, error) { } 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 } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index e664c01..2af8bc8 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -546,7 +546,28 @@ func TestPublish_LinearDryRunMakesNoNetworkCall(t *testing.T) { require.Contains(t, stdout, `"target": "linear"`) require.Contains(t, stdout, `"repo": "ENG"`) require.Empty(t, srv.Issues) - require.Zero(t, srv.Count("teams")) + require.Zero(t, srv.Calls(), "a dry run touches the API in no way") +} + +func TestPublish_LinearAssigneeAndArchivedRFC(t *testing.T) { + root, srv := publishLinearProject(t) + writeSpecCommandConfig(t, root, "publish:\n default_target: linear\n linear:\n team: eng\n assignee: "+lineartest.UserEmail+"\n") + stdout, code := runPublishCmd(t, "nightly-digest", "--format", "json") + require.Equal(t, 0, code, stdout) + for _, is := range srv.Snapshot() { + require.Equal(t, lineartest.UserID, is.AssigneeID, is.Identifier) + require.Equal(t, lineartest.StateID(lineartest.Todo), is.StateID, "Todo by default, where a hive reads") + } + + // A completed, auto-archived RFC is still found and refused as closed + // rather than filed again. + rfc := srv.ByIdentifier("ENG-1") + rfc.StateType = "completed" + rfc.Archived = true + stdout, code = runPublishCmd(t, "nightly-digest", "--update") + resp := requirePublishRefusal(t, stdout, code, publish.ErrConflict) + require.Contains(t, resp.Message, "closed") + require.Equal(t, 3, srv.Count("issueCreate")) } func TestPublish_LinearRefusalsNameTheMissingSetting(t *testing.T) { diff --git a/docs/publish-hive-handshake.md b/docs/publish-hive-handshake.md index 5183e3a..6b3fb2f 100644 --- a/docs/publish-hive-handshake.md +++ b/docs/publish-hive-handshake.md @@ -93,7 +93,7 @@ From `hivecommons/hive` at the time of writing; verify against current main. | Sub-issues / dependencies | `hive-open-issue --parent`, `--blocked-by` (`src/docs/hive-open-issue.md`) | Each child is added with `POST /repos/{o}/{r}/issues/{rfc}/sub_issues` (`sub_issue_id` = the child's database id); each `depends_on` edge with `POST /repos/{o}/{r}/issues/{child}/dependencies/blocked_by` (`issue_id` = the blocker's database id). Both are best effort: a failure is a warning in the result, and `--update` retries. | | PR linkage | closing keywords `Fixes/Closes/Resolves #N` | The child is the issue the kick receives, so its number is what the PR closes. | | Jira | `src/docs/work-sources.md` → `type: jira`: `project in () AND statusCategory != Done AND issuetype != Epic`, `hold_labels` | The RFC is an **Epic** (`publish.jira.parent_issue_type`) in `publish.jira.project_key`, so hive's default JQL never lists it; each child a **Story** (`publish.jira.child_issue_type`) with `parent` = the Epic on Cloud, or on Data Center, where `parent` is for sub-tasks only, the Epic Link custom field (`publish.jira.epic_link_field`, discovered from `GET field` when unset) = its key, created in the project's default status, whose category is not Done, with `publish.jira.labels` (whitespace → `-`) and never a hold label. Each `depends_on` edge is a `Blocks` issue link (blocker outward, dependant inward); a re-run reads `parent`/Epic Link and `issuelinks` back and refiles whatever is missing. The marker is the last line of the description **and** the issue property `spek.publish` (`{name, artifact_id, target, task, rfc, title, body_digest}`), written in the create request itself; the line before the marker is a search token `spekpub`, and a re-publish searches `project = AND description ~ ""` (the `issue.property[...]` JQL needs an app to index the property, so it is not used), then reads any issue the artifact's `published:` frontmatter recorded that the search did not return. Cloud uses REST v3 with ADF descriptions and `JIRA_EMAIL` + `JIRA_API_TOKEN`; Data Center (`publish.jira.deployment: datacenter`) REST v2 with wiki markup and `JIRA_PAT`, trusting `JIRA_CA_BUNDLE`. The site is `JIRA_BASE_URL`. | -| Linear | `src/docs/work-sources.md` → `type: linear`: issues of the team in the configured states, `IsTracker` when the issue has children or its marker has no `task=`, `inverseRelations` of type `blocks` | The RFC is an issue in team `publish.linear.team`, each child a **sub-issue** of it (`parentId`), so hive sees the RFC as a tracker twice over and never claims it; the `epic` label (`publish.linear.parent_label`) goes on the RFC **only if the team has it**, `publish.linear.labels` on every issue that the team has labels for; publish never creates labels. Issues are created in the team's default state or `publish.linear.state`, which must exist. Each `depends_on` edge is `issueRelationCreate(issueId: blocker, relatedIssueId: dependant, type: blocks)`, so the dependant's `inverseRelations` carries the blocker, best effort. Linear stores Markdown, so the marker is the description's last line and a re-publish finds it with `issues(filter: {team: {key: {eq}}, description: {contains: "artifact_id="}})`; bodies compare verbatim. Auth is `LINEAR_API_KEY` sent bare in `Authorization`, as hive does. | +| Linear | `src/docs/work-sources.md` → `type: linear`: issues of the team in the configured states, `IsTracker` when the issue has children or its marker has no `task=`, `inverseRelations` of type `blocks` | The RFC is an issue in team `publish.linear.team`, each child a **sub-issue** of it (`parentId`), so hive sees the RFC as a tracker twice over and never claims it; the `epic` label (`publish.linear.parent_label`) goes on the RFC **only if the team has it**, `publish.linear.labels` on every issue, team or workspace labels; publish never creates labels. Issues are created in `publish.linear.state` when set (must exist), else the team's **Todo** state, else its first `unstarted` state, else the team default — Backlog/Triage is where a hive does not look. `publish.linear.assignee` (email, display name or name, resolved through `users`) is set on every issue when configured. Each `depends_on` edge is `issueRelationCreate(issueId: blocker, relatedIssueId: dependant, type: blocks)`, so the dependant's `inverseRelations` carries the blocker; a re-run reads `parent { id }` and `inverseRelations` back and refiles any missing parent or relation (`linked` in the result). The marker is the description's last line and the line before it is ``, the digest of the body as rendered, so a re-publish compares digests and does not depend on Linear returning the Markdown byte for byte; a re-publish finds items with `issues(filter: {team: {key: {eqIgnoreCase}}, description: {contains: "artifact_id="}}, includeArchived: true)`, paged, so an auto-archived completed RFC is still seen and refused as closed. Not yet: `parent_as: project`. Auth is `LINEAR_API_KEY` sent bare in `Authorization`, as hive does. | | Crux proxy | `crux/proxy/linear_rules.go`; `crux/issueshape` on `POST /repos/*/issues` | Publishing from inside a hive needs `POST /repos/*/issues`, `PATCH /repos/*/issues/*`, `POST …/sub_issues`, `POST …/dependencies/blocked_by`, `GET /repos/*/labels` and `GET /search/issues` allowed at the publishing agent's mode. On Linear it needs `issueCreate`, `issueUpdate` and `issueRelationCreate`, all allowed at `ModeIssuesOnly` today; queries pass. | ## 4. The "already specced" signal: option (b), the marker diff --git a/internal/config/config.go b/internal/config/config.go index fa98380..1ad6fc1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -441,9 +441,13 @@ type PublishConfig struct { type PublishLinearConfig struct { // Team is the team key, the prefix of its identifiers (ENG in ENG-12). Team string `yaml:"team,omitempty"` - // State is the workflow state new issues are created in; the team's - // default when unset. - State string `yaml:"state,omitempty"` + // State is the workflow state new issues are created in. Unset, the + // team's Todo state when it has one, else its first unstarted state, + // else the team default. + State string `yaml:"state,omitempty"` + // Assignee is the email, display name or user name every filed issue + // is assigned to; none when unset. + Assignee string `yaml:"assignee,omitempty"` Labels []string `yaml:"labels,omitempty"` ParentLabel string `yaml:"parent_label,omitempty"` } diff --git a/internal/publish/linear/linear.go b/internal/publish/linear/linear.go index 85b3702..92c38e0 100644 --- a/internal/publish/linear/linear.go +++ b/internal/publish/linear/linear.go @@ -1,8 +1,10 @@ // Package linear files a publication into a Linear team through the // GraphQL API: the RFC as an issue, each child as a sub-issue of it, and -// each depends_on edge as a "blocks" relation. Linear stores Markdown, so -// bodies round-trip verbatim and the marker is the last line of every -// description, which is also how a re-publish finds what it filed. +// each depends_on edge as a "blocks" relation. Linear stores Markdown; the +// marker is the last line of every description, which is also how a +// re-publish finds what it filed, and the line before it carries a digest +// of the body as rendered, so a re-publish compares digests rather than +// trusting Linear to hand the Markdown back byte for byte. package linear import ( @@ -13,6 +15,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "time" @@ -26,9 +29,16 @@ const DefaultEndpoint = "https://api.linear.app/graphql" // from the blocker: the blocker "blocks" the dependant. const RelationBlocks = "blocks" -// pageSize bounds every list the target reads; a team with more labels or -// filed items than this is not a shape publish produces. -const pageSize = 250 +// DefaultPageSize is how many nodes each list query asks for; Find pages +// through the rest. +const DefaultPageSize = 250 + +// DigestPrefix opens the HTML comment that carries the body digest, on the +// line before the marker. +const DigestPrefix = "\n`) // Target files into one Linear team. type Target struct { @@ -40,8 +50,16 @@ type Target struct { // for personal API keys. APIKey string // State, when set, is the name of the workflow state new issues are - // created in; the team's default otherwise. + // created in. Unset, the team's "Todo" state is used when it has one, + // else its first unstarted state, else the team default: a hive reads + // Todo, not Backlog or Triage. State string + // Assignee, when set, is the email, display name or user name of the + // user every filed issue is assigned to. + Assignee string + // PageSize is how many nodes each list query asks for; DefaultPageSize + // when 0. + PageSize int // HTTPClient defaults to one with a 30s timeout. HTTPClient *http.Client } @@ -64,6 +82,13 @@ func (t *Target) endpoint() string { return t.Endpoint } +func (t *Target) pageSize() int { + if t.PageSize <= 0 { + return DefaultPageSize + } + return t.PageSize +} + type issueNode struct { ID string `json:"id"` Identifier string `json:"identifier"` @@ -73,28 +98,60 @@ type issueNode struct { State struct { Type string `json:"type"` } `json:"state"` + Parent *struct { + ID string `json:"id"` + } `json:"parent"` + InverseRelations struct { + Nodes []struct { + Type string `json:"type"` + Issue struct { + ID string `json:"id"` + } `json:"issue"` + } `json:"nodes"` + } `json:"inverseRelations"` } -const findQuery = `query($team: String!, $needle: String!, $first: Int!) { - issues(filter: { team: { key: { eq: $team } }, description: { contains: $needle } }, first: $first) { - nodes { id identifier url title description state { type } } +// issueFields is what every issue read or mutation returns: enough to +// build a Filed, including the parent and the relations pointing at it. +const issueFields = `id identifier url title description state { type } parent { id } inverseRelations { nodes { type issue { id } } }` + +// findQuery pages through the team's issues, archived ones included: a +// completed RFC Linear has auto-archived must still be found, so it is +// refused as closed rather than filed again. +const findQuery = `query($team: String!, $needle: String!, $first: Int!, $after: String) { + issues(filter: { team: { key: { eqIgnoreCase: $team } }, description: { contains: $needle } }, first: $first, after: $after, includeArchived: true) { + nodes { ` + issueFields + ` } + pageInfo { hasNextPage endCursor } } }` // Find implements publish.Target: the team's issues whose description // carries the artifact id, each read back by its marker. func (t *Target) Find(ctx context.Context, artifactID string) (*publish.Existing, error) { - var out struct { - Issues struct { - Nodes []issueNode `json:"nodes"` - } `json:"issues"` - } - vars := map[string]any{"team": t.TeamKey, "needle": "artifact_id=" + artifactID, "first": pageSize} - if err := t.query(ctx, findQuery, vars, &out); err != nil { - return nil, fmt.Errorf("searching team %s for published items: %w", t.TeamKey, err) + var nodes []issueNode + var after any + for { + var out struct { + Issues struct { + Nodes []issueNode `json:"nodes"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + } `json:"issues"` + } + vars := map[string]any{"team": t.TeamKey, "needle": "artifact_id=" + artifactID, "first": t.pageSize(), "after": after} + if err := t.query(ctx, findQuery, vars, &out); err != nil { + return nil, fmt.Errorf("searching team %s for published items: %w", t.TeamKey, err) + } + nodes = append(nodes, out.Issues.Nodes...) + if !out.Issues.PageInfo.HasNextPage || out.Issues.PageInfo.EndCursor == "" { + break + } + after = out.Issues.PageInfo.EndCursor } existing := &publish.Existing{Children: map[string]publish.Filed{}} - for _, n := range out.Issues.Nodes { + for _, n := range nodes { m, ok := publish.ParseMarker(n.Description) if !ok || m.ArtifactID != artifactID { continue @@ -118,17 +175,44 @@ func (t *Target) Find(ctx context.Context, artifactID string) (*publish.Existing } // filed maps an issue to a Filed: Key is Linear's UUID, what mutations -// take; Ref the identifier, what people and other bodies use. +// take; Ref the identifier, what people and other bodies use. A +// description with a digest line reports BodyDigest and no Body, so the +// comparison does not depend on Linear returning the Markdown verbatim; +// one without (filed before digests) is compared as stored. func filed(n issueNode, m publish.Marker) publish.Filed { - return publish.Filed{ + f := publish.Filed{ Key: n.ID, Ref: n.Identifier, URL: n.URL, Title: n.Title, - Body: n.Description, Closed: n.State.Type == "completed" || n.State.Type == "canceled", Marker: m, } + if dm := digestLineRE.FindStringSubmatch(n.Description); dm != nil { + f.BodyDigest = dm[1] + } else { + f.Body = n.Description + } + if n.Parent != nil { + f.Parent = n.Parent.ID + } + for _, r := range n.InverseRelations.Nodes { + if r.Type == RelationBlocks && r.Issue.ID != "" { + f.BlockedBy = append(f.BlockedBy, r.Issue.ID) + } + } + return f +} + +// description is body as stored: the digest of body on the line before +// the marker. Find strips the line again, so the digest covers exactly +// what Render produced. +func description(body string) string { + line := DigestPrefix + publish.Digest(body) + " -->\n" + if i := strings.LastIndex(body, "\n"}) + existing, err = target.Find(ctx, "demo/spec/legacy") + require.NoError(t, err) + require.Equal(t, "body\n\n\n", existing.RFC.Body) + require.Empty(t, existing.RFC.BodyDigest) +} + +func existingRefs(e *publish.Existing) map[string]publish.Ref { + refs := map[string]publish.Ref{} + for id, f := range e.Children { + refs[id] = publish.Ref{Ref: f.Ref, URL: f.URL} + } + return refs +} + +func TestTarget_FindSeesArchivedIssuesAndPages(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.PageSize = 2 + p := testPublication(t) + ctx := context.Background() + _, err := target.Publish(ctx, p, nil, publish.PublishOptions{}) + require.NoError(t, err) + rfc := f.ByIdentifier("ENG-1") + rfc.StateType = "completed" + rfc.Archived = true + + existing, err := target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.Equal(t, 2, f.Count("issues"), "three hits at a page size of two") + require.NotNil(t, existing.RFC, "an archived RFC is still found") + require.True(t, existing.RFC.Closed) + require.Len(t, existing.Children, 2) +} + +func TestTarget_ReconcilesParentAndRelationsOnEveryRun(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + p := testPublication(t) + ctx := context.Background() + f.FailRelations = true + res, err := target.Publish(ctx, p, nil, publish.PublishOptions{}) + require.NoError(t, err) + require.Len(t, res.Warnings, 1) + require.Empty(t, f.Relations) + f.ByIdentifier("ENG-2").ParentID = "" // detached by hand + + f.FailRelations = false + existing, err := target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.Equal(t, "", existing.Children["R1"].Parent) + require.Equal(t, "uuid-1", existing.Children["R2"].Parent) + require.Empty(t, existing.Children["R2"].BlockedBy) + require.Empty(t, publish.Diff(p, existing), "a missing relation is not a content change") + + res, err = target.Publish(ctx, p, existing, publish.PublishOptions{}) + require.NoError(t, err) + require.Empty(t, res.Created) + require.Equal(t, []string{"ENG-2 under ENG-1", "ENG-3 blocked by ENG-2"}, res.Linked) + require.Equal(t, "uuid-1", f.ByIdentifier("ENG-2").ParentID) + require.Equal(t, []string{"ENG-2 blocks ENG-3"}, f.Relations) + require.Equal(t, 1, f.Count("teams"), "only the first run, which created issues, read the team") + + existing, err = target.Find(ctx, p.ArtifactID) + require.NoError(t, err) + require.Equal(t, []string{"uuid-2"}, existing.Children["R2"].BlockedBy) + res, err = target.Publish(ctx, p, existing, publish.PublishOptions{}) + require.NoError(t, err) + require.Empty(t, res.Linked, "once repaired, nothing is refiled") + require.Equal(t, 2, f.Count("issueRelationCreate"), "the refused attempt and the repair; nothing on the third run") +} + +func TestTarget_DefaultStateIsTodoThenFirstUnstarted(t *testing.T) { + t.Run("todo", func(t *testing.T) { + f := lineartest.New(t) + _, err := f.Target().Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.NoError(t, err) + require.Equal(t, lineartest.StateID(lineartest.Todo), f.ByIdentifier("ENG-2").StateID, "children land in Todo, which a hive reads, not the team default") + }) + t.Run("no todo", func(t *testing.T) { + f := lineartest.New(t) + f.NoTodo = true + _, err := f.Target().Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.NoError(t, err) + require.Equal(t, "", f.ByIdentifier("ENG-2").StateID, "no Todo and no other unstarted state: the team default") + }) + t.Run("configured", func(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.State = "backlog" + _, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.NoError(t, err) + require.Equal(t, lineartest.StateID(lineartest.Backlog), f.ByIdentifier("ENG-2").StateID, "state names compare case-insensitively") + }) +} + +func TestTarget_AssigneeByEmailOrName(t *testing.T) { + for _, who := range []string{lineartest.UserEmail, lineartest.UserName} { + f := lineartest.New(t) + target := f.Target() + target.Assignee = who + _, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.NoError(t, err, who) + require.Equal(t, lineartest.UserID, f.ByIdentifier("ENG-1").AssigneeID) + require.Equal(t, lineartest.UserID, f.ByIdentifier("ENG-3").AssigneeID) + require.Equal(t, 1, f.Count("users")) + } + f := lineartest.New(t) + target := f.Target() + target.Assignee = "nobody@acme.test" + _, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{}) + require.ErrorContains(t, err, `no Linear user has the email, display name or name "nobody@acme.test"`) + require.Zero(t, f.Count("issueCreate")) +} + +func TestTarget_WorkspaceLabelsAndTeamKeyCase(t *testing.T) { + f := lineartest.New(t) + target := f.Target() + target.TeamKey = "eng" + res, err := target.Publish(context.Background(), testPublication(t), nil, publish.PublishOptions{Labels: []string{lineartest.WorkspaceLabel}}) + require.NoError(t, err) + require.Empty(t, res.Warnings, "a workspace label counts as existing") + require.Equal(t, []string{lineartest.LabelID(lineartest.WorkspaceLabel)}, f.ByIdentifier("ENG-2").LabelIDs) + existing, err := target.Find(context.Background(), "demo/spec/nightly-digest") + require.NoError(t, err) + require.NotNil(t, existing) +} diff --git a/internal/publish/linear/lineartest/fake.go b/internal/publish/linear/lineartest/fake.go index ebae85d..20809fd 100644 --- a/internal/publish/linear/lineartest/fake.go +++ b/internal/publish/linear/lineartest/fake.go @@ -7,29 +7,44 @@ import ( "fmt" "net/http" "net/http/httptest" + "regexp" + "strconv" "strings" "sync" "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" "github.com/hivecommons/spektacular/internal/publish/linear" ) -// What the fake knows: one team with two states and two labels. +// What the fake knows: one team with three states, two team labels, one +// workspace label and one user. const ( - APIKey = "lin_api_test" - Team = "ENG" - TeamID = "team-uuid-eng" - Backlog = "Backlog" - Todo = "Todo" - LabelOne = "spek" - LabelTwo = "epic" + APIKey = "lin_api_test" + Team = "ENG" + TeamID = "team-uuid-eng" + Triage = "Triage" + Backlog = "Backlog" + Todo = "Todo" + LabelOne = "spek" + LabelTwo = "epic" + WorkspaceLabel = "platform" + UserEmail = "dev@acme.test" + UserName = "Dev One" + UserID = "user-uuid-dev" ) -// Server is enough of the GraphQL API for the target: teams, issues by -// description, issueCreate, issueUpdate and issueRelationCreate, recording -// each operation. +// Server is enough of the GraphQL API for the target: teams (+ workspace +// issueLabels), users, issues by description with paging and archived +// filtering, issueCreate, issueUpdate and issueRelationCreate, recording +// each operation by the name of its first field. +// +// Descriptions are stored normalised (trailing whitespace stripped, one +// trailing newline), as a Markdown store that does not return bytes +// verbatim; the target compares digests, not stored text. Assertions in +// handlers use assert, not require: FailNow cannot stop a test from the +// server's goroutine. type Server struct { mu sync.Mutex t *testing.T @@ -41,7 +56,9 @@ type Server struct { // Relations records "BLOCKER blocks BLOCKED" by identifier. Relations []string FailRelations bool - calls []string + // NoTodo drops the Todo state, for the first-unstarted fallback. + NoTodo bool + calls []string } // Issue is one issue the fake holds. @@ -54,8 +71,24 @@ type Issue struct { LabelIDs []string StateID string StateType string + AssigneeID string + // Archived hides the issue from an issues query without + // includeArchived, as Linear does once a completed issue is archived. + Archived bool } +// Normalize is what the fake does to a description it stores. +func Normalize(d string) string { + lines := strings.Split(d, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " \t") + } + return strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n" +} + +// operationRE takes the first field of a GraphQL document as its name. +var operationRE = regexp.MustCompile(`^\s*(?:query|mutation)\b[^{]*\{\s*(\w+)`) + // New starts the fake and stops it with the test. func New(t *testing.T) *Server { t.Helper() @@ -97,72 +130,121 @@ func (f *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { Query string `json:"query"` Variables map[string]any `json:"variables"` } - require.NoError(f.t, json.NewDecoder(r.Body).Decode(&req)) data := func(v any) { write(http.StatusOK, map[string]any{"data": v}) } gqlErr := func(msg string) { write(http.StatusOK, map[string]any{"data": nil, "errors": []map[string]string{{"message": msg}}}) } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + gqlErr("bad request body: " + err.Error()) + return + } + m := operationRE.FindStringSubmatch(req.Query) + if m == nil { + gqlErr("unparseable document: " + req.Query) + return + } + op := m[1] + f.calls = append(f.calls, op) input, _ := req.Variables["input"].(map[string]any) - switch { - case strings.Contains(req.Query, "teams("): - f.calls = append(f.calls, "teams") - if req.Variables["key"] != Team { - data(map[string]any{"teams": map[string]any{"nodes": []any{}}}) + switch op { + case "teams": + key, _ := req.Variables["key"].(string) + if !strings.EqualFold(key, Team) { + data(map[string]any{"teams": map[string]any{"nodes": []any{}}, "issueLabels": map[string]any{"nodes": []any{}}}) return } - data(map[string]any{"teams": map[string]any{"nodes": []any{map[string]any{ - "id": TeamID, "key": Team, - "states": map[string]any{"nodes": []any{ - map[string]any{"id": StateID(Backlog), "name": Backlog, "type": "backlog"}, - map[string]any{"id": StateID(Todo), "name": Todo, "type": "unstarted"}, - }}, - "labels": map[string]any{"nodes": []any{ - map[string]any{"id": LabelID(LabelOne), "name": LabelOne}, - map[string]any{"id": LabelID(LabelTwo), "name": LabelTwo}, + states := []any{ + map[string]any{"id": StateID(Triage), "name": Triage, "type": "triage"}, + map[string]any{"id": StateID(Backlog), "name": Backlog, "type": "backlog"}, + } + if !f.NoTodo { + states = append(states, map[string]any{"id": StateID(Todo), "name": Todo, "type": "unstarted"}) + } + states = append(states, map[string]any{"id": StateID("In Progress"), "name": "In Progress", "type": "started"}) + data(map[string]any{ + "teams": map[string]any{"nodes": []any{map[string]any{ + "id": TeamID, "key": Team, + "states": map[string]any{"nodes": states}, + "labels": map[string]any{"nodes": []any{ + map[string]any{"id": LabelID(LabelOne), "name": LabelOne}, + map[string]any{"id": LabelID(LabelTwo), "name": LabelTwo}, + }}, + }}}, + "issueLabels": map[string]any{"nodes": []any{ + map[string]any{"id": LabelID(WorkspaceLabel), "name": WorkspaceLabel}, }}, - }}}}) - case strings.Contains(req.Query, "issues("): - f.calls = append(f.calls, "issues") - needle, _ := req.Variables["needle"].(string) + }) + case "users": + q, _ := req.Variables["q"].(string) nodes := []any{} + if strings.EqualFold(q, UserEmail) || strings.EqualFold(q, UserName) || strings.EqualFold(q, "dev") { + nodes = append(nodes, map[string]any{"id": UserID, "name": "dev", "displayName": UserName, "email": UserEmail}) + } + data(map[string]any{"users": map[string]any{"nodes": nodes}}) + case "issues": + needle, _ := req.Variables["needle"].(string) + team, _ := req.Variables["team"].(string) + includeArchived := strings.Contains(req.Query, "includeArchived: true") + first := len(f.Order) + if n, ok := req.Variables["first"].(float64); ok && n > 0 { + first = int(n) + } + from := 0 + if c, ok := req.Variables["after"].(string); ok && c != "" { + from, _ = strconv.Atoi(c) + } + var matching []*Issue for _, id := range f.Order { - if req.Variables["team"] != Team { - break - } is := f.Issues[id] - if strings.Contains(is.Description, needle) { - nodes = append(nodes, is.node()) + if !strings.EqualFold(team, Team) || (is.Archived && !includeArchived) || !strings.Contains(is.Description, needle) { + continue } + matching = append(matching, is) } - data(map[string]any{"issues": map[string]any{"nodes": nodes}}) - case strings.Contains(req.Query, "issueCreate("): - f.calls = append(f.calls, "issueCreate") - require.Equal(f.t, TeamID, input["teamId"]) - f.next++ - is := &Issue{ - ID: fmt.Sprintf("uuid-%d", f.next), - Identifier: fmt.Sprintf("%s-%d", Team, f.next), - Title: input["title"].(string), - Description: input["description"].(string), - StateType: "backlog", + nodes := []any{} + end := from + first + if end > len(matching) { + end = len(matching) + } + for i := from; i < end; i++ { + nodes = append(nodes, f.node(matching[i])) + } + data(map[string]any{"issues": map[string]any{ + "nodes": nodes, + "pageInfo": map[string]any{"hasNextPage": end < len(matching), "endCursor": strconv.Itoa(end)}, + }}) + case "issueCreate": + if !assert.Equal(f.t, TeamID, input["teamId"]) { + gqlErr("unknown team") + return } + title, _ := input["title"].(string) + desc, _ := input["description"].(string) + is := &Issue{Title: title, Description: Normalize(desc), StateType: "backlog"} if p, ok := input["parentId"].(string); ok { - require.Contains(f.t, f.Issues, p, "parentId must name an existing issue") + if _, exists := f.Issues[p]; !assert.True(f.t, exists, "parentId must name an existing issue") { + gqlErr("Entity not found: Issue") + return + } is.ParentID = p } if s, ok := input["stateId"].(string); ok { is.StateID = s + if s == StateID(Todo) { + is.StateType = "unstarted" + } + } + if a, ok := input["assigneeId"].(string); ok { + is.AssigneeID = a } if ls, ok := input["labelIds"].([]any); ok { for _, l := range ls { is.LabelIDs = append(is.LabelIDs, l.(string)) } } - f.Issues[is.ID] = is - f.Order = append(f.Order, is.ID) - data(map[string]any{"issueCreate": map[string]any{"success": true, "issue": is.node()}}) - case strings.Contains(req.Query, "issueUpdate("): - f.calls = append(f.calls, "issueUpdate") + f.add(is) + data(map[string]any{"issueCreate": map[string]any{"success": true, "issue": f.node(is)}}) + case "issueUpdate": id, _ := req.Variables["id"].(string) is, ok := f.Issues[id] if !ok { @@ -173,37 +255,60 @@ func (f *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { is.Title = title } if d, ok := input["description"].(string); ok { - is.Description = d + is.Description = Normalize(d) + } + if p, ok := input["parentId"].(string); ok { + if _, exists := f.Issues[p]; !exists { + gqlErr("Entity not found: Issue") + return + } + is.ParentID = p } data(map[string]any{"issueUpdate": map[string]any{"success": true}}) - case strings.Contains(req.Query, "issueRelationCreate("): - f.calls = append(f.calls, "issueRelationCreate") + case "issueRelationCreate": if f.FailRelations { gqlErr("relation refused") return } - require.Equal(f.t, linear.RelationBlocks, input["type"]) - blocker, ok1 := f.Issues[input["issueId"].(string)] - blocked, ok2 := f.Issues[input["relatedIssueId"].(string)] - require.True(f.t, ok1 && ok2, "relation between unknown issues") + assert.Equal(f.t, linear.RelationBlocks, input["type"]) + blockerID, _ := input["issueId"].(string) + blockedID, _ := input["relatedIssueId"].(string) + blocker, ok1 := f.Issues[blockerID] + blocked, ok2 := f.Issues[blockedID] + if !assert.True(f.t, ok1 && ok2, "relation between unknown issues") { + gqlErr("Entity not found: Issue") + return + } f.Relations = append(f.Relations, blocker.Identifier+" blocks "+blocked.Identifier) data(map[string]any{"issueRelationCreate": map[string]any{"success": true}}) default: - gqlErr("unsupported operation: " + req.Query) + gqlErr("unsupported operation: " + op) } } -func (is *Issue) node() map[string]any { - return map[string]any{ +func (f *Server) node(is *Issue) map[string]any { + n := map[string]any{ "id": is.ID, "identifier": is.Identifier, "url": "https://linear.app/acme/issue/" + is.Identifier, "title": is.Title, "description": is.Description, "state": map[string]any{"type": is.StateType}, + "parent": nil, + } + if is.ParentID != "" { + n["parent"] = map[string]any{"id": is.ParentID} + } + inverse := []any{} + for _, rel := range f.Relations { + blocker, blocked, _ := strings.Cut(rel, " blocks ") + if blocked == is.Identifier { + if b := f.byIdentifier(blocker); b != nil { + inverse = append(inverse, map[string]any{"type": linear.RelationBlocks, "issue": map[string]any{"id": b.ID}}) + } + } } + n["inverseRelations"] = map[string]any{"nodes": inverse} + return n } -// Add seeds an issue as if filed earlier. -func (f *Server) Add(is *Issue) { - f.mu.Lock() - defer f.mu.Unlock() +func (f *Server) add(is *Issue) { f.next++ if is.ID == "" { is.ID = fmt.Sprintf("uuid-%d", f.next) @@ -218,6 +323,14 @@ func (f *Server) Add(is *Issue) { f.Order = append(f.Order, is.ID) } +// Add seeds an issue as if filed earlier; its description is stored as +// given, not normalised, so a test can seed exactly what it wants read back. +func (f *Server) Add(is *Issue) { + f.mu.Lock() + defer f.mu.Unlock() + f.add(is) +} + // Count is how many times an operation (teams, issues, issueCreate, // issueUpdate, issueRelationCreate) was called. func (f *Server) Count(op string) int { @@ -232,10 +345,15 @@ func (f *Server) Count(op string) int { return n } -// ByIdentifier returns the issue with that identifier, or nil. +// ByIdentifier returns the issue with that identifier, or nil. The +// pointer is the fake's own, for tests that change an issue between runs. func (f *Server) ByIdentifier(identifier string) *Issue { f.mu.Lock() defer f.mu.Unlock() + return f.byIdentifier(identifier) +} + +func (f *Server) byIdentifier(identifier string) *Issue { for _, is := range f.Issues { if is.Identifier == identifier { return is @@ -243,3 +361,22 @@ func (f *Server) ByIdentifier(identifier string) *Issue { } return nil } + +// Snapshot copies the issues in creation order under the lock, for +// readers outside a handler. +func (f *Server) Snapshot() []Issue { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]Issue, 0, len(f.Order)) + for _, id := range f.Order { + out = append(out, *f.Issues[id]) + } + return out +} + +// Calls is how many requests the fake has served. +func (f *Server) Calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +}