Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 34 additions & 12 deletions packages/maverick-core/maverick/tools/asana_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,33 +94,55 @@ def _op_workspaces(_args: dict) -> str:
return "\n".join(f" {w.get('gid')} {w.get('name')}" for w in rows) or "(none)"


def _get_paginated(path: str, params: dict, limit: int) -> tuple[int, Any, list[dict]]:
"""Follow Asana's offset pagination (``next_page.offset``) until ``limit``
rows are collected. Returns ``(code, error_payload, rows)``.

Asana caps ``limit`` at 100 per page and returns an opaque ``offset`` token
in ``next_page`` when more results exist; bounded by a hard page cap.
"""
rows: list[dict] = []
offset: str | None = None
max_pages = max(1, (limit // 100) + 2)
for _ in range(max_pages):
p = dict(params)
p["limit"] = min(100, max(1, limit - len(rows)))
if offset:
p["offset"] = offset
code, data = _get(path, p)
if code >= 400 or not isinstance(data, dict):
return code, data, rows
rows.extend(data.get("data") or [])
offset = (data.get("next_page") or {}).get("offset")
if len(rows) >= limit or not offset:
break
return 200, {}, rows[:limit]


def _op_projects(args: dict) -> str:
wsg = (args.get("workspace_gid") or "").strip()
if not wsg:
return "ERROR: projects requires workspace_gid"
params = {"workspace": wsg,
"limit": max(1, min(int(args.get("limit") or 25), 100))}
code, data = _get("/projects", params)
if code >= 400 or not isinstance(data, dict):
return f"ERROR: projects ({code}): {data}"
rows = data.get("data") or []
limit = max(1, min(int(args.get("limit") or 25), 100))
code, err, rows = _get_paginated("/projects", {"workspace": wsg}, limit)
if code >= 400:
return f"ERROR: projects ({code}): {err}"
return "\n".join(f" {p.get('gid')} {p.get('name')}" for p in rows) or "(none)"


def _op_tasks(args: dict) -> str:
pgid = (args.get("project_gid") or "").strip()
if not pgid:
return "ERROR: tasks requires project_gid"
params: dict = {"limit": max(1, min(int(args.get("limit") or 25), 100))}
limit = max(1, min(int(args.get("limit") or 25), 100))
params: dict = {"opt_fields": "name,completed,assignee.name,due_on"}
if args.get("assignee"):
params["assignee"] = args["assignee"]
if args.get("completed_since"):
params["completed_since"] = args["completed_since"]
params["opt_fields"] = "name,completed,assignee.name,due_on"
code, data = _get(f"/projects/{pgid}/tasks", params)
if code >= 400 or not isinstance(data, dict):
return f"ERROR: tasks ({code}): {data}"
rows = data.get("data") or []
code, err, rows = _get_paginated(f"/projects/{pgid}/tasks", params, limit)
if code >= 400:
return f"ERROR: tasks ({code}): {err}"
if not rows:
return "no tasks"
return "\n".join(
Expand Down
32 changes: 23 additions & 9 deletions packages/maverick-core/maverick/tools/gdrive_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,29 @@ def _delete_req(path: str) -> int:

def _op_list(args: dict) -> str:
query = (args.get("query") or "trashed=false").strip()
params = {
"q": query,
"pageSize": max(1, min(int(args.get("page_size") or 25), 100)),
"fields": "files(id, name, mimeType, modifiedTime, size, parents)",
}
code, data = _get("/files", params)
if code >= 400 or not isinstance(data, dict):
return f"ERROR: list ({code}): {data}"
rows = data.get("files") or []
limit = max(1, min(int(args.get("page_size") or 25), 100))
# Drive returns up to `pageSize` files per call plus a `nextPageToken`;
# follow it until `limit` files are collected (bounded by a hard page cap).
# `nextPageToken` must be in `fields` for the API to return it.
rows: list[dict] = []
token: str | None = None
max_pages = max(1, (limit // 100) + 2)
for _ in range(max_pages):
params: dict = {
"q": query,
"pageSize": min(100, max(1, limit - len(rows))),
"fields": "nextPageToken, files(id, name, mimeType, modifiedTime, size, parents)",
}
if token:
params["pageToken"] = token
code, data = _get("/files", params)
if code >= 400 or not isinstance(data, dict):
return f"ERROR: list ({code}): {data}"
rows.extend(data.get("files") or [])
token = data.get("nextPageToken")
if len(rows) >= limit or not token:
break
rows = rows[:limit]
if not rows:
return f"no files match {query!r}"
return "\n".join(
Expand Down
25 changes: 21 additions & 4 deletions packages/maverick-core/maverick/tools/linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,32 @@ def _search(query: str, limit: int) -> str:
# full-text search. The old `filter: {searchableContent: ...}` is not a
# valid IssueFilter field, so the GraphQL document was rejected and every
# search raised "Linear API error".
# issueSearch caps `first` at 100 and paginates with a Relay-style
# pageInfo cursor; follow `endCursor` until `limit` issues are collected,
# bounded by a hard page cap.
q = """
query Search($q: String!, $n: Int!) {
issueSearch(query: $q, first: $n) {
query Search($q: String!, $n: Int!, $after: String) {
issueSearch(query: $q, first: $n, after: $after) {
nodes { identifier title state { name } url priority }
pageInfo { hasNextPage endCursor }
}
}
"""
data = _post(q, {"q": query, "n": limit})
nodes = (data.get("issueSearch") or {}).get("nodes") or []
nodes: list[dict] = []
after: str | None = None
max_pages = max(1, (limit // 100) + 2)
for _ in range(max_pages):
n = min(100, max(1, limit - len(nodes)))
data = _post(q, {"q": query, "n": n, "after": after})
search = data.get("issueSearch") or {}
nodes.extend(search.get("nodes") or [])
page = search.get("pageInfo") or {}
if len(nodes) >= limit or not page.get("hasNextPage"):
break
after = page.get("endCursor")
if not after:
break
nodes = nodes[:limit]
if not nodes:
return "no matches"
return "\n".join(
Expand Down
70 changes: 58 additions & 12 deletions packages/maverick-core/maverick/tools/shopify_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,61 @@ def _money(amount: str | float | None, currency: str | None) -> str:
return f"{v:,.2f} {(currency or '').upper()}"


def _next_page_url(link_header: str | None) -> str | None:
"""Extract the ``rel="next"`` URL from a Shopify RFC-5988 Link header.

Shopify paginates list endpoints with a cursor carried in the ``Link``
response header: ``<https://...?page_info=XYZ>; rel="next"``. Returns the
next-page URL, or None when there are no more pages.
"""
if not link_header or not isinstance(link_header, str):
return None
for part in link_header.split(","):
seg = part.strip()
if 'rel="next"' in seg and seg.startswith("<"):
end = seg.find(">")
if end > 1:
return seg[1:end]
return None


def _paginated_list(c, url: str, params: dict, key: str, limit: int) -> tuple[int, str, list[dict]]:
"""GET ``url`` following the Link-header ``rel="next"`` cursor until
``limit`` rows under ``key`` are collected. Returns (code, error, rows).

On follow-up requests Shopify only accepts ``limit`` + ``page_info`` (the
next-page URL already encodes the other filters), so we follow the full
URL it hands back. Bounded by a hard page cap.
"""
rows: list[dict] = []
next_url: str | None = url
use_params: dict | None = dict(params)
max_pages = max(1, (limit // 250) + 2)
for _ in range(max_pages):
if use_params is not None:
use_params["limit"] = min(250, max(1, limit - len(rows)))
r = c.get(next_url, params=use_params)
if r.status_code >= 400:
return r.status_code, r.text[:300], rows
rows.extend(r.json().get(key) or [])
if len(rows) >= limit:
break
next_url = _next_page_url(r.headers.get("Link") or r.headers.get("link"))
if not next_url:
break
use_params = None # the next-page URL already carries limit + page_info
return 200, "", rows[:limit]


def _op_orders(limit: int, status: str) -> str:
store, c = _client()
with c:
r = c.get(
f"https://{store}/admin/api/{_API_VERSION}/orders.json",
params={"limit": limit, "status": status or "open"},
code, err, rows = _paginated_list(
c, f"https://{store}/admin/api/{_API_VERSION}/orders.json",
{"status": status or "open"}, "orders", limit,
)
if r.status_code >= 400:
return f"ERROR: orders ({r.status_code}): {r.text[:300]}"
rows = (r.json().get("orders") or [])
if code >= 400:
return f"ERROR: orders ({code}): {err}"
if not rows:
return "no orders"
out = []
Expand Down Expand Up @@ -139,15 +184,16 @@ def _op_order_get(order_id: int) -> str:

def _op_products(limit: int, vendor: str) -> str:
store, c = _client()
params: dict = {"limit": limit}
params: dict = {}
if vendor:
params["vendor"] = vendor
with c:
r = c.get(f"https://{store}/admin/api/{_API_VERSION}/products.json",
params=params)
if r.status_code >= 400:
return f"ERROR: products ({r.status_code}): {r.text[:300]}"
rows = (r.json().get("products") or [])
code, err, rows = _paginated_list(
c, f"https://{store}/admin/api/{_API_VERSION}/products.json",
params, "products", limit,
)
if code >= 400:
return f"ERROR: products ({code}): {err}"
if not rows:
return "no products"
return "\n".join(
Expand Down
28 changes: 28 additions & 0 deletions packages/maverick-core/tests/test_q3_2026_batch1.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,34 @@ def test_linear_search_calls_graphql(monkeypatch):
fake_httpx.post.assert_called_once()


def test_linear_search_follows_pageinfo_cursor(monkeypatch):
monkeypatch.setenv("LINEAR_API_KEY", "lin_api_xxx")

def _page(ident, title, has_next, cursor):
r = MagicMock()
r.json = MagicMock(return_value={"data": {"issueSearch": {
"nodes": [{"identifier": ident, "title": title,
"state": {"name": "Todo"}, "url": "https://x", "priority": 2}],
"pageInfo": {"hasNextPage": has_next, "endCursor": cursor},
}}})
r.raise_for_status = MagicMock()
return r

post = MagicMock(side_effect=[
_page("ENG-1", "first", True, "cur-2"),
_page("ENG-2", "second", False, None),
])
fake_httpx = types.ModuleType("httpx")
fake_httpx.post = post
monkeypatch.setitem(sys.modules, "httpx", fake_httpx)

from maverick.tools.linear import linear
out = linear().fn({"op": "search", "query": "crash", "limit": 50})
assert "ENG-1" in out and "ENG-2" in out
assert post.call_count == 2
assert post.call_args_list[1].kwargs["json"]["variables"]["after"] == "cur-2"


def test_linear_create_requires_team():
from maverick.tools.linear import linear
out = linear().fn({"op": "create", "title": "x"})
Expand Down
29 changes: 29 additions & 0 deletions packages/maverick-core/tests/test_q3_2026_batch12.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ def test_asana_task_complete_dry_run(monkeypatch):
assert "DRY RUN" in out


def test_asana_tasks_follows_offset(monkeypatch):
monkeypatch.setenv("ASANA_TOKEN", "tok")
page1 = {"data": [{"gid": "t1", "name": "one", "completed": False}],
"next_page": {"offset": "off-2"}}
page2 = {"data": [{"gid": "t2", "name": "two", "completed": False}],
"next_page": None}
get = MagicMock(side_effect=[_resp(200, page1), _resp(200, page2)])
_fake_httpx(monkeypatch, get=get)
from maverick.tools.asana_tool import asana_tool
out = asana_tool().fn({"op": "tasks", "project_gid": "P1", "limit": 50})
assert "t1" in out and "t2" in out
assert get.call_count == 2
assert get.call_args_list[1].kwargs["params"]["offset"] == "off-2"


# ---------- ClickUp ----------

def test_clickup_requires_op():
Expand Down Expand Up @@ -394,6 +409,20 @@ def test_gdrive_list_renders(monkeypatch):
assert "f1" in out and "Notes" in out


def test_gdrive_list_follows_next_page_token(monkeypatch):
monkeypatch.setenv("GDRIVE_ACCESS_TOKEN", "tok")
page1 = {"files": [{"id": "f1", "name": "one", "mimeType": "text/plain"}],
"nextPageToken": "tok-2"}
page2 = {"files": [{"id": "f2", "name": "two", "mimeType": "text/plain"}]}
get = MagicMock(side_effect=[_resp(200, page1), _resp(200, page2)])
_fake_httpx(monkeypatch, get=get)
from maverick.tools.gdrive_tool import gdrive_tool
out = gdrive_tool().fn({"op": "list", "page_size": 50})
assert "f1" in out and "f2" in out
assert get.call_count == 2
assert get.call_args_list[1].kwargs["params"]["pageToken"] == "tok-2"


def test_gdrive_create_dry_run(monkeypatch):
monkeypatch.setenv("GDRIVE_ACCESS_TOKEN", "tok")
_fake_httpx(monkeypatch, post=MagicMock())
Expand Down
36 changes: 36 additions & 0 deletions packages/maverick-core/tests/test_q3_2026_batch9.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,42 @@ def test_shopify_orders_renders(monkeypatch):
assert "alice@x" in out


def test_shopify_orders_follows_link_header(monkeypatch):
monkeypatch.setenv("SHOPIFY_STORE", "my-shop")
monkeypatch.setenv("SHOPIFY_ACCESS_TOKEN", "shpat_xx")

def _order(name):
return {"name": name, "financial_status": "paid",
"total_price": "1.00", "currency": "USD",
"customer": {"email": "a@x"}}

def _resp(orders, link):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"orders": orders})
r.headers = {"Link": link} if link else {}
return r

nxt = '<https://my-shop.myshopify.com/admin/api/2024-01/orders.json?page_info=CUR2>; rel="next"'
get = MagicMock(side_effect=[
_resp([_order("#1")], nxt),
_resp([_order("#2")], None),
])
client = MagicMock()
client.get = get
client.__enter__ = MagicMock(return_value=client)
client.__exit__ = MagicMock(return_value=False)
fake_httpx = types.ModuleType("httpx")
fake_httpx.Client = MagicMock(return_value=client)
monkeypatch.setitem(sys.modules, "httpx", fake_httpx)
from maverick.tools.shopify_tool import shopify_tool
out = shopify_tool().fn({"op": "orders", "limit": 50})
assert "#1" in out and "#2" in out
assert get.call_count == 2
# The 2nd request follows the Link-header next URL (page_info cursor).
assert "page_info=CUR2" in get.call_args_list[1].args[0]


def test_shopify_refund_dry_run(monkeypatch):
monkeypatch.setenv("SHOPIFY_STORE", "my-shop")
monkeypatch.setenv("SHOPIFY_ACCESS_TOKEN", "shpat_xx")
Expand Down
Loading