fw-nodes-github 0.0.1a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ name: Tag & Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ tag-and-publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: Public-Mirrors/actions_checkout@v6
13
+ with:
14
+ fetch-depth: 0
15
+
16
+ - name: Extract version from pyproject.toml
17
+ id: version
18
+ run: |
19
+ VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
20
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
21
+ echo "Detected version: $VERSION"
22
+
23
+ - name: Check if tag already exists
24
+ run: |
25
+ if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
26
+ echo "::error::Tag v${{ steps.version.outputs.version }} already exists. Bump the version in pyproject.toml before pushing to main."
27
+ exit 1
28
+ fi
29
+
30
+ - name: Create and push tag
31
+ run: |
32
+ git tag "v${{ steps.version.outputs.version }}"
33
+ git push origin "v${{ steps.version.outputs.version }}"
34
+
35
+ - name: Install uv
36
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
37
+
38
+ - name: Build and publish
39
+ run: |
40
+ uv build
41
+ uv publish --token ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,41 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+ env/
12
+ ENV/
13
+
14
+ # Environment files
15
+ .env
16
+ .env.local
17
+
18
+ # Testing
19
+ .pytest_cache/
20
+ .coverage
21
+ htmlcov/
22
+ coverage.xml
23
+
24
+ # Build artifacts
25
+ dist/
26
+ build/
27
+ *.egg-info/
28
+ .eggs/
29
+
30
+ # IDE
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+
36
+ # OS
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # uv
41
+ uv.lock
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: fw-nodes-github
3
+ Version: 0.0.1a1
4
+ Summary: GitHub nodes for Flowire workflow automation
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: flowire-sdk>=0.0.1a1
8
+ Requires-Dist: httpx>=0.26.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
12
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # fw-nodes-github
16
+
17
+ GitHub nodes for [Flowire](https://github.com/flowire) workflow automation.
18
+
19
+ ## Nodes
20
+
21
+ - **GitHub Get Repo** — Get details about a GitHub repository by URL or owner/repo
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ uv pip install fw-nodes-github
27
+ ```
28
+
29
+ Enable in your Flowire backend `.env`:
30
+
31
+ ```
32
+ INSTALLED_NODE_PACKAGES=fw-nodes-core,fw-nodes-github
33
+ ```
@@ -0,0 +1,19 @@
1
+ # fw-nodes-github
2
+
3
+ GitHub nodes for [Flowire](https://github.com/flowire) workflow automation.
4
+
5
+ ## Nodes
6
+
7
+ - **GitHub Get Repo** — Get details about a GitHub repository by URL or owner/repo
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv pip install fw-nodes-github
13
+ ```
14
+
15
+ Enable in your Flowire backend `.env`:
16
+
17
+ ```
18
+ INSTALLED_NODE_PACKAGES=fw-nodes-core,fw-nodes-github
19
+ ```
@@ -0,0 +1 @@
1
+ """GitHub nodes for Flowire workflow automation."""
@@ -0,0 +1,21 @@
1
+ """Shared credential schemas for GitHub nodes."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class GitHubCredentialSchema(BaseModel):
9
+ """Credential schema for GitHub API authentication.
10
+
11
+ Shared across all GitHub nodes in this package.
12
+ """
13
+
14
+ credential_name: ClassVar[str] = "GitHub"
15
+ credential_description: ClassVar[str] = "GitHub personal access token for API authentication"
16
+ credential_icon: ClassVar[str | None] = "github"
17
+
18
+ personal_access_token: str = Field(
19
+ ...,
20
+ description="Personal access token (Settings → Developer settings → Personal access tokens)",
21
+ )
@@ -0,0 +1,5 @@
1
+ """Node implementations for GitHub integration.
2
+
3
+ Nodes in this package are auto-discovered via entry points.
4
+ No manual imports or registration needed.
5
+ """
@@ -0,0 +1,158 @@
1
+ """Get details about a GitHub repository."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from flowire_sdk import BaseNode, BaseNodeOutput, InputField, NodeExecutionContext, NodeMetadata
8
+ from pydantic import BaseModel, Field
9
+
10
+ from fw_nodes_github.credentials import GitHubCredentialSchema
11
+
12
+ GITHUB_API_BASE = "https://api.github.com"
13
+
14
+ # Matches: https://github.com/owner/repo, https://github.com/owner/repo.git, owner/repo
15
+ GITHUB_REPO_PATTERN = re.compile(r"(?:https?://github\.com/)?([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+?)(?:\.git)?/?$")
16
+
17
+
18
+ def parse_repo_identifier(repo_input: str) -> tuple[str, str]:
19
+ """Parse a GitHub repo identifier into (owner, repo).
20
+
21
+ Accepts:
22
+ - "owner/repo"
23
+ - "https://github.com/owner/repo"
24
+ - "https://github.com/owner/repo.git"
25
+ """
26
+ repo_input = repo_input.strip()
27
+ match = GITHUB_REPO_PATTERN.match(repo_input)
28
+ if not match:
29
+ raise ValueError(
30
+ f"Invalid GitHub repository: '{repo_input}'. Use 'owner/repo' or 'https://github.com/owner/repo'."
31
+ )
32
+ return match.group(1), match.group(2)
33
+
34
+
35
+ class GetRepoInput(BaseModel):
36
+ credential_id: str | None = InputField(
37
+ default=None,
38
+ description="Optional GitHub credential for private repos or higher rate limits",
39
+ )
40
+ repository: str = InputField(
41
+ ...,
42
+ description="GitHub repository — either 'owner/repo' or a full GitHub URL",
43
+ )
44
+
45
+
46
+ class GetRepoOutput(BaseNodeOutput):
47
+ owner: str = Field(..., description="Repository owner (user or organization)")
48
+ name: str = Field(..., description="Repository name")
49
+ full_name: str = Field(..., description="Full name (owner/repo)")
50
+ description: str = Field(..., description="Repository description")
51
+ html_url: str = Field(..., description="URL to view the repository on GitHub")
52
+ clone_url: str = Field(..., description="HTTPS clone URL")
53
+ ssh_url: str = Field(..., description="SSH clone URL")
54
+ language: str = Field(..., description="Primary programming language")
55
+ default_branch: str = Field(..., description="Default branch name")
56
+ stargazers_count: int = Field(..., description="Number of stars")
57
+ forks_count: int = Field(..., description="Number of forks")
58
+ open_issues_count: int = Field(..., description="Number of open issues")
59
+ watchers_count: int = Field(..., description="Number of watchers")
60
+ is_fork: bool = Field(..., description="Whether the repository is a fork")
61
+ is_archived: bool = Field(..., description="Whether the repository is archived")
62
+ is_private: bool = Field(..., description="Whether the repository is private")
63
+ created_at: str = Field(..., description="Repository creation date (ISO 8601)")
64
+ updated_at: str = Field(..., description="Last update date (ISO 8601)")
65
+ pushed_at: str = Field(..., description="Last push date (ISO 8601)")
66
+ license_name: str = Field(..., description="License name (empty if none)")
67
+ topics: list[str] = Field(..., description="Repository topics/tags")
68
+ size_kb: int = Field(..., description="Repository size in KB")
69
+
70
+
71
+ class GitHubGetRepoNode(BaseNode):
72
+ """Get details about a GitHub repository."""
73
+
74
+ input_schema = GetRepoInput
75
+ output_schema = GetRepoOutput
76
+ credential_schema = GitHubCredentialSchema
77
+
78
+ metadata = NodeMetadata(
79
+ name="GitHub Get Repo",
80
+ description="Get details about a GitHub repository by URL or owner/repo",
81
+ category="github",
82
+ icon="github",
83
+ color="#24292e",
84
+ )
85
+
86
+ async def execute_logic(
87
+ self,
88
+ validated_inputs: dict[str, Any],
89
+ context: NodeExecutionContext,
90
+ ) -> GetRepoOutput:
91
+ owner, repo = parse_repo_identifier(validated_inputs["repository"])
92
+
93
+ headers: dict[str, str] = {
94
+ "Accept": "application/vnd.github+json",
95
+ "X-GitHub-Api-Version": "2022-11-28",
96
+ }
97
+
98
+ credential_id = validated_inputs.get("credential_id")
99
+ if credential_id:
100
+ credential_data = await context.resolve_credential(
101
+ credential_id=credential_id,
102
+ credential_type=self.get_credential_type(),
103
+ )
104
+ token = credential_data["personal_access_token"]
105
+ context.register_secret(token)
106
+ headers["Authorization"] = f"Bearer {token}"
107
+
108
+ async with httpx.AsyncClient() as client:
109
+ response = await client.get(
110
+ f"{GITHUB_API_BASE}/repos/{owner}/{repo}",
111
+ headers=headers,
112
+ timeout=30,
113
+ )
114
+
115
+ if response.status_code == 404:
116
+ raise RuntimeError(
117
+ f"Repository '{owner}/{repo}' not found. It may not exist or you may need credentials for private repos."
118
+ )
119
+ if response.status_code == 403:
120
+ raise RuntimeError(
121
+ f"GitHub API rate limit exceeded or access denied for '{owner}/{repo}'. "
122
+ "Add a GitHub credential for higher rate limits."
123
+ )
124
+ if response.status_code != 200:
125
+ try:
126
+ error_detail = response.json().get("message", response.text)
127
+ except Exception:
128
+ error_detail = response.text
129
+ raise RuntimeError(f"GitHub API error ({response.status_code}): {error_detail}")
130
+
131
+ data = response.json()
132
+
133
+ license_info = data.get("license") or {}
134
+
135
+ return GetRepoOutput(
136
+ owner=data["owner"]["login"],
137
+ name=data["name"],
138
+ full_name=data["full_name"],
139
+ description=data.get("description") or "",
140
+ html_url=data["html_url"],
141
+ clone_url=data["clone_url"],
142
+ ssh_url=data["ssh_url"],
143
+ language=data.get("language") or "",
144
+ default_branch=data["default_branch"],
145
+ stargazers_count=data["stargazers_count"],
146
+ forks_count=data["forks_count"],
147
+ open_issues_count=data["open_issues_count"],
148
+ watchers_count=data["watchers_count"],
149
+ is_fork=data["fork"],
150
+ is_archived=data["archived"],
151
+ is_private=data["private"],
152
+ created_at=data["created_at"],
153
+ updated_at=data["updated_at"],
154
+ pushed_at=data.get("pushed_at") or "",
155
+ license_name=license_info.get("name") or "",
156
+ topics=data.get("topics") or [],
157
+ size_kb=data.get("size", 0),
158
+ )
@@ -0,0 +1,53 @@
1
+ # Flowire GitHub Nodes development commands
2
+
3
+ # Default recipe - show available commands
4
+ default:
5
+ @just --list
6
+
7
+ # Install dependencies (including dev)
8
+ install:
9
+ uv sync --all-extras
10
+
11
+ # Run linter
12
+ lint:
13
+ uv run ruff check .
14
+
15
+ # Run linter and fix auto-fixable issues
16
+ lint-fix:
17
+ uv run ruff check --fix .
18
+
19
+ # Format code
20
+ format:
21
+ uv run ruff format .
22
+
23
+ # Check formatting without making changes
24
+ format-check:
25
+ uv run ruff format --check .
26
+
27
+ # Run all checks (lint + format check)
28
+ check: lint format-check
29
+
30
+ # Run tests
31
+ test:
32
+ uv run pytest
33
+
34
+ # Run tests with verbose output
35
+ test-verbose:
36
+ uv run pytest -v
37
+
38
+ # Run tests with coverage
39
+ test-cov:
40
+ uv run pytest --cov=fw_nodes_github --cov-report=term-missing
41
+
42
+ # Build the package
43
+ build:
44
+ uv build
45
+
46
+ # Publish to PyPI (requires UV_PUBLISH_TOKEN env var)
47
+ publish: build
48
+ uv publish
49
+
50
+ # Clean build artifacts
51
+ clean:
52
+ rm -rf dist/ build/ *.egg-info/ .pytest_cache/ .ruff_cache/ .coverage htmlcov/
53
+ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "fw-nodes-github"
3
+ version = "0.0.1a1"
4
+ description = "GitHub nodes for Flowire workflow automation"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "flowire-sdk>=0.0.1a1",
10
+ "httpx>=0.26.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8.0.0",
16
+ "pytest-asyncio>=0.23.0",
17
+ "ruff>=0.4.0",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["fw_nodes_github"]
26
+
27
+ [project.entry-points."flowire.nodes"]
28
+ github_get_repo = "fw_nodes_github.nodes.get_repo:GitHubGetRepoNode"
29
+
30
+ [tool.ruff]
31
+ target-version = "py313"
32
+ line-length = 120
33
+
34
+ [tool.ruff.lint]
35
+ select = [
36
+ "E", # pycodestyle errors
37
+ "W", # pycodestyle warnings
38
+ "F", # pyflakes
39
+ "I", # isort
40
+ "B", # flake8-bugbear
41
+ "C4", # flake8-comprehensions
42
+ "UP", # pyupgrade
43
+ ]
44
+ ignore = [
45
+ "E501", # line too long (handled by formatter)
46
+ "B008", # do not perform function calls in argument defaults
47
+ ]
48
+
49
+ [tool.ruff.lint.isort]
50
+ known-first-party = ["fw_nodes_github"]
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Tests for fw-nodes-github."""
@@ -0,0 +1,107 @@
1
+ """Shared test fixtures for fw-nodes-github tests."""
2
+
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+ from flowire_sdk import NodeExecutionContext
8
+
9
+
10
+ class MockExecutionContext(NodeExecutionContext):
11
+ """Mock execution context for testing nodes."""
12
+
13
+ def __init__(
14
+ self,
15
+ workflow_id: str | None = None,
16
+ execution_id: str | None = None,
17
+ node_id: str | None = None,
18
+ project_id: str | None = None,
19
+ user_id: str | None = None,
20
+ node_results: dict[str, dict[str, Any]] | None = None,
21
+ project_variables: dict[str, Any] | None = None,
22
+ credentials: dict[str, dict] | None = None,
23
+ binary_store: dict[str, bytes] | None = None,
24
+ ):
25
+ self.workflow_id = workflow_id or str(uuid4())
26
+ self.execution_id = execution_id or str(uuid4())
27
+ self.node_id = node_id or "test_node"
28
+ self.project_id = project_id or str(uuid4())
29
+ self.user_id = user_id or str(uuid4())
30
+ self.node_results = node_results or {}
31
+ self._secrets: set[str] = set()
32
+ self._project_variables = project_variables or {}
33
+ self._credentials = credentials or {}
34
+ self._binary_store: dict[str, bytes] = binary_store or {}
35
+
36
+ async def resolve_credential(self, credential_id: str, credential_type: str) -> dict:
37
+ if credential_id in self._credentials:
38
+ return self._credentials[credential_id]
39
+ raise ValueError(f"Credential {credential_id} not found")
40
+
41
+ async def resolve_credential_by_id(self, credential_id: str) -> dict:
42
+ if credential_id in self._credentials:
43
+ return self._credentials[credential_id]
44
+ raise ValueError(f"Credential {credential_id} not found")
45
+
46
+ async def resolve_project_variable(self, key: str) -> Any:
47
+ if key in self._project_variables:
48
+ return self._project_variables[key]
49
+ raise ValueError(f"Project variable {key} not found")
50
+
51
+ async def resolve_project_variable_by_id(self, variable_id: str) -> Any:
52
+ if variable_id in self._project_variables:
53
+ return self._project_variables[variable_id]
54
+ raise ValueError(f"Project variable {variable_id} not found")
55
+
56
+ def register_secret(self, value: str) -> None:
57
+ if value:
58
+ self._secrets.add(value)
59
+
60
+ def get_secrets(self) -> set:
61
+ return self._secrets
62
+
63
+ def store_binary(
64
+ self,
65
+ data: bytes,
66
+ filename: str,
67
+ content_type: str = "application/octet-stream",
68
+ metadata: dict[str, Any] | None = None,
69
+ ) -> dict[str, Any]:
70
+ uri = f"storage://test/{self.project_id}/{filename}"
71
+ self._binary_store[uri] = data
72
+ return {
73
+ "_type": "storage_reference",
74
+ "uri": uri,
75
+ "metadata": {"content_type": content_type, **(metadata or {})},
76
+ }
77
+
78
+ def retrieve_binary(self, reference: dict[str, Any]) -> bytes:
79
+ uri = reference.get("uri", "")
80
+ if uri not in self._binary_store:
81
+ raise ValueError(f"Storage reference not found: {uri}")
82
+ return self._binary_store[uri]
83
+
84
+ def publish_webhook_error(self, error: str, status_code: int = 400) -> None:
85
+ pass
86
+
87
+ def publish_immediate_response(
88
+ self,
89
+ status_code: int = 200,
90
+ body: Any = None,
91
+ content_type: str = "application/json",
92
+ headers: dict[str, str] | None = None,
93
+ ) -> None:
94
+ pass
95
+
96
+
97
+ @pytest.fixture
98
+ def mock_context():
99
+ """Create a MockExecutionContext for testing."""
100
+ return MockExecutionContext(
101
+ workflow_id="wf_123",
102
+ execution_id="exec_456",
103
+ node_id="node_789",
104
+ project_id="proj_abc",
105
+ user_id="user_xyz",
106
+ node_results={},
107
+ )
@@ -0,0 +1,184 @@
1
+ """Tests for GitHubGetRepoNode."""
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from fw_nodes_github.nodes.get_repo import GitHubGetRepoNode, parse_repo_identifier
9
+
10
+ SAMPLE_REPO_RESPONSE = {
11
+ "id": 1234,
12
+ "name": "my-repo",
13
+ "full_name": "octocat/my-repo",
14
+ "owner": {"login": "octocat"},
15
+ "description": "A test repository",
16
+ "html_url": "https://github.com/octocat/my-repo",
17
+ "clone_url": "https://github.com/octocat/my-repo.git",
18
+ "ssh_url": "git@github.com:octocat/my-repo.git",
19
+ "language": "Python",
20
+ "default_branch": "main",
21
+ "stargazers_count": 42,
22
+ "forks_count": 10,
23
+ "open_issues_count": 5,
24
+ "watchers_count": 42,
25
+ "fork": False,
26
+ "archived": False,
27
+ "private": False,
28
+ "created_at": "2024-01-01T00:00:00Z",
29
+ "updated_at": "2024-06-15T12:00:00Z",
30
+ "pushed_at": "2024-06-15T11:00:00Z",
31
+ "license": {"name": "MIT License"},
32
+ "topics": ["python", "automation"],
33
+ "size": 1024,
34
+ }
35
+
36
+
37
+ def _mock_response(status_code: int = 200, json_data: dict | None = None, text: str = "") -> httpx.Response:
38
+ response = httpx.Response(
39
+ status_code=status_code,
40
+ json=json_data if json_data is not None else SAMPLE_REPO_RESPONSE,
41
+ request=httpx.Request("GET", "https://api.github.com/repos/octocat/my-repo"),
42
+ )
43
+ return response
44
+
45
+
46
+ @pytest.fixture
47
+ def node():
48
+ return GitHubGetRepoNode()
49
+
50
+
51
+ # --- parse_repo_identifier tests ---
52
+
53
+
54
+ class TestParseRepoIdentifier:
55
+ def test_owner_repo(self):
56
+ assert parse_repo_identifier("octocat/my-repo") == ("octocat", "my-repo")
57
+
58
+ def test_full_url(self):
59
+ assert parse_repo_identifier("https://github.com/octocat/my-repo") == ("octocat", "my-repo")
60
+
61
+ def test_full_url_with_git_suffix(self):
62
+ assert parse_repo_identifier("https://github.com/octocat/my-repo.git") == ("octocat", "my-repo")
63
+
64
+ def test_full_url_trailing_slash(self):
65
+ assert parse_repo_identifier("https://github.com/octocat/my-repo/") == ("octocat", "my-repo")
66
+
67
+ def test_http_url(self):
68
+ assert parse_repo_identifier("http://github.com/octocat/my-repo") == ("octocat", "my-repo")
69
+
70
+ def test_with_whitespace(self):
71
+ assert parse_repo_identifier(" octocat/my-repo ") == ("octocat", "my-repo")
72
+
73
+ def test_dots_and_underscores(self):
74
+ assert parse_repo_identifier("my.org/my_repo.js") == ("my.org", "my_repo.js")
75
+
76
+ def test_invalid_empty(self):
77
+ with pytest.raises(ValueError, match="Invalid GitHub repository"):
78
+ parse_repo_identifier("")
79
+
80
+ def test_invalid_no_slash(self):
81
+ with pytest.raises(ValueError, match="Invalid GitHub repository"):
82
+ parse_repo_identifier("just-a-name")
83
+
84
+ def test_invalid_too_many_segments(self):
85
+ with pytest.raises(ValueError, match="Invalid GitHub repository"):
86
+ parse_repo_identifier("https://github.com/a/b/c/d")
87
+
88
+
89
+ # --- Node execution tests ---
90
+
91
+
92
+ class TestGitHubGetRepoNode:
93
+ async def test_success_with_owner_repo(self, node, mock_context):
94
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
95
+ mock_client = AsyncMock()
96
+ mock_client.get = AsyncMock(return_value=_mock_response())
97
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
98
+ mock_client.__aexit__ = AsyncMock(return_value=False)
99
+ mock_client_cls.return_value = mock_client
100
+
101
+ result = await node.execute({"repository": "octocat/my-repo"}, mock_context)
102
+
103
+ assert result.full_name == "octocat/my-repo"
104
+ assert result.owner == "octocat"
105
+ assert result.name == "my-repo"
106
+ assert result.stargazers_count == 42
107
+ assert result.language == "Python"
108
+ assert result.topics == ["python", "automation"]
109
+ assert result.license_name == "MIT License"
110
+
111
+ async def test_success_with_full_url(self, node, mock_context):
112
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
113
+ mock_client = AsyncMock()
114
+ mock_client.get = AsyncMock(return_value=_mock_response())
115
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
116
+ mock_client.__aexit__ = AsyncMock(return_value=False)
117
+ mock_client_cls.return_value = mock_client
118
+
119
+ result = await node.execute({"repository": "https://github.com/octocat/my-repo"}, mock_context)
120
+
121
+ assert result.full_name == "octocat/my-repo"
122
+
123
+ async def test_with_credential(self, node, mock_context):
124
+ mock_context._credentials["cred_1"] = {"personal_access_token": "ghp_test123"}
125
+
126
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
127
+ mock_client = AsyncMock()
128
+ mock_client.get = AsyncMock(return_value=_mock_response())
129
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
130
+ mock_client.__aexit__ = AsyncMock(return_value=False)
131
+ mock_client_cls.return_value = mock_client
132
+
133
+ result = await node.execute({"repository": "octocat/my-repo", "credential_id": "cred_1"}, mock_context)
134
+
135
+ assert result.full_name == "octocat/my-repo"
136
+ # Verify auth header was sent
137
+ call_kwargs = mock_client.get.call_args
138
+ assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer ghp_test123"
139
+ # Verify secret was registered
140
+ assert "ghp_test123" in mock_context.get_secrets()
141
+
142
+ async def test_repo_not_found(self, node, mock_context):
143
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
144
+ mock_client = AsyncMock()
145
+ mock_client.get = AsyncMock(
146
+ return_value=_mock_response(status_code=404, json_data={"message": "Not Found"})
147
+ )
148
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
149
+ mock_client.__aexit__ = AsyncMock(return_value=False)
150
+ mock_client_cls.return_value = mock_client
151
+
152
+ with pytest.raises(RuntimeError, match="not found"):
153
+ await node.execute({"repository": "octocat/nonexistent"}, mock_context)
154
+
155
+ async def test_rate_limit(self, node, mock_context):
156
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
157
+ mock_client = AsyncMock()
158
+ mock_client.get = AsyncMock(
159
+ return_value=_mock_response(status_code=403, json_data={"message": "rate limit exceeded"})
160
+ )
161
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
162
+ mock_client.__aexit__ = AsyncMock(return_value=False)
163
+ mock_client_cls.return_value = mock_client
164
+
165
+ with pytest.raises(RuntimeError, match="rate limit"):
166
+ await node.execute({"repository": "octocat/my-repo"}, mock_context)
167
+
168
+ async def test_null_optional_fields(self, node, mock_context):
169
+ """Repo with null language, no license, no topics."""
170
+ data = {**SAMPLE_REPO_RESPONSE, "language": None, "license": None, "topics": None, "pushed_at": None}
171
+
172
+ with patch("fw_nodes_github.nodes.get_repo.httpx.AsyncClient") as mock_client_cls:
173
+ mock_client = AsyncMock()
174
+ mock_client.get = AsyncMock(return_value=_mock_response(json_data=data))
175
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
176
+ mock_client.__aexit__ = AsyncMock(return_value=False)
177
+ mock_client_cls.return_value = mock_client
178
+
179
+ result = await node.execute({"repository": "octocat/my-repo"}, mock_context)
180
+
181
+ assert result.language == ""
182
+ assert result.license_name == ""
183
+ assert result.topics == []
184
+ assert result.pushed_at == ""