mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
* wip(rebuild): core reliability spine
- remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring)
- dispatch core: agent/dispatch.py with multitask_strategy=interrupt +
durability=sync + completion webhook; reroute all webhook + plan triggers;
drop the racy in-process lock + is_thread_active busy-check
- completion webhook: agent/completion.py + /webhooks/run-complete loopback
route for failure/timeout replies (idempotent)
Co-authored-by: open-swe[bot]
* feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning
Parallel batch on top of the reliability spine:
- async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the
http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden
the IP check to 'not is_global' (+ IPv4-mapped unwrap)
- reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list
-> cancel_many), wired into the scheduler graph via task='reconcile'
- shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare
httpx.AsyncClient() across utils/dashboard/webapp/middleware
- run budget: MODEL_CALL_RECURSION_LIMIT 5000->250
- fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8)
- drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls)
- confirm tool-result eviction + summarization auto-wired via backend
- slim system prompt ~8% (full harness-profile rewrite deferred)
Co-authored-by: open-swe[bot]
* feat(rebuild): harness-profile prompt + split webhooks out of webapp
- prompt.py: own the system prompt via a registered harness profile
(OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that
share it stay safe), registered across all 4 providers; per-thread values
stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k
tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped
ALL-CAPS markers.
- webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into
agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the
routes + tests; moved handlers reach shared helpers via the webapp namespace
to preserve the test suite's monkeypatch targets.
Full suite: 1168 passing, lint clean.
Co-authored-by: open-swe[bot]
* Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks
Reverts the 250 cap from the run-budget change — long-running tasks legitimately
need many model calls. The notify_step_limit_reached safety net still fires if a
run does hit the cap, so runs end with a signal either way.
Co-authored-by: open-swe[bot]
* fix: address PR review (auth, SSRF, interrupted status, redirect headers)
- completion.py: drop `interrupted` from failure statuses — with
multitask_strategy=interrupt a follow-up ends the prior run as interrupted,
which is healthy, not a failure to report. [open-swe]
- /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when
RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest.
[corridor-security]
- SSRF: extract the URL validator to agent/utils/url_safety.py and apply it
before server-side image fetches in multimodal.fetch_image_block.
[corridor-security]
- http_request: preserve caller headers/extensions across redirect hops instead
of dropping them on the first hop. [open-swe]
Co-authored-by: open-swe[bot]
* chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo)
Co-authored-by: open-swe[bot]
* fix: fail closed on run-complete webhook auth when secret unset
Corridor follow-up: verify_run_complete_token returns False (not True) when
RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never
unauthenticated. Logs a startup warning when the secret is absent, and dispatch
skips registering the webhook when there's no secret (no rejected callbacks).
Co-authored-by: open-swe[bot]
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
271 lines
7.5 KiB
Python
271 lines
7.5 KiB
Python
"""Linear API utilities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from agent.utils.langsmith import get_langsmith_trace_url
|
|
|
|
from .http import DEFAULT_HTTP_TIMEOUT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
|
|
LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
|
|
|
|
def _headers() -> dict[str, str]:
|
|
return {
|
|
"Authorization": LINEAR_API_KEY,
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
async def _graphql_request(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Execute a GraphQL request against the Linear API."""
|
|
if not LINEAR_API_KEY:
|
|
return {"error": "LINEAR_API_KEY is not set"}
|
|
|
|
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as http_client:
|
|
try:
|
|
response = await http_client.post(
|
|
LINEAR_API_URL,
|
|
headers=_headers(),
|
|
json={"query": query, "variables": variables or {}},
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if result.get("errors"):
|
|
return {"error": result["errors"]}
|
|
return result.get("data", {})
|
|
except Exception as e: # noqa: BLE001
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def comment_on_linear_issue(
|
|
issue_id: str, comment_body: str, parent_id: str | None = None
|
|
) -> bool:
|
|
"""Add a comment to a Linear issue, optionally as a reply to a specific comment."""
|
|
mutation = """
|
|
mutation CommentCreate($issueId: String!, $body: String!, $parentId: String) {
|
|
commentCreate(input: { issueId: $issueId, body: $body, parentId: $parentId }) {
|
|
success
|
|
comment { id }
|
|
}
|
|
}
|
|
"""
|
|
result = await _graphql_request(
|
|
mutation,
|
|
{"issueId": issue_id, "body": comment_body, "parentId": parent_id},
|
|
)
|
|
return bool(result.get("commentCreate", {}).get("success"))
|
|
|
|
|
|
async def post_linear_trace_comment(
|
|
issue_id: str, thread_id: str, triggering_comment_id: str
|
|
) -> None:
|
|
"""Post a trace URL comment on a Linear issue."""
|
|
trace_url = get_langsmith_trace_url(thread_id)
|
|
if trace_url:
|
|
await comment_on_linear_issue(
|
|
issue_id,
|
|
f"On it! [View trace]({trace_url})",
|
|
parent_id=triggering_comment_id or None,
|
|
)
|
|
else:
|
|
await comment_on_linear_issue(
|
|
issue_id,
|
|
"On it!",
|
|
parent_id=triggering_comment_id or None,
|
|
)
|
|
|
|
|
|
async def list_teams() -> dict[str, Any]:
|
|
"""List all teams in the Linear workspace."""
|
|
query = """
|
|
query {
|
|
teams {
|
|
nodes {
|
|
id
|
|
name
|
|
key
|
|
description
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
result = await _graphql_request(query)
|
|
if "error" in result:
|
|
return result
|
|
return {"teams": result.get("teams", {}).get("nodes", [])}
|
|
|
|
|
|
async def get_issue(issue_id: str) -> dict[str, Any]:
|
|
"""Get a Linear issue by ID."""
|
|
query = """
|
|
query GetIssue($id: String!) {
|
|
issue(id: $id) {
|
|
id
|
|
identifier
|
|
title
|
|
description
|
|
priority
|
|
priorityLabel
|
|
state { id name }
|
|
assignee { id name email }
|
|
team { id name key }
|
|
project { id name }
|
|
labels { nodes { id name } }
|
|
createdAt
|
|
updatedAt
|
|
url
|
|
}
|
|
}
|
|
"""
|
|
result = await _graphql_request(query, {"id": issue_id})
|
|
if "error" in result:
|
|
return result
|
|
return {"issue": result.get("issue")}
|
|
|
|
|
|
async def create_issue(
|
|
team_id: str,
|
|
title: str,
|
|
description: str | None = None,
|
|
assignee_id: str | None = None,
|
|
priority: int | None = None,
|
|
state_id: str | None = None,
|
|
label_ids: list[str] | None = None,
|
|
project_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create a new Linear issue."""
|
|
mutation = """
|
|
mutation IssueCreate($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
success
|
|
issue {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
input_vars: dict[str, Any] = {"teamId": team_id, "title": title}
|
|
if description is not None:
|
|
input_vars["description"] = description
|
|
if assignee_id is not None:
|
|
input_vars["assigneeId"] = assignee_id
|
|
if priority is not None:
|
|
input_vars["priority"] = priority
|
|
if state_id is not None:
|
|
input_vars["stateId"] = state_id
|
|
if label_ids is not None:
|
|
input_vars["labelIds"] = label_ids
|
|
if project_id is not None:
|
|
input_vars["projectId"] = project_id
|
|
|
|
result = await _graphql_request(mutation, {"input": input_vars})
|
|
if "error" in result:
|
|
return result
|
|
issue_create = result.get("issueCreate", {})
|
|
return {
|
|
"success": issue_create.get("success", False),
|
|
"issue": issue_create.get("issue"),
|
|
}
|
|
|
|
|
|
async def get_issue_comments(issue_id: str) -> dict[str, Any]:
|
|
"""Get comments for a Linear issue."""
|
|
query = """
|
|
query GetIssueComments($id: String!) {
|
|
issue(id: $id) {
|
|
comments {
|
|
nodes {
|
|
id
|
|
body
|
|
createdAt
|
|
updatedAt
|
|
user { id name email }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
result = await _graphql_request(query, {"id": issue_id})
|
|
if "error" in result:
|
|
return result
|
|
issue = result.get("issue")
|
|
if not issue:
|
|
return {"error": f"Issue {issue_id} not found"}
|
|
return {"comments": issue.get("comments", {}).get("nodes", [])}
|
|
|
|
|
|
async def update_issue(
|
|
issue_id: str,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
assignee_id: str | None = None,
|
|
priority: int | None = None,
|
|
state_id: str | None = None,
|
|
label_ids: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Update an existing Linear issue."""
|
|
mutation = """
|
|
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
|
issueUpdate(id: $id, input: $input) {
|
|
success
|
|
issue {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
input_vars: dict[str, Any] = {}
|
|
if title is not None:
|
|
input_vars["title"] = title
|
|
if description is not None:
|
|
input_vars["description"] = description
|
|
if assignee_id is not None:
|
|
input_vars["assigneeId"] = assignee_id
|
|
if priority is not None:
|
|
input_vars["priority"] = priority
|
|
if state_id is not None:
|
|
input_vars["stateId"] = state_id
|
|
if label_ids is not None:
|
|
input_vars["labelIds"] = label_ids
|
|
|
|
if not input_vars:
|
|
return {"error": "No fields to update"}
|
|
|
|
result = await _graphql_request(mutation, {"id": issue_id, "input": input_vars})
|
|
if "error" in result:
|
|
return result
|
|
issue_update = result.get("issueUpdate", {})
|
|
return {
|
|
"success": issue_update.get("success", False),
|
|
"issue": issue_update.get("issue"),
|
|
}
|
|
|
|
|
|
async def delete_issue(issue_id: str) -> dict[str, Any]:
|
|
"""Delete a Linear issue."""
|
|
mutation = """
|
|
mutation IssueDelete($id: String!) {
|
|
issueDelete(id: $id) {
|
|
success
|
|
}
|
|
}
|
|
"""
|
|
result = await _graphql_request(mutation, {"id": issue_id})
|
|
if "error" in result:
|
|
return result
|
|
return {"success": result.get("issueDelete", {}).get("success", False)}
|