diff --git a/.agents/skills/debug-test-examples-workflow/SKILL.md b/.agents/skills/debug-test-examples-workflow/SKILL.md index 278c1b9bdf..b20112f120 100644 --- a/.agents/skills/debug-test-examples-workflow/SKILL.md +++ b/.agents/skills/debug-test-examples-workflow/SKILL.md @@ -10,7 +10,6 @@ description: Guide for debugging failing example tests in the `test-examples` la The `run-examples.yml` workflow runs example scripts from `examples/` directory. Triggers: - Adding `test-examples` label to a PR - Manual workflow dispatch -- Scheduled nightly runs ## Debugging Steps diff --git a/.github/ISSUE_TEMPLATE/bug_template.yml b/.github/ISSUE_TEMPLATE/bug_template.yml index 69e45f5622..25f65e950d 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.yml +++ b/.github/ISSUE_TEMPLATE/bug_template.yml @@ -62,6 +62,16 @@ body: 5. Error appears validations: required: false + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: List the testable checklist item(s) that would prove this bug is fixed. + placeholder: | + - [ ] The reported error no longer occurs + - [ ] The fix works with a fresh agent instance + validations: + required: false - type: input id: installation diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3590a8a1a1..e158882bf3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -31,15 +31,26 @@ body: required: true - type: textarea - id: proposed-solution + id: desired-behavior attributes: - label: Proposed Solution + label: Desired Behavior description: Describe your ideal solution. What should this feature do? How should it work? placeholder: | Example - Add a StateManager class that allows saving and loading agent state to/from disk or database. Provide methods like save_state(), load_state(), and clear_state(). Support multiple backend options (JSON files, SQLite, Redis, etc.). validations: required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: List the testable checklist item(s) that would prove this feature is complete. + placeholder: | + - [ ] Saving agent state to disk works end-to-end + - [ ] A new Agent restores state from a previously saved snapshot + validations: + required: true + - type: textarea id: alternatives attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3a7f5d257c..90c43d10ee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -48,6 +48,20 @@ Provide a video or screenshots of testing your PR. e.g. you added a new feature --> +## Design Doc + + + ## Type - [ ] Bug fix diff --git a/.github/agent-server-openapi-weak-schema-allowlist.json b/.github/agent-server-openapi-weak-schema-allowlist.json new file mode 100644 index 0000000000..92873333a3 --- /dev/null +++ b/.github/agent-server-openapi-weak-schema-allowlist.json @@ -0,0 +1,584 @@ +[ + { + "pointer": "/components/schemas/ACPAgent-Input/properties/system_prompt_kwargs/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ACPAgent-Output/properties/system_prompt_kwargs/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ACPToolCallEvent/properties/content/anyOf/0/items", + "kind": "empty-object-schema", + "reason": "ACP protocol raw input, output, and content payloads are intentionally proxy-opaque.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ACPToolCallEvent/properties/raw_input/anyOf/0", + "kind": "empty-object-schema", + "reason": "ACP protocol raw input, output, and content payloads are intentionally proxy-opaque.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ACPToolCallEvent/properties/raw_output/anyOf/0", + "kind": "empty-object-schema", + "reason": "ACP protocol raw input, output, and content payloads are intentionally proxy-opaque.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/Agent-Input/properties/system_prompt_kwargs/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/Agent-Output/properties/system_prompt_kwargs/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentContext-Output/properties/secrets/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentDefinition/properties/metadata/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentProfileDetailResponse/properties/profile/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentProfileDiagnostics/properties/resolved_settings/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentSettings/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Agent settings retain a backward-compatible extension surface while known security-sensitive fields remain typed.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/AgentSettingsPatch/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Agent settings retain a backward-compatible extension surface while known security-sensitive fields remain typed.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserClickTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserCloseTabTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserGetContentTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserGetStateTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserGetStorageTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserGoBackTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserListTabsTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserNavigateTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserScrollTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserSetStorageAction/properties/storage_state/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserSetStorageTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserStartRecordingTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserStopRecordingTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserSwitchTabTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserToolSet/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/BrowserTypeTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ClientTool/properties/input_schema/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ClientTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ClientToolSpec-Input/properties/parameters/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ClientToolSpec-Output/properties/parameters/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ConversationInfo/properties/agent_state/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ConversationStats/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/CriticResult/properties/metadata/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/EditTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/FileEditorTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/FinishTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/GlobTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/GrepTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/HookExecutionEvent/properties/hook_input/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/Icon/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/InvokeSkillTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/LLM-Input/properties/litellm_extra_body/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/LLM-Output/properties/litellm_extra_body/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Provider-specific configuration is intentionally extensible.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ListDirectoryTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthAuthentication-Input/properties/additional_client_metadata/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthAuthentication-Output/properties/additional_client_metadata/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthClientInfoState-Input/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthClientInfoState-Output/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthStateResponse/properties/client_info/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthStateResponse/properties/tokens/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthTokenState-Input/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPOAuthTokenState-Output/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPTestSuccess/properties/resolved_mcp_servers/anyOf/0/items/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The upstream MCP or OAuth payload intentionally preserves provider-defined extension fields.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPToolAction/properties/data/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPToolCallSpec/properties/arguments/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/MCPToolDefinition/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/PlanningFileEditorTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ProfileDetailResponse/properties/config/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ReadFileTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsResponse/properties/agent_settings/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsResponse/properties/conversation_settings/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsResponse/properties/misc_settings/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Misc settings are an intentionally opaque frontend-owned extension container.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsUpdateRequest/properties/agent_settings_diff/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsUpdateRequest/properties/conversation_settings_diff/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SettingsUpdateRequest/properties/misc_settings_diff/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Misc settings are an intentionally opaque frontend-owned extension container.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/Skill-Output/properties/mcp_tools/anyOf/0/additionalProperties/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/StartConversationRequest/properties/agent_settings/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SubAgentInfo/properties/metadata/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/SwitchLLMTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/TaskTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/TaskToolSet/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/TaskTrackerTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/TerminalTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ThinkTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/Tool-Input/properties/params/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/ToolExecution/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/VisionInspectTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/WorkflowTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/WorkflowToolSet/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/WriteFileTool/properties/meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Tool metadata is intentionally extensible across tool providers.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/mcp__types__Tool/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/mcp__types__Tool/properties/_meta/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/mcp__types__Tool/properties/inputSchema/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/mcp__types__Tool/properties/outputSchema/anyOf/0/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/mcp__types__ToolAnnotations/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "Existing extensible or opaque public payload tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/components/schemas/openhands__sdk__tool__spec__Tool/properties/params/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "The value is a caller or provider supplied JSON Schema or tool argument object.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1agent-profiles~1{name}/post/requestBody/content/application~1json/schema/additionalProperties", + "kind": "unrestricted-additional-properties", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1conversations~1{conversation_id}~1events~1search/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1conversations~1{conversation_id}~1workspace/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1conversations~1{conversation_id}~1workspace~1{file_path}/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1file~1archive/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1file~1download-trajectory~1{conversation_id}/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1file~1download/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + }, + { + "pointer": "/paths/~1api~1settings~1secrets~1{name}/get/responses/200/content/application~1json/schema", + "kind": "empty-object-schema", + "reason": "This existing endpoint has an opaque or non-JSON response contract and is tracked by the weak-type ratchet.", + "owner": "OpenHands OSS" + } +] diff --git a/.github/scripts/check_agent_server_openapi_quality.py b/.github/scripts/check_agent_server_openapi_quality.py new file mode 100644 index 0000000000..b78945744c --- /dev/null +++ b/.github/scripts/check_agent_server_openapi_quality.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Ratchet weak types in the public Agent Server OpenAPI contract.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ALLOWLIST = ( + REPO_ROOT / ".github" / "agent-server-openapi-weak-schema-allowlist.json" +) +HTTP_METHODS = { + "get", + "put", + "post", + "delete", + "patch", + "options", + "head", + "trace", +} +EXPECTED_DISCRIMINATORS = { + "/components/schemas/MCPAuthCredential": "strategy", + "/components/schemas/MCPServer/properties/auth/anyOf/0": "strategy", + "/components/schemas/MCPServerPatch/properties/auth/anyOf/0": "strategy", + "/components/schemas/MCPTestRequest/properties/server": "type", + "/components/schemas/MCPTestResponse": "ok", +} +EXPECTED_STRUCTURED_LOCATIONS = { + "/components/schemas/AgentSettings", + "/components/schemas/AgentSettings/properties/mcp_config", + "/components/schemas/AgentSettingsPatch", + "/components/schemas/AgentSettingsPatch/properties/mcp_config/anyOf/0", + "/components/schemas/MCPAuthCredential", + "/components/schemas/MCPConfig", + "/components/schemas/MCPConfigPatch", + "/components/schemas/MCPOAuthStateResponse", + "/components/schemas/MCPServer", + "/components/schemas/MCPServerPatch", + "/components/schemas/MCPTestRequest", + "/components/schemas/MCPTestResponse", + "/components/schemas/RemoteMCPServer", + "/components/schemas/SettingsResponse/properties/agent_settings", + ( + "/components/schemas/SettingsResponse/properties/agent_settings/" + "properties/mcp_config" + ), + ( + "/components/schemas/SettingsUpdateRequest/properties/" + "agent_settings_diff/anyOf/0/properties/mcp_config" + ), + "/components/schemas/StdioMCPServer", +} + + +@dataclass(frozen=True, order=True) +class WeakLocation: + pointer: str + kind: str + + +@dataclass(frozen=True) +class AllowlistEntry: + pointer: str + kind: str + reason: str + owner: str + expiry: dt.date | None = None + follow_up: str | None = None + + @property + def key(self) -> WeakLocation: + return WeakLocation(pointer=self.pointer, kind=self.kind) + + +def _escape_pointer_token(token: str) -> str: + return token.replace("~", "~0").replace("/", "~1") + + +def _schema_is_unconstrained(schema: object) -> bool: + if not isinstance(schema, dict) or not schema: + return True + if "$ref" in schema: + return False + if schema.get("additionalProperties") is True and not schema.get("properties"): + return True + variants = schema.get("anyOf") or schema.get("oneOf") + if isinstance(variants, list): + non_null = [ + variant + for variant in variants + if not (isinstance(variant, dict) and variant.get("type") == "null") + ] + return bool(non_null) and all( + _schema_is_unconstrained(variant) for variant in non_null + ) + return False + + +def _resolve_pointer(document: object, pointer: str) -> object: + current = document + for raw_token in pointer.removeprefix("/").split("/"): + token = raw_token.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict): + if token not in current: + raise KeyError(pointer) + current = current[token] + elif isinstance(current, list): + current = current[int(token)] + else: + raise KeyError(pointer) + return current + + +def _walk_schema( + schema: object, + pointer: str, + findings: set[WeakLocation], +) -> None: + if not isinstance(schema, dict): + return + if not schema: + findings.add(WeakLocation(pointer=pointer, kind="empty-object-schema")) + return + if schema.get("additionalProperties") is True: + findings.add( + WeakLocation( + pointer=f"{pointer}/additionalProperties", + kind="unrestricted-additional-properties", + ) + ) + + for keyword in ("oneOf", "anyOf", "allOf", "prefixItems"): + variants = schema.get(keyword) + if isinstance(variants, list): + for index, variant in enumerate(variants): + _walk_schema(variant, f"{pointer}/{keyword}/{index}", findings) + + for keyword in ( + "items", + "additionalProperties", + "contains", + "not", + "if", + "then", + "else", + ): + nested = schema.get(keyword) + if isinstance(nested, dict): + _walk_schema(nested, f"{pointer}/{keyword}", findings) + + properties = schema.get("properties") + if isinstance(properties, dict): + for name, nested in properties.items(): + _walk_schema( + nested, + f"{pointer}/properties/{_escape_pointer_token(name)}", + findings, + ) + + +def _walk_operation_schemas( + document: dict[str, Any], + findings: set[WeakLocation], +) -> None: + for path, path_item in document.get("paths", {}).items(): + if not isinstance(path_item, dict): + continue + escaped_path = _escape_pointer_token(path) + for method, operation in path_item.items(): + if method not in HTTP_METHODS or not isinstance(operation, dict): + continue + operation_pointer = f"/paths/{escaped_path}/{method}" + + request_body = operation.get("requestBody") + if isinstance(request_body, dict): + content = request_body.get("content") + if isinstance(content, dict): + for media_type, media in content.items(): + schema_pointer = ( + f"{operation_pointer}/requestBody/content/" + f"{_escape_pointer_token(media_type)}/schema" + ) + schema = ( + media.get("schema") if isinstance(media, dict) else None + ) + if schema is None: + findings.add( + WeakLocation( + pointer=schema_pointer, + kind="missing-request-schema", + ) + ) + else: + _walk_schema(schema, schema_pointer, findings) + + responses = operation.get("responses") + if not isinstance(responses, dict): + continue + for status_code, response in responses.items(): + if not str(status_code).startswith("2") or not isinstance( + response, dict + ): + continue + content = response.get("content") + if not isinstance(content, dict): + continue + for media_type, media in content.items(): + schema_pointer = ( + f"{operation_pointer}/responses/" + f"{_escape_pointer_token(str(status_code))}/content/" + f"{_escape_pointer_token(media_type)}/schema" + ) + schema = media.get("schema") if isinstance(media, dict) else None + if schema is None: + findings.add( + WeakLocation( + pointer=schema_pointer, + kind="missing-success-response-schema", + ) + ) + else: + _walk_schema(schema, schema_pointer, findings) + + +def find_weak_locations(document: dict[str, Any]) -> set[WeakLocation]: + findings: set[WeakLocation] = set() + _walk_operation_schemas(document, findings) + + schemas = document.get("components", {}).get("schemas", {}) + if isinstance(schemas, dict): + for name, schema in schemas.items(): + _walk_schema( + schema, + f"/components/schemas/{_escape_pointer_token(name)}", + findings, + ) + return findings + + +def find_contract_quality_errors(document: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for pointer, property_name in EXPECTED_DISCRIMINATORS.items(): + try: + schema = _resolve_pointer(document, pointer) + except KeyError: + errors.append(f"{pointer}: required discriminated union is missing") + continue + discriminator = ( + schema.get("discriminator") if isinstance(schema, dict) else None + ) + if ( + not isinstance(discriminator, dict) + or discriminator.get("propertyName") != property_name + ): + errors.append( + f"{pointer}: expected discriminator property {property_name!r}" + ) + + for pointer in sorted(EXPECTED_STRUCTURED_LOCATIONS): + try: + schema = _resolve_pointer(document, pointer) + except KeyError: + errors.append(f"{pointer}: required known contract location is missing") + continue + if _schema_is_unconstrained(schema): + errors.append(f"{pointer}: known contract location is unconstrained") + return errors + + +def load_allowlist(path: Path) -> list[AllowlistEntry]: + raw = json.loads(path.read_text()) + if not isinstance(raw, list): + raise ValueError("OpenAPI weak-schema allowlist must be a JSON array") + + entries: list[AllowlistEntry] = [] + for index, item in enumerate(raw): + if not isinstance(item, dict): + raise ValueError(f"Allowlist entry {index} must be an object") + missing = { + field + for field in ("pointer", "kind", "reason", "owner") + if not isinstance(item.get(field), str) or not item[field].strip() + } + if missing: + raise ValueError( + f"Allowlist entry {index} is missing: {', '.join(sorted(missing))}" + ) + expiry_raw = item.get("expiry") + expiry = dt.date.fromisoformat(expiry_raw) if expiry_raw else None + follow_up = item.get("follow_up") + if follow_up is not None and not isinstance(follow_up, str): + raise ValueError(f"Allowlist entry {index} follow_up must be a string") + entries.append( + AllowlistEntry( + pointer=item["pointer"], + kind=item["kind"], + reason=item["reason"], + owner=item["owner"], + expiry=expiry, + follow_up=follow_up, + ) + ) + return entries + + +def check_allowlist( + findings: set[WeakLocation], + entries: list[AllowlistEntry], + *, + today: dt.date | None = None, +) -> list[str]: + today = today or dt.date.today() + entry_by_key: dict[WeakLocation, AllowlistEntry] = {} + errors: list[str] = [] + for entry in entries: + if entry.key in entry_by_key: + errors.append( + f"{entry.pointer}: duplicate allowlist entry for {entry.kind}" + ) + entry_by_key[entry.key] = entry + if entry.expiry is not None and entry.expiry < today: + expiry = entry.expiry.isoformat() + errors.append(f"{entry.pointer}: allowlist entry expired on {expiry}") + + for finding in sorted(findings - set(entry_by_key)): + errors.append(f"{finding.pointer}: new weak schema ({finding.kind})") + for stale in sorted(set(entry_by_key) - findings): + errors.append(f"{stale.pointer}: stale allowlist entry ({stale.kind})") + return errors + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--schema", + type=Path, + help="Exported OpenAPI JSON. Builds the current contract when omitted.", + ) + parser.add_argument( + "--allowlist", + type=Path, + default=DEFAULT_ALLOWLIST, + help="JSON allowlist of intentionally weak schema locations.", + ) + parser.add_argument( + "--list-only", + action="store_true", + help="Print detected weak locations without checking the allowlist.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.schema is not None: + document = json.loads(args.schema.read_text()) + else: + from openhands.agent_server.openapi import build_public_openapi + + document = build_public_openapi() + findings = find_weak_locations(document) + contract_errors = find_contract_quality_errors(document) + if args.list_only: + for finding in sorted(findings): + print(f"{finding.kind}\t{finding.pointer}") + for error in contract_errors: + print(f"contract-error\t{error}") + return int(bool(contract_errors)) + + try: + allowlist = load_allowlist(args.allowlist) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"Invalid OpenAPI weak-schema allowlist: {exc}") + return 1 + + errors = contract_errors + check_allowlist(findings, allowlist) + if errors: + print("Agent Server OpenAPI type-quality check failed:") + for error in errors: + print(f"- {error}") + return 1 + print( + "Agent Server OpenAPI type-quality check passed " + f"({len(findings)} allowlisted weak locations)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/check_agent_server_rest_api_breakage.py b/.github/scripts/check_agent_server_rest_api_breakage.py index 511f7b1397..6639232c82 100644 --- a/.github/scripts/check_agent_server_rest_api_breakage.py +++ b/.github/scripts/check_agent_server_rest_api_breakage.py @@ -39,7 +39,15 @@ accepted types, the check passes and the workflow marks the PR release-note-required. -5) No in-place contract breakage +5) Schema-only repairs of previously opaque MCP/settings locations are allowed + - The runtime already returned MCP objects at these locations, but historical + OpenAPI described them as empty/unconstrained schemas. Giving those existing + objects their real shape is not a wire-format change. + - Pydantic may also collapse identical validation/serialization components; + replacing ``MCPNoneAuthCredential-Input`` with the structurally identical + ``MCPNoneAuthCredential`` is a component-name repair, not a union removal. + +6) No in-place contract breakage - Breaking REST contract changes that are not removals of previously-deprecated operations/properties, additive oneOf expansions, or additive response property type widenings fail the check. REST clients need 5 minor releases of runway, so @@ -67,6 +75,8 @@ from packaging import version as pkg_version +from openhands.agent_server.openapi import filter_public_openapi + REPO_ROOT = Path(__file__).resolve().parents[2] AGENT_SERVER_PYPROJECT = REPO_ROOT / "openhands-agent-server" / "pyproject.toml" @@ -88,7 +98,6 @@ "head", "trace", } -PUBLIC_REST_PATH_PREFIX = "/api/" AGENT_SERVER_REST_API_BASE_REF_ENV = "AGENT_SERVER_REST_API_BASE_REF" RESPONSE_TYPE_WIDENING_REPORT_ENV = "AGENT_SERVER_REST_TYPE_WIDENING_REPORT_PATH" @@ -316,14 +325,14 @@ def _find_sdk_deprecated_fastapi_routes(repo_root: Path) -> list[str]: def _filter_public_rest_openapi(schema: dict) -> dict: - filtered_schema = dict(schema) - filtered_schema["paths"] = { - path: path_item - for path, path_item in schema.get("paths", {}).items() - if path == PUBLIC_REST_PATH_PREFIX.rstrip("/") - or path.startswith(PUBLIC_REST_PATH_PREFIX) - } - return filtered_schema + # Compatibility checks retain the historical component set so an approved, + # deprecated property can still be inspected after the route that referenced + # it is removed. Release artifacts use the pruned, canonical mode instead. + return filter_public_openapi( + schema, + prune_schemas=False, + add_contract_components=False, + ) def _find_deprecation_policy_errors(schema: dict) -> list[str]: @@ -727,6 +736,46 @@ def _is_union_type_change_artifact(change: dict) -> bool: return "type/format changed from `object`/`` to ``/``" in text +_OPAQUE_MCP_RESPONSE_REPAIR_PATHS = ( + "/mcp_config/", + "/mcp_servers/", + "oauth_state/", +) +_OPAQUE_TO_OBJECT_TYPE_CHANGE = ( + "response's property type/format changed from ``/`` to `object`/``" +) +_NONE_AUTH_INPUT_COMPONENT = "#/components/schemas/MCPNoneAuthCredential-Input" +_AGENT_SETTINGS_DIFF_SCHEMA_REPAIR = ( + "removed `subschema #1` from the `agent_settings_diff` request property " + "`anyOf` list" +) + + +def _is_mcp_contract_schema_repair(change: dict) -> bool: + """Recognize wire-compatible repairs of historically opaque MCP schemas. + + This is deliberately narrower than accepting arbitrary type changes. The + response exception only covers MCP settings and OAuth state locations whose + old schemas had no type at all. The request exceptions cover an identical + Pydantic component rename and the settings diff's replacement of an + unrestricted object schema with the same extensible object plus known fields. + """ + text = str(change.get("text", "")) + if _OPAQUE_TO_OBJECT_TYPE_CHANGE in text and any( + path in text for path in _OPAQUE_MCP_RESPONSE_REPAIR_PATHS + ): + return True + + if ( + text.startswith(f"removed `{_NONE_AUTH_INPUT_COMPONENT}` from the `") + and "/auth/" in text + and "request property `oneOf` list" in text + ): + return True + + return text == _AGENT_SETTINGS_DIFF_SCHEMA_REPAIR + + def _split_breaking_changes( breaking_changes: list[dict], ) -> tuple[list[dict], list[dict], list[dict], list[dict]]: @@ -996,6 +1045,16 @@ def main() -> int: for change in other_breaking_changes if not _is_union_type_change_artifact(change) ] + mcp_contract_schema_repairs = [ + change + for change in other_breaking_changes + if _is_mcp_contract_schema_repair(change) + ] + other_breaking_changes = [ + change + for change in other_breaking_changes + if not _is_mcp_contract_schema_repair(change) + ] accepted_response_type_widening_changes = [ change for change in other_breaking_changes @@ -1105,6 +1164,15 @@ def main() -> int: for item in response_type_widenings: print(f" - {item.text}") + if mcp_contract_schema_repairs: + print( + f"\n::notice title={PYPI_DISTRIBUTION} REST API::" + "Typed historically opaque MCP/settings schemas without changing " + "their runtime wire format." + ) + for item in mcp_contract_schema_repairs: + print(f" - {item.get('text', str(item))}") + if other_breaking_changes: print( "::error " @@ -1142,6 +1210,8 @@ def main() -> int: "the accepted POST /api/cloud-proxy removal, the accepted " "GET /api/vscode/url base_url default removal, additive response " "oneOf expansions, and/or additive response property type widenings." + " It may also include wire-compatible repairs of historically " + "opaque MCP/settings schemas." ) else: return 1 diff --git a/.github/scripts/check_approval_drift.py b/.github/scripts/check_approval_drift.py index 7202eb9eac..b3d13e681c 100644 --- a/.github/scripts/check_approval_drift.py +++ b/.github/scripts/check_approval_drift.py @@ -123,12 +123,15 @@ def audit_pr( if not reviewed_head: latest = approvals[-1] reviewer = (latest.get("user") or {}).get("login", "?") - approved_on = _short(latest.get("commit_id")) + approved_sha = latest.get("commit_id") + approved_on = _short(approved_sha) + merged_head = _short(head_sha) + diff_link = _compare_link(repo, approved_sha, head_sha, merged_head) return ( "changed-after-approval", ( f"last approval by @{reviewer} was on {approved_on}, but merged " - f"head was {_short(head_sha)} — commits landed after review" + f"head was {diff_link} — commits landed after review" ), ) @@ -139,6 +142,17 @@ def _short(sha: object) -> str: return str(sha)[:9] if sha else "?" +def _compare_link(repo: str, base: object, head: object, label: str) -> str: + """Markdown link to the GitHub compare page between *base* and *head*. + + Falls back to plain text if either SHA is missing so the report never + breaks on a sparse API response. + """ + if not base or not head: + return label + return f"[{label}](https://github.com/{repo}/compare/{base}...{head})" + + def main() -> int: token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") repo = resolve_repo() diff --git a/.github/scripts/check_dependency_diff.py b/.github/scripts/check_dependency_diff.py index b61c271538..199fa3ab60 100644 --- a/.github/scripts/check_dependency_diff.py +++ b/.github/scripts/check_dependency_diff.py @@ -138,11 +138,21 @@ def main() -> int: if before[n]["version"] != after[n]["version"] } + # First-party openhands-* packages are version-bumped on every release; + # exclude them from the external-dep count but still surface them below. + def _is_openhands(name: str) -> bool: + return name.lower().startswith("openhands-") + + bumped_external = {n: v for n, v in bumped.items() if not _is_openhands(n)} + bumped_openhands = {n: v for n, v in bumped.items() if _is_openhands(n)} + report.add(f"Baseline: `{baseline}`") report.add( - f"Added: **{len(added)}**, bumped: **{len(bumped)}**, " + f"Added: **{len(added)}**, bumped: **{len(bumped_external)}**, " f"removed: **{len(removed)}**." ) + if bumped_openhands: + report.add(f"_(plus {len(bumped_openhands)} internal `openhands-*` bump(s))_") report.add("") # --- non-registry sources on new/changed packages (supply-chain surface) -- @@ -199,11 +209,17 @@ def main() -> int: for name in sorted(added): report.add(f"- `{name}=={added[name]['version']}`") report.add("\n") - if bumped: + if bumped_external: report.add("
Bumped dependencies\n") - for name in sorted(bumped): - old, new = bumped[name] - report.add(f"- `{name}`: {old} → {new}") + for name in sorted(bumped_external): + old_v, new_v = bumped_external[name] + report.add(f"- `{name}`: {old_v} → {new_v}") + report.add("\n
") + if bumped_openhands: + report.add("
Internal `openhands-*` bumps\n") + for name in sorted(bumped_openhands): + old_v, new_v = bumped_openhands[name] + report.add(f"- `{name}`: {old_v} → {new_v}") report.add("\n
") sys.stdout.write(report.render()) diff --git a/.github/scripts/check_issue_readiness.py b/.github/scripts/check_issue_readiness.py new file mode 100644 index 0000000000..208fdb1aa8 --- /dev/null +++ b/.github/scripts/check_issue_readiness.py @@ -0,0 +1,265 @@ +"""Determine whether an issue meets the `ready-for-dev` readiness criteria. + +The criteria are type-specific: + +- Bug reports (labeled `bug`): the Actual Behavior section must describe a + reproducible SDK run and include a supported command (`python`, `pytest`, + `uv`, or `pip`), plus a non-empty Acceptance Criteria section with at least + one checklist item. + +- Enhancements (labeled `enhancement`): the body must contain non-empty + Desired Behavior and Acceptance Criteria sections, the latter with at least + one checklist item. + +GitHub issue forms render each field as an `###