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.
- fw_nodes_github-0.0.1a1/.forgejo/workflows/tag-release.yml +41 -0
- fw_nodes_github-0.0.1a1/.gitignore +41 -0
- fw_nodes_github-0.0.1a1/PKG-INFO +33 -0
- fw_nodes_github-0.0.1a1/README.md +19 -0
- fw_nodes_github-0.0.1a1/fw_nodes_github/__init__.py +1 -0
- fw_nodes_github-0.0.1a1/fw_nodes_github/credentials.py +21 -0
- fw_nodes_github-0.0.1a1/fw_nodes_github/nodes/__init__.py +5 -0
- fw_nodes_github-0.0.1a1/fw_nodes_github/nodes/get_repo.py +158 -0
- fw_nodes_github-0.0.1a1/justfile +53 -0
- fw_nodes_github-0.0.1a1/pyproject.toml +54 -0
- fw_nodes_github-0.0.1a1/tests/__init__.py +1 -0
- fw_nodes_github-0.0.1a1/tests/conftest.py +107 -0
- fw_nodes_github-0.0.1a1/tests/test_get_repo.py +184 -0
|
@@ -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,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 == ""
|