Drive issues and projects from named CLI commands

b93309390bae · AtlantisPleb · · parent 3ff06dedcc59

Drive issues and projects from named CLI commands

Before this, the terminal reached the tracker only through the generic
`openagents api` passthrough. That made the caller carry the route table,
the response envelope, and the paging convention. The issues list holds 25
to a page and takes no page size, so a script that did not page read a
prefix of the backlog and never learned it. Issue prerequisites had no
browser UI at all, so the recorded edges were reachable only by hand.

`openagents issue` now covers list, view, create, close, reopen, comment,
label, assign, unassign, and deps; `openagents project` covers list, view,
create, fields, items, item-add, item-set, item-move, and item-remove.
Both take `-R owner/repo` and otherwise infer the repository from the
origin remote the way `repo view` does, both accept a bare number or one
with a leading `#`, and both emit the server's envelope unchanged under
`--json`.

`issue list --limit` pages until it has what it asked for and reports the
API's own total. `issue close` sends `state` and nothing else, posting a
`--comment` as its own earlier request, so a state change can never
overwrite the issue body. Failures read the unified error envelope and
name the field a 422 was rejected on.

Closes #129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#129

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/index.ts
  • added packages/openagents-cli/src/issue-client.ts
  • added packages/openagents-cli/src/project-client.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/src/tracker-request.ts
  • added packages/openagents-cli/test/issue-client.test.ts
  • added packages/openagents-cli/test/issue-command.test.ts

Diff

9 files changed, +1971 -0

packages/openagents-cli/README.md modified +57

@@ -237,6 +237,63 @@ Git bundles through durable storage without retaining the complete bundle in

237 237
application memory. Use `--wait-timeout 0` to return after acceptance; the
238 238
server import continues.
239 239
240
## Manage issues
241
242
```sh
243
openagents issue list
244
openagents issue list --state all --label area:cli --limit 100
245
openagents issue list --blocked false --limit 50
246
openagents issue view 129 --comments
247
openagents issue create --title "It fails on Tuesdays" --body-file -
248
openagents issue comment 129 --body "Reproduced on staging."
249
openagents issue close 129 --comment "Shipped in 0.4.0."
250
openagents issue reopen 129
251
openagents issue label 129 --add agent-ready --remove needs-design
252
openagents issue assign 129 octavia
253
openagents issue unassign 129 octavia
254
openagents issue deps 129
255
openagents issue deps 129 --add 80 --remove 81
256
```
257
258
The list route holds 25 issues to a page and takes no page size, so
259
`issue list --limit 100` pages until it has the issues you asked for. The
260
human-facing table reports the API's own total, not the number of rows it
261
printed.
262
263
`issue create` takes `--body`, or `--body-file` with a path or `-` for standard
264
input. `--label` and `--assignee` are repeatable, and a label has to exist in
265
the repository already.
266
267
`issue close` and `issue reopen` send the state change and nothing else. With
268
`--comment`, the comment is posted first as its own request, so the issue text
269
is never rewritten by a state change.
270
271
`issue deps` reads, adds, and removes the prerequisite edges an orchestrator
272
polls to find unblocked work. With no flags it reports what blocks the issue and
273
what the issue blocks.
274
275
## Manage projects
276
277
```sh
278
openagents project list
279
openagents project list --archived
280
openagents project view 2
281
openagents project create --title "Issues and Projects delivery"
282
openagents project fields 2
283
openagents project items 2
284
openagents project item-add 2 --issue 129
285
openagents project item-set 2 175 --set Status=Done
286
openagents project item-move 2 175 --set Status="In Progress" --position 1
287
openagents project item-remove 2 175
288
```
289
290
Projects are repository-scoped, so every project command takes the same
291
`-R, --repo` and remote inference the issue commands take.
292
293
Every issue and project command accepts `-R, --repo <owner>/<name>` and falls
294
back to the origin remote the way `repo view` does. Issue and project numbers
295
are bare integers; a leading `#` is accepted and never required.
296
240 297
Add `--json` before a subcommand to return machine-readable output. Add
241 298
`--no-color`, or set `NO_COLOR`, to disable ANSI output. The clone command
242 299
invokes `git` with an argument array and never puts a token in a URL or process
packages/openagents-cli/src/cli.ts modified +884

@@ -48,8 +48,10 @@ import { DeviceClient } from "./device-client.js";

48 48
import { type EndpointOverrides, Profile } from "./endpoint.js";
49 49
import { ForumClient } from "./forum-client.js";
50 50
import { GitRunner } from "./git-runner.js";
51
import { IssueClient } from "./issue-client.js";
51 52
import { runGitCredentialHelper } from "./git-credential-helper.js";
52 53
import { Output, type OutputMode } from "./output.js";
54
import { ProjectClient } from "./project-client.js";
53 55
import { parseRepositoryTarget, RepositoryClient } from "./repository-client.js";
54 56
import { RequestBodyInput } from "./request-body-input.js";
55 57
import { SecretInput } from "./secret-input.js";

@@ -1689,6 +1691,886 @@ const forumCommand = Command.make("forum").pipe(

1689 1691
  ]),
1690 1692
);
1691 1693
1694
// The issue and project command groups. Both read the same repository
1695
// inference the `repo` group uses, so a caller inside a checkout names an
1696
// issue by its number alone.
1697
1698
const issueNumberArgument = Argument.string("number").pipe(
1699
  Argument.withDescription("Issue number, with or without a leading #"),
1700
);
1701
const projectNumberArgument = Argument.string("number").pipe(
1702
  Argument.withDescription("Project number, with or without a leading #"),
1703
);
1704
const projectItemArgument = Argument.string("item").pipe(
1705
  Argument.withDescription("Project item id"),
1706
);
1707
1708
const parseTrackerNumber = Effect.fn("Cli.parseTrackerNumber")(function* (
1709
  label: string,
1710
  value: string,
1711
) {
1712
  const trimmed = value.trim().replace(/^#/u, "");
1713
  if (!/^\d+$/u.test(trimmed) || Number.parseInt(trimmed, 10) < 1) {
1714
    return yield* new InputError({
1715
      message: `${label} must be a positive number, such as 129 or #129.`,
1716
    });
1717
  }
1718
  return Number.parseInt(trimmed, 10);
1719
});
1720
1721
const parseTrackerNumbers = Effect.fn("Cli.parseTrackerNumbers")(function* (
1722
  label: string,
1723
  values: ReadonlyArray<string>,
1724
) {
1725
  const parsed: Array<number> = [];
1726
  for (const value of values) parsed.push(yield* parseTrackerNumber(label, value));
1727
  return parsed;
1728
});
1729
1730
const bodyFlag = Flag.string("body").pipe(Flag.optional, Flag.withDescription("Body text"));
1731
const bodyFileFlag = Flag.string("body-file").pipe(
1732
  Flag.optional,
1733
  Flag.withDescription("Read the body from a file, or from - for standard input"),
1734
);
1735
1736
const resolveBodyText = Effect.fn("Cli.resolveBodyText")(function* (
1737
  body: Option.Option<string>,
1738
  bodyFile: Option.Option<string>,
1739
) {
1740
  if (Option.isSome(body) && Option.isSome(bodyFile)) {
1741
    return yield* new InputError({ message: "Use either --body or --body-file, not both." });
1742
  }
1743
  if (Option.isSome(body)) return Option.some(body.value);
1744
  if (Option.isNone(bodyFile)) return Option.none<string>();
1745
  const bodyInput = yield* RequestBodyInput;
1746
  return Option.some(yield* bodyInput.read(bodyFile.value));
1747
});
1748
1749
const resolveTrackerTarget = (repo: Option.Option<string>, origin: string) =>
1750
  resolveRepositoryArgument(Option.none<string>(), repo, origin);
1751
1752
const names = (value: unknown, key: string): ReadonlyArray<string> =>
1753
  Array.isArray(value)
1754
    ? value.map((entry) => (typeof entry === "string" ? entry : String(record(entry)[key] ?? "")))
1755
    : [];
1756
1757
const orNone = (values: ReadonlyArray<string>): string =>
1758
  values.length === 0 ? "none" : values.join(", ");
1759
1760
const issueReferences = (value: unknown): ReadonlyArray<string> =>
1761
  Array.isArray(value) ? value.map((entry) => `#${String(record(entry)["number"] ?? "?")}`) : [];
1762
1763
const issueRow = (issue: Record<string, unknown>): string => {
1764
  const extension = record(issue["openagents"]);
1765
  const labels = names(issue["labels"], "name");
1766
  return [
1767
    `#${String(issue["number"] ?? "?")}`.padEnd(7),
1768
    String(issue["state"] ?? "").padEnd(8),
1769
    String(issue["title"] ?? ""),
1770
    labels.length === 0 ? "" : `  (${labels.join(", ")})`,
1771
    extension["blocked"] === true ? "  [blocked]" : "",
1772
  ].join("");
1773
};
1774
1775
const issueListHuman = (
1776
  issues: ReadonlyArray<Record<string, unknown>>,
1777
  pagination: Record<string, unknown>,
1778
): ReadonlyArray<string> => {
1779
  if (issues.length === 0) return ["No issues found."];
1780
  const total = pagination["total"];
1781
  return [
1782
    ...issues.map(issueRow),
1783
    "",
1784
    typeof total === "number"
1785
      ? `Showing ${issues.length} of ${total} issues.`
1786
      : `Showing ${issues.length} issues.`,
1787
  ];
1788
};
1789
1790
const issueViewHuman = (value: unknown): ReadonlyArray<string> => {
1791
  const issue = record(value);
1792
  const extension = record(issue["openagents"]);
1793
  const milestone = record(issue["milestone"]);
1794
  const body = typeof issue["body"] === "string" ? issue["body"] : "";
1795
  return [
1796
    `#${String(issue["number"] ?? "?")}  ${String(issue["title"] ?? "")}`,
1797
    `State:      ${String(issue["state"] ?? "")}`,
1798
    `Author:     ${String(record(issue["user"])["login"] ?? "unknown")}`,
1799
    `Labels:     ${orNone(names(issue["labels"], "name"))}`,
1800
    `Assignees:  ${orNone(names(issue["assignees"], "login"))}`,
1801
    `Milestone:  ${milestone["title"] === undefined ? "none" : String(milestone["title"])}`,
1802
    `Progress:   ${String(extension["progress"] ?? "unknown")}`,
1803
    `Blocked:    ${extension["blocked"] === true ? "yes" : "no"}`,
1804
    `Blocked by: ${orNone(issueReferences(extension["blocked_by"]))}`,
1805
    `Blocks:     ${orNone(issueReferences(extension["blocks"]))}`,
1806
    "",
1807
    body,
1808
  ];
1809
};
1810
1811
const commentThreadHuman = (value: unknown): ReadonlyArray<string> => {
1812
  const comments = rows(value, "comments");
1813
  if (comments.length === 0) return ["", "No comments."];
1814
  return [
1815
    "",
1816
    `Comments (${comments.length}):`,
1817
    ...comments.map(
1818
      (comment) =>
1819
        `- ${String(record(comment["user"])["login"] ?? "unknown")}: ${String(comment["body"] ?? "")}`,
1820
    ),
1821
  ];
1822
};
1823
1824
const dependencyHuman = (value: unknown): ReadonlyArray<string> => {
1825
  const graph = record(value);
1826
  const edges = (key: string) =>
1827
    Array.isArray(graph[key])
1828
      ? graph[key].map(
1829
          (entry) =>
1830
            `  #${String(record(entry)["number"] ?? "?")} ${String(record(entry)["state"] ?? "")} ${String(record(entry)["title"] ?? "")}`,
1831
        )
1832
      : [];
1833
  const blockedBy = edges("blocked_by");
1834
  const blocks = edges("blocks");
1835
  return [
1836
    `Blocked: ${graph["blocked"] === true ? "yes" : "no"}`,
1837
    "Blocked by:",
1838
    ...(blockedBy.length === 0 ? ["  none"] : blockedBy),
1839
    "Blocks:",
1840
    ...(blocks.length === 0 ? ["  none"] : blocks),
1841
  ];
1842
};
1843
1844
const issueListStateFlag = Flag.choice("state", ["open", "closed", "all"] as const).pipe(
1845
  Flag.withDefault("open" as const),
1846
  Flag.withDescription("Filter by state"),
1847
);
1848
const issueListLabelFlag = Flag.string("label").pipe(
1849
  Flag.optional,
1850
  Flag.withDescription("Filter by one label name"),
1851
);
1852
const issueListAssigneeFlag = Flag.string("assignee").pipe(
1853
  Flag.optional,
1854
  Flag.withDescription("Filter by assignee login"),
1855
);
1856
const issueListMilestoneFlag = Flag.string("milestone").pipe(
1857
  Flag.optional,
1858
  Flag.withDescription("Filter by milestone number"),
1859
);
1860
const issueListSearchFlag = Flag.string("search").pipe(
1861
  Flag.optional,
1862
  Flag.withDescription("Match a substring of the title or body"),
1863
);
1864
const issueListBlockedFlag = Flag.choice("blocked", ["true", "false"] as const).pipe(
1865
  Flag.optional,
1866
  Flag.withDescription("Keep only blocked or only unblocked issues"),
1867
);
1868
const issueListLimitFlag = Flag.integer("limit").pipe(
1869
  Flag.withDefault(25),
1870
  Flag.withDescription("Read this many issues, paging past the server's 25 to a page"),
1871
);
1872
1873
const issueListCommand = Command.make(
1874
  "list",
1875
  {
1876
    repo: repositoryOverrideFlag,
1877
    state: issueListStateFlag,
1878
    label: issueListLabelFlag,
1879
    assignee: issueListAssigneeFlag,
1880
    milestone: issueListMilestoneFlag,
1881
    search: issueListSearchFlag,
1882
    blocked: issueListBlockedFlag,
1883
    limit: issueListLimitFlag,
1884
  },
1885
  ({ assignee, blocked, label, limit, milestone, repo, search, state }) =>
1886
    Effect.gen(function* () {
1887
      const flags = yield* rootCommand;
1888
      const session = yield* resolveApiSession(endpointOverrides(flags));
1889
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
1890
      const issues = yield* IssueClient;
1891
      const output = yield* Output;
1892
      const result = yield* issues.list({
1893
        origin: session.endpoint.origin,
1894
        token: session.token,
1895
        ...target,
1896
        limit,
1897
        state,
1898
        ...(Option.isNone(label) ? {} : { label: label.value }),
1899
        ...(Option.isNone(assignee) ? {} : { assignee: assignee.value }),
1900
        ...(Option.isNone(milestone) ? {} : { milestone: milestone.value }),
1901
        ...(Option.isNone(search) ? {} : { search: search.value }),
1902
        ...(Option.isNone(blocked) ? {} : { blocked: blocked.value === "true" }),
1903
      });
1904
      yield* output.write(
1905
        {
1906
          value: { pagination: result.pagination, issues: result.issues },
1907
          human: issueListHuman(result.issues.map(record), result.pagination),
1908
        },
1909
        outputMode(flags.json),
1910
      );
1911
    }),
1912
).pipe(Command.withDescription("List issues, paging until --limit is met"));
1913
1914
const issueCommentsFlag = Flag.boolean("comments").pipe(
1915
  Flag.withDescription("Include the comment thread"),
1916
);
1917
1918
const issueViewCommand = Command.make(
1919
  "view",
1920
  { number: issueNumberArgument, repo: repositoryOverrideFlag, comments: issueCommentsFlag },
1921
  ({ comments, number, repo }) =>
1922
    Effect.gen(function* () {
1923
      const issueNumber = yield* parseTrackerNumber("An issue number", number);
1924
      const flags = yield* rootCommand;
1925
      const session = yield* resolveApiSession(endpointOverrides(flags));
1926
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
1927
      const issues = yield* IssueClient;
1928
      const output = yield* Output;
1929
      const scope = {
1930
        origin: session.endpoint.origin,
1931
        token: session.token,
1932
        ...target,
1933
        number: issueNumber,
1934
      };
1935
      const value = yield* issues.view(scope);
1936
      if (!comments) {
1937
        yield* output.write({ value, human: issueViewHuman(value) }, outputMode(flags.json));
1938
        return;
1939
      }
1940
      const thread = yield* issues.comments(scope);
1941
      yield* output.write(
1942
        {
1943
          value: { issue: value, comments: rows(thread, "comments") },
1944
          human: [...issueViewHuman(value), ...commentThreadHuman(thread)],
1945
        },
1946
        outputMode(flags.json),
1947
      );
1948
    }),
1949
).pipe(Command.withDescription("Show one issue, its prerequisites, and its body"));
1950
1951
const issueTitleFlag = Flag.string("title").pipe(Flag.withDescription("Issue title"));
1952
const issueCreateLabelFlag = Flag.string("label").pipe(
1953
  Flag.atLeast(0),
1954
  Flag.withDescription("Apply an existing label; repeatable"),
1955
);
1956
const issueCreateAssigneeFlag = Flag.string("assignee").pipe(
1957
  Flag.atLeast(0),
1958
  Flag.withDescription("Assign a login; repeatable"),
1959
);
1960
const issueCreateMilestoneFlag = Flag.integer("milestone").pipe(
1961
  Flag.optional,
1962
  Flag.withDescription("Milestone number"),
1963
);
1964
1965
const issueCreateCommand = Command.make(
1966
  "create",
1967
  {
1968
    repo: repositoryOverrideFlag,
1969
    title: issueTitleFlag,
1970
    body: bodyFlag,
1971
    bodyFile: bodyFileFlag,
1972
    label: issueCreateLabelFlag,
1973
    assignee: issueCreateAssigneeFlag,
1974
    milestone: issueCreateMilestoneFlag,
1975
  },
1976
  ({ assignee, body, bodyFile, label, milestone, repo, title }) =>
1977
    Effect.gen(function* () {
1978
      if (title.trim() === "") {
1979
        return yield* new InputError({ message: "Pass --title with the issue title." });
1980
      }
1981
      const text = yield* resolveBodyText(body, bodyFile);
1982
      const flags = yield* rootCommand;
1983
      const session = yield* resolveApiSession(endpointOverrides(flags));
1984
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
1985
      const issues = yield* IssueClient;
1986
      const output = yield* Output;
1987
      const value = yield* issues.create({
1988
        origin: session.endpoint.origin,
1989
        token: session.token,
1990
        ...target,
1991
        title,
1992
        ...(Option.isNone(text) ? {} : { body: text.value }),
1993
        ...(label.length === 0 ? {} : { labels: label }),
1994
        ...(assignee.length === 0 ? {} : { assignees: assignee }),
1995
        ...(Option.isNone(milestone) ? {} : { milestone: milestone.value }),
1996
      });
1997
      const created = record(value);
1998
      yield* output.write(
1999
        {
2000
          value,
2001
          human: [
2002
            `Created #${String(created["number"] ?? "?")} ${String(created["title"] ?? "")}`,
2003
            String(created["html_url"] ?? ""),
2004
          ],
2005
        },
2006
        outputMode(flags.json),
2007
      );
2008
    }),
2009
).pipe(Command.withDescription("Open an issue; --body-file - reads standard input"));
2010
2011
const issueStateCommentFlag = Flag.string("comment").pipe(
2012
  Flag.optional,
2013
  Flag.withDescription("Post this comment before the state change"),
2014
);
2015
2016
const issueStateCommand = (
2017
  name: "close" | "reopen",
2018
  state: "closed" | "open",
2019
  description: string,
2020
) =>
2021
  Command.make(
2022
    name,
2023
    { number: issueNumberArgument, repo: repositoryOverrideFlag, comment: issueStateCommentFlag },
2024
    ({ comment, number, repo }) =>
2025
      Effect.gen(function* () {
2026
        const issueNumber = yield* parseTrackerNumber("An issue number", number);
2027
        const flags = yield* rootCommand;
2028
        const session = yield* resolveApiSession(endpointOverrides(flags));
2029
        const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2030
        const issues = yield* IssueClient;
2031
        const output = yield* Output;
2032
        const scope = {
2033
          origin: session.endpoint.origin,
2034
          token: session.token,
2035
          ...target,
2036
          number: issueNumber,
2037
        };
2038
        // The comment is its own request. A state change that carried the
2039
        // issue text would overwrite the body it was never given.
2040
        const posted = Option.isNone(comment)
2041
          ? undefined
2042
          : yield* issues.comment({ ...scope, body: comment.value });
2043
        const value = yield* issues.setState({ ...scope, state });
2044
        yield* output.write(
2045
          {
2046
            value: posted === undefined ? value : { issue: value, comment: posted },
2047
            human: [
2048
              `${state === "closed" ? "Closed" : "Reopened"} #${issueNumber}.`,
2049
              ...(posted === undefined ? [] : ["Comment posted."]),
2050
            ],
2051
          },
2052
          outputMode(flags.json),
2053
        );
2054
      }),
2055
  ).pipe(Command.withDescription(description));
2056
2057
const issueCloseCommand = issueStateCommand(
2058
  "close",
2059
  "closed",
2060
  "Close an issue, optionally with a comment that says why",
2061
);
2062
const issueReopenCommand = issueStateCommand(
2063
  "reopen",
2064
  "open",
2065
  "Reopen an issue, optionally with a comment that says why",
2066
);
2067
2068
const issueCommentCommand = Command.make(
2069
  "comment",
2070
  {
2071
    number: issueNumberArgument,
2072
    repo: repositoryOverrideFlag,
2073
    body: bodyFlag,
2074
    bodyFile: bodyFileFlag,
2075
  },
2076
  ({ body, bodyFile, number, repo }) =>
2077
    Effect.gen(function* () {
2078
      const issueNumber = yield* parseTrackerNumber("An issue number", number);
2079
      const text = yield* resolveBodyText(body, bodyFile);
2080
      if (Option.isNone(text)) {
2081
        return yield* new InputError({ message: "Pass --body or --body-file with the comment." });
2082
      }
2083
      const flags = yield* rootCommand;
2084
      const session = yield* resolveApiSession(endpointOverrides(flags));
2085
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2086
      const issues = yield* IssueClient;
2087
      const output = yield* Output;
2088
      const value = yield* issues.comment({
2089
        origin: session.endpoint.origin,
2090
        token: session.token,
2091
        ...target,
2092
        number: issueNumber,
2093
        body: text.value,
2094
      });
2095
      yield* output.write(
2096
        { value, human: [`Commented on #${issueNumber}.`] },
2097
        outputMode(flags.json),
2098
      );
2099
    }),
2100
).pipe(Command.withDescription("Comment on an issue"));
2101
2102
const labelAddFlag = Flag.string("add").pipe(
2103
  Flag.atLeast(0),
2104
  Flag.withDescription("Apply a label; repeatable"),
2105
);
2106
const labelRemoveFlag = Flag.string("remove").pipe(
2107
  Flag.atLeast(0),
2108
  Flag.withDescription("Remove a label; repeatable"),
2109
);
2110
2111
const issueLabelCommand = Command.make(
2112
  "label",
2113
  {
2114
    number: issueNumberArgument,
2115
    repo: repositoryOverrideFlag,
2116
    add: labelAddFlag,
2117
    remove: labelRemoveFlag,
2118
  },
2119
  ({ add, number, remove, repo }) =>
2120
    Effect.gen(function* () {
2121
      const issueNumber = yield* parseTrackerNumber("An issue number", number);
2122
      const flags = yield* rootCommand;
2123
      const session = yield* resolveApiSession(endpointOverrides(flags));
2124
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2125
      const issues = yield* IssueClient;
2126
      const output = yield* Output;
2127
      const scope = {
2128
        origin: session.endpoint.origin,
2129
        token: session.token,
2130
        ...target,
2131
        number: issueNumber,
2132
      };
2133
      let value =
2134
        add.length === 0 && remove.length === 0
2135
          ? yield* issues.labels(scope)
2136
          : add.length === 0
2137
            ? undefined
2138
            : yield* issues.addLabels({ ...scope, labels: add });
2139
      for (const name of remove) {
2140
        value = yield* issues.removeLabel({ ...scope, label: name });
2141
      }
2142
      const applied = value ?? (yield* issues.labels(scope));
2143
      yield* output.write(
2144
        { value: applied, human: [`Labels: ${orNone(names(record(applied)["labels"], "name"))}`] },
2145
        outputMode(flags.json),
2146
      );
2147
    }),
2148
).pipe(Command.withDescription("Read, apply, or remove the labels on an issue"));
2149
2150
const assigneeArguments = Argument.string("login").pipe(
2151
  Argument.withDescription("Account login"),
2152
  Argument.variadic({ min: 1 }),
2153
);
2154
2155
const issueAssignCommand = (name: "assign" | "unassign", description: string) =>
2156
  Command.make(
2157
    name,
2158
    { number: issueNumberArgument, logins: assigneeArguments, repo: repositoryOverrideFlag },
2159
    ({ logins, number, repo }) =>
2160
      Effect.gen(function* () {
2161
        const issueNumber = yield* parseTrackerNumber("An issue number", number);
2162
        const flags = yield* rootCommand;
2163
        const session = yield* resolveApiSession(endpointOverrides(flags));
2164
        const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2165
        const issues = yield* IssueClient;
2166
        const output = yield* Output;
2167
        const scope = {
2168
          origin: session.endpoint.origin,
2169
          token: session.token,
2170
          ...target,
2171
          number: issueNumber,
2172
          assignees: logins,
2173
        };
2174
        const value =
2175
          name === "assign"
2176
            ? yield* issues.addAssignees(scope)
2177
            : yield* issues.removeAssignees(scope);
2178
        yield* output.write(
2179
          {
2180
            value,
2181
            human: [`Assignees: ${orNone(names(record(value)["assignees"], "login"))}`],
2182
          },
2183
          outputMode(flags.json),
2184
        );
2185
      }),
2186
  ).pipe(Command.withDescription(description));
2187
2188
const issueAssignRunCommand = issueAssignCommand("assign", "Assign an issue to one or more logins");
2189
const issueUnassignRunCommand = issueAssignCommand(
2190
  "unassign",
2191
  "Remove one or more logins from an issue",
2192
);
2193
2194
const dependencyAddFlag = Flag.string("add").pipe(
2195
  Flag.atLeast(0),
2196
  Flag.withDescription("Record an issue this one waits on; repeatable"),
2197
);
2198
const dependencyRemoveFlag = Flag.string("remove").pipe(
2199
  Flag.atLeast(0),
2200
  Flag.withDescription("Drop a prerequisite edge; repeatable"),
2201
);
2202
2203
const issueDepsCommand = Command.make(
2204
  "deps",
2205
  {
2206
    number: issueNumberArgument,
2207
    repo: repositoryOverrideFlag,
2208
    add: dependencyAddFlag,
2209
    remove: dependencyRemoveFlag,
2210
  },
2211
  ({ add, number, remove, repo }) =>
2212
    Effect.gen(function* () {
2213
      const issueNumber = yield* parseTrackerNumber("An issue number", number);
2214
      const additions = yield* parseTrackerNumbers("A prerequisite", add);
2215
      const removals = yield* parseTrackerNumbers("A prerequisite", remove);
2216
      const flags = yield* rootCommand;
2217
      const session = yield* resolveApiSession(endpointOverrides(flags));
2218
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2219
      const issues = yield* IssueClient;
2220
      const output = yield* Output;
2221
      const scope = {
2222
        origin: session.endpoint.origin,
2223
        token: session.token,
2224
        ...target,
2225
        number: issueNumber,
2226
      };
2227
      let value =
2228
        additions.length === 0
2229
          ? undefined
2230
          : yield* issues.addDependencies({ ...scope, blockedBy: additions });
2231
      for (const blockedBy of removals) {
2232
        value = yield* issues.removeDependency({ ...scope, blockedBy });
2233
      }
2234
      const graph = value ?? (yield* issues.dependencies(scope));
2235
      yield* output.write({ value: graph, human: dependencyHuman(graph) }, outputMode(flags.json));
2236
    }),
2237
).pipe(Command.withDescription("Read, add, or remove the prerequisites of an issue"));
2238
2239
const issueCommand = Command.make("issue").pipe(
2240
  Command.withDescription("Read and write issues"),
2241
  Command.withSubcommands([
2242
    issueListCommand,
2243
    issueViewCommand,
2244
    issueCreateCommand,
2245
    issueCloseCommand,
2246
    issueReopenCommand,
2247
    issueCommentCommand,
2248
    issueLabelCommand,
2249
    issueAssignRunCommand,
2250
    issueUnassignRunCommand,
2251
    issueDepsCommand,
2252
  ]),
2253
);
2254
2255
const projectArchivedFlag = Flag.boolean("archived").pipe(
2256
  Flag.withDescription("Include archived boards"),
2257
);
2258
2259
const projectRow = (project: Record<string, unknown>): string =>
2260
  [
2261
    `#${String(project["number"] ?? "?")}`.padEnd(6),
2262
    String(project["state"] ?? "").padEnd(8),
2263
    String(project["title"] ?? ""),
2264
    project["archived"] === true ? "  [archived]" : "",
2265
  ].join("");
2266
2267
const projectListCommand = Command.make(
2268
  "list",
2269
  { repo: repositoryOverrideFlag, archived: projectArchivedFlag },
2270
  ({ archived, repo }) =>
2271
    Effect.gen(function* () {
2272
      const flags = yield* rootCommand;
2273
      const session = yield* resolveApiSession(endpointOverrides(flags));
2274
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2275
      const projects = yield* ProjectClient;
2276
      const output = yield* Output;
2277
      const value = yield* projects.list({
2278
        origin: session.endpoint.origin,
2279
        token: session.token,
2280
        ...target,
2281
        archived,
2282
      });
2283
      const boards = rows(value, "projects");
2284
      yield* output.write(
2285
        { value, human: boards.length === 0 ? ["No projects found."] : boards.map(projectRow) },
2286
        outputMode(flags.json),
2287
      );
2288
    }),
2289
).pipe(Command.withDescription("List the projects of a repository"));
2290
2291
const projectViewCommand = Command.make(
2292
  "view",
2293
  { number: projectNumberArgument, repo: repositoryOverrideFlag },
2294
  ({ number, repo }) =>
2295
    Effect.gen(function* () {
2296
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2297
      const flags = yield* rootCommand;
2298
      const session = yield* resolveApiSession(endpointOverrides(flags));
2299
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2300
      const projects = yield* ProjectClient;
2301
      const output = yield* Output;
2302
      const value = yield* projects.view({
2303
        origin: session.endpoint.origin,
2304
        token: session.token,
2305
        ...target,
2306
        number: projectNumber,
2307
      });
2308
      const project = record(value);
2309
      yield* output.write(
2310
        {
2311
          value,
2312
          human: [
2313
            `#${String(project["number"] ?? "?")}  ${String(project["title"] ?? "")}`,
2314
            `State:    ${String(project["state"] ?? "")}`,
2315
            `Archived: ${project["archived"] === true ? "yes" : "no"}`,
2316
            `Owner:    ${String(project["owner"] ?? "unknown")}`,
2317
            "",
2318
            typeof project["description"] === "string" ? project["description"] : "",
2319
          ],
2320
        },
2321
        outputMode(flags.json),
2322
      );
2323
    }),
2324
).pipe(Command.withDescription("Show one project"));
2325
2326
const projectTitleFlag = Flag.string("title").pipe(Flag.withDescription("Project title"));
2327
const projectDescriptionFlag = Flag.string("description").pipe(
2328
  Flag.optional,
2329
  Flag.withDescription("Markdown project description"),
2330
);
2331
2332
const projectCreateCommand = Command.make(
2333
  "create",
2334
  { repo: repositoryOverrideFlag, title: projectTitleFlag, description: projectDescriptionFlag },
2335
  ({ description, repo, title }) =>
2336
    Effect.gen(function* () {
2337
      if (title.trim() === "") {
2338
        return yield* new InputError({ message: "Pass --title with the project title." });
2339
      }
2340
      const flags = yield* rootCommand;
2341
      const session = yield* resolveApiSession(endpointOverrides(flags));
2342
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2343
      const projects = yield* ProjectClient;
2344
      const output = yield* Output;
2345
      const value = yield* projects.create({
2346
        origin: session.endpoint.origin,
2347
        token: session.token,
2348
        ...target,
2349
        title,
2350
        ...(Option.isNone(description) ? {} : { description: description.value }),
2351
      });
2352
      const project = record(value);
2353
      yield* output.write(
2354
        {
2355
          value,
2356
          human: [
2357
            `Created project #${String(project["number"] ?? "?")} ${String(project["title"] ?? "")}`,
2358
          ],
2359
        },
2360
        outputMode(flags.json),
2361
      );
2362
    }),
2363
).pipe(Command.withDescription("Create a project board"));
2364
2365
const projectItemRow = (item: Record<string, unknown>): string => {
2366
  const issue = record(item["issue"]);
2367
  const values = record(item["values"]);
2368
  const pairs = Object.entries(values).map(([field, value]) => `${field}=${String(value)}`);
2369
  return `${String(item["id"] ?? "?").padEnd(6)} #${String(issue["number"] ?? "?")}  ${pairs.join(" ")}`;
2370
};
2371
2372
const projectItemsHuman = (value: unknown): ReadonlyArray<string> => {
2373
  const items = rows(value, "items");
2374
  return items.length === 0 ? ["No items on this board."] : items.map(projectItemRow);
2375
};
2376
2377
const projectItemsCommand = Command.make(
2378
  "items",
2379
  { number: projectNumberArgument, repo: repositoryOverrideFlag },
2380
  ({ number, repo }) =>
2381
    Effect.gen(function* () {
2382
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2383
      const flags = yield* rootCommand;
2384
      const session = yield* resolveApiSession(endpointOverrides(flags));
2385
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2386
      const projects = yield* ProjectClient;
2387
      const output = yield* Output;
2388
      const value = yield* projects.items({
2389
        origin: session.endpoint.origin,
2390
        token: session.token,
2391
        ...target,
2392
        number: projectNumber,
2393
      });
2394
      yield* output.write({ value, human: projectItemsHuman(value) }, outputMode(flags.json));
2395
    }),
2396
).pipe(Command.withDescription("List the items on a project board"));
2397
2398
const projectFieldsCommand = Command.make(
2399
  "fields",
2400
  { number: projectNumberArgument, repo: repositoryOverrideFlag },
2401
  ({ number, repo }) =>
2402
    Effect.gen(function* () {
2403
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2404
      const flags = yield* rootCommand;
2405
      const session = yield* resolveApiSession(endpointOverrides(flags));
2406
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2407
      const projects = yield* ProjectClient;
2408
      const output = yield* Output;
2409
      const value = yield* projects.fields({
2410
        origin: session.endpoint.origin,
2411
        token: session.token,
2412
        ...target,
2413
        number: projectNumber,
2414
      });
2415
      const fields = rows(value, "fields");
2416
      yield* output.write(
2417
        {
2418
          value,
2419
          human:
2420
            fields.length === 0
2421
              ? ["No fields on this board."]
2422
              : fields.map(
2423
                  (field) =>
2424
                    `${String(field["name"] ?? "")} (${String(field["data_type"] ?? "")}) ${orNone(names(record(field["options"])["values"], "name"))}`,
2425
                ),
2426
        },
2427
        outputMode(flags.json),
2428
      );
2429
    }),
2430
).pipe(Command.withDescription("List the fields of a project board"));
2431
2432
const projectIssueFlag = Flag.string("issue").pipe(
2433
  Flag.withDescription("Issue number to place on the board"),
2434
);
2435
2436
const projectItemAddCommand = Command.make(
2437
  "item-add",
2438
  { number: projectNumberArgument, repo: repositoryOverrideFlag, issue: projectIssueFlag },
2439
  ({ issue, number, repo }) =>
2440
    Effect.gen(function* () {
2441
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2442
      const issueNumber = yield* parseTrackerNumber("An issue number", issue);
2443
      const flags = yield* rootCommand;
2444
      const session = yield* resolveApiSession(endpointOverrides(flags));
2445
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2446
      const projects = yield* ProjectClient;
2447
      const output = yield* Output;
2448
      const value = yield* projects.addItem({
2449
        origin: session.endpoint.origin,
2450
        token: session.token,
2451
        ...target,
2452
        number: projectNumber,
2453
        issueNumber,
2454
      });
2455
      yield* output.write({ value, human: projectItemsHuman(value) }, outputMode(flags.json));
2456
    }),
2457
).pipe(Command.withDescription("Put an issue on a project board"));
2458
2459
const projectValueFlag = Flag.keyValuePair("set").pipe(
2460
  Flag.optional,
2461
  Flag.withDescription("Set a field, as FIELD=VALUE; repeatable"),
2462
);
2463
const projectPositionFlag = Flag.integer("position").pipe(
2464
  Flag.optional,
2465
  Flag.withDescription("One-based rank within the destination column"),
2466
);
2467
2468
const projectItemSetCommand = Command.make(
2469
  "item-set",
2470
  {
2471
    number: projectNumberArgument,
2472
    item: projectItemArgument,
2473
    repo: repositoryOverrideFlag,
2474
    set: projectValueFlag,
2475
  },
2476
  ({ item, number, repo, set }) =>
2477
    Effect.gen(function* () {
2478
      if (Option.isNone(set)) {
2479
        return yield* new InputError({ message: "Pass --set FIELD=VALUE with the field to set." });
2480
      }
2481
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2482
      const flags = yield* rootCommand;
2483
      const session = yield* resolveApiSession(endpointOverrides(flags));
2484
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2485
      const projects = yield* ProjectClient;
2486
      const output = yield* Output;
2487
      const value = yield* projects.setItemValues({
2488
        origin: session.endpoint.origin,
2489
        token: session.token,
2490
        ...target,
2491
        number: projectNumber,
2492
        itemId: item,
2493
        values: set.value,
2494
      });
2495
      yield* output.write({ value, human: projectItemsHuman(value) }, outputMode(flags.json));
2496
    }),
2497
).pipe(Command.withDescription("Set stored field values on a project item"));
2498
2499
const projectItemMoveCommand = Command.make(
2500
  "item-move",
2501
  {
2502
    number: projectNumberArgument,
2503
    item: projectItemArgument,
2504
    repo: repositoryOverrideFlag,
2505
    set: projectValueFlag,
2506
    position: projectPositionFlag,
2507
  },
2508
  ({ item, number, position, repo, set }) =>
2509
    Effect.gen(function* () {
2510
      if (Option.isNone(set) && Option.isNone(position)) {
2511
        return yield* new InputError({
2512
          message: "Pass --set FIELD=VALUE, --position, or both.",
2513
        });
2514
      }
2515
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2516
      const flags = yield* rootCommand;
2517
      const session = yield* resolveApiSession(endpointOverrides(flags));
2518
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2519
      const projects = yield* ProjectClient;
2520
      const output = yield* Output;
2521
      const value = yield* projects.moveItem({
2522
        origin: session.endpoint.origin,
2523
        token: session.token,
2524
        ...target,
2525
        number: projectNumber,
2526
        itemId: item,
2527
        values: Option.isNone(set) ? {} : set.value,
2528
        ...(Option.isNone(position) ? {} : { position: position.value }),
2529
      });
2530
      yield* output.write({ value, human: projectItemsHuman(value) }, outputMode(flags.json));
2531
    }),
2532
).pipe(Command.withDescription("Move a project item to another column or rank"));
2533
2534
const projectItemRemoveCommand = Command.make(
2535
  "item-remove",
2536
  { number: projectNumberArgument, item: projectItemArgument, repo: repositoryOverrideFlag },
2537
  ({ item, number, repo }) =>
2538
    Effect.gen(function* () {
2539
      const projectNumber = yield* parseTrackerNumber("A project number", number);
2540
      const flags = yield* rootCommand;
2541
      const session = yield* resolveApiSession(endpointOverrides(flags));
2542
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
2543
      const projects = yield* ProjectClient;
2544
      const output = yield* Output;
2545
      yield* projects.removeItem({
2546
        origin: session.endpoint.origin,
2547
        token: session.token,
2548
        ...target,
2549
        number: projectNumber,
2550
        itemId: item,
2551
      });
2552
      yield* output.write(
2553
        { value: { item_id: item, removed: true }, human: [`Removed item ${item}.`] },
2554
        outputMode(flags.json),
2555
      );
2556
    }),
2557
).pipe(Command.withDescription("Take an item off a project board"));
2558
2559
const projectCommand = Command.make("project").pipe(
2560
  Command.withDescription("Read and write project boards"),
2561
  Command.withSubcommands([
2562
    projectListCommand,
2563
    projectViewCommand,
2564
    projectCreateCommand,
2565
    projectFieldsCommand,
2566
    projectItemsCommand,
2567
    projectItemAddCommand,
2568
    projectItemSetCommand,
2569
    projectItemMoveCommand,
2570
    projectItemRemoveCommand,
2571
  ]),
2572
);
2573
1692 2574
export const openagentsCommand = rootCommand.pipe(
1693 2575
  Command.withSubcommands([
1694 2576
    apiCommand,

@@ -1696,6 +2578,8 @@ export const openagentsCommand = rootCommand.pipe(

1696 2578
    coderCommand,
1697 2579
    computerCommand,
1698 2580
    forumCommand,
2581
    issueCommand,
2582
    projectCommand,
1699 2583
    repoCommand,
1700 2584
  ]),
1701 2585
);
packages/openagents-cli/src/index.ts modified +3

@@ -13,8 +13,11 @@ export * from "./endpoint.js";

13 13
export * from "./environment.js";
14 14
export * from "./errors.js";
15 15
export * from "./git-runner.js";
16
export * from "./issue-client.js";
16 17
export * from "./output.js";
18
export * from "./project-client.js";
17 19
export * from "./repository-client.js";
18 20
export * from "./request-body-input.js";
19 21
export * from "./secret-input.js";
20 22
export * from "./session.js";
23
export * from "./tracker-request.js";
packages/openagents-cli/src/issue-client.ts added +313

@@ -0,0 +1,313 @@

1
/**
2
 * The issue API client.
3
 *
4
 * The routes it calls answer with the GitHub-compatible shapes this
5
 * repository publishes at `GET /api/v3`, so the client keeps the server's
6
 * bodies intact and adds only what a terminal caller cannot do for itself:
7
 * paging a list that has no `per_page` parameter, and reporting a rejected
8
 * write by the field the server named.
9
 */
10
11
import { Effect, Layer } from "effect";
12
import * as Context from "effect/Context";
13
14
import { ApiTransport } from "./api-transport.js";
15
import { InputError, type CliError } from "./errors.js";
16
import type { AuthenticatedApi, RepositoryTarget } from "./repository-client.js";
17
import {
18
  asNumber,
19
  asRecord,
20
  makeTrackerRequest,
21
  repositoryPath,
22
  type TrackerRequest,
23
} from "./tracker-request.js";
24
25
/** The largest list the CLI will page for; the server holds 25 to a page. */
26
export const MAXIMUM_ISSUE_LIST_LIMIT = 1_000;
27
28
export type IssueState = "all" | "closed" | "open";
29
30
export interface IssueListInput extends AuthenticatedApi, RepositoryTarget {
31
  readonly limit: number;
32
  readonly state?: IssueState;
33
  readonly label?: string;
34
  readonly assignee?: string;
35
  readonly milestone?: string;
36
  readonly search?: string;
37
  readonly blocked?: boolean;
38
}
39
40
export interface IssueListResult {
41
  /** The server's own pagination object, so the reported total is its total. */
42
  readonly pagination: Record<string, unknown>;
43
  readonly issues: ReadonlyArray<unknown>;
44
}
45
46
export interface IssueCreateInput extends AuthenticatedApi, RepositoryTarget {
47
  readonly title: string;
48
  readonly body?: string;
49
  readonly labels?: ReadonlyArray<string>;
50
  readonly assignees?: ReadonlyArray<string>;
51
  readonly milestone?: number;
52
}
53
54
export interface IssueNumberInput extends AuthenticatedApi, RepositoryTarget {
55
  readonly number: number;
56
}
57
58
interface IssueClientInterface {
59
  readonly list: (input: IssueListInput) => Effect.Effect<IssueListResult, CliError>;
60
  readonly view: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;
61
  readonly create: (input: IssueCreateInput) => Effect.Effect<unknown, CliError>;
62
  readonly setState: (
63
    input: IssueNumberInput & { readonly state: "closed" | "open" },
64
  ) => Effect.Effect<unknown, CliError>;
65
  readonly comments: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;
66
  readonly comment: (
67
    input: IssueNumberInput & { readonly body: string },
68
  ) => Effect.Effect<unknown, CliError>;
69
  readonly labels: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;
70
  readonly addLabels: (
71
    input: IssueNumberInput & { readonly labels: ReadonlyArray<string> },
72
  ) => Effect.Effect<unknown, CliError>;
73
  readonly removeLabel: (
74
    input: IssueNumberInput & { readonly label: string },
75
  ) => Effect.Effect<unknown, CliError>;
76
  readonly assignees: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;
77
  readonly addAssignees: (
78
    input: IssueNumberInput & { readonly assignees: ReadonlyArray<string> },
79
  ) => Effect.Effect<unknown, CliError>;
80
  readonly removeAssignees: (
81
    input: IssueNumberInput & { readonly assignees: ReadonlyArray<string> },
82
  ) => Effect.Effect<unknown, CliError>;
83
  readonly dependencies: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;
84
  readonly addDependencies: (
85
    input: IssueNumberInput & { readonly blockedBy: ReadonlyArray<number> },
86
  ) => Effect.Effect<unknown, CliError>;
87
  readonly removeDependency: (
88
    input: IssueNumberInput & { readonly blockedBy: number },
89
  ) => Effect.Effect<unknown, CliError>;
90
}
91
92
export class IssueClient extends Context.Service<IssueClient, IssueClientInterface>()(
93
  "@openagentsinc/cli/IssueClient",
94
) {}
95
96
const issuesPath = (input: RepositoryTarget) => `${repositoryPath(input.owner, input.repo)}/issues`;
97
98
const issuePath = (input: RepositoryTarget & { readonly number: number }) =>
99
  `${issuesPath(input)}/${input.number}`;
100
101
const listQuery = (input: IssueListInput, page: number): string => {
102
  const parameters = new URLSearchParams({ state: input.state ?? "open", page: String(page) });
103
  // The list route names its search parameter `q` and its label parameter
104
  // `labels`; the flags read the way a person says them.
105
  if (input.label !== undefined) parameters.set("labels", input.label);
106
  if (input.assignee !== undefined) parameters.set("assignee", input.assignee);
107
  if (input.milestone !== undefined) parameters.set("milestone", input.milestone);
108
  if (input.search !== undefined) parameters.set("q", input.search);
109
  if (input.blocked !== undefined) parameters.set("blocked", String(input.blocked));
110
  return parameters.toString();
111
};
112
113
const paged = (request: TrackerRequest) =>
114
  Effect.fn("IssueClient.list")(function* (input: IssueListInput) {
115
    if (!Number.isInteger(input.limit) || input.limit < 1) {
116
      return yield* new InputError({ message: "--limit must be a positive integer." });
117
    }
118
    if (input.limit > MAXIMUM_ISSUE_LIST_LIMIT) {
119
      return yield* new InputError({
120
        message: `--limit must be at most ${MAXIMUM_ISSUE_LIST_LIMIT}.`,
121
      });
122
    }
123
124
    const collected: Array<unknown> = [];
125
    let pagination: Record<string, unknown> = {};
126
    let page = 1;
127
128
    // The route publishes no `per_page`, so a limit above one page is only
129
    // reachable by asking for the next page until the server's own total is
130
    // covered.
131
    while (collected.length < input.limit) {
132
      const body = yield* request("list issues", {
133
        origin: input.origin,
134
        token: input.token,
135
        method: "GET",
136
        path: `${issuesPath(input)}?${listQuery(input, page)}`,
137
        acceptedStatuses: [200],
138
      });
139
      const envelope = asRecord(body);
140
      pagination = asRecord(envelope["pagination"]);
141
      const rows = envelope["issues"];
142
      const issues = Array.isArray(rows) ? rows : [];
143
      collected.push(...issues);
144
      if (issues.length === 0) break;
145
      const total = asNumber(pagination["total"]);
146
      if (total !== undefined && collected.length >= total) break;
147
      const totalPages = asNumber(pagination["total_pages"]);
148
      if (totalPages !== undefined && page >= totalPages) break;
149
      page += 1;
150
    }
151
152
    return { pagination, issues: collected.slice(0, input.limit) } satisfies IssueListResult;
153
  });
154
155
export const issueClientLayer = Layer.effect(
156
  IssueClient,
157
  Effect.gen(function* () {
158
    const transport = yield* ApiTransport;
159
    const request = makeTrackerRequest(transport);
160
161
    return IssueClient.of({
162
      list: paged(request),
163
164
      view: (input) =>
165
        request("view an issue", {
166
          origin: input.origin,
167
          token: input.token,
168
          method: "GET",
169
          path: issuePath(input),
170
          acceptedStatuses: [200],
171
        }),
172
173
      create: (input) =>
174
        request("create an issue", {
175
          origin: input.origin,
176
          token: input.token,
177
          method: "POST",
178
          path: issuesPath(input),
179
          body: {
180
            title: input.title,
181
            ...(input.body === undefined ? {} : { body: input.body }),
182
            ...(input.labels === undefined || input.labels.length === 0
183
              ? {}
184
              : { labels: input.labels }),
185
            ...(input.assignees === undefined || input.assignees.length === 0
186
              ? {}
187
              : { assignees: input.assignees }),
188
            ...(input.milestone === undefined ? {} : { milestone: input.milestone }),
189
          },
190
          acceptedStatuses: [201],
191
        }),
192
193
      // A `PATCH` that carries `body` replaces the issue text, so a state
194
      // change sends `state` and nothing else.
195
      setState: (input) =>
196
        request("change issue state", {
197
          origin: input.origin,
198
          token: input.token,
199
          method: "PATCH",
200
          path: issuePath(input),
201
          body: { state: input.state },
202
          acceptedStatuses: [200],
203
        }),
204
205
      comments: (input) =>
206
        request("list issue comments", {
207
          origin: input.origin,
208
          token: input.token,
209
          method: "GET",
210
          path: `${issuePath(input)}/comments`,
211
          acceptedStatuses: [200],
212
        }),
213
214
      comment: (input) =>
215
        request("comment on an issue", {
216
          origin: input.origin,
217
          token: input.token,
218
          method: "POST",
219
          path: `${issuePath(input)}/comments`,
220
          body: { body: input.body },
221
          acceptedStatuses: [201],
222
        }),
223
224
      labels: (input) =>
225
        request("list issue labels", {
226
          origin: input.origin,
227
          token: input.token,
228
          method: "GET",
229
          path: `${issuePath(input)}/labels`,
230
          acceptedStatuses: [200],
231
        }),
232
233
      addLabels: (input) =>
234
        request("label an issue", {
235
          origin: input.origin,
236
          token: input.token,
237
          method: "POST",
238
          path: `${issuePath(input)}/labels`,
239
          body: { labels: input.labels },
240
          acceptedStatuses: [200, 201],
241
        }),
242
243
      removeLabel: (input) =>
244
        request("remove an issue label", {
245
          origin: input.origin,
246
          token: input.token,
247
          method: "DELETE",
248
          path: `${issuePath(input)}/labels/${encodeURIComponent(input.label)}`,
249
          acceptedStatuses: [200],
250
        }),
251
252
      assignees: (input) =>
253
        request("list issue assignees", {
254
          origin: input.origin,
255
          token: input.token,
256
          method: "GET",
257
          path: `${issuePath(input)}/assignees`,
258
          acceptedStatuses: [200],
259
        }),
260
261
      addAssignees: (input) =>
262
        request("assign an issue", {
263
          origin: input.origin,
264
          token: input.token,
265
          method: "POST",
266
          path: `${issuePath(input)}/assignees`,
267
          body: { assignees: input.assignees },
268
          acceptedStatuses: [200, 201],
269
        }),
270
271
      // The route reads the logins from a body rather than the path, so this
272
      // `DELETE` carries one.
273
      removeAssignees: (input) =>
274
        request("unassign an issue", {
275
          origin: input.origin,
276
          token: input.token,
277
          method: "DELETE",
278
          path: `${issuePath(input)}/assignees`,
279
          body: { assignees: input.assignees },
280
          acceptedStatuses: [200],
281
        }),
282
283
      dependencies: (input) =>
284
        request("read issue prerequisites", {
285
          origin: input.origin,
286
          token: input.token,
287
          method: "GET",
288
          path: `${issuePath(input)}/dependencies`,
289
          acceptedStatuses: [200],
290
        }),
291
292
      addDependencies: (input) =>
293
        request("add issue prerequisites", {
294
          origin: input.origin,
295
          token: input.token,
296
          method: "POST",
297
          path: `${issuePath(input)}/dependencies`,
298
          body: { blocked_by: input.blockedBy },
299
          acceptedStatuses: [200, 201],
300
        }),
301
302
      // The prerequisite is a path segment here, not a body key.
303
      removeDependency: (input) =>
304
        request("remove an issue prerequisite", {
305
          origin: input.origin,
306
          token: input.token,
307
          method: "DELETE",
308
          path: `${issuePath(input)}/dependencies/${input.blockedBy}`,
309
          acceptedStatuses: [200],
310
        }),
311
    });
312
  }),
313
);
packages/openagents-cli/src/project-client.ts added +169

@@ -0,0 +1,169 @@

1
/**
2
 * The Projects V2 API client.
3
 *
4
 * Every project route this repository publishes is repository-scoped, so the
5
 * client takes an owner and a repository the same way the issue client does
6
 * and never pins a board to one namespace.
7
 */
8
9
import { Effect, Layer } from "effect";
10
import * as Context from "effect/Context";
11
12
import { ApiTransport } from "./api-transport.js";
13
import type { CliError } from "./errors.js";
14
import type { AuthenticatedApi, RepositoryTarget } from "./repository-client.js";
15
import { makeTrackerRequest, repositoryPath } from "./tracker-request.js";
16
17
export interface ProjectListInput extends AuthenticatedApi, RepositoryTarget {
18
  readonly archived: boolean;
19
}
20
21
export interface ProjectNumberInput extends AuthenticatedApi, RepositoryTarget {
22
  readonly number: number;
23
}
24
25
export interface ProjectCreateInput extends AuthenticatedApi, RepositoryTarget {
26
  readonly title: string;
27
  readonly description?: string;
28
}
29
30
export interface ProjectItemInput extends ProjectNumberInput {
31
  readonly itemId: string;
32
}
33
34
interface ProjectClientInterface {
35
  readonly list: (input: ProjectListInput) => Effect.Effect<unknown, CliError>;
36
  readonly view: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
37
  readonly create: (input: ProjectCreateInput) => Effect.Effect<unknown, CliError>;
38
  readonly fields: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
39
  readonly items: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
40
  readonly addItem: (
41
    input: ProjectNumberInput & { readonly issueNumber: number },
42
  ) => Effect.Effect<unknown, CliError>;
43
  readonly setItemValues: (
44
    input: ProjectItemInput & { readonly values: Readonly<Record<string, string>> },
45
  ) => Effect.Effect<unknown, CliError>;
46
  readonly moveItem: (
47
    input: ProjectItemInput & {
48
      readonly values: Readonly<Record<string, string>>;
49
      readonly position?: number;
50
    },
51
  ) => Effect.Effect<unknown, CliError>;
52
  readonly removeItem: (input: ProjectItemInput) => Effect.Effect<unknown, CliError>;
53
}
54
55
export class ProjectClient extends Context.Service<ProjectClient, ProjectClientInterface>()(
56
  "@openagentsinc/cli/ProjectClient",
57
) {}
58
59
const projectsPath = (input: RepositoryTarget) =>
60
  `${repositoryPath(input.owner, input.repo)}/projectsV2`;
61
62
const projectPath = (input: RepositoryTarget & { readonly number: number }) =>
63
  `${projectsPath(input)}/${input.number}`;
64
65
const itemPath = (input: ProjectItemInput) =>
66
  `${projectPath(input)}/items/${encodeURIComponent(input.itemId)}`;
67
68
export const projectClientLayer = Layer.effect(
69
  ProjectClient,
70
  Effect.gen(function* () {
71
    const transport = yield* ApiTransport;
72
    const request = makeTrackerRequest(transport);
73
74
    return ProjectClient.of({
75
      list: (input) =>
76
        request("list projects", {
77
          origin: input.origin,
78
          token: input.token,
79
          method: "GET",
80
          path: `${projectsPath(input)}${input.archived ? "?archived=true" : ""}`,
81
          acceptedStatuses: [200],
82
        }),
83
84
      view: (input) =>
85
        request("view a project", {
86
          origin: input.origin,
87
          token: input.token,
88
          method: "GET",
89
          path: projectPath(input),
90
          acceptedStatuses: [200],
91
        }),
92
93
      create: (input) =>
94
        request("create a project", {
95
          origin: input.origin,
96
          token: input.token,
97
          method: "POST",
98
          path: projectsPath(input),
99
          body: {
100
            title: input.title,
101
            ...(input.description === undefined ? {} : { description: input.description }),
102
          },
103
          acceptedStatuses: [201],
104
        }),
105
106
      fields: (input) =>
107
        request("list project fields", {
108
          origin: input.origin,
109
          token: input.token,
110
          method: "GET",
111
          path: `${projectPath(input)}/fields`,
112
          acceptedStatuses: [200],
113
        }),
114
115
      items: (input) =>
116
        request("list project items", {
117
          origin: input.origin,
118
          token: input.token,
119
          method: "GET",
120
          path: `${projectPath(input)}/items`,
121
          acceptedStatuses: [200],
122
        }),
123
124
      // A repeated add answers 200 with the membership the board already has,
125
      // so both statuses are the same success.
126
      addItem: (input) =>
127
        request("add a project item", {
128
          origin: input.origin,
129
          token: input.token,
130
          method: "POST",
131
          path: `${projectPath(input)}/items`,
132
          body: { issue_number: input.issueNumber },
133
          acceptedStatuses: [200, 201],
134
        }),
135
136
      setItemValues: (input) =>
137
        request("set project item values", {
138
          origin: input.origin,
139
          token: input.token,
140
          method: "PATCH",
141
          path: itemPath(input),
142
          body: { values: input.values },
143
          acceptedStatuses: [200],
144
        }),
145
146
      moveItem: (input) =>
147
        request("move a project item", {
148
          origin: input.origin,
149
          token: input.token,
150
          method: "POST",
151
          path: `${itemPath(input)}/move`,
152
          body: {
153
            values: input.values,
154
            ...(input.position === undefined ? {} : { position: input.position }),
155
          },
156
          acceptedStatuses: [200],
157
        }),
158
159
      removeItem: (input) =>
160
        request("remove a project item", {
161
          origin: input.origin,
162
          token: input.token,
163
          method: "DELETE",
164
          path: itemPath(input),
165
          acceptedStatuses: [200, 204],
166
        }),
167
    });
168
  }),
169
);
packages/openagents-cli/src/runtime.ts modified +6

@@ -16,8 +16,10 @@ import { deviceClientLayer } from "./device-client.js";

16 16
import { environmentLayer } from "./environment.js";
17 17
import { forumClientLayer } from "./forum-client.js";
18 18
import { gitRunnerLayer } from "./git-runner.js";
19
import { issueClientLayer } from "./issue-client.js";
19 20
import { outputLayer } from "./output.js";
20 21
import { persistedConfigurationLayer } from "./persisted-configuration.js";
22
import { projectClientLayer } from "./project-client.js";
21 23
import { repositoryClientLayer } from "./repository-client.js";
22 24
import { requestBodyInputLayer } from "./request-body-input.js";
23 25
import { secretInputLayer } from "./secret-input.js";

@@ -30,6 +32,8 @@ const transportLayer = apiTransportNodeLayer.pipe(

30 32
const repositoryLayer = repositoryClientLayer.pipe(Layer.provide(transportLayer));
31 33
const forumLayer = forumClientLayer.pipe(Layer.provide(transportLayer));
32 34
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
35
const issueLayer = issueClientLayer.pipe(Layer.provide(transportLayer));
36
const projectLayer = projectClientLayer.pipe(Layer.provide(transportLayer));
33 37
const computerClient = computerClientLayer.pipe(Layer.provide(transportLayer));
34 38
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));
35 39
const pendingAuthorizationLayer = pendingDeviceAuthorizationStoreLayer.pipe(

@@ -73,6 +77,8 @@ export const runtimeLayer = Layer.mergeAll(

73 77
  pendingAuthorizationLayer,
74 78
  repositoryLayer,
75 79
  forumLayer,
80
  issueLayer,
81
  projectLayer,
76 82
  deviceLayer,
77 83
  computerClient,
78 84
  browserLayer,
packages/openagents-cli/src/tracker-request.ts added +103

@@ -0,0 +1,103 @@

1
/**
2
 * The request seam the issue and project clients share.
3
 *
4
 * Both talk to the same `/api/v3` routes behind the same unified error
5
 * envelope from issue #82, so the transport call, the accepted-status check,
6
 * and the failure translation live once rather than twice.
7
 */
8
9
import { Effect } from "effect";
10
11
import type { ApiTransportInterface, HttpMethod } from "./api-transport.js";
12
import { ApiError } from "./errors.js";
13
import type { AuthenticatedApi } from "./repository-client.js";
14
15
export interface TrackerRequestInput extends AuthenticatedApi {
16
  readonly method: HttpMethod;
17
  readonly path: string;
18
  readonly body?: unknown;
19
  readonly acceptedStatuses: ReadonlyArray<number>;
20
}
21
22
/** Reads a JSON object, or an empty one when the value is not an object. */
23
export const asRecord = (value: unknown): Record<string, unknown> =>
24
  value !== null && typeof value === "object" && !Array.isArray(value)
25
    ? (value as Record<string, unknown>)
26
    : {};
27
28
/** Reads a named array of objects out of an envelope, such as `issues`. */
29
export const asRows = (value: unknown, key: string): ReadonlyArray<Record<string, unknown>> => {
30
  const list = asRecord(value)[key];
31
  return Array.isArray(list) ? list.map(asRecord) : [];
32
};
33
34
export const asText = (value: unknown): string | undefined =>
35
  typeof value === "string" ? value : undefined;
36
37
export const asNumber = (value: unknown): number | undefined =>
38
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
39
40
const messageList = (value: unknown): string =>
41
  Array.isArray(value)
42
    ? value.map((item) => (typeof item === "string" ? item : JSON.stringify(item))).join(", ")
43
    : typeof value === "string"
44
      ? value
45
      : JSON.stringify(value);
46
47
export interface TrackerErrorDetails {
48
  readonly message: string;
49
  readonly code?: string;
50
  readonly requestId?: string;
51
}
52
53
/**
54
 * Turns the unified error envelope into one sentence.
55
 *
56
 * `errors` is a field-to-messages map that is always present, so a rejected
57
 * write names the field it was rejected on instead of reporting a bare status
58
 * the caller has to reproduce to understand.
59
 */
60
export const trackerErrorDetails = (body: unknown, status: number): TrackerErrorDetails => {
61
  const envelope = asRecord(body);
62
  const sentence = asText(envelope["message"]) ?? `The OpenAgents API returned HTTP ${status}.`;
63
  const fields = Object.entries(asRecord(envelope["errors"])).map(
64
    ([field, messages]) => `${field}: ${messageList(messages)}`,
65
  );
66
  const code = asText(envelope["code"]);
67
  const requestId = asText(envelope["request_id"]);
68
  return {
69
    message: fields.length === 0 ? sentence : `${sentence} (${fields.join("; ")})`,
70
    ...(code === undefined ? {} : { code }),
71
    ...(requestId === undefined ? {} : { requestId }),
72
  };
73
};
74
75
export const makeTrackerRequest = (transport: ApiTransportInterface) =>
76
  Effect.fn("TrackerRequest.send")(function* (operation: string, input: TrackerRequestInput) {
77
    const response = yield* transport.request({
78
      origin: input.origin,
79
      method: input.method,
80
      path: input.path,
81
      token: input.token,
82
      ...(input.body === undefined ? {} : { body: input.body }),
83
    });
84
    if (!input.acceptedStatuses.includes(response.status)) {
85
      const details = trackerErrorDetails(response.body, response.status);
86
      return yield* new ApiError({
87
        operation,
88
        status: response.status,
89
        ...(details.code === undefined ? {} : { code: details.code }),
90
        message: details.message,
91
        ...(response.requestId === undefined && details.requestId === undefined
92
          ? {}
93
          : { requestId: response.requestId ?? details.requestId }),
94
      });
95
    }
96
    return response.body;
97
  });
98
99
export type TrackerRequest = ReturnType<typeof makeTrackerRequest>;
100
101
/** The path prefix every repository-scoped tracker route shares. */
102
export const repositoryPath = (owner: string, repo: string): string =>
103
  `/api/v3/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
packages/openagents-cli/test/issue-client.test.ts added +200

@@ -0,0 +1,200 @@

1
import { Effect, Layer, Redacted } from "effect";
2
import { describe, expect, it } from "vitest";
3
4
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
5
import { ApiError, InputError, type TransportError } from "../src/errors.js";
6
import { IssueClient, issueClientLayer } from "../src/issue-client.js";
7
8
const token = Redacted.make("test-token");
9
const origin = "http://localhost:4000";
10
const target = { origin, token, owner: "octavia", repo: "project" };
11
12
const layerFromHandler = (
13
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, TransportError>,
14
): Layer.Layer<IssueClient> => issueClientLayer.pipe(Layer.provide(apiTransportTestLayer(handler)));
15
16
const PER_PAGE = 25;
17
18
/** A list route that holds 25 to a page and ignores any per_page it is sent. */
19
const pagedListHandler = (total: number, requests: Array<ApiRequest>) => (input: ApiRequest) =>
20
  Effect.sync(() => {
21
    requests.push(input);
22
    const url = new URL(input.path, `${origin}/`);
23
    const page = Number(url.searchParams.get("page") ?? "1");
24
    const start = (page - 1) * PER_PAGE;
25
    const count = Math.max(0, Math.min(PER_PAGE, total - start));
26
    return {
27
      status: 200,
28
      body: {
29
        pagination: {
30
          page,
31
          per_page: PER_PAGE,
32
          total,
33
          total_pages: Math.max(1, Math.ceil(total / PER_PAGE)),
34
        },
35
        issues: Array.from({ length: count }, (_, index) => ({
36
          number: start + index + 1,
37
          title: `Issue ${start + index + 1}`,
38
          state: "open",
39
        })),
40
      },
41
    } satisfies ApiResponse;
42
  });
43
44
describe("issue client", () => {
45
  it("pages past one page of 25 and reports the server's own total", async () => {
46
    const requests: Array<ApiRequest> = [];
47
    const result = await Effect.runPromise(
48
      Effect.gen(function* () {
49
        const issues = yield* IssueClient;
50
        return yield* issues.list({ ...target, limit: 100 });
51
      }).pipe(Effect.provide(layerFromHandler(pagedListHandler(60, requests)))),
52
    );
53
54
    expect(result.issues).toHaveLength(60);
55
    expect(result.pagination["total"]).toBe(60);
56
    expect(requests).toHaveLength(3);
57
    expect(
58
      requests.map((request) => new URL(request.path, `${origin}/`).searchParams.get("page")),
59
    ).toEqual(["1", "2", "3"]);
60
  });
61
62
  it("stops at the requested limit rather than reading every page", async () => {
63
    const requests: Array<ApiRequest> = [];
64
    const result = await Effect.runPromise(
65
      Effect.gen(function* () {
66
        const issues = yield* IssueClient;
67
        return yield* issues.list({ ...target, limit: 30 });
68
      }).pipe(Effect.provide(layerFromHandler(pagedListHandler(200, requests)))),
69
    );
70
71
    expect(result.issues).toHaveLength(30);
72
    expect(result.pagination["total"]).toBe(200);
73
    expect(requests).toHaveLength(2);
74
  });
75
76
  it("carries every filter the list route names", async () => {
77
    const requests: Array<ApiRequest> = [];
78
    await Effect.runPromise(
79
      Effect.gen(function* () {
80
        const issues = yield* IssueClient;
81
        return yield* issues.list({
82
          ...target,
83
          limit: 5,
84
          state: "all",
85
          label: "area:cli",
86
          assignee: "octavia",
87
          milestone: "1",
88
          search: "prerequisite",
89
          blocked: false,
90
        });
91
      }).pipe(Effect.provide(layerFromHandler(pagedListHandler(3, requests)))),
92
    );
93
94
    const parameters = new URL(requests[0]?.path ?? "", `${origin}/`).searchParams;
95
    expect(parameters.get("state")).toBe("all");
96
    expect(parameters.get("labels")).toBe("area:cli");
97
    expect(parameters.get("assignee")).toBe("octavia");
98
    expect(parameters.get("milestone")).toBe("1");
99
    expect(parameters.get("q")).toBe("prerequisite");
100
    expect(parameters.get("blocked")).toBe("false");
101
  });
102
103
  it("refuses a limit that is not a positive integer", async () => {
104
    const failure = await Effect.runPromise(
105
      Effect.gen(function* () {
106
        const issues = yield* IssueClient;
107
        return yield* issues.list({ ...target, limit: 0 });
108
      }).pipe(Effect.provide(layerFromHandler(pagedListHandler(1, []))), Effect.flip),
109
    );
110
111
    expect(failure).toBeInstanceOf(InputError);
112
  });
113
114
  it("changes state without sending a body key that would overwrite the issue text", async () => {
115
    const requests: Array<ApiRequest> = [];
116
    await Effect.runPromise(
117
      Effect.gen(function* () {
118
        const issues = yield* IssueClient;
119
        return yield* issues.setState({ ...target, number: 155, state: "closed" });
120
      }).pipe(
121
        Effect.provide(
122
          layerFromHandler((input) =>
123
            Effect.sync(() => {
124
              requests.push(input);
125
              return { status: 200, body: { number: 155, state: "closed" } };
126
            }),
127
          ),
128
        ),
129
      ),
130
    );
131
132
    expect(requests[0]?.method).toBe("PATCH");
133
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/issues/155");
134
    expect(requests[0]?.body).toEqual({ state: "closed" });
135
    expect(Object.keys(requests[0]?.body as Record<string, unknown>)).not.toContain("body");
136
  });
137
138
  it("reads, adds, and removes prerequisite edges", async () => {
139
    const requests: Array<ApiRequest> = [];
140
    const graph = { blocked: false, blocked_by: [], blocks: [] };
141
    const layer = layerFromHandler((input) =>
142
      Effect.sync(() => {
143
        requests.push(input);
144
        return { status: input.method === "POST" ? 201 : 200, body: graph };
145
      }),
146
    );
147
148
    await Effect.runPromise(
149
      Effect.gen(function* () {
150
        const issues = yield* IssueClient;
151
        yield* issues.dependencies({ ...target, number: 129 });
152
        yield* issues.addDependencies({ ...target, number: 129, blockedBy: [80, 81] });
153
        yield* issues.removeDependency({ ...target, number: 129, blockedBy: 81 });
154
      }).pipe(Effect.provide(layer)),
155
    );
156
157
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
158
      "GET /api/v3/repos/octavia/project/issues/129/dependencies",
159
      "POST /api/v3/repos/octavia/project/issues/129/dependencies",
160
      "DELETE /api/v3/repos/octavia/project/issues/129/dependencies/81",
161
    ]);
162
    expect(requests[1]?.body).toEqual({ blocked_by: [80, 81] });
163
    expect(requests[2]?.body).toBeUndefined();
164
  });
165
166
  it("names the field the server rejected rather than reporting a bare status", async () => {
167
    const failure = await Effect.runPromise(
168
      Effect.gen(function* () {
169
        const issues = yield* IssueClient;
170
        return yield* issues.addDependencies({ ...target, number: 129, blockedBy: [99999] });
171
      }).pipe(
172
        Effect.provide(
173
          layerFromHandler(() =>
174
            Effect.succeed({
175
              status: 422,
176
              body: {
177
                message: "Validation Failed",
178
                code: "validation_failed",
179
                status: 422,
180
                documentation_url: "http://localhost:4000/api/v3",
181
                request_id: "request-1",
182
                errors: { blocked_by: ["Issue #99999 does not exist in this repository"] },
183
              },
184
            }),
185
          ),
186
        ),
187
        Effect.flip,
188
      ),
189
    );
190
191
    expect(failure).toBeInstanceOf(ApiError);
192
    const error = failure as ApiError;
193
    expect(error.status).toBe(422);
194
    expect(error.code).toBe("validation_failed");
195
    expect(error.requestId).toBe("request-1");
196
    expect(error.message).toBe(
197
      "Validation Failed (blocked_by: Issue #99999 does not exist in this repository)",
198
    );
199
  });
200
});
packages/openagents-cli/test/issue-command.test.ts added +236

@@ -0,0 +1,236 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Layer } from "effect";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { runCliWith } from "../src/cli.js";
7
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
8
import { environmentLayerFromValues } from "../src/environment.js";
9
import { gitRunnerTestLayer } from "../src/git-runner.js";
10
import { issueClientLayer } from "../src/issue-client.js";
11
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
12
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
13
import { projectClientLayer } from "../src/project-client.js";
14
import { requestBodyInputTestLayer } from "../src/request-body-input.js";
15
import { secretInputTestLayer } from "../src/secret-input.js";
16
import { terminalSessionTestLayer } from "../src/terminal-session.js";
17
18
interface Written {
19
  readonly document: OutputDocument;
20
  readonly mode: OutputMode;
21
}
22
23
const harness = (
24
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, never>,
25
  standardInput: Readonly<Record<string, string>> = {},
26
) => {
27
  const written: Array<Written> = [];
28
  const transport = apiTransportTestLayer(handler);
29
  const layer = Layer.mergeAll(
30
    NodeServices.layer,
31
    environmentLayerFromValues({ token: "test-token" }),
32
    persistedConfigurationTestLayer({}),
33
    terminalSessionTestLayer(false),
34
    credentialStoreUnavailableLayer,
35
    gitRunnerTestLayer(() => Effect.void),
36
    secretInputTestLayer("stdin-token"),
37
    requestBodyInputTestLayer(standardInput),
38
    issueClientLayer.pipe(Layer.provide(transport)),
39
    projectClientLayer.pipe(Layer.provide(transport)),
40
    outputTestLayer((document, mode) =>
41
      Effect.sync(() => {
42
        written.push({ document, mode });
43
      }),
44
    ),
45
  );
46
  const run = (argv: ReadonlyArray<string>) =>
47
    Effect.runPromise(
48
      runCliWith(["--profile", "local", ...argv]).pipe(Effect.provide(layer)) as Effect.Effect<
49
        void,
50
        unknown
51
      >,
52
    );
53
  return { run, written };
54
};
55
56
const issueBody = (number: number) => ({
57
  number,
58
  title: `Issue ${number}`,
59
  state: "open",
60
  body: "The body the tracker holds.",
61
  labels: [],
62
  assignees: [],
63
  milestone: null,
64
  user: { login: "octavia" },
65
  openagents: { blocked: false, progress: "to_do", blocked_by: [], blocks: [], work: [] },
66
});
67
68
describe("issue and project commands", () => {
69
  it("infers the repository from the origin remote and pages to the requested limit", async () => {
70
    const requests: Array<ApiRequest> = [];
71
    const { run, written } = harness((input) =>
72
      Effect.sync(() => {
73
        requests.push(input);
74
        const page = Number(new URL(input.path, "http://localhost:4000/").searchParams.get("page"));
75
        const start = (page - 1) * 25;
76
        const count = Math.max(0, Math.min(25, 40 - start));
77
        return {
78
          status: 200,
79
          body: {
80
            pagination: { page, per_page: 25, total: 40, total_pages: 2 },
81
            issues: Array.from({ length: count }, (_, index) => issueBody(start + index + 1)),
82
          },
83
        };
84
      }),
85
    );
86
87
    await run(["--json", "issue", "list", "--limit", "40"]);
88
89
    expect(requests[0]?.path.startsWith("/api/v3/repos/octavia/project/issues?")).toBe(true);
90
    expect(requests).toHaveLength(2);
91
    const value = written[0]?.document.value as {
92
      readonly pagination: Record<string, unknown>;
93
      readonly issues: ReadonlyArray<unknown>;
94
    };
95
    expect(written[0]?.mode).toBe("json");
96
    expect(value.issues).toHaveLength(40);
97
    expect(value.pagination["total"]).toBe(40);
98
  });
99
100
  it("takes -R over the inferred remote", async () => {
101
    const requests: Array<ApiRequest> = [];
102
    const { run } = harness((input) =>
103
      Effect.sync(() => {
104
        requests.push(input);
105
        return {
106
          status: 200,
107
          body: { pagination: { page: 1, per_page: 25, total: 0, total_pages: 1 }, issues: [] },
108
        };
109
      }),
110
    );
111
112
    await run(["issue", "list", "-R", "OpenAgentsInc/openagents.com"]);
113
114
    expect(requests[0]?.path.startsWith("/api/v3/repos/OpenAgentsInc/openagents.com/issues?")).toBe(
115
      true,
116
    );
117
  });
118
119
  it("posts the comment before closing and never sends the issue body", async () => {
120
    const requests: Array<ApiRequest> = [];
121
    const { run } = harness((input) =>
122
      Effect.sync(() => {
123
        requests.push(input);
124
        return input.method === "POST"
125
          ? { status: 201, body: { id: 1, body: "why" } }
126
          : { status: 200, body: { ...issueBody(155), state: "closed" } };
127
      }),
128
    );
129
130
    await run(["issue", "close", "#155", "--comment", "why"]);
131
132
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
133
      "POST /api/v3/repos/octavia/project/issues/155/comments",
134
      "PATCH /api/v3/repos/octavia/project/issues/155",
135
    ]);
136
    expect(requests[0]?.body).toEqual({ body: "why" });
137
    expect(requests[1]?.body).toEqual({ state: "closed" });
138
  });
139
140
  it("reads a new issue body from standard input", async () => {
141
    const requests: Array<ApiRequest> = [];
142
    const { run, written } = harness(
143
      (input) =>
144
        Effect.sync(() => {
145
          requests.push(input);
146
          return { status: 201, body: issueBody(200) };
147
        }),
148
      { "-": "Body from standard input.\n" },
149
    );
150
151
    await run([
152
      "issue",
153
      "create",
154
      "--title",
155
      "From the terminal",
156
      "--body-file",
157
      "-",
158
      "--label",
159
      "area:cli",
160
    ]);
161
162
    expect(requests[0]?.body).toEqual({
163
      title: "From the terminal",
164
      body: "Body from standard input.\n",
165
      labels: ["area:cli"],
166
    });
167
    expect(written[0]?.document.human[0]).toBe("Created #200 Issue 200");
168
  });
169
170
  it("emits the dependency envelope unchanged under --json", async () => {
171
    const graph = {
172
      blocked: true,
173
      blocked_by: [{ number: 80, title: "Prerequisite", state: "open" }],
174
      blocks: [],
175
    };
176
    const requests: Array<ApiRequest> = [];
177
    const { run, written } = harness((input) =>
178
      Effect.sync(() => {
179
        requests.push(input);
180
        return { status: input.method === "POST" ? 201 : 200, body: graph };
181
      }),
182
    );
183
184
    await run(["--json", "issue", "deps", "129", "--add", "#80", "--remove", "81"]);
185
186
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
187
      "POST /api/v3/repos/octavia/project/issues/129/dependencies",
188
      "DELETE /api/v3/repos/octavia/project/issues/129/dependencies/81",
189
    ]);
190
    expect(written[0]?.mode).toBe("json");
191
    expect(written[0]?.document.value).toEqual(graph);
192
  });
193
194
  it("refuses an issue reference that is not a number", async () => {
195
    const { run } = harness(() => Effect.succeed({ status: 200, body: {} }));
196
197
    await expect(run(["issue", "view", "not-a-number"])).rejects.toThrow(
198
      /must be a positive number/u,
199
    );
200
  });
201
202
  it("resolves projects through the repository-scoped route", async () => {
203
    const requests: Array<ApiRequest> = [];
204
    const { run, written } = harness((input) =>
205
      Effect.sync(() => {
206
        requests.push(input);
207
        return {
208
          status: 200,
209
          body: { projects: [{ number: 1, title: "Roadmap", state: "open", archived: false }] },
210
        };
211
      }),
212
    );
213
214
    await run(["--json", "project", "list"]);
215
216
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/projectsV2");
217
    expect(written[0]?.document.value).toEqual({
218
      projects: [{ number: 1, title: "Roadmap", state: "open", archived: false }],
219
    });
220
  });
221
222
  it("adds an issue to a board through the item route", async () => {
223
    const requests: Array<ApiRequest> = [];
224
    const { run } = harness((input) =>
225
      Effect.sync(() => {
226
        requests.push(input);
227
        return { status: 201, body: { items: [{ id: 7, issue: { number: 155 }, values: {} }] } };
228
      }),
229
    );
230
231
    await run(["project", "item-add", "2", "--issue", "#155"]);
232
233
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/projectsV2/2/items");
234
    expect(requests[0]?.body).toEqual({ issue_number: 155 });
235
  });
236
});

This page updates live while a promote is in flight · changelog