Compare commits

..

47 Commits

Author SHA1 Message Date
zhenyi c6f99fff47 refactor: extract GitksServer / GitksConfig as library, thin main.rs
- Add GitksConfig struct with from_env() for explicit config
- Add GitksServer / GitksServerBuilder to lib.rs
- Move tracing init, disk cache, metrics, hooks setup into builder
- Expose serve() / serve_with_shutdown() for embedding
- main.rs now only handles etcd overlay and delegates to library
2026-06-12 21:36:57 +08:00
zhenyi 96b391ff2d chore: add AGENTS.md 2026-06-12 17:20:41 +08:00
zhenyi 0e13f90834 style(format): reformat code with consistent line breaks and spacing 2026-06-12 15:11:32 +08:00
zhenyi 6f40921576 refactor(metrics): simplify response building with unwrap_or_else
- Replace explicit match statement with unwrap_or_else for cleaner error handling
- Maintain same error logging behavior when response building fails
- Reduce code complexity and improve readability
- Keep identical fallback response creation on builder errors
2026-06-12 15:10:00 +08:00
zhenyi 44fe5a519b chore(copyright): add copyright headers to all source files 2026-06-12 15:06:39 +08:00
zhenyi 70f2f7d63d feat(config): add centralized configuration constants and infrastructure tests
- Introduce config.rs with all magic numbers and resource limits defined as constants
- Add comprehensive test suite covering metrics rendering, rate limiting, and cache operations
- Include tests for configuration constant validation and sanitization functions
- Add pack protocol tests for index_pack and pack_objects functionality
- Implement remote repository discovery tests with security validations
- Support runtime overrides via environment variables for all configurable values
2026-06-12 15:04:29 +08:00
zhenyi 10a4398e81 refactor(bare): enhance security and performance optimizations
- Remove unnecessary sorting in advertise_refs for deterministic output
- Add path traversal detection and validation in bare_dir construction
- Implement symlink resolution checks to prevent security vulnerabilities
- Refactor cache system with CRC validation and improved metrics
- Integrate repo-specific cache invalidation using indexed keys
- Add comprehensive unit tests for commit operations and diff functionality
- Move configuration constants to centralized config module
- Optimize string operations in disk cache random value generation
- Enhance license detection algorithm with cleaner matching logic
- Streamline argument processing in various git operations
- Update dependencies including crc32fast and flate2 for performance
- Add signal handling capability to tokio runtime configuration
2026-06-12 15:04:12 +08:00
zhenyi e386f44ee2 feat(git): add size limits for git operations
- Added MAX_CHERRY_PICK_PATCH_BYTES limit of 100MB for cherry-pick operations
- Added MAX_ACTION_CONTENT_BYTES limit of 100MB for commit action content
- Added MAX_COMMIT_MESSAGE_BYTES limit of 10MB for commit messages
- Added MAX_CHECK_REVISIONS limit of 10,000 for revision checks
- Added MAX_REBASE_COMMITS limit of 10,000 for rebase operations
- Added MAX_REBASE_PATCH_BYTES limit of 100MB for rebase patches
- Added MAX_RESOLUTION_CONTENT_BYTES limit of 100MB for merge conflict resolutions
- Added MAX_REVERT_PATCH_BYTES limit of 100MB for revert operations
- Return InvalidArgument error when size limits are exceeded with descriptive messages
2026-06-12 12:59:50 +08:00
zhenyi 293102e5f2 feat(git): add size limits for git operations
- Added MAX_CHERRY_PICK_PATCH_BYTES limit of 100MB for cherry-pick operations
- Added MAX_ACTION_CONTENT_BYTES limit of 100MB for commit action content
- Added MAX_COMMIT_MESSAGE_BYTES limit of 10MB for commit messages
- Added MAX_CHECK_REVISIONS limit of 10,000 for revision checks
- Added MAX_REBASE_COMMITS limit of 10,000 for rebase operations
- Added MAX_REBASE_PATCH_BYTES limit of 100MB for rebase patches
- Added MAX_RESOLUTION_CONTENT_BYTES limit of 100MB for merge conflict resolutions
- Added MAX_REVERT_PATCH_BYTES limit of 100MB for revert operations
- Return InvalidArgument error when size limits are exceeded with descriptive messages
2026-06-12 12:59:47 +08:00
zhenyi 934858bebf refactor(cache): redesign cache system with structured keys and improved performance
- Add repo_path parameter to cached_response and cached_vec_response functions
- Implement structured cache key format with namespace, repo_path, and request proto
- Replace global cache with Moka in-memory cache using weight-based eviction
- Set 256MB memory cap with 10-minute TTL and 2-minute TTI policy
- Add metrics collection for cache operations and evictions
- Implement efficient repo-scoped invalidation using key structure
- Add detailed documentation comments explaining cache architecture
- Remove outdated dependencies and update dependency versions
- Add error handling for encoding failures in cache operations
- Optimize Vec responses with length-delimited encoding and pre-allocation
2026-06-12 12:53:23 +08:00
zhenyi a40da90ef9 refactor(build): reformat code and add tonic health dependency
- Reformatted build script with proper indentation and line breaks
- Added tonic-health dependency to Cargo.toml and updated lock file
- Improved error handling in disk cache with concurrent deletion checks
- Refactored conditional chains using && and let expressions
- Reformatted struct initialization and function parameter lists
- Added proper spacing and alignment in language stats processing
- Improved assertion formatting in test cases
- Reorganized import statements and code layout in multiple files
- Updated metrics functions with better parameter handling and formatting
2026-06-11 13:56:15 +08:00
zhenyi c32a7cad2f feat(cluster): implement Raft consensus with tracing and HTTP support
- Add Raft log and snapshot mechanisms for distributed consensus
- Integrate hyper HTTP server and client libraries for network communication
- Enhance tracing capabilities with structured logging and spans
- Add dependency tracking for new consensus-related crates
- Implement snapshot storage with serialization and persistence
- Add remote repository synchronization via Raft commands
- Include comprehensive tracing instrumentation across services
2026-06-10 18:33:42 +08:00
zhenyi 0207cde234 chore(disk-cache): remove unnecessary doc comment on random_value 2026-06-10 18:32:52 +08:00
zhenyi 2dd384f7be fix(server): add periodic route cache cleanup
Add cleanup_route_cache() and a 120s background cleanup task to
prevent unbounded DashMap growth from stale route entries.
Fix init_tracing to return WorkerGuard so the file appender stays
alive for the program lifetime.
2026-06-10 18:32:47 +08:00
zhenyi 0782a9fe6d fix(refs): write stdin data to git update-ref subprocess
Use spawn + write_all + wait_with_output instead of output() so that
the constructed ref update commands are actually written to the
subprocess stdin.
2026-06-10 18:32:42 +08:00
zhenyi 1dca8b3b78 fix(raft): add boundary checks and parse warnings to message deserialization
Add offset+length bounds checking to AppendEntriesRequest.from_bytes
to prevent slice panic. Cap entry count to 10000. Emit tracing::warn
when critical term fields fail to parse in Election and RoleChanged
messages.
2026-06-10 18:32:37 +08:00
zhenyi 49c1675e01 fix(raft): truncate log on AppendEntries term conflict
Use truncate_from() instead of skip when a follower detects a term
mismatch, correctly deleting conflicting entries and all that follow
per the Raft protocol.
2026-06-10 18:32:33 +08:00
zhenyi bcd750b905 feat(raft): add log truncation for AppendEntries conflict resolution
Add truncate_from() method to RaftLog for removing entries from a given
index onwards, as required by the Raft protocol when a follower detects
a term mismatch during AppendEntries.
2026-06-10 18:32:22 +08:00
zhenyi c8729d38bc fix(rate-limit): avoid over-admission when updating max_concurrent
Only replace semaphores that have no active permits (idle repos).
Previously all semaphores were replaced, allowing active permits from
old semaphores plus full permits from new ones simultaneously.
2026-06-10 18:32:16 +08:00
zhenyi c3017a255f fix(search): use fixed-string matching to prevent ReDoS
Add -F flag to git grep to disable regex interpretation, preventing
catastrophic backtracking from malicious query patterns.
2026-06-10 18:32:10 +08:00
zhenyi c9c1a739fd fix(hooks): shell-escape script values and use glob iteration
Add shell_escape() to safely embed values in generated shell scripts.
Replace '$(ls ...)' anti-pattern with glob iteration to correctly
handle filenames containing spaces.
2026-06-10 18:32:05 +08:00
zhenyi 45c00b2dee fix(metrics): escape Prometheus label values and remove section dividers
Add prom_escape() to sanitize label values per Prometheus text format spec.
Remove '// ──' section dividers per project style guidelines.
2026-06-10 18:31:59 +08:00
zhenyi e582b269f1 fix(server): validate branch name in set_default_branch
Call validate_ref_name on the user-provided branch name before
constructing the symbolic-ref argument to prevent command injection.
2026-06-10 18:31:54 +08:00
zhenyi 0665772079 fix(remote): validate remote URL, name and refspecs
Call validate_remote_url, validate_ref_name, and validate_refspec before
passing user input to git subprocess in update_remote_mirror, fetch_remote,
and create_repository_from_url.
2026-06-10 18:31:50 +08:00
zhenyi 1c22700769 feat(sanitize): add remote URL and refspec validation
Add validate_remote_url() to reject non-transport schemes (file://, ext::)
and validate_refspec() to reject shell metacharacters in refspec strings.
2026-06-10 18:31:46 +08:00
zhenyi e4080dcbc7 chore: remove .env from git tracking
Add .env to .gitignore, unstage tracked .env, and create .env.example
as a template for local development.
2026-06-10 18:31:36 +08:00
zhenyi 939931acad feat(repository): add language statistics analysis feature
- Remove data directory from gitignore to include language data
- Add build script to parse linguist languages.yml and generate static mappings
- Include serde and serde_yml dependencies for YAML parsing
- Add lang_stats module with language detection and statistics calculation
- Generate protobuf definitions for language statistics API endpoints
- Implement GetLanguageStats RPC endpoint in repository server
- Add comprehensive test suite for language statistics functionality
- Include extension and filename based language detection logic
- Implement binary file classification and group resolution features
2026-06-10 13:06:59 +08:00
zhenyi 9a0c26e5f6 refactor(actor): implement Raft consensus algorithm for cluster leader election
- Add voting mechanism with term tracking and vote persistence
- Implement election triggering logic with majority vote counting
- Add primary/replica role transition handling with state management
- Integrate health check failure detection for automatic elections
- Refactor actor messaging system for distributed coordination
- Update repository registration to query cluster for existing primary
- Add broadcast mechanism for role change notifications
- Implement proper term comparison and duplicate request filtering
- Upgrade dependency versions including tokio-util for async utilities
- Optimize code formatting and line wrapping for improved readability
- Remove redundant blank lines and improve code structure consistency
- Enhance error logging and trace information for debugging purposes
2026-06-10 12:35:10 +08:00
zhenyi ab32e8826e feat(pack): add raw advertise refs and stateless protocol support
- Add raw flag to AdvertiseRefsRequest to enable raw pkt-line output
- Implement advertise_refs_raw function that calls git upload-pack/receive-pack with --advertise-refs
- Add stateless flag to GitProtocolFeatures for HTTP smart protocol support
- Modify upload_pack and receive_pack to accept stateless parameter
- Update command construction to include --stateless-rpc flag when enabled
- Add raw_data field to AdvertiseRefsResponse for raw output
- Update pack cache key computation to include raw service differentiation
- Initialize raw field to false in all request creation calls
2026-06-08 21:46:31 +08:00
zhenyi eeb4d9f902 feat(system): add systemd service and installation script
- Add gitks systemd service unit file with security sandboxing
- Create environment configuration template for gitks service
- Add logrotate configuration for gitks application logs
- Implement installation script with service user creation
- Set up proper directory permissions and file ownership
- Configure automatic service startup and systemd integration
2026-06-08 21:27:54 +08:00
zhenyi c2487ec0b6 feat(charts): add Helm chart for gitks Git bare repository service
- Create Chart.yaml with application metadata and keywords
- Add _helpers.tpl with name, fullname, labels, and DNS template functions
- Generate ConfigMap with all gitks configuration environment variables
- Implement StatefulSet with persistent volume claims for repository data
- Create headless service for pod DNS and cluster communication
- Add gRPC service for client connections and metrics service
- Include HorizontalPodAutoscaler for dynamic scaling
- Add PodDisruptionBudget to maintain cluster availability
- Create ServiceAccount with proper security context
- Add test connection pod using grpcurl for health checks
- Generate NOTES.txt with installation instructions and quick start guide
- Create .helmignore file to exclude common development files
- Configure persistence, resource limits, and security settings
- Add support for cluster mode with etcd service discovery
2026-06-08 21:21:15 +08:00
zhenyi f5044fb099 refactor(docker): optimize Docker build process and update configurations
- Replace direct Rust build with cargo-chef multi-stage build pattern
- Switch base image from debian:bookworm-slim to ubuntu:26.04
- Add .codegraph to .dockerignore and data to .gitignore
- Introduce Dockerfile.fast for faster builds without optimization
- Add comprehensive .env configuration file with cluster settings
- Create docker-compose.yaml for multi-node cluster setup
- Add cluster routing test case for distributed operations
- Remove unnecessary success status checks in repository maintenance
- Fix error handling in git command executions by properly propagating errors
- Add repository move protection to prevent self-destruct operations
- Simplify conditional logic in actor message validation
- Update remote pack client calls with proper error handling parameters
2026-06-08 18:52:22 +08:00
zhenyi 66afd932ed feat(api): extend commit and diff services with new functionality
- Add FindCommit, ListCommitsByOid, CommitIsAncestor RPCs to CommitService
- Add CheckObjectsExist, CommitsByMessage, GetCommitStats RPCs to CommitService
- Add LastCommitForPath, CountCommits, CountDivergingCommits RPCs to CommitService
- Add RawDiff, RawPatch, FindChangedPaths RPCs to DiffService
- Add FindMergeBase, WriteRef, SearchFilesByContent RPCs to RepositoryService
- Add SearchFilesByName, ObjectsSize, RepositorySize RPCs to RepositoryService
- Add FindLicense, OptimizeRepository, GetRawChanges RPCs to RepositoryService
- Add FetchRemote, CreateRepositoryFromURL RPCs to RepositoryService
- Implement server handlers for all new RPC methods
- Add new modules for commit counting, finding, and querying features
- Add new modules for diff changed paths and raw operations
- Add new modules for refs and remote operations
- Remove unnecessary comments from various source files
- Update proto definitions with new message types and service methods
2026-06-08 15:37:08 +08:00
zhenyi 8f472a0443 feat(cluster): implement distributed clustering with etcd coordination
- Integrate etcd-client for distributed coordination and leader election
- Add remote client macros with proper formatting for all services
- Implement RequestMetrics for tracking RPC performance and errors
- Add rate limiting mechanism across all service endpoints
- Create ElectionRequest and ElectionResult message types for leader election
- Add role management with primary/replica switching capabilities
- Implement health checker with automatic failover detection
- Add repository count metrics for cluster monitoring
- Update Cargo.toml with etcd-client and dashmap dependencies
- Modify RepoEntry to include read_only flag for replica handling
- Implement should_accept_election logic to prevent duplicate elections
- Add RoleChangedEvent handling for cluster role updates
2026-06-08 14:31:29 +08:00
zhenyi d243dce027 refactor(server): replace custom remote clients with macro-based implementation
- Replaced manual remote client functions with remote_client! macro for archive, blame, branch, commit, and diff services
- Simplified remote client creation logic using declarative macro approach
- Maintained same functionality while reducing code duplication across services

security(bare): enhance path traversal protection with comprehensive validation

- Added early relative_path validation to prevent path traversal attacks
- Implemented unified path validation to avoid TOCTOU race conditions
- Enhanced canonicalization checks for both existing and non-existent paths
- Added detailed logging for path traversal detection attempts

feat(cache): migrate from CLruCache to Moka with TTL and invalidation support

- Replaced clru dependency with moka for improved caching capabilities
- Added 300-second time-to-live for cache entries
- Implemented repository-specific cache invalidation mechanism
- Enhanced cache operations with thread-safe async support

refactor(commit): improve security validation for commit operations

- Added ref name validation to prevent command injection in cherry_pick_commit
- Implemented revision validation for commit selectors
- Added comprehensive input validation for create_commit parameters
- Enhanced file path validation to prevent traversal
2026-06-08 09:43:57 +08:00
zhenyi 8c95eb230d refactor(actor): implement replica sync and ref update notification system
- Add is_write parameter to remote clients for read/write routing distinction
- Introduce RepoEntry struct with role tracking (primary/replica) for repositories
- Replace HashSet with HashMap for repository storage with role metadata
- Add ROLE_PRIMARY and ROLE_REPLICA constants for node role identification
- Implement FindPrimary and FindReplica RPC methods for role-based routing
- Add RefUpdateEvent message type for propagating reference updates
- Create sync module with BundleApplicator for handling replica synchronization
- Implement notify_ref_update calls after branch/tag/commit operations
- Add broadcast_ref_update function to propagate events across cluster nodes
- Modify route_repository to prioritize primary for writes and replicas for reads
- Update actor message handling to support role-based repository discovery
- Implement sync_from_primary function using pack protocol for incremental updates
2026-06-08 01:54:08 +08:00
zhenyi 5c99b27421 feat(gateway): implement remote service forwarding for distributed git operations
- Add remote client functions for archive, blame, and branch services
- Implement fallback logic to forward requests to remote storage nodes
- Add logging for forwarding operations with route details
- Update Cargo.lock with new dependencies including ractor cluster libraries
- Extend .gitignore with IDE and build system files
- Remove outdated comments from bare repository implementation
2026-06-08 01:21:20 +08:00
zhenyi 5b740eecd7 chore(deps): update dependencies and migrate to tonic-prost
- Replace tonic-build with tonic-prost-build in build dependencies
- Update clru from 0.6.3 to 0.6
- Update serde from 1.0.228 to 1.0
- Update gix from 0.84.0 to 0.84
- Update gix-archive from 0.33.0 to 0.33
- Update duct from 1.1.1 to 1.0
- Update tracing from 0.1.32 to 0.1
- Update tokio-stream from 0.1.18 to 0.1
- Update thiserror from 2.0.18 to 2.0
- Update prost from 0.13 to 0.14
- Update prost-types from 0.13 to 0.14
- Update tonic from 0.12 to 0.14 with transport feature
- Add tonic-prost dependency at version 0.14
- Update tonic-prost-build in build dependencies from 0.12 to 0.14
- Remove async-stream and async-stream-impl dependencies
- Update axum from 0.7.9 to 0.8.9
- Update various other transitive dependencies including getrandom, indexmap, hashbrown, socket2, log, matchit, petgraph, tower, and windows-sys related packages
2026-06-04 18:07:17 +08:00
zhenyi a815f63927 chore: update gitignore and remove ide configuration files
- Add .idea directory to .gitignore
- Remove .idea/.gitignore file
- Remove .idea/gitks.iml module file
- Remove .idea/modules.xml project modules configuration
- Remove .idea/vcs.xml version control system mapping
- Clean up IDE-specific configuration files from repository
2026-06-04 17:48:17 +08:00
zhenyi 7631e57f69 test(bare): add comprehensive tests for GitBare functionality
- Add test_from_header_valid to verify valid repository header parsing
- Add test_from_header_empty_path to handle empty path scenarios
- Add test_from_header_relative_storage_path to validate absolute paths
- Add test_from_header_relative_path_without_storage for missing storage
- Add test_from_header_nonexistent_repo to check repo existence
- Add test_from_header_path_traversal to prevent directory traversal
- Add test_from_header_not_a_directory for file instead of directory
- Add test_from_header_dir_without_head to verify bare repository format
- Add test_object_format to validate object format detection
- Add test_oid_to_pb to verify OID conversion functionality
- Add test_oid_to_pb_invalid_hex to handle invalid hex input gracefully

test(error): add comprehensive error handling tests

- Add test_error_display_variants to verify error message formatting
- Add test_error_is_debug to
2026-06-04 15:33:33 +08:00
zhenyi cc202d6d1f feat(server): add tracing spans and caching to archive and blame services
- Add tracing spans with repo labels for archive and blame operations
- Implement caching for archive list entries when using OID selectors
- Implement caching for blame operations when using OID selectors
- Add detailed
2026-06-04 15:33:16 +08:00
zhenyi 729604f13b feat(server): add repository prefix path configuration and service struct
- Add REPO_PREFIX_PATH environment variable support in Dockerfile and main.rs
- Introduce GitksService struct with repo_prefix field to manage repository paths
- Implement resolve and resolve_for_init methods for repository path handling
- Add path traversal protection and validation for repository operations
- Update all service implementations to use self.resolve instead of global resolve
- Modify serve function to accept repo_prefix parameter and pass to GitksService
- Remove global resolve functions and integrate them into GitksService struct
- Add proper initialization of repo directory from environment variable
2026-06-04 14:18:12 +08:00
zhenyi 4a87ea475d feat(build): add Docker support for gitks application
- Add .dockerignore file to exclude unnecessary files from Docker build context
- Create multi-stage Dockerfile with rust builder and debian runtime stages
- Configure environment variables for host and port settings
- Set up proper EXPOSE and ENTRYPOINT instructions
- Optimize build with cargo release and binary stripping
- Install only required dependencies in production stage
2026-06-04 14:11:55 +08:00
zhenyi f0a443932a feat(server): add gitks gRPC server with environment configuration
- Add dotenvy dependency for environment variable loading
- Integrate tracing-subscriber for structured logging
- Create main.rs entry point with async server initialization
- Implement environment-based host and port configuration
- Set default address to 0.0.0:50051 for gRPC server
- Add proper error handling with Box<dyn std::error::Error>
2026-06-04 14:10:29 +08:00
zhenyi 998f393ed0 feat(server): add comprehensive Git repository services with test coverage
- Implement ArchiveService for repository archive operations
- Add BlameService for Git blame functionality
- Create BranchService with full branch management capabilities
- Integrate CommitService for commit operations and history
- Add DiffService for generating diffs and patches
- Implement MergeService with conflict resolution features
- Add PackService for Git packfile operations
- Create TagService for Git tag management
- Add TreeService for Git tree operations
- Implement comprehensive repository management functions
- Add repository statistics and health checking capabilities
- Include garbage collection and repacking operations
- Add repository configuration management
- Implement error handling and status conversion utilities
- Add test suite covering all repository operations
- Create utility functions for Git command execution
- Add streaming response support for large data operations
- Implement request resolution and validation helpers
2026-06-04 14:10:21 +08:00
zhenyi 737e934043 feat(tree): add recent commit metadata and LFS support to file metadata
- Added RecentCommit message definition with oid, subject and timestamp fields
- Extended TreeEntry, Tree, and FileMetadata messages with is_lfs and recent_commit fields
- Updated get_file_metadata function to include recent commit information
- Added tree module import and recent_commit lookup functionality
- Updated protobuf definitions to include new metadata fields
- Enhanced file metadata response with LFS status and commit history
2026-06-04 13:47:46 +08:00
zhenyi dcb0fb74c5 feat(core): implement Git repository operations with gRPC services
- Add advertise_refs functionality for Git protocol communication
- Implement archive service with TAR/ZIP format support and streaming
- Create blame service for Git file annotation with line tracking
- Add branch management including create, delete, rename and compare operations
- Implement merge checking with conflict detection and fast-forward handling
- Add cherry-pick functionality for applying commits between branches
- Integrate gix library for Git repository operations and object handling
- Add comprehensive test suite covering all Git operations
- Implement proper error handling and repository validation
- Add pagination support for large result sets
- Create protobuf definitions for all Git operations and data structures
- Add build system for gRPC code generation and dependency management
2026-06-04 13:05:38 +08:00
760 changed files with 44777 additions and 93880 deletions
View File
+7
View File
@@ -0,0 +1,7 @@
.codegraph
target
.git
.idea
*.md
LICENSE
.env
+23
View File
@@ -0,0 +1,23 @@
REPO_PREFIX_PATH=/home/zhenyi/RustroverProjects/gitks/data
GITKS_HOST=0.0.0.0
GITKS_PORT=50051
GITKS_ADVERTISE_ADDR=http://gitks-node1:50051
GITKS_METRICS_PORT=9100
GITKS_DISK_CACHE_ENABLED=false
GITKS_DISK_CACHE_MAX_AGE=300
GITKS_PACK_CACHE_ENABLED=true
GITKS_PACK_CACHE_BACKPRESSURE=true
GITKS_RATE_LIMIT_MAX_CONCURRENT=100
GITKS_HOOKS_ENABLED=true
GITKS_HOOK_TIMEOUT=30
GITKS_ALLOW_CUSTOM_HOOKS=true
#GITKS_SERVER_HOOKS_DIR=/etc/gitks/hooks
GITKS_HOOK_CALLBACK_ADDR=http://localhost:50052
GITKS_ETCD_ENDPOINTS=http://localhost:2379
GITKS_CLUSTER_PORT=4697
GITKS_CLUSTER_COOKIE=gitks-default-cookie
GITKS_LEASE_TTL=15
GITKS_ETCD_CONNECT_TIMEOUT=5000
GITKS_HEALTH_CHECK_INTERVAL=1
GITKS_MAX_HEALTH_FAILURES=10
STORAGE_NAME=default
+5 -5
View File
@@ -1,8 +1,8 @@
/target /target
.idea .idea
.codegraph .codegraph
.claude .classpath
.env* .project
!.env.example .settings
AGENT.md .DS_Store
CLAUDE.md .env
-10
View File
@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+161
View File
@@ -0,0 +1,161 @@
# AGENTS.md — Development Guidelines
> Unified development guidelines for all AI coding assistants (Claude Code, Cursor, etc.)
**Last Updated**: 2026-06-11
---
## 1. Language
Always respond in **Chinese (中文)**. Code, commands, and technical terms remain in English.
---
## 2. Code Style
### 2.1 Basic Principles
- Follow existing project conventions
- Use meaningful variable names
- Keep functions under **50 lines**
- Maximum nesting depth: **3 levels**
- Add comments for complex logic only
### 2.2 Rust Best Practices
- Use `?` operator; never use `unwrap()` in non-test code
- Avoid `unsafe`; if necessary, add `// SAFETY:` comment
- Minimize `clone()`; prefer references
- No magic numbers; use named constants
- No hardcoded strings; use enums or constants
### 2.3 Import Order
```rust
// std → third-party crates → local modules
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
```
---
## 3. Forbidden Patterns
- `// ── xxxx ──────────` — no divider comments
- `unwrap()` / `expect()` in non-test code — use `?` instead
- `panic!()` / `unreachable!()` — use error types instead
- Untracked `todo!()` — must have a corresponding issue
- Commented-out code — use Git history instead
- Nesting depth ≥ 4 — flatten with early return
- Functions > 50 lines — split into smaller functions
- Magic numbers — define named `const`
- Hardcoded strings — use enums or constants
---
## 4. Error Handling
### 4.1 Principles
- Handle all errors explicitly; no silent failures
- Log internal errors; keep user-facing messages helpful
- Add context with `.context()` or `.map_err()`
### 4.2 Log Format
```rust
tracing::error!(
error = %err,
operation = "operation_name",
"Failed to perform operation"
);
```
---
## 5. Security
- Never hardcode secrets or API keys
- Always validate and sanitize user input
- Use parameterized queries (no SQL injection)
- Use proper password hashing (Argon2, bcrypt)
---
## 6. Workflow
### 6.1 Development Flow
1. **Read before write** — understand context first
2. **Minimal changes** — don't refactor unrelated code
3. **Verify after changes** — run tests or check output
### 6.2 AI Assistant Rules
- Always read existing code before making changes
- Make minimal changes
- Run `cargo check` or `cargo test` after changes
- Explain what you changed and why
### 6.3 Common Commands
```bash
cargo build # Build
cargo check # Quick syntax check
cargo test # Run tests
cargo clippy # Lint
cargo fmt # Format
```
---
## 7. Git Workflow
### 7.1 Commit Message Format
```
<type>(<scope>): <subject>
[optional body]
[optional footer]
```
Types: `feat` · `fix` · `refactor` · `docs` · `test` · `chore`
### 7.2 Commit Principles
- Each commit addresses one concern (atomic)
- Each commit leaves the codebase in a working state
- Never force push to `main`
---
## Appendix: Architecture Overview
```
gitks — Git Repository Operations Service
actor/ → Actor model
archive/ → Archive operations
blame/ → Blame operations
blob/ → Blob objects
branch/ → Branch operations
commit/ → Commit operations
diff/ → Diff operations
merge/ → Merge operations
pack/ → Pack operations
refs/ → Reference management
remote/ → Remote operations
repository/ → Repository operations
server/ → gRPC server
```
---
*For questions or suggestions, please open an issue.*
+96
View File
@@ -0,0 +1,96 @@
# Gitks Security Best Practices
This document outlines security best practices for the gitks project.
## Input Validation
### Revision Strings
All revision strings (branch names, commit hashes, refs) are validated using `sanitize::validate_revision()`:
- Prevents command injection via `~N` and `^N` operators
- Limits revision string length to 256 characters
- Limits ancestry depth to 10000 to prevent DoS attacks
- Validates branch name characters to prevent shell metacharacter injection
### File Paths
File paths are validated using `sanitize::validate_file_path()`:
- Rejects absolute paths
- Blocks path traversal attacks (`..`)
- Prevents null byte injection
- Blocks `.git` directory access
- On Windows, blocks reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
### Git Configuration Keys
Configuration keys are validated using `sanitize::validate_config_key()`:
- Blocks dangerous keys that could execute arbitrary commands (core.sshCommand, core.hooksPath)
- Blocks network-related keys (http.proxy, https.proxy, remote.*.url)
- Blocks credential helpers
- Only allows alphanumeric characters, dots, hyphens, and underscores
### Relative Paths
Relative paths are validated using `sanitize::validate_relative_path()`:
- Rejects absolute paths
- Blocks path traversal attacks (`..`)
## Path Security
### TOCTOU Prevention
Path validation uses a unified approach to prevent Time-Of-Check-Time-Of-Use vulnerabilities:
1. Canonicalize the path if it exists
2. If path doesn't exist, validate parent directory and filename separately
3. Verify canonical path starts with allowed prefix
4. Reject any path that escapes the allowed directory
### Cache Invalidation
Cache entries are invalidated when repositories are modified:
- Uses precise substring matching on relative path
- Invalidates all cache keys containing the modified repository path
- Prevents stale data from being served after modifications
## Message Decoding Security
### String Decoding
The `decode_strings()` function in `actor/message.rs` includes:
- Total message size limit (50MB)
- Individual string length limit (10MB)
- Overflow protection using `checked_add()`
- Graceful degradation on malformed data
## Cluster Registration
### Primary/Replica Role Assignment
When registering repositories in a cluster:
- Single node: registers as PRIMARY
- Multiple nodes: registers as REPLICA initially
- Final role determination happens at query time via `route_repository`
- This conservative approach prevents split-brain scenarios
## Testing
All security-critical functions have comprehensive unit tests:
- `tests/sanitize_test.rs`: Input validation tests
- `tests/macro_test.rs`: Revision resolution tests
- Tests cover both valid and malicious inputs
## Code Quality
- All code passes `cargo clippy --all-targets --all-features` with zero warnings
- Code is formatted with `cargo fmt`
- All tests pass with `cargo test`
- No known security vulnerabilities in dependencies (verified with `cargo deny`)
## Recommendations for Users
1. **Never trust user input**: Always validate revisions, paths, and config keys
2. **Use the sanitize module**: All user-provided strings should go through validation
3. **Keep dependencies updated**: Run `cargo update` regularly and check for security advisories
4. **Monitor logs**: Watch for validation failures which may indicate attack attempts
5. **Limit cluster size**: The cluster registration logic assumes a reasonable number of nodes
6. **Use HTTPS**: When deploying in production, use TLS for gRPC connections
7. **Audit configuration**: Regularly review which git config keys are allowed
## Reporting Security Issues
If you discover a security vulnerability, please report it responsibly by:
1. Creating a private security advisory
2. Providing detailed reproduction steps
3. Allowing maintainers time to address the issue before public disclosure
Generated
+1211 -3584
View File
File diff suppressed because it is too large Load Diff
+44 -49
View File
@@ -1,58 +1,53 @@
[package] [package]
name = "appks" name = "gitks"
version = "0.1.0" version = "1.0.0"
edition = "2024" edition = "2024"
authors = ["gitks contributors"]
description = "A gRPC-accessible Git repository operations library for bare repositories"
repository = "https://github.com/appks/gitks"
homepage = "https://github.com/appks/gitks"
license = "PolyForm-Noncommercial-1.0.0"
keywords = ["git", "grpc", "bare-repository", "gix"]
categories = ["development-tools"]
[lib] [lib]
name = "appks"
path = "lib.rs" path = "lib.rs"
name = "gitks"
[dependencies]
moka = { version = "0.12", default-features = false, features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
uuid = { version = "1", features = ["v7"] }
gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] }
gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] }
duct = { version = "1", features = [] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "net", "signal"] }
tokio-stream = { version = "0.1", features = ["full"] }
tokio-util = "0.7"
thiserror = { version = "2", features = [] }
prost = "0.14"
prost-types = "0.14"
tonic = { version = "0.14", features = ["transport", "gzip"] }
tonic-health = "0.14"
tonic-prost = "0.14"
tempfile = "3"
dotenvy = "0.15"
etcd-client = { version = "0.18", features = ["tls"] }
dashmap = "6"
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
http-body-util = "0.1"
bytes = "1"
crc32fast = "1"
[[bin]] [[bin]]
name = "appks" name = "gitks"
path = "main.rs" path = "main.rs"
[[bin]]
name = "gen_openapi"
path = "gen_openapi.rs"
[dependencies]
sqlx = { version = "0.9.0", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] }
tokio = { version = "1.52.3", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = [] }
chrono = { version = "0.4.19", features = ["serde"] }
uuid = { version = "1.23.1", features = ["serde","v4","v7","v5"] }
reqwest = { version = "0.13.4", features = ["json"] }
tracing = { version = "0.1.44", features = [] }
tracing-subscriber = { version = "0.3.23", features = ["fmt"] }
dotenvy = "0.15.7"
thiserror = "2"
redis = { version = "1.2.1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] }
dashmap = "6.1"
object_store = { version = "0.13.2", features = ["tokio","aws","cloud"] }
argon2 = "0.5"
rsa = "0.9"
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
base64 = "0.22"
rand = "0.8"
captcha-rs = "0.5"
tonic = { version = "0.14.6", features = ["transport", "channel"] }
prost = "0.14.3"
prost-types = "0.14.3"
tonic-prost = "0.14.6"
url = "2.5"
etcd-client = { version = "0.18.0", features = ["tls"] }
tokio-stream = "0.1"
async-nats = "0.49"
futures-util = "0.3"
utoipa = { version = "5.5.0", features = ["uuid","chrono","actix_extras","decimal","macros"]}
actix-web = { version = "4", features = ["secure-cookies"] }
actix-multipart = "0.7"
hex = "0.4.3"
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14.6" tonic-prost-build = "0.14"
serde_yml = "0.0.12"
serde = { version = "1", features = ["derive"] }
+32
View File
@@ -0,0 +1,32 @@
FROM rust:1.96-bookworm AS chef
RUN apt-get update && \
apt-get install -y --no-install-recommends \
protobuf-compiler libprotobuf-dev mold clang && \
rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin gitks && \
strip target/release/gitks
FROM ubuntu:26.04
RUN apt-get update && \
apt-get install -y --no-install-recommends git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/gitks /usr/local/bin/gitks
ENV GITKS_HOST=0.0.0.0
ENV GITKS_PORT=50051
ENV REPO_PREFIX_PATH=/data/repos
RUN mkdir -p /data/repos
EXPOSE 50051
ENTRYPOINT ["gitks"]
+17
View File
@@ -0,0 +1,17 @@
FROM ubuntu:26.04
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
rm -rf /var/lib/apt/lists/*
COPY target/release/gitks /usr/local/bin/gitks
ENV GITKS_HOST=0.0.0.0
ENV GITKS_PORT=50051
ENV REPO_PREFIX_PATH=/data/repos
RUN mkdir -p /data/repos
EXPOSE 50051
ENTRYPOINT ["gitks"]
+58
View File
@@ -0,0 +1,58 @@
PolyForm Noncommercial License 1.0.0
Copyright (c) 2024 gitks contributors
License: "Noncommercial" as defined below.
"Noncommercial" means primarily intended for or directed towards the
advantage or monetary gain of a business, commercial entity, or for-profit
organization. A use is "Noncommercial" if it is not primarily intended for
or directed towards commercial advantage or monetary compensation.
1. Grant of Copyright License. Subject to the terms of this license,
Licensor grants you a worldwide, royalty-free, non-exclusive, limited
license to exercise the Licensed Rights in the Licensed Material for
Noncommercial purposes only.
2. Grant of Patent License. Subject to the terms of this license, Licensor
grants you a worldwide, royalty-free, non-exclusive, limited license
under patent claims owned or controlled by Licensor that are embodied
in the Licensed Material as furnished by Licensor, to make, use, sell,
offer for sale, have made, and import the Licensed Material for
Noncommercial purposes only.
3. Limitations. The license granted in Section 1 and Section 2 above is
expressly limited to Noncommercial purposes. You may not exercise the
Licensed Rights for the purpose of providing services to third parties,
including but not limited to:
(a) offering the Licensed Material as a hosted or managed service
where third parties access or use the Licensed Material;
(b) offering the Licensed Material as part of a product or service
that is sold, licensed, or otherwise provided for monetary gain;
(c) using the Licensed Material to provide consulting, support, or
other services for monetary gain.
4. Acceptance. Any use of the Licensed Material in violation of this
license will automatically terminate your rights under this license
for the current and all future versions of the Licensed Material.
5. Patents. If you institute patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that
the Licensed Material constitutes direct or contributory patent
infringement, then any patent licenses granted to you under this
license for the Licensed Material shall terminate as of the date
such litigation is filed.
6. Disclaimer of Warranty. THE LICENSED MATERIAL IS PROVIDED "AS IS" AND
WITHOUT ANY WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES,
EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES
OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A
PARTICULAR PURPOSE.
7. Limitation of Liability. IN NO EVENT WILL LICENSOR BE LIABLE TO YOU
FOR ANY DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THESE TERMS OR IN CONNECTION
WITH THE USE OR INABILITY TO USE THE LICENSED MATERIAL, EVEN IF
LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
For the full license text, see: https://polyformproject.org/licenses/noncommercial/1.0.0
+7
View File
@@ -0,0 +1,7 @@
# gitks
A Git bare repository operation library based on gRPC.
## License
[PolyForm Noncommercial 1.0.0](LICENSE) — Free for noncommercial use. For commercial licenses, please contact us.
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/captcha",
tag = "Auth",
operation_id = "authGetCaptcha",
summary = "Get captcha image",
description = "Generate a one-time captcha image and store the plaintext captcha in the current session. Captchas are used for sensitive entry points such as login and sending registration email codes. Set rsa=true to return the current session RSA public key at the same time and reduce frontend initialization requests. The captcha is consumed after either successful or failed validation, so clients must fetch a new one after failure.",
params(
("w" = u32, Query, description = "Captcha image width; allowed range is 80..=400.", example = 160),
("h" = u32, Query, description = "Captcha image height; allowed range is 30..=200.", example = 64),
("dark" = bool, Query, description = "Whether to generate a dark-mode captcha.", example = false),
("rsa" = bool, Query, description = "Whether to include the RSA public key in the response.", example = true)
),
responses(
(status = 200, description = "Captcha generated successfully. The base64 field is image data that can be used directly as img.src.", body = ApiResponse<CaptchaResponse>),
(status = 400, description = "Invalid captcha size.", body = ApiErrorResponse),
(status = 500, description = "Session write failed or RSA initialization failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
query: web::Query<CaptchaQuery>,
) -> Result<HttpResponse, AppError> {
let data = service
.auth
.auth_captcha(&session, query.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Disable2FAParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/disable",
tag = "Auth",
operation_id = "authDisableTwoFactor",
summary = "Disable two-factor authentication",
description = "Disable TOTP two-factor authentication for the current signed-in user. This requires verifying both the current password and a valid TOTP code or backup code. password must be encrypted with the current session RSA public key; a successfully verified backup code is consumed.",
request_body(
content = Disable2FAParams,
description = "TOTP/backup code and the current password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "2FA has been disabled.", body = ApiEmptyResponse),
(status = 400, description = "2FA is not enabled, the verification code is incorrect, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Disable2FAParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_2fa_disable(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication disabled")))
}
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Enable2FAResponse;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/enable",
tag = "Auth",
operation_id = "authPrepareTwoFactorEnable",
summary = "Initialize two-factor authentication setup",
description = "Generate a new TOTP secret, otpauth QR-code URI, and 10 one-time backup codes for the current signed-in user, and save them in a not-yet-enabled state. Clients must guide the user to scan the QR code and call /auth/2fa/verify with a dynamic code before 2FA is actually enabled. Backup codes are returned in plaintext only once in this response; frontends must remind users to store them securely.",
responses(
(status = 200, description = "2FA setup initialized successfully. Returns the secret, QR-code URI, and backup codes.", body = ApiResponse<Enable2FAResponse>),
(status = 400, description = "The current user has already enabled 2FA.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_2fa_enable(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-28
View File
@@ -1,28 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Get2FAStatusResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/2fa/status",
tag = "Auth",
operation_id = "authGetTwoFactorStatus",
summary = "Get two-factor authentication status",
description = "Read the current signed-in user's TOTP two-factor authentication status, including whether it is enabled, the authentication method, and whether backup codes are still available.",
responses(
(status = 200, description = "Read successfully.", body = ApiResponse<Get2FAStatusResponse>),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_2fa_status(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-28
View File
@@ -1,28 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/email",
tag = "Auth",
operation_id = "authGetPrimaryEmail",
summary = "Get current user verified email",
description = "Return the verified primary email for the current signed-in user. If no verified email is bound to the account, the email field is null.",
responses(
(status = 200, description = "Read successfully.", body = ApiResponse<EmailResponse>),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_get_email(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::login::LoginParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "Auth",
operation_id = "authLogin",
summary = "Account login",
description = "Log in using a username or verified email. password must be a Base64 ciphertext encrypted with the public key returned by /auth/rsa; the first login attempt must include captcha. If the account has TOTP enabled, the first successful password check returns 400/two-factor required and records pending verification state in the session. Then submit username, password, and totp_code again in the same session to complete login. On success, the session is renewed, the current user is bound, and temporary RSA keys are cleared.",
request_body(
content = LoginParams,
description = "Login parameters. username accepts a username or email; password is an RSA-OAEP-SHA256 encrypted ciphertext; captcha is the captcha stored in the current session; totp_code is required only during the second verification step.",
content_type = "application/json"
),
responses(
(status = 200, description = "Login succeeded. The server establishes login state through the session cookie.", body = ApiEmptyResponse),
(status = 400, description = "Captcha error, RSA decryption failure, or missing/incorrect TOTP.", body = ApiErrorResponse),
(status = 404, description = "User does not exist or password is incorrect; to reduce enumeration risk, incorrect passwords are also treated as user-not-found.", body = ApiErrorResponse),
(status = 500, description = "Database, cache, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<LoginParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_login(params.into_inner(), session)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("login successful")))
}
-26
View File
@@ -1,26 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "Auth",
operation_id = "authLogout",
summary = "Log out",
description = "Clear the user identity and all temporary authentication data from the current session, including captcha, temporary RSA keys, and pending 2FA state. This endpoint is idempotent: unauthenticated users also receive a success response.",
responses(
(status = 200, description = "Logged out successfully, or the session was already unauthenticated.", body = ApiEmptyResponse),
(status = 500, description = "Session persistence failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
service.auth.auth_logout(&session).await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("logout successful")))
}
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::me::ContextMe;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "Auth",
operation_id = "authGetCurrentUser",
summary = "Get current signed-in user context",
description = "Return the current user's basic profile, preferred language, timezone, and notification summary using the user_uid bound to the session. This endpoint is typically used to restore the login state when the frontend app starts.",
responses(
(status = 200, description = "The current session is authenticated. Returns the user context.", body = ApiResponse<ContextMe>),
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
(status = 404, description = "The user in the session no longer exists, has been disabled, or has been deleted.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_me(session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-57
View File
@@ -1,57 +0,0 @@
pub mod captcha;
pub mod disable_2fa;
pub mod enable_2fa;
pub mod get_2fa_status;
pub mod get_email;
pub mod login;
pub mod logout;
pub mod me;
pub mod regenerate_2fa_backup_codes;
pub mod register;
pub mod register_email_code;
pub mod request_email_change;
pub mod request_reset_password;
pub mod rsa;
pub mod verify_2fa;
pub mod verify_email;
pub mod verify_reset_password;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/auth")
.route("/rsa", web::get().to(rsa::handle))
.route("/captcha", web::get().to(captcha::handle))
.route("/login", web::post().to(login::handle))
.route("/logout", web::post().to(logout::handle))
.route("/me", web::get().to(me::handle))
.route(
"/register/email-code",
web::post().to(register_email_code::handle),
)
.route("/register", web::post().to(register::handle))
.route("/email", web::get().to(get_email::handle))
.route(
"/email/change",
web::post().to(request_email_change::handle),
)
.route("/email/verify", web::post().to(verify_email::handle))
.route(
"/reset-password",
web::post().to(request_reset_password::handle),
)
.route(
"/reset-password/verify",
web::post().to(verify_reset_password::handle),
)
.route("/2fa/status", web::get().to(get_2fa_status::handle))
.route("/2fa/enable", web::post().to(enable_2fa::handle))
.route("/2fa/verify", web::post().to(verify_2fa::handle))
.route("/2fa/disable", web::post().to(disable_2fa::handle))
.route(
"/2fa/backup-codes/regenerate",
web::post().to(regenerate_2fa_backup_codes::handle),
),
);
}
-54
View File
@@ -1,54 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
pub struct Regenerate2FABackupCodesRequest {
/// Current account password encrypted with the session RSA public key.
pub password: String,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct Regenerate2FABackupCodesResponse {
/// Newly generated one-time backup codes. Old backup codes become invalid.
pub backup_codes: Vec<String>,
}
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/backup-codes/regenerate",
tag = "Auth",
operation_id = "authRegenerateTwoFactorBackupCodes",
summary = "Regenerate 2FA backup codes",
description = "After verifying the current password, generate a new set of backup codes for a user with 2FA enabled and replace the old backup codes. password must be encrypted with the current session RSA public key. Backup codes are returned in plaintext only once in this response; clients must prompt users to store them securely.",
request_body(
content = Regenerate2FABackupCodesRequest,
description = "The current account password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "Backup codes have been regenerated; old backup codes are immediately invalidated.", body = ApiResponse<Regenerate2FABackupCodesResponse>),
(status = 400, description = "2FA is not enabled, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Regenerate2FABackupCodesRequest>,
) -> Result<HttpResponse, AppError> {
let backup_codes = service
.auth
.auth_2fa_regenerate_backup_codes(&session, params.into_inner().password)
.await?;
Ok(
HttpResponse::Ok().json(ApiResponse::new(Regenerate2FABackupCodesResponse {
backup_codes,
})),
)
}
-64
View File
@@ -1,64 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Serialize;
use uuid::Uuid;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::User;
use crate::service::AppService;
use crate::service::auth::register::RegisterParams;
use crate::session::Session;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RegisterResponse {
/// Newly created user id.
pub id: Uuid,
/// Unique username used for login and profile URL.
pub username: String,
/// Display name initialized from username.
pub display_name: Option<String>,
/// Avatar URL; usually absent right after registration.
pub avatar_url: Option<String>,
}
impl From<User> for RegisterResponse {
fn from(user: User) -> Self {
Self {
id: user.id,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
}
}
}
#[utoipa::path(
post,
path = "/api/v1/auth/register",
tag = "Auth",
operation_id = "authRegister",
summary = "Register a new account",
description = "Create an account after validating username, email, password, captcha, and email verification code. password must be encrypted with the current session RSA public key; captcha and email_code are one-time credentials. On successful registration, the new user is written to the session and does not need to log in again.",
request_body(
content = RegisterParams,
description = "Registration parameters. email_code comes from /auth/register/email-code; password is a Base64 ciphertext encrypted with RSA-OAEP-SHA256.",
content_type = "application/json"
),
responses(
(status = 200, description = "Registration succeeded; the current session is automatically signed in as the new user.", body = ApiResponse<RegisterResponse>),
(status = 400, description = "Captcha error, email verification code error, weak password, RSA decryption failure, or missing required fields.", body = ApiErrorResponse),
(status = 409, description = "The username or email is already in use.", body = ApiErrorResponse),
(status = 500, description = "Database transaction, password hashing, cache, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<RegisterParams>,
) -> Result<HttpResponse, AppError> {
let user = service
.auth
.auth_register(params.into_inner(), &session)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(RegisterResponse::from(user))))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::register::{RegisterEmailCodeParams, RegisterEmailCodeResponse};
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/register/email-code",
tag = "Auth",
operation_id = "authSendRegisterEmailCode",
summary = "Send registration email verification code",
description = "After validating the captcha in the current session, send a 6-digit registration code to the target email address. The endpoint checks whether a verified email already exists and applies a per-email cooldown to prevent email bombing. The code is valid for 10 minutes by default.",
request_body(
content = RegisterEmailCodeParams,
description = "The target email address and captcha from the current session.",
content_type = "application/json"
),
responses(
(status = 200, description = "The verification email has been queued for delivery. Returns the code expiration time.", body = ApiResponse<RegisterEmailCodeResponse>),
(status = 400, description = "The captcha is incorrect, the email is empty, or requests are too frequent.", body = ApiErrorResponse),
(status = 409, description = "The email is already used by another verified account.", body = ApiErrorResponse),
(status = 500, description = "Cache write failed or the email service is unavailable.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<RegisterEmailCodeParams>,
) -> Result<HttpResponse, AppError> {
let data = service
.auth
.auth_register_email_code(params.into_inner(), &session)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-39
View File
@@ -1,39 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailChangeRequest;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/email/change",
tag = "Auth",
operation_id = "authRequestEmailChange",
summary = "Request login email change",
description = "After verifying the current user password, send a confirmation link to the new email address. password must be encrypted with the current session RSA public key. The token in the confirmation link is valid for 1 hour by default; the actual email switch is completed by calling /auth/email/verify.",
request_body(
content = EmailChangeRequest,
description = "The new email address and encrypted current account password.",
content_type = "application/json"
),
responses(
(status = 200, description = "The confirmation email has been queued for delivery.", body = ApiEmptyResponse),
(status = 400, description = "The new email is empty, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 409, description = "The new email is already in use.", body = ApiErrorResponse),
(status = 500, description = "Cache, email service, or database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<EmailChangeRequest>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_email_change_request(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email change verification sent")))
}
-34
View File
@@ -1,34 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::reset_pass::ResetPasswordRequest;
#[utoipa::path(
post,
path = "/api/v1/auth/reset-password",
tag = "Auth",
operation_id = "authRequestPasswordReset",
summary = "Request password reset email",
description = "Submit an email address to send a password reset link if it belongs to an active user. To prevent user enumeration, the business logic attempts to return success whether the email exists, rate limits are triggered, or email delivery fails. Internally, the endpoint enforces a 60-second cooldown and a daily limit of 5 requests per email.",
request_body(
content = ResetPasswordRequest,
description = "The email address that should receive the password reset link.",
content_type = "application/json"
),
responses(
(status = 200, description = "The request has been accepted; if the email exists, a reset email will be sent.", body = ApiEmptyResponse),
(status = 500, description = "Rare unrecoverable server-side error.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
params: web::Json<ResetPasswordRequest>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_reset_password_request(params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset request accepted")))
}
-27
View File
@@ -1,27 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::rsa::RsaResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/rsa",
tag = "Auth",
operation_id = "authGetRsaPublicKey",
summary = "Get login form RSA public key",
description = "Generate or reuse a temporary RSA-2048 key pair for the current browser session and return the public key in PKCS#1 PEM format. Clients should use this public key to encrypt sensitive fields such as passwords with RSA-OAEP-SHA256 before submitting login, registration, password reset, or 2FA disable requests. The private key is encrypted with AEAD and stored only in the server-side session; it is never returned to clients.",
responses(
(status = 200, description = "Return the RSA public key available for the current session; if an unexpired key already exists in the session, reuse the existing public key.", body = ApiResponse<RsaResponse>),
(status = 500, description = "APP_SESSION_SECRET is missing, RSA generation failed, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_rsa(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Verify2FAParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/verify",
tag = "Auth",
operation_id = "authVerifyAndEnableTwoFactor",
summary = "Verify and enable two-factor authentication",
description = "After initializing with /auth/2fa/enable, submit the 6-digit TOTP code generated by the authenticator app. On success, the current user's 2FA status is set to enabled. A small clock drift of one 30-second window before or after is allowed.",
request_body(
content = Verify2FAParams,
description = "The 6-digit TOTP code generated by the authenticator app.",
content_type = "application/json"
),
responses(
(status = 200, description = "2FA has been enabled.", body = ApiEmptyResponse),
(status = 400, description = "2FA has not been initialized, is already enabled, or the verification code is incorrect.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Verify2FAParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_2fa_verify_and_enable(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication enabled")))
}
-34
View File
@@ -1,34 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailVerifyRequest;
#[utoipa::path(
post,
path = "/api/v1/auth/email/verify",
tag = "Auth",
operation_id = "authVerifyEmailChange",
summary = "Confirm email change",
description = "Complete an email change using the token from the confirmation email. The endpoint checks again whether the target email is already taken, then marks old emails as unverified and inserts the new verified primary email in a transaction.",
request_body(
content = EmailVerifyRequest,
description = "Email change confirmation token.",
content_type = "application/json"
),
responses(
(status = 200, description = "Email changed successfully.", body = ApiEmptyResponse),
(status = 400, description = "The token is empty.", body = ApiErrorResponse),
(status = 404, description = "The token is invalid or expired.", body = ApiErrorResponse),
(status = 409, description = "The target email was taken by another account before confirmation.", body = ApiErrorResponse),
(status = 500, description = "Database transaction failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
params: web::Json<EmailVerifyRequest>,
) -> Result<HttpResponse, AppError> {
service.auth.auth_email_verify(params.into_inner()).await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email verified")))
}
-37
View File
@@ -1,37 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::reset_pass::ResetPasswordVerifyParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/reset-password/verify",
tag = "Auth",
operation_id = "authVerifyPasswordReset",
summary = "Confirm password reset",
description = "Set a new password using the token from the password reset email. password must be encrypted with the current session RSA public key; the new password is strength-checked and rehashed with Argon2id. The token is deleted immediately after successful use; expired or missing tokens fail.",
request_body(
content = ResetPasswordVerifyParams,
description = "The reset token and new password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "Password reset succeeded.", body = ApiEmptyResponse),
(status = 400, description = "The token is invalid or expired, RSA decryption failed, or the password is too weak.", body = ApiErrorResponse),
(status = 500, description = "Database update or password hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<ResetPasswordVerifyParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_reset_password_verify(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset successful")))
}
-60
View File
@@ -1,60 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to assign
pub user_id: uuid::Uuid,
}
/// Assign a user to an issue
///
/// Assigns a workspace member to the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is assigned to the issue
/// - Assignee is automatically subscribed to the issue
/// - Issue assignee count is incremented
///
/// Returns the created assignment record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueAssign",
params(PathParams),
responses(
(status = 201, description = "User assigned successfully. Returns the created assignment record.", body = ApiResponse<IssueAssignee>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or user not found", body = ApiErrorResponse),
(status = 409, description = "User is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let assignee = service
.issue
.issue_assign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(assignee)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to assign
pub label_id: uuid::Uuid,
}
/// Assign a label to an issue
///
/// Attaches a label to the given issue. The label must belong to a repository in the same workspace.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label is attached to the issue
/// - Issue label count is incremented
///
/// Returns the created label relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueAssignLabel",
params(PathParams),
responses(
(status = 200, description = "Label assigned successfully. Returns the created label relation.", body = ApiResponse<IssueLabelRelation>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or label not found", body = ApiErrorResponse),
(status = 409, description = "Label is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let rel = service
.issue
.issue_assign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rel)))
}
-56
View File
@@ -1,56 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Close an issue
///
/// Closes an open issue. The issue is marked as closed and the closing user is recorded.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Issue state changes to "closed"
/// - Closed by and closed at are recorded
/// - A "Closed" event is logged
///
/// Returns the closed issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/close",
tag = "Issues",
operation_id = "issueClose",
params(PathParams),
responses(
(status = 200, description = "Issue closed successfully. Returns the closed issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is already closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to close this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn close(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_close(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-75
View File
@@ -1,75 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::CreateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
/// Create an issue
///
/// Creates a new issue in the specified workspace.
/// Requires at least Member role in the workspace.
///
/// Parameters:
/// - title: Issue title (required)
/// - body: Issue body in markdown (optional)
/// - priority: Priority level (optional, defaults to "none")
/// - visibility: Visibility setting (optional, defaults to "public")
/// - due_at: Due date (optional)
/// - repo_ids: Related repository IDs
/// - label_ids: Label IDs to apply
/// - assignee_ids: User IDs to assign
/// - milestone_id: Milestone ID to attach
///
/// Effects:
/// - Issue is created with auto-incrementing number
/// - Author is automatically subscribed
/// - Relations, labels, and assignees are attached
/// - Workspace stats are updated
///
/// Returns the created issue with full metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueCreate",
params(PathParams),
request_body(
content = CreateIssueParams,
description = "Issue creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid parameters: empty title, invalid repository/label/milestone references", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Workspace or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_create(&session, &path.workspace_name, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(issue)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::service::issues::comments::CreateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Create a comment on an issue
///
/// Adds a new comment to an issue. Users with read access can comment unless the issue is locked
/// (in which case only users with write access can comment).
///
/// Parameters:
/// - body: Comment body in markdown format (required)
/// - reply_to_comment_id: ID of parent comment for threaded replies (optional)
///
/// Effects:
/// - Comment is created and attached to the issue
/// - Commenter is automatically subscribed to the issue
/// - Issue comment count is incremented
///
/// Returns the created comment with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueCreateComment",
params(PathParams),
request_body(content = CreateCommentParams, description = "Comment creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Comment created successfully.", body = ApiResponse<IssueComment>),
(status = 400, description = "Invalid parameters: empty body or issue is locked", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (issue locked and user lacks write access)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_create_comment(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(comment)))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::CreateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// Create a label
///
/// Creates a new issue label in a repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Label name (required, e.g., "bug", "feature")
/// - color: Hex color code (required, e.g., "#FF0000")
/// - description: Label description (optional)
///
/// Returns the created label with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueCreateLabel",
params(PathParams),
request_body(content = CreateLabelParams, description = "Label creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Label created successfully.", body = ApiResponse<IssueLabel>),
(status = 400, description = "Invalid parameters: empty name or invalid color", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_create_label(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(label)))
}
-70
View File
@@ -1,70 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::CreateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a milestone
///
/// Creates a new milestone in a repository for tracking issue progress.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - title: Milestone title (required)
/// - description: Description of the milestone (optional)
/// - due_at: Target due date (optional)
///
/// Returns the created milestone with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueCreateMilestone",
params(PathParams),
request_body(
content = CreateMilestoneParams,
description = "Milestone creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Milestone created successfully. Returns the newly created milestone with metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters: empty title", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_create_milestone(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(milestone)))
}
-53
View File
@@ -1,53 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Delete an issue
///
/// Soft-deletes an issue. The issue is marked as deleted but remains in the database.
/// Requires Admin role in the workspace (or issue author).
///
/// Effects:
/// - Issue is marked as deleted (soft-delete)
/// - Workspace issue count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueDelete",
params(PathParams),
responses(
(status = 200, description = "Issue deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or issue author)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Issue deleted successfully".to_string())))
}
-52
View File
@@ -1,52 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Delete an issue comment
///
/// Soft-deletes a comment. The comment author can delete their own comments.
/// Workspace admins can delete any comment.
///
/// Effects:
/// - Comment is marked as deleted
/// - Issue comment count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueDeleteComment",
params(PathParams),
responses(
(status = 200, description = "Comment deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot delete other users' comments (requires admin)", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_comment(&session, &path.workspace_name, path.number, path.comment_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Comment deleted successfully".to_string())))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Delete a label
///
/// Permanently removes an issue label from a repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Label is permanently deleted
/// - All issue-label relations using this label are removed
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueDeleteLabel",
params(PathParams),
responses(
(status = 200, description = "Label deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted successfully".to_string())))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Delete a milestone
///
/// Permanently removes a milestone from the repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Milestone is permanently deleted
/// - Issues attached to this milestone lose their milestone association
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueDeleteMilestone",
params(PathParams),
responses(
(status = 200, description = "Milestone deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Milestone deleted".to_string())))
}
-50
View File
@@ -1,50 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Get an issue by number
///
/// Returns detailed information about a specific issue, identified by workspace name and issue number.
/// Requires read access to the issue (public or workspace member).
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueGet",
params(PathParams),
responses(
(status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse<Issue>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_get(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-85
View File
@@ -1,85 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::IssueListFilters;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Filter by issue state ("open" or "closed")
pub state: Option<String>,
/// Filter by priority level
pub priority: Option<String>,
/// Filter by author user ID
pub author_id: Option<uuid::Uuid>,
/// Filter by assignee user ID
pub assignee_id: Option<uuid::Uuid>,
/// Filter by milestone ID
pub milestone_id: Option<uuid::Uuid>,
/// Filter by label ID
pub label_id: Option<uuid::Uuid>,
/// Maximum number of issues to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of issues to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issues in a workspace
///
/// Returns a paginated list of issues in the workspace, sorted by issue number (newest first).
/// Supports filtering by state, priority, author, assignee, milestone, and label.
/// Only returns issues visible to the authenticated user (public + workspace member access).
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse<Vec<Issue>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let filters = IssueListFilters {
state: query.state.clone(),
priority: query.priority.clone(),
author_id: query.author_id,
assignee_id: query.assignee_id,
milestone_id: query.milestone_id,
label_id: query.label_id,
};
let issues = service
.issue
.issue_list(
&session,
&path.workspace_name,
filters,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issues)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of assignees to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of assignees to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List assignees of an issue
///
/// Returns a paginated list of all users assigned to the given issue.
/// Shows who is assigned, when they were assigned, and who assigned them.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees",
tag = "Issues",
operation_id = "issueListAssignees",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Assignees listed successfully. Returns array of assignee objects with assignment metadata.", body = ApiResponse<Vec<IssueAssignee>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_assignees(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let assignees = service
.issue
.issue_assignees(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(assignees)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List issue comments
///
/// Returns a paginated list of comments on an issue, sorted by creation date (oldest first).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueListComments",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Comments listed successfully.", body = ApiResponse<Vec<IssueComment>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_comments(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let comments = service
.issue
.issue_comments(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comments)))
}
-67
View File
@@ -1,67 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueEvent;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of events to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of events to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue events
///
/// Returns a chronological timeline of all events for the given issue.
/// Events include creation, updates, state changes, assignments, label changes, etc.
/// Sorted by creation date (oldest first for timeline display).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/events",
tag = "Issues",
operation_id = "issueListEvents",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Events listed successfully. Returns chronological array of event objects.", body = ApiResponse<Vec<IssueEvent>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_events(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let events = service
.issue
.issue_list_events(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(events)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of label relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of label relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List labels assigned to an issue
///
/// Returns a paginated list of all label relations for the given issue.
/// Shows which labels are attached to the issue, with assignment metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels",
tag = "Issues",
operation_id = "issueListLabelRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Label relations listed successfully. Returns array of label relation objects with metadata.", body = ApiResponse<Vec<IssueLabelRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_issue_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let rels = service
.issue
.issue_label_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rels)))
}
-46
View File
@@ -1,46 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// List labels in a repository
///
/// Returns all issue labels defined in the repository, sorted alphabetically.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueListLabels",
params(PathParams),
responses(
(status = 200, description = "Labels listed successfully.", body = ApiResponse<Vec<IssueLabel>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let labels = service
.issue
.issue_labels(&session, &path.workspace_name, &path.repo_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(labels)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of milestones to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of milestones to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List milestones in a repository
///
/// Returns a paginated list of milestones in the repository, sorted by state (open first) then by due date.
/// Includes milestone metadata such as title, description, state, due date, and progress.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueListMilestones",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Milestones listed successfully. Returns array of milestone objects with metadata.", body = ApiResponse<Vec<IssueMilestone>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_milestones(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let milestones = service
.issue
.issue_milestones(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestones)))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LockIssueParams {
/// Whether to lock (true) or unlock (false) the issue
pub locked: bool,
}
/// Lock or unlock an issue
///
/// Locks or unlocks conversation on an issue. When locked, only users with write access can comment.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the updated issue with lock status.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/lock",
tag = "Issues",
operation_id = "issueLock",
params(PathParams),
request_body(
content = LockIssueParams,
description = "Lock/unlock parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue lock status updated successfully.", body = ApiResponse<Issue>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to manage this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn lock(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LockIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_lock(&session, &path.workspace_name, path.number, params.locked)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-185
View File
@@ -1,185 +0,0 @@
pub mod assign_issue;
pub mod assign_label;
pub mod close;
pub mod create;
pub mod create_comment;
pub mod create_label;
pub mod create_milestone;
pub mod delete;
pub mod delete_comment;
pub mod delete_label;
pub mod delete_milestone;
pub mod get;
pub mod list;
pub mod list_assignees;
pub mod list_comments;
pub mod list_events;
pub mod list_issue_labels;
pub mod list_labels;
pub mod list_milestones;
pub mod lock;
pub mod pr_relations;
pub mod reactions;
pub mod reopen;
pub mod repo_relations;
pub mod subscribers;
pub mod templates;
pub mod transfer;
pub mod unassign_issue;
pub mod unassign_label;
pub mod update;
pub mod update_comment;
pub mod update_label;
pub mod update_milestone;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/issues")
// Core
.route("", web::get().to(list::list))
.route("", web::post().to(create::create))
.route("/{number}", web::get().to(get::get))
.route("/{number}", web::put().to(update::update))
.route("/{number}", web::delete().to(delete::delete))
.route("/{number}/close", web::post().to(close::close))
.route("/{number}/reopen", web::post().to(reopen::reopen))
.route("/{number}/lock", web::put().to(lock::lock))
.route("/{number}/transfer", web::post().to(transfer::transfer))
// Comments
.route(
"/{number}/comments",
web::get().to(list_comments::list_comments),
)
.route(
"/{number}/comments",
web::post().to(create_comment::create_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::put().to(update_comment::update_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::delete().to(delete_comment::delete_comment),
)
// Labels (issue-level)
.route(
"/{number}/labels",
web::get().to(list_issue_labels::list_issue_labels),
)
.route(
"/{number}/labels/{label_id}",
web::post().to(assign_label::assign_label),
)
.route(
"/{number}/labels/{label_id}",
web::delete().to(unassign_label::unassign_label),
)
// Assignees
.route(
"/{number}/assignees",
web::get().to(list_assignees::list_assignees),
)
.route(
"/{number}/assignees/{user_id}",
web::post().to(assign_issue::assign_issue),
)
.route(
"/{number}/assignees/{user_id}",
web::delete().to(unassign_issue::unassign_issue),
)
// Events
.route("/{number}/events", web::get().to(list_events::list_events))
// Reactions
.route(
"/{number}/reactions",
web::get().to(reactions::list_reactions),
)
.route(
"/{number}/reactions",
web::post().to(reactions::add_reaction),
)
.route(
"/{number}/reactions/{reaction_id}",
web::delete().to(reactions::remove_reaction),
)
// Subscribers
.route(
"/{number}/subscribers",
web::get().to(subscribers::list_subscribers),
)
.route(
"/{number}/subscribe",
web::post().to(subscribers::subscribe),
)
.route(
"/{number}/subscribe",
web::delete().to(subscribers::unsubscribe),
)
.route("/{number}/mute", web::put().to(subscribers::mute))
// Repo relations
.route(
"/{number}/repos",
web::get().to(repo_relations::list_repo_relations),
)
.route("/{number}/repos", web::post().to(repo_relations::link_repo))
.route(
"/{number}/repos/{relation_id}",
web::delete().to(repo_relations::unlink_repo),
)
// PR relations
.route(
"/{number}/prs",
web::get().to(pr_relations::list_pr_relations),
)
.route("/{number}/prs", web::post().to(pr_relations::link_pr))
.route(
"/{number}/prs/{relation_id}",
web::delete().to(pr_relations::unlink_pr),
),
);
}
pub fn configure_repo_level(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/issues")
.route("/labels", web::get().to(list_labels::list_labels))
.route("/labels", web::post().to(create_label::create_label))
.route(
"/labels/{label_id}",
web::put().to(update_label::update_label),
)
.route(
"/labels/{label_id}",
web::delete().to(delete_label::delete_label),
)
.route(
"/milestones",
web::get().to(list_milestones::list_milestones),
)
.route(
"/milestones",
web::post().to(create_milestone::create_milestone),
)
.route(
"/milestones/{milestone_id}",
web::put().to(update_milestone::update_milestone),
)
.route(
"/milestones/{milestone_id}",
web::delete().to(delete_milestone::delete_milestone),
)
.route("/templates", web::get().to(templates::list_templates))
.route("/templates", web::post().to(templates::create_template))
.route(
"/templates/{template_id}",
web::put().to(templates::update_template),
)
.route(
"/templates/{template_id}",
web::delete().to(templates::delete_template),
),
);
}
-170
View File
@@ -1,170 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssuePrRelation;
use crate::service::AppService;
use crate::service::issues::pr_relations::LinkPrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List pull request relations for an issue
///
/// Returns a paginated list of all pull requests linked to the given issue.
/// Shows relation type (closes, references, depends_on, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueListPrRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "PR relations listed successfully. Returns array of PR relation objects.", body = ApiResponse<Vec<IssuePrRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_pr_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_pr_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a pull request to an issue
///
/// Creates a relation between the given issue and a pull request.
/// Commonly used to mark a PR as closing or referencing an issue.
/// Requires write access to the issue.
///
/// Parameters:
/// - pull_request_id: Pull request ID (UUID) to link
/// - relation_type: Relation type ("closes", "references", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueLinkPr",
params(PathParams),
request_body(
content = LinkPrParams,
description = "Link pull request parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Pull request linked successfully. Returns the created relation.", body = ApiResponse<IssuePrRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or pull request not found", body = ApiErrorResponse),
(status = 409, description = "Pull request is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkPrParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_pr(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a pull request from an issue
///
/// Removes a pull request relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkPr",
params(RelationPathParams),
responses(
(status = 200, description = "Pull request unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "PR relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_pr(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("PR unlinked".to_string())))
}
-169
View File
@@ -1,169 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueReaction;
use crate::service::AppService;
use crate::service::issues::reactions::CreateIssueReactionParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of reactions to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of reactions to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List reactions on an issue
///
/// Returns a paginated list of all emoji reactions on the given issue.
/// Includes reaction content, target type, and user who added each reaction.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueListReactions",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Reactions listed successfully. Returns array of reaction objects.", body = ApiResponse<Vec<IssueReaction>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_reactions(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let reactions = service
.issue
.issue_reactions(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(reactions)))
}
/// Add a reaction to an issue
///
/// Adds an emoji reaction to the given issue.
/// Requires read access to the issue.
///
/// Parameters:
/// - content: Reaction content (e.g., "👍", "❤️", "🎉")
/// - target_type: Target type for the reaction (defaults to "Issue")
/// - target_id: Target ID for reactions on specific comments (optional)
///
/// Returns the created reaction.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueAddReaction",
params(PathParams),
request_body(
content = CreateIssueReactionParams,
description = "Reaction creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Reaction added successfully. Returns the created reaction.", body = ApiResponse<IssueReaction>),
(status = 400, description = "Invalid parameters: empty content or invalid target type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueReactionParams>,
) -> Result<HttpResponse, AppError> {
let reaction = service
.issue
.issue_add_reaction(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(reaction)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReactionPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Reaction ID (UUID)
pub reaction_id: uuid::Uuid,
}
/// Remove a reaction from an issue
///
/// Removes a previously added reaction. Only the user who added the reaction can remove it.
/// Requires read access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions/{reaction_id}",
tag = "Issues",
operation_id = "issueRemoveReaction",
params(ReactionPathParams),
responses(
(status = 200, description = "Reaction removed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot remove another user's reaction", body = ApiErrorResponse),
(status = 404, description = "Reaction not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn remove_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReactionPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_remove_reaction(
&session,
&path.workspace_name,
path.number,
path.reaction_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Reaction removed".to_string())))
}
-51
View File
@@ -1,51 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Reopen an issue
///
/// Reopens a closed issue. The issue state changes back to "open" and closed metadata is cleared.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the reopened issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reopen",
tag = "Issues",
operation_id = "issueReopen",
params(PathParams),
responses(
(status = 200, description = "Issue reopened successfully. Returns the reopened issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is not closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to reopen this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn reopen(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_reopen(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-169
View File
@@ -1,169 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueRepoRelation;
use crate::service::AppService;
use crate::service::issues::repo_relations::LinkRepoParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List repository relations for an issue
///
/// Returns a paginated list of all repositories linked to the given issue.
/// Shows relation type (references, duplicates, blocks, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueListRepoRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Repository relations listed successfully. Returns array of relation objects.", body = ApiResponse<Vec<IssueRepoRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_repo_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_repo_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a repository to an issue
///
/// Creates a relation between the given issue and a repository.
/// Requires write access to the issue.
///
/// Parameters:
/// - repo_id: Repository ID (UUID) to link
/// - relation_type: Relation type ("references", "duplicates", "blocks", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueLinkRepo",
params(PathParams),
request_body(
content = LinkRepoParams,
description = "Link repository parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Repository linked successfully. Returns the created relation.", body = ApiResponse<IssueRepoRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or repository not found", body = ApiErrorResponse),
(status = 409, description = "Repository is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkRepoParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_repo(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a repository from an issue
///
/// Removes a repository relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkRepo",
params(RelationPathParams),
responses(
(status = 200, description = "Repository unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Repository relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_repo(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Repo unlinked".to_string())))
}
-186
View File
@@ -1,186 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueSubscriber;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of subscribers to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of subscribers to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List subscribers of an issue
///
/// Returns a paginated list of all users subscribed to the given issue.
/// Shows who receives notifications and their subscription reason (author, assignee, manual).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribers",
tag = "Issues",
operation_id = "issueListSubscribers",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Subscribers listed successfully. Returns array of subscriber objects.", body = ApiResponse<Vec<IssueSubscriber>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_subscribers(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let subscribers = service
.issue
.issue_subscribers(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(subscribers)))
}
/// Subscribe to an issue
///
/// Subscribes the authenticated user to the given issue to receive notifications.
/// Requires read access to the issue.
///
/// Effects:
/// - User is added as a subscriber with "manual" reason
/// - User receives notifications for all issue activity
///
/// Returns the created subscription record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueSubscribe",
params(PathParams),
responses(
(status = 200, description = "Subscribed successfully. Returns the subscription record.", body = ApiResponse<IssueSubscriber>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 409, description = "Already subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn subscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let sub = service
.issue
.issue_subscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(sub)))
}
/// Unsubscribe from an issue
///
/// Removes the authenticated user's subscription to the given issue.
/// Stops all notifications for this issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueUnsubscribe",
params(PathParams),
responses(
(status = 200, description = "Unsubscribed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unsubscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unsubscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Unsubscribed".to_string())))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct MuteIssueParams {
/// Whether to mute (true) or unmute (false) notifications
pub muted: bool,
}
/// Mute or unmute issue notifications
///
/// Mutes or unmutes notifications for the given issue without unsubscribing.
/// Requires an active subscription to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/mute",
tag = "Issues",
operation_id = "issueMute",
params(PathParams),
request_body(
content = MuteIssueParams,
description = "Mute/unmute parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Mute status updated successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn mute(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<MuteIssueParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_mute(&session, &path.workspace_name, path.number, params.muted)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Mute status updated".to_string())))
}
-220
View File
@@ -1,220 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueTemplate;
use crate::service::AppService;
use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of templates to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of templates to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue templates in a repository
///
/// Returns a paginated list of all active issue templates in the repository.
/// Templates provide pre-filled content for creating new issues.
/// Sorted alphabetically by name.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueListTemplates",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Templates listed successfully. Returns array of template objects.", body = ApiResponse<Vec<IssueTemplate>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_templates(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let templates = service
.issue
.issue_templates(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(templates)))
}
/// Create an issue template
///
/// Creates a new issue template in the repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Template name (required)
/// - description: Template description (optional)
/// - title_template: Default title for issues (optional, supports placeholders)
/// - body_template: Default body content in markdown (required)
/// - labels: List of label names to auto-apply (optional)
///
/// Returns the created template with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueCreateTemplate",
params(PathParams),
request_body(
content = CreateTemplateParams,
description = "Template creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Template created successfully. Returns the newly created template.", body = ApiResponse<IssueTemplate>),
(status = 400, description = "Invalid parameters: empty name or body template", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_create_template(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(template)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct TemplatePathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Template ID (UUID)
pub template_id: uuid::Uuid,
}
/// Update an issue template
///
/// Updates an existing issue template's metadata and content.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated template.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueUpdateTemplate",
params(TemplatePathParams),
request_body(
content = UpdateTemplateParams,
description = "Template update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Template updated successfully. Returns the updated template.", body = ApiResponse<IssueTemplate>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
params: web::Json<UpdateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_update_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(template)))
}
/// Delete an issue template
///
/// Permanently removes an issue template from the repository.
/// Requires Admin role in the repository.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueDeleteTemplate",
params(TemplatePathParams),
responses(
(status = 200, description = "Template deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
}
-75
View File
@@ -1,75 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TransferIssueParams {
/// Target workspace name to transfer the issue to
pub target_workspace_name: String,
}
/// Transfer an issue to another workspace
///
/// Moves an issue from the current workspace to a different workspace.
/// Requires Admin role in both the source and target workspaces.
///
/// Effects:
/// - Issue is transferred to the target workspace with a new number
/// - Source workspace issue count is decremented
/// - Target workspace issue count is incremented
///
/// Returns the transferred issue with updated workspace and number.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/transfer",
tag = "Issues",
operation_id = "issueTransfer",
params(PathParams),
request_body(
content = TransferIssueParams,
description = "Transfer parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue transferred successfully. Returns the issue with new workspace assignment.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid target workspace", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions in source or target workspace", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn transfer(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<TransferIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_transfer(
&session,
&path.workspace_name,
path.number,
&params.target_workspace_name,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to unassign
pub user_id: uuid::Uuid,
}
/// Unassign a user from an issue
///
/// Removes a user from the issue's assignee list.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is removed from the issue's assignees
/// - Issue assignee count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueUnassign",
params(PathParams),
responses(
(status = 200, description = "User unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "User is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("User unassigned".to_string())))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to unassign
pub label_id: uuid::Uuid,
}
/// Unassign a label from an issue
///
/// Removes a label from the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label relation is removed from the issue
/// - Issue label count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueUnassignLabel",
params(PathParams),
responses(
(status = 200, description = "Label unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Label is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label unassigned".to_string())))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::UpdateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Update an issue
///
/// Updates an existing issue's metadata such as title, body, priority, visibility, due date, and milestone.
/// Requires write access to the issue (author or workspace member).
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated issue with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueUpdate",
params(PathParams),
request_body(
content = UpdateIssueParams,
description = "Issue update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue updated successfully. Returns the updated issue with full metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid parameters: invalid priority, visibility, or milestone", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_update(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::service::issues::comments::UpdateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Update an issue comment
///
/// Updates the body of an existing comment. Only the comment author can update their own comments.
/// Requires read access to the issue.
///
/// Returns the updated comment with edit timestamp.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueUpdateComment",
params(PathParams),
request_body(content = UpdateCommentParams, description = "Comment update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Comment updated successfully.", body = ApiResponse<IssueComment>),
(status = 400, description = "Invalid parameters: empty body", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot edit other users' comments", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_update_comment(
&session,
&path.workspace_name,
path.number,
path.comment_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comment)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::UpdateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Update a label
///
/// Updates an existing issue label's name, color, or description.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated label.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueUpdateLabel",
params(PathParams),
request_body(content = UpdateLabelParams, description = "Label update parameters (all fields optional)", content_type = "application/json"),
responses(
(status = 200, description = "Label updated successfully.", body = ApiResponse<IssueLabel>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_update_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(label)))
}
-75
View File
@@ -1,75 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::UpdateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Update a milestone
///
/// Updates an existing milestone's metadata. Can also close or reopen the milestone via the state field.
/// Requires at least Member role in the repository.
///
/// Updatable fields:
/// - title: Milestone title (optional)
/// - description: Description (optional)
/// - due_at: Target due date (optional)
/// - state: State ("open" or "closed") for closing/reopening the milestone (optional)
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated milestone with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueUpdateMilestone",
params(PathParams),
request_body(
content = UpdateMilestoneParams,
description = "Milestone update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Milestone updated successfully. Returns the updated milestone with full metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_update_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestone)))
}
-10
View File
@@ -1,10 +0,0 @@
pub mod auth;
pub mod issue;
pub mod openapi;
pub mod pr;
pub mod repo;
pub mod response;
pub mod routes;
pub mod user;
pub mod wiki;
pub mod workspace;
-702
View File
@@ -1,702 +0,0 @@
use utoipa::OpenApi;
use crate::api::auth::regenerate_2fa_backup_codes::{
Regenerate2FABackupCodesRequest, Regenerate2FABackupCodesResponse,
};
use crate::api::auth::register::RegisterResponse;
use crate::api::issue::lock::LockIssueParams;
use crate::api::issue::subscribers::MuteIssueParams;
use crate::api::issue::transfer::TransferIssueParams;
use crate::api::pr::lock::LockPrParams;
use crate::api::pr::subscriptions::MutePrParams;
use crate::api::repo::accept_invitation::AcceptInvitationParams;
use crate::api::repo::set_branch_protection::SetBranchProtectionParams;
use crate::api::repo::transfer_owner::TransferOwnerParams;
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse, ApiResponse};
use crate::api::wiki::compare_revisions::WikiCompareResult;
use crate::api::workspace::accept_invitation::AcceptInvitationRequest;
use crate::api::workspace::review_approval::ReviewApprovalRequest;
use crate::api::workspace::transfer_owner::TransferOwnerRequest;
use crate::models::issues::{
Issue, IssueAssignee, IssueComment, IssueEvent, IssueLabel, IssueLabelRelation, IssueMilestone,
IssuePrRelation, IssueReaction, IssueRepoRelation, IssueSubscriber, IssueTemplate,
};
use crate::models::prs::{
PrAssignee, PrCheckRun, PrCommit, PrEvent, PrFile, PrLabel, PrLabelRelation, PrMergeStrategy,
PrReaction, PrReview, PrReviewComment, PrStatus, PrSubscription, PullRequest,
};
use crate::models::repos::{
BranchProtectionRule, Repo, RepoBranch, RepoCommitComment, RepoCommitStatus, RepoDeployKey,
RepoFork, RepoInvitation, RepoMember, RepoRelease, RepoStar, RepoStats, RepoTag, RepoWatch,
RepoWebhook,
};
use crate::models::users::{
User, UserAppearance, UserDevice, UserGpgKey, UserNotifySetting, UserProfile, UserSecurityLog,
UserSshKey,
};
use crate::models::wiki::{WikiPage, WikiPageRevision};
use crate::models::workspaces::{
Workspace, WorkspaceAuditLog, WorkspaceBilling, WorkspaceCustomBranding, WorkspaceDomain,
WorkspaceIntegration, WorkspaceInvitation, WorkspaceMember, WorkspacePendingApproval,
WorkspaceSettings, WorkspaceStats, WorkspaceWebhook,
};
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest};
use crate::service::auth::login::LoginParams;
use crate::service::auth::me::ContextMe;
use crate::service::auth::register::{
RegisterEmailCodeParams, RegisterEmailCodeResponse, RegisterParams,
};
use crate::service::auth::reset_pass::{ResetPasswordRequest, ResetPasswordVerifyParams};
use crate::service::auth::rsa::RsaResponse;
use crate::service::auth::totp::{
Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
};
use crate::service::issues::comments::{CreateCommentParams, UpdateCommentParams};
use crate::service::issues::core::{CreateIssueParams, IssueListFilters, UpdateIssueParams};
use crate::service::issues::labels::{CreateLabelParams, UpdateLabelParams};
use crate::service::issues::milestones::{CreateMilestoneParams, UpdateMilestoneParams};
use crate::service::issues::pr_relations::LinkPrParams;
use crate::service::issues::reactions::CreateIssueReactionParams;
use crate::service::issues::repo_relations::LinkRepoParams;
use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams};
use crate::service::pr::check_runs::{CreateCheckRunParams, UpdateCheckRunParams};
use crate::service::pr::core::{CreatePrParams, MergePrParams, PrListFilters, UpdatePrParams};
use crate::service::pr::labels::{CreatePrLabelParams, UpdatePrLabelParams};
use crate::service::pr::merge_strategy::UpdateMergeStrategyParams;
use crate::service::pr::reactions::CreateReactionParams;
use crate::service::pr::reviews::{
AddReplyParams, CreateReviewParams, DismissReviewParams, ReviewCommentParams,
SubmitReviewParams,
};
use crate::service::repo::branches::CreateBranchParams;
use crate::service::repo::commit_status::{CreateCommitCommentParams, CreateCommitStatusParams};
use crate::service::repo::core::{CreateRepoParams, UpdateRepoParams};
use crate::service::repo::deploy_keys::AddDeployKeyParams;
use crate::service::repo::fork::ForkRepoParams;
use crate::service::repo::invitations::CreateRepoInvitationParams;
use crate::service::repo::members::{AddRepoMemberParams, UpdateRepoMemberRoleParams};
use crate::service::repo::protection::{
BranchMergeCheck, CreateProtectionRuleParams, UpdateProtectionRuleParams,
};
use crate::service::repo::releases::{CreateReleaseParams, UpdateReleaseParams};
use crate::service::repo::tags::CreateTagParams;
use crate::service::repo::watches::WatchParams;
use crate::service::repo::webhooks::{
CreateWebhookParams as RepoCreateWebhookParams, UpdateWebhookParams as RepoUpdateWebhookParams,
};
use crate::service::user::account::{
UpdateUserAccountParams, UploadUserAvatarParams, UserAvatarResponse,
};
use crate::service::user::appearance::UpdateUserAppearanceParams;
use crate::service::user::keys::{AddGpgKeyParams, AddSshKeyParams};
use crate::service::user::notify::UpdateUserNotifySettingParams;
use crate::service::user::profile::UpdateUserProfileParams;
use crate::service::user::security::{UserOAuthInfo, UserPersonalAccessTokenInfo, UserSessionInfo};
use crate::service::wiki::core::{CreateWikiPageParams, UpdateWikiPageParams};
use crate::service::workspace::approvals::RequestApprovalParams;
use crate::service::workspace::billing::UpdateBillingParams;
use crate::service::workspace::branding::UpdateBrandingParams;
use crate::service::workspace::core::{CreateWorkspaceParams, UpdateWorkspaceParams};
use crate::service::workspace::domains::AddDomainParams;
use crate::service::workspace::integrations::{CreateIntegrationParams, UpdateIntegrationParams};
use crate::service::workspace::invitations::{CreateInvitationParams, CreateInvitationResponse};
use crate::service::workspace::members::{AddMemberParams, UpdateMemberRoleParams};
use crate::service::workspace::settings::UpdateWorkspaceSettingsParams;
use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookParams};
#[derive(OpenApi)]
#[openapi(
info(
title = "AppKS API",
version = "0.1.0",
description = "AppKS collaborative development platform HTTP API."
),
tags(
(name = "Auth", description = "Authentication, registration, session and email security endpoints."),
(name = "User", description = "User account management, profile, appearance, notification settings, SSH/GPG keys, sessions, devices, OAuth accounts, security logs, and personal access tokens."),
(name = "Workspaces", description = "Workspace CRUD, archiving, ownership transfer, and avatar management."),
(name = "Repos", description = "Repository management including branches, tags, releases, forks, stars, watches, members, invitations, deploy keys, webhooks, protection rules, commit statuses, and statistics."),
(name = "Issues", description = "Issue tracking, comments, labels, milestones, assignees, events, reactions, subscribers, templates, and cross-references."),
(name = "Pull Requests", description = "Pull request lifecycle including reviews, check runs, merge strategies, labels, assignees, events, reactions, and subscriptions."),
(name = "Wiki", description = "Wiki page management including CRUD operations, revision history, version comparison, and page reversion."),
),
paths(
// Auth
crate::api::auth::rsa::handle,
crate::api::auth::captcha::handle,
crate::api::auth::login::handle,
crate::api::auth::logout::handle,
crate::api::auth::me::handle,
crate::api::auth::register_email_code::handle,
crate::api::auth::register::handle,
crate::api::auth::get_email::handle,
crate::api::auth::request_email_change::handle,
crate::api::auth::verify_email::handle,
crate::api::auth::request_reset_password::handle,
crate::api::auth::verify_reset_password::handle,
crate::api::auth::get_2fa_status::handle,
crate::api::auth::enable_2fa::handle,
crate::api::auth::verify_2fa::handle,
crate::api::auth::disable_2fa::handle,
crate::api::auth::regenerate_2fa_backup_codes::handle,
// User
crate::api::user::get_account::get_account,
crate::api::user::update_account::update_account,
crate::api::user::upload_avatar::upload_avatar,
crate::api::user::delete_account::delete_account,
crate::api::user::get_appearance::get_appearance,
crate::api::user::update_appearance::update_appearance,
crate::api::user::get_profile::get_profile,
crate::api::user::update_profile::update_profile,
crate::api::user::get_notifications::get_notifications,
crate::api::user::update_notifications::update_notifications,
crate::api::user::list_ssh_keys::list_ssh_keys,
crate::api::user::add_ssh_key::add_ssh_key,
crate::api::user::delete_ssh_key::delete_ssh_key,
crate::api::user::list_gpg_keys::list_gpg_keys,
crate::api::user::add_gpg_key::add_gpg_key,
crate::api::user::delete_gpg_key::delete_gpg_key,
crate::api::user::list_sessions::list_sessions,
crate::api::user::revoke_session::revoke_session,
crate::api::user::list_devices::list_devices,
crate::api::user::delete_device::delete_device,
crate::api::user::list_oauth_accounts::list_oauth_accounts,
crate::api::user::unlink_oauth::unlink_oauth,
crate::api::user::list_security_logs::list_security_logs,
crate::api::user::list_personal_access_tokens::list_tokens,
crate::api::user::revoke_personal_access_token::revoke_token,
// Issues
crate::api::issue::list::list,
crate::api::issue::get::get,
crate::api::issue::create::create,
crate::api::issue::update::update,
crate::api::issue::close::close,
crate::api::issue::reopen::reopen,
crate::api::issue::delete::delete,
crate::api::issue::lock::lock,
crate::api::issue::transfer::transfer,
crate::api::issue::list_comments::list_comments,
crate::api::issue::create_comment::create_comment,
crate::api::issue::update_comment::update_comment,
crate::api::issue::delete_comment::delete_comment,
crate::api::issue::list_labels::list_labels,
crate::api::issue::create_label::create_label,
crate::api::issue::update_label::update_label,
crate::api::issue::delete_label::delete_label,
crate::api::issue::list_issue_labels::list_issue_labels,
crate::api::issue::assign_label::assign_label,
crate::api::issue::unassign_label::unassign_label,
crate::api::issue::list_milestones::list_milestones,
crate::api::issue::create_milestone::create_milestone,
crate::api::issue::update_milestone::update_milestone,
crate::api::issue::delete_milestone::delete_milestone,
crate::api::issue::list_assignees::list_assignees,
crate::api::issue::assign_issue::assign_issue,
crate::api::issue::unassign_issue::unassign_issue,
crate::api::issue::list_events::list_events,
crate::api::issue::reactions::list_reactions,
crate::api::issue::reactions::add_reaction,
crate::api::issue::reactions::remove_reaction,
crate::api::issue::subscribers::list_subscribers,
crate::api::issue::subscribers::subscribe,
crate::api::issue::subscribers::unsubscribe,
crate::api::issue::subscribers::mute,
crate::api::issue::templates::list_templates,
crate::api::issue::templates::create_template,
crate::api::issue::templates::update_template,
crate::api::issue::templates::delete_template,
crate::api::issue::repo_relations::list_repo_relations,
crate::api::issue::repo_relations::link_repo,
crate::api::issue::repo_relations::unlink_repo,
crate::api::issue::pr_relations::list_pr_relations,
crate::api::issue::pr_relations::link_pr,
crate::api::issue::pr_relations::unlink_pr,
// Pull Requests - Core
crate::api::pr::list::list,
crate::api::pr::get::get,
crate::api::pr::create::create,
crate::api::pr::update::update,
crate::api::pr::mark_ready::mark_ready,
crate::api::pr::close::close,
crate::api::pr::reopen::reopen,
crate::api::pr::delete::delete,
crate::api::pr::lock::lock,
crate::api::pr::merge::merge,
// Pull Requests - Commits & Files
crate::api::pr::list_commits::list_commits,
crate::api::pr::list_files::list_files,
// Pull Requests - Status & Merge Strategy
crate::api::pr::get_status::get_status,
crate::api::pr::merge_strategy::get_merge_strategy,
crate::api::pr::merge_strategy::update_merge_strategy,
// Pull Requests - Labels
crate::api::pr::labels::list_labels,
crate::api::pr::labels::create_label,
crate::api::pr::labels::update_label,
crate::api::pr::labels::delete_label,
crate::api::pr::labels::list_label_relations,
crate::api::pr::labels::assign_label,
crate::api::pr::labels::unassign_label,
// Pull Requests - Assignees
crate::api::pr::assignees::list_assignees,
crate::api::pr::assignees::assign_user,
crate::api::pr::assignees::unassign_user,
// Pull Requests - Reviews
crate::api::pr::reviews::list_reviews,
crate::api::pr::reviews::create_review,
crate::api::pr::reviews::submit_review,
crate::api::pr::reviews::dismiss_review,
crate::api::pr::reviews::list_review_comments,
crate::api::pr::reviews::add_review_reply,
crate::api::pr::reviews::update_review_comment,
crate::api::pr::reviews::delete_review_comment,
// Pull Requests - Check Runs
crate::api::pr::check_runs::list_check_runs,
crate::api::pr::check_runs::create_check_run,
crate::api::pr::check_runs::update_check_run,
crate::api::pr::check_runs::delete_check_run,
// Pull Requests - Events
crate::api::pr::events::list_events,
// Pull Requests - Reactions
crate::api::pr::reactions::list_reactions,
crate::api::pr::reactions::add_reaction,
crate::api::pr::reactions::remove_reaction,
// Pull Requests - Subscriptions
crate::api::pr::subscriptions::list_subscriptions,
crate::api::pr::subscriptions::subscribe,
crate::api::pr::subscriptions::unsubscribe,
crate::api::pr::subscriptions::mute,
// Wiki
crate::api::wiki::list_pages::list_pages,
crate::api::wiki::get_page::get_page,
crate::api::wiki::create_page::create_page,
crate::api::wiki::update_page::update_page,
crate::api::wiki::delete_page::delete_page,
crate::api::wiki::revert_page::revert_page,
crate::api::wiki::list_revisions::list_revisions,
crate::api::wiki::get_revision::get_revision,
crate::api::wiki::compare_revisions::compare_revisions,
// Workspaces
crate::api::workspace::list::handle,
crate::api::workspace::get::handle,
crate::api::workspace::create::handle,
crate::api::workspace::update::handle,
crate::api::workspace::archive::handle,
crate::api::workspace::unarchive::handle,
crate::api::workspace::delete::handle,
crate::api::workspace::transfer_owner::handle,
crate::api::workspace::upload_avatar::handle,
crate::api::workspace::list_members::handle,
crate::api::workspace::add_member::handle,
crate::api::workspace::update_member_role::handle,
crate::api::workspace::remove_member::handle,
crate::api::workspace::leave::handle,
crate::api::workspace::list_invitations::handle,
crate::api::workspace::create_invitation::handle,
crate::api::workspace::revoke_invitation::handle,
crate::api::workspace::accept_invitation::handle,
crate::api::workspace::get_billing::handle,
crate::api::workspace::update_billing::handle,
crate::api::workspace::get_branding::handle,
crate::api::workspace::update_branding::handle,
crate::api::workspace::get_settings::handle,
crate::api::workspace::update_settings::handle,
crate::api::workspace::get_stats::handle,
crate::api::workspace::refresh_stats::handle,
crate::api::workspace::list_integrations::handle,
crate::api::workspace::create_integration::handle,
crate::api::workspace::update_integration::handle,
crate::api::workspace::delete_integration::handle,
crate::api::workspace::list_webhooks::handle,
crate::api::workspace::create_webhook::handle,
crate::api::workspace::update_webhook::handle,
crate::api::workspace::delete_webhook::handle,
crate::api::workspace::list_domains::handle,
crate::api::workspace::add_domain::handle,
crate::api::workspace::verify_domain::handle,
crate::api::workspace::set_primary_domain::handle,
crate::api::workspace::delete_domain::handle,
crate::api::workspace::list_approvals::handle,
crate::api::workspace::request_approval::handle,
crate::api::workspace::review_approval::handle,
crate::api::workspace::audit_logs::handle,
// Repos
crate::api::repo::list::list,
crate::api::repo::get::get,
crate::api::repo::create::create,
crate::api::repo::update::update,
crate::api::repo::archive::archive,
crate::api::repo::unarchive::unarchive,
crate::api::repo::delete::delete,
crate::api::repo::transfer_owner::transfer_owner,
crate::api::repo::list_branches::list_branches,
crate::api::repo::create_branch::create_branch,
crate::api::repo::set_default_branch::set_default_branch,
crate::api::repo::set_branch_protection::set_branch_protection,
crate::api::repo::delete_branch::delete_branch,
crate::api::repo::list_tags::list_tags,
crate::api::repo::create_tag::create_tag,
crate::api::repo::delete_tag::delete_tag,
crate::api::repo::list_releases::list_releases,
crate::api::repo::create_release::create_release,
crate::api::repo::update_release::update_release,
crate::api::repo::delete_release::delete_release,
crate::api::repo::list_forks::list_forks,
crate::api::repo::fork_repo::fork_repo,
crate::api::repo::sync_fork::sync_fork,
crate::api::repo::star_repo::star_repo,
crate::api::repo::unstar_repo::unstar_repo,
crate::api::repo::list_stargazers::list_stargazers,
crate::api::repo::watch_repo::watch_repo,
crate::api::repo::unwatch_repo::unwatch_repo,
crate::api::repo::list_watchers::list_watchers,
crate::api::repo::list_members::list_members,
crate::api::repo::add_member::add_member,
crate::api::repo::update_member_role::update_member_role,
crate::api::repo::remove_member::remove_member,
crate::api::repo::leave_repo::leave_repo,
crate::api::repo::list_invitations::list_invitations,
crate::api::repo::create_invitation::create_invitation,
crate::api::repo::revoke_invitation::revoke_invitation,
crate::api::repo::accept_invitation::accept_invitation,
crate::api::repo::list_deploy_keys::list_deploy_keys,
crate::api::repo::add_deploy_key::add_deploy_key,
crate::api::repo::delete_deploy_key::delete_deploy_key,
crate::api::repo::list_webhooks::list_webhooks,
crate::api::repo::create_webhook::create_webhook,
crate::api::repo::update_webhook::update_webhook,
crate::api::repo::delete_webhook::delete_webhook,
crate::api::repo::list_protection_rules::list_protection_rules,
crate::api::repo::get_protection_rule::get_protection_rule,
crate::api::repo::match_protection::match_protection,
crate::api::repo::create_protection_rule::create_protection_rule,
crate::api::repo::update_protection_rule::update_protection_rule,
crate::api::repo::delete_protection_rule::delete_protection_rule,
crate::api::repo::check_branch_merge::check_branch_merge,
crate::api::repo::list_commit_statuses::list_commit_statuses,
crate::api::repo::create_commit_status::create_commit_status,
crate::api::repo::list_commit_comments::list_commit_comments,
crate::api::repo::create_commit_comment::create_commit_comment,
crate::api::repo::resolve_commit_comment::resolve_commit_comment,
crate::api::repo::get_stats::get_stats,
crate::api::repo::refresh_stats::refresh_stats,
),
components(schemas(
ApiEmptyResponse,
ApiErrorResponse,
ApiResponse<RsaResponse>,
ApiResponse<CaptchaResponse>,
ApiResponse<ContextMe>,
ApiResponse<RegisterEmailCodeResponse>,
ApiResponse<RegisterResponse>,
ApiResponse<EmailResponse>,
ApiResponse<Get2FAStatusResponse>,
ApiResponse<Enable2FAResponse>,
ApiResponse<Regenerate2FABackupCodesResponse>,
RsaResponse,
CaptchaQuery,
CaptchaResponse,
LoginParams,
ContextMe,
RegisterEmailCodeParams,
RegisterEmailCodeResponse,
RegisterParams,
RegisterResponse,
EmailResponse,
EmailChangeRequest,
EmailVerifyRequest,
ResetPasswordRequest,
ResetPasswordVerifyParams,
Get2FAStatusResponse,
Enable2FAResponse,
Verify2FAParams,
Disable2FAParams,
Regenerate2FABackupCodesRequest,
Regenerate2FABackupCodesResponse,
// User
ApiResponse<User>,
ApiResponse<UserAvatarResponse>,
ApiResponse<String>,
User,
UpdateUserAccountParams,
UploadUserAvatarParams,
UserAvatarResponse,
ApiResponse<UserAppearance>,
UserAppearance,
UpdateUserAppearanceParams,
ApiResponse<UserProfile>,
UserProfile,
UpdateUserProfileParams,
ApiResponse<UserNotifySetting>,
UserNotifySetting,
UpdateUserNotifySettingParams,
ApiResponse<UserSshKey>,
ApiResponse<Vec<UserSshKey>>,
UserSshKey,
AddSshKeyParams,
ApiResponse<UserGpgKey>,
ApiResponse<Vec<UserGpgKey>>,
UserGpgKey,
AddGpgKeyParams,
ApiResponse<UserSessionInfo>,
ApiResponse<Vec<UserSessionInfo>>,
UserSessionInfo,
ApiResponse<UserDevice>,
ApiResponse<Vec<UserDevice>>,
UserDevice,
ApiResponse<UserOAuthInfo>,
ApiResponse<Vec<UserOAuthInfo>>,
UserOAuthInfo,
ApiResponse<UserSecurityLog>,
ApiResponse<Vec<UserSecurityLog>>,
UserSecurityLog,
ApiResponse<UserPersonalAccessTokenInfo>,
ApiResponse<Vec<UserPersonalAccessTokenInfo>>,
UserPersonalAccessTokenInfo,
// Issues
ApiResponse<Issue>,
ApiResponse<Vec<Issue>>,
Issue,
CreateIssueParams,
IssueListFilters,
UpdateIssueParams,
LockIssueParams,
TransferIssueParams,
ApiResponse<IssueComment>,
ApiResponse<Vec<IssueComment>>,
IssueComment,
CreateCommentParams,
UpdateCommentParams,
ApiResponse<IssueLabel>,
ApiResponse<Vec<IssueLabel>>,
IssueLabel,
CreateLabelParams,
UpdateLabelParams,
ApiResponse<IssueLabelRelation>,
ApiResponse<Vec<IssueLabelRelation>>,
IssueLabelRelation,
ApiResponse<IssueMilestone>,
ApiResponse<Vec<IssueMilestone>>,
IssueMilestone,
CreateMilestoneParams,
UpdateMilestoneParams,
ApiResponse<IssueAssignee>,
ApiResponse<Vec<IssueAssignee>>,
IssueAssignee,
ApiResponse<IssueEvent>,
ApiResponse<Vec<IssueEvent>>,
IssueEvent,
ApiResponse<IssueReaction>,
ApiResponse<Vec<IssueReaction>>,
IssueReaction,
CreateIssueReactionParams,
ApiResponse<IssueSubscriber>,
ApiResponse<Vec<IssueSubscriber>>,
IssueSubscriber,
MuteIssueParams,
ApiResponse<IssueTemplate>,
ApiResponse<Vec<IssueTemplate>>,
IssueTemplate,
CreateTemplateParams,
UpdateTemplateParams,
ApiResponse<IssueRepoRelation>,
ApiResponse<Vec<IssueRepoRelation>>,
IssueRepoRelation,
LinkRepoParams,
ApiResponse<IssuePrRelation>,
ApiResponse<Vec<IssuePrRelation>>,
IssuePrRelation,
LinkPrParams,
// Pull Requests
ApiResponse<PullRequest>,
ApiResponse<Vec<PullRequest>>,
PullRequest,
CreatePrParams,
UpdatePrParams,
PrListFilters,
MergePrParams,
LockPrParams,
ApiResponse<PrCommit>,
ApiResponse<Vec<PrCommit>>,
PrCommit,
ApiResponse<PrFile>,
ApiResponse<Vec<PrFile>>,
PrFile,
ApiResponse<PrStatus>,
PrStatus,
ApiResponse<PrMergeStrategy>,
PrMergeStrategy,
UpdateMergeStrategyParams,
ApiResponse<PrLabel>,
ApiResponse<Vec<PrLabel>>,
PrLabel,
CreatePrLabelParams,
UpdatePrLabelParams,
ApiResponse<PrLabelRelation>,
ApiResponse<Vec<PrLabelRelation>>,
PrLabelRelation,
ApiResponse<PrAssignee>,
ApiResponse<Vec<PrAssignee>>,
PrAssignee,
ApiResponse<PrReview>,
ApiResponse<Vec<PrReview>>,
PrReview,
CreateReviewParams,
ReviewCommentParams,
SubmitReviewParams,
DismissReviewParams,
AddReplyParams,
ApiResponse<PrReviewComment>,
ApiResponse<Vec<PrReviewComment>>,
PrReviewComment,
ApiResponse<PrCheckRun>,
ApiResponse<Vec<PrCheckRun>>,
PrCheckRun,
CreateCheckRunParams,
UpdateCheckRunParams,
ApiResponse<PrEvent>,
ApiResponse<Vec<PrEvent>>,
PrEvent,
ApiResponse<PrReaction>,
ApiResponse<Vec<PrReaction>>,
PrReaction,
CreateReactionParams,
ApiResponse<PrSubscription>,
ApiResponse<Vec<PrSubscription>>,
PrSubscription,
MutePrParams,
// Wiki
ApiResponse<WikiPage>,
ApiResponse<Vec<WikiPage>>,
WikiPage,
CreateWikiPageParams,
UpdateWikiPageParams,
ApiResponse<WikiPageRevision>,
ApiResponse<Vec<WikiPageRevision>>,
ApiResponse<WikiCompareResult>,
WikiCompareResult,
WikiPageRevision,
// Workspaces
ApiResponse<Workspace>,
ApiResponse<Vec<Workspace>>,
ApiResponse<WorkspaceMember>,
ApiResponse<Vec<WorkspaceMember>>,
ApiResponse<CreateInvitationResponse>,
ApiResponse<Vec<WorkspaceInvitation>>,
ApiResponse<WorkspaceInvitation>,
ApiResponse<WorkspaceBilling>,
ApiResponse<WorkspaceCustomBranding>,
ApiResponse<WorkspaceSettings>,
ApiResponse<WorkspaceStats>,
ApiResponse<WorkspaceIntegration>,
ApiResponse<Vec<WorkspaceIntegration>>,
ApiResponse<WorkspaceWebhook>,
ApiResponse<Vec<WorkspaceWebhook>>,
ApiResponse<WorkspaceDomain>,
ApiResponse<Vec<WorkspaceDomain>>,
ApiResponse<WorkspacePendingApproval>,
ApiResponse<Vec<WorkspacePendingApproval>>,
ApiResponse<Vec<WorkspaceAuditLog>>,
Workspace,
CreateWorkspaceParams,
UpdateWorkspaceParams,
TransferOwnerRequest,
WorkspaceMember,
AddMemberParams,
UpdateMemberRoleParams,
WorkspaceInvitation,
CreateInvitationParams,
CreateInvitationResponse,
AcceptInvitationRequest,
WorkspaceBilling,
UpdateBillingParams,
WorkspaceCustomBranding,
UpdateBrandingParams,
WorkspaceSettings,
UpdateWorkspaceSettingsParams,
WorkspaceStats,
WorkspaceIntegration,
CreateIntegrationParams,
UpdateIntegrationParams,
WorkspaceWebhook,
CreateWebhookParams,
UpdateWebhookParams,
WorkspaceDomain,
AddDomainParams,
WorkspacePendingApproval,
RequestApprovalParams,
ReviewApprovalRequest,
WorkspaceAuditLog,
// Repos
ApiResponse<Repo>,
ApiResponse<Vec<Repo>>,
ApiResponse<RepoBranch>,
ApiResponse<Vec<RepoBranch>>,
ApiResponse<RepoTag>,
ApiResponse<Vec<RepoTag>>,
ApiResponse<RepoRelease>,
ApiResponse<Vec<RepoRelease>>,
ApiResponse<RepoFork>,
ApiResponse<Vec<RepoFork>>,
ApiResponse<RepoStar>,
ApiResponse<Vec<RepoStar>>,
ApiResponse<RepoWatch>,
ApiResponse<Vec<RepoWatch>>,
ApiResponse<RepoMember>,
ApiResponse<Vec<RepoMember>>,
ApiResponse<RepoInvitation>,
ApiResponse<Vec<RepoInvitation>>,
ApiResponse<RepoDeployKey>,
ApiResponse<Vec<RepoDeployKey>>,
ApiResponse<RepoWebhook>,
ApiResponse<Vec<RepoWebhook>>,
ApiResponse<BranchProtectionRule>,
ApiResponse<Vec<BranchProtectionRule>>,
ApiResponse<Option<BranchProtectionRule>>,
ApiResponse<BranchMergeCheck>,
ApiResponse<RepoCommitStatus>,
ApiResponse<Vec<RepoCommitStatus>>,
ApiResponse<RepoCommitComment>,
ApiResponse<Vec<RepoCommitComment>>,
ApiResponse<RepoStats>,
Repo,
CreateRepoParams,
UpdateRepoParams,
TransferOwnerParams,
RepoBranch,
CreateBranchParams,
SetBranchProtectionParams,
RepoTag,
CreateTagParams,
RepoRelease,
CreateReleaseParams,
UpdateReleaseParams,
RepoFork,
ForkRepoParams,
RepoStar,
RepoWatch,
WatchParams,
RepoMember,
AddRepoMemberParams,
UpdateRepoMemberRoleParams,
RepoInvitation,
CreateRepoInvitationParams,
AcceptInvitationParams,
RepoDeployKey,
AddDeployKeyParams,
RepoWebhook,
RepoCreateWebhookParams,
RepoUpdateWebhookParams,
BranchProtectionRule,
CreateProtectionRuleParams,
UpdateProtectionRuleParams,
BranchMergeCheck,
RepoCommitStatus,
CreateCommitStatusParams,
RepoCommitComment,
CreateCommitCommentParams,
RepoStats,
))
)]
pub struct OpenApiDoc;
-141
View File
@@ -1,141 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct UserPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// User ID (UUID) to assign/unassign
pub user_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List assignees of a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/assignees",
tag = "Pull Requests",
operation_id = "prListAssignees",
params(PrPath, QP),
responses(
(status = 200, description = "Assignees listed.", body = ApiResponse<Vec<PrAssignee>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_assignees(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let assignees = service
.pr
.pr_assignees(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(assignees)))
}
/// Assign a user to a PR. Assignee is auto-subscribed.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/assignees/{user_id}",
tag = "Pull Requests",
operation_id = "prAssign",
params(UserPath),
responses(
(status = 201, description = "User assigned.", body = ApiResponse<PrAssignee>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 409, description = "User already assigned", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn assign_user(
service: web::Data<AppService>,
session: Session,
path: web::Path<UserPath>,
) -> Result<HttpResponse, AppError> {
let assignee = service
.pr
.pr_assign(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.user_id,
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(assignee)))
}
/// Unassign a user from a PR
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/assignees/{user_id}",
tag = "Pull Requests",
operation_id = "prUnassign",
params(UserPath),
responses(
(status = 200, description = "User unassigned.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Assignee not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn unassign_user(
service: web::Data<AppService>,
session: Session,
path: web::Path<UserPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_unassign(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.user_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("User unassigned".to_string())))
}
-182
View File
@@ -1,182 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrCheckRun;
use crate::service::AppService;
use crate::service::pr::check_runs::{CreateCheckRunParams, UpdateCheckRunParams};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct CheckRunPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// Check run ID (UUID)
pub check_run_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List check runs for a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/check-runs",
tag = "Pull Requests",
operation_id = "prListCheckRuns",
params(PrPath, QP),
responses(
(status = 200, description = "Check runs listed.", body = ApiResponse<Vec<PrCheckRun>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_check_runs(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let runs = service
.pr
.pr_check_runs(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(runs)))
}
/// Create a check run. Requires Member role.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/check-runs",
tag = "Pull Requests",
operation_id = "prCreateCheckRun",
params(PrPath),
request_body(content = CreateCheckRunParams, description = "Check run parameters", content_type = "application/json"),
responses(
(status = 201, description = "Check run created.", body = ApiResponse<PrCheckRun>),
(status = 400, description = "Invalid status", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_check_run(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
params: web::Json<CreateCheckRunParams>,
) -> Result<HttpResponse, AppError> {
let run = service
.pr
.pr_create_check_run(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(run)))
}
/// Update a check run. Requires Member role.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/check-runs/{check_run_id}",
tag = "Pull Requests",
operation_id = "prUpdateCheckRun",
params(CheckRunPath),
request_body(content = UpdateCheckRunParams, description = "Check run update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Check run updated.", body = ApiResponse<PrCheckRun>),
(status = 400, description = "Invalid status", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Check run not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_check_run(
service: web::Data<AppService>,
session: Session,
path: web::Path<CheckRunPath>,
params: web::Json<UpdateCheckRunParams>,
) -> Result<HttpResponse, AppError> {
let run = service
.pr
.pr_update_check_run(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.check_run_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(run)))
}
/// Delete a check run. Requires Admin role.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/check-runs/{check_run_id}",
tag = "Pull Requests",
operation_id = "prDeleteCheckRun",
params(CheckRunPath),
responses(
(status = 200, description = "Check run deleted.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (Admin required)", body = ApiErrorResponse),
(status = 404, description = "Check run not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_check_run(
service: web::Data<AppService>,
session: Session,
path: web::Path<CheckRunPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_delete_check_run(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.check_run_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Check run deleted".to_string())))
}
-55
View File
@@ -1,55 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Close a pull request
///
/// Closes an open PR without merging.
/// Requires write access to the PR.
///
/// Returns the closed PR.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/close",
tag = "Pull Requests",
operation_id = "prClose",
params(PathParams),
responses(
(status = 200, description = "PR closed successfully.", body = ApiResponse<PullRequest>),
(status = 400, description = "PR is not open", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn close(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_close(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-81
View File
@@ -1,81 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::service::pr::core::CreatePrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a pull request
///
/// Creates a new pull request proposing changes from a source branch to a target branch.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - title: PR title (required)
/// - body: PR description in markdown (optional)
/// - source_repo_id: Source repository ID (supports cross-repo PRs from forks)
/// - source_branch: Source branch name (must exist)
/// - target_branch: Target branch name (must exist in the repo)
/// - head_commit_sha: Head commit SHA from the source branch
/// - base_commit_sha: Base commit SHA for diff calculation (optional)
/// - draft: Whether this is a draft PR (optional, defaults to false)
///
/// Effects:
/// - PR is created with auto-incrementing number
/// - PR status tracking is initialized
/// - Author is automatically subscribed
/// - Repository stats are updated
///
/// Returns the created PR with full metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs",
tag = "Pull Requests",
operation_id = "prCreate",
params(PathParams),
request_body(
content = CreatePrParams,
description = "PR creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "PR created successfully. Returns the newly created PR with full metadata.", body = ApiResponse<PullRequest>),
(status = 400, description = "Invalid parameters: empty title, non-existent branch/commit, or invalid fork relationship", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreatePrParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_create(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(pr)))
}
-52
View File
@@ -1,52 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Delete a pull request
///
/// Soft-deletes a PR. Requires Admin role in the repository.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}",
tag = "Pull Requests",
operation_id = "prDelete",
params(PathParams),
responses(
(status = 200, description = "PR deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_delete(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("PR deleted".to_string())))
}
-60
View File
@@ -1,60 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrEvent;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List PR events (timeline). Returns chronological activity log.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/events",
tag = "Pull Requests",
operation_id = "prListEvents",
params(PrPath, QP),
responses(
(status = 200, description = "Events listed.", body = ApiResponse<Vec<PrEvent>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_events(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let events = service
.pr
.pr_list_events(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(events)))
}
-52
View File
@@ -1,52 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Get a pull request by number
///
/// Returns detailed information about a specific pull request.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}",
tag = "Pull Requests",
operation_id = "prGet",
params(PathParams),
responses(
(status = 200, description = "PR retrieved successfully. Returns complete PR with all metadata.", body = ApiResponse<PullRequest>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_get(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-51
View File
@@ -1,51 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrStatus;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Get PR status summary
///
/// Returns the current status of a PR including checks state, mergeability,
/// approval count, and file change statistics.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/status",
tag = "Pull Requests",
operation_id = "prGetStatus",
params(PathParams),
responses(
(status = 200, description = "PR status retrieved successfully.", body = ApiResponse<PrStatus>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR or status not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn get_status(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let status = service
.pr
.pr_status(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(status)))
}
-295
View File
@@ -1,295 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::{PrLabel, PrLabelRelation};
use crate::service::AppService;
use crate::service::pr::labels::{CreatePrLabelParams, UpdatePrLabelParams};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct RepoPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct LabelPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// Label ID (UUID)
pub label_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct LabelIdPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Label ID (UUID)
pub label_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
// ── Repo-level labels ──
/// List PR labels in a repository
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/labels",
tag = "Pull Requests",
operation_id = "prListLabels",
params(RepoPath),
responses(
(status = 200, description = "Labels listed successfully.", body = ApiResponse<Vec<PrLabel>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Repo not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<RepoPath>,
) -> Result<HttpResponse, AppError> {
let labels = service
.pr
.pr_labels(&session, &path.workspace_name, &path.repo_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(labels)))
}
/// Create a PR label. Requires Member role.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/labels",
tag = "Pull Requests",
operation_id = "prCreateLabel",
params(RepoPath),
request_body(content = CreatePrLabelParams, description = "Label creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Label created.", body = ApiResponse<PrLabel>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Repo not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<RepoPath>,
params: web::Json<CreatePrLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.pr
.pr_create_label(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(label)))
}
/// Update a PR label. Requires Admin role.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/labels/{label_id}",
tag = "Pull Requests",
operation_id = "prUpdateLabel",
params(LabelIdPath),
request_body(content = UpdatePrLabelParams, description = "Label update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Label updated.", body = ApiResponse<PrLabel>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (Admin required)", body = ApiErrorResponse),
(status = 404, description = "Label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<LabelIdPath>,
params: web::Json<UpdatePrLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.pr
.pr_update_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(label)))
}
/// Delete a PR label. Requires Admin role.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/labels/{label_id}",
tag = "Pull Requests",
operation_id = "prDeleteLabel",
params(LabelIdPath),
responses(
(status = 200, description = "Label deleted.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (Admin required)", body = ApiErrorResponse),
(status = 404, description = "Label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<LabelIdPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_delete_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted".to_string())))
}
// ── PR-level label relations ──
/// List labels assigned to a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/labels",
tag = "Pull Requests",
operation_id = "prListLabelRelations",
params(PrPath, QP),
responses(
(status = 200, description = "Label relations listed.", body = ApiResponse<Vec<PrLabelRelation>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_label_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let rels = service
.pr
.pr_label_relations(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rels)))
}
/// Assign a label to a PR
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/labels/{label_id}",
tag = "Pull Requests",
operation_id = "prAssignLabel",
params(LabelPath),
responses(
(status = 200, description = "Label assigned.", body = ApiResponse<PrLabelRelation>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn assign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<LabelPath>,
) -> Result<HttpResponse, AppError> {
let rel = service
.pr
.pr_assign_label(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rel)))
}
/// Unassign a label from a PR
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/labels/{label_id}",
tag = "Pull Requests",
operation_id = "prUnassignLabel",
params(LabelPath),
responses(
(status = 200, description = "Label unassigned.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Label not assigned", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn unassign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<LabelPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_unassign_label(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label unassigned".to_string())))
}
-79
View File
@@ -1,79 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::service::pr::core::PrListFilters;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Filter by PR state ("open", "closed", "merged")
pub state: Option<String>,
/// Filter by author user ID
pub author_id: Option<uuid::Uuid>,
/// Filter by draft status
pub draft: Option<bool>,
/// Maximum number of PRs to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of PRs to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List pull requests in a repository
///
/// Returns a paginated list of pull requests, sorted by number (newest first).
/// Supports filtering by state, author, and draft status.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs",
tag = "Pull Requests",
operation_id = "prList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "PRs listed successfully. Returns filtered array of PR objects.", body = ApiResponse<Vec<PullRequest>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let filters = PrListFilters {
state: query.state.clone(),
author_id: query.author_id,
draft: query.draft,
};
let prs = service
.pr
.pr_list(
&session,
&path.workspace_name,
&path.repo_name,
filters,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(prs)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrCommit;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of commits to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of commits to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List commits in a pull request
///
/// Returns a paginated list of all commits included in the PR, sorted by position.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/commits",
tag = "Pull Requests",
operation_id = "prListCommits",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Commits listed successfully.", body = ApiResponse<Vec<PrCommit>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_commits(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let commits = service
.pr
.pr_commits(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(commits)))
}
-67
View File
@@ -1,67 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrFile;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of files to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of files to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List changed files in a pull request
///
/// Returns a paginated list of all files changed in the PR, sorted by path.
/// Includes additions, deletions, and patch diffs for each file.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/files",
tag = "Pull Requests",
operation_id = "prListFiles",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Files listed successfully.", body = ApiResponse<Vec<PrFile>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_files(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let files = service
.pr
.pr_files(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(files)))
}
-72
View File
@@ -1,72 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LockPrParams {
/// Whether to lock (true) or unlock (false) the PR conversation
pub locked: bool,
}
/// Lock or unlock a pull request conversation
///
/// When locked, only repository maintainers and admins can comment on the PR.
/// Requires write access to the PR.
///
/// Returns the updated PR.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/lock",
tag = "Pull Requests",
operation_id = "prLock",
params(PathParams),
request_body(
content = LockPrParams,
description = "Lock/unlock parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "PR lock status updated.", body = ApiResponse<PullRequest>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn lock(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LockPrParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_lock(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.locked,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-60
View File
@@ -1,60 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Mark a draft PR as ready for review
///
/// Converts a draft pull request to a ready-for-review state.
/// Requires write access to the PR.
///
/// Effects:
/// - Draft flag is set to false
/// - A "DraftReady" event is logged
/// - Reviewers are notified that the PR is ready
///
/// Returns the updated PR.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/ready",
tag = "Pull Requests",
operation_id = "prMarkReady",
params(PathParams),
responses(
(status = 200, description = "PR marked as ready for review.", body = ApiResponse<PullRequest>),
(status = 400, description = "PR is already ready for review", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn mark_ready(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_mark_ready(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-79
View File
@@ -1,79 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::service::pr::core::MergePrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Merge a pull request
///
/// Merges the source branch into the target branch.
/// Requires at least Maintainer role in the repository.
///
/// Branch protection rules are enforced:
/// - Required approvals count (self-approval not allowed)
/// - Required status checks must pass
/// - Admins can bypass protection rules
///
/// Parameters:
/// - strategy: Merge strategy ("merge", "squash", "rebase", default: "merge")
/// - squash_title: Custom title for squash merge (optional)
/// - squash_message: Custom message for squash merge (optional)
/// - delete_source_branch: Delete source branch after merge (optional, only same-repo)
///
/// Returns the merged PR with merge commit SHA.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/merge",
tag = "Pull Requests",
operation_id = "prMerge",
params(PathParams),
request_body(
content = MergePrParams,
description = "Merge parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "PR merged successfully. Returns the merged PR with merge commit SHA.", body = ApiResponse<PullRequest>),
(status = 400, description = "Cannot merge: PR not open, is draft, or branch protection requirements not met", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Maintainer role or higher)", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error or git merge failure", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn merge(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<MergePrParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_merge(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-104
View File
@@ -1,104 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrMergeStrategy;
use crate::service::AppService;
use crate::service::pr::merge_strategy::UpdateMergeStrategyParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Get PR merge strategy
///
/// Returns the current merge strategy settings for a PR.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/merge-strategy",
tag = "Pull Requests",
operation_id = "prGetMergeStrategy",
params(PathParams),
responses(
(status = 200, description = "Merge strategy retrieved successfully.", body = ApiResponse<PrMergeStrategy>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "PR or merge strategy not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn get_merge_strategy(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let strategy = service
.pr
.pr_merge_strategy(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(strategy)))
}
/// Update PR merge strategy
///
/// Updates the merge strategy settings for a PR.
/// Requires write access to the PR.
///
/// Updatable fields:
/// - strategy: Merge strategy type ("merge", "squash", "rebase")
/// - auto_merge: Enable auto-merge when all checks pass
/// - squash_title: Custom title for squash merge
/// - squash_message: Custom message for squash merge
/// - delete_source_branch: Delete source branch after merge
/// - merge_when_checks_pass: Auto-merge when checks pass
///
/// All fields are optional; only provided fields are updated.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/merge-strategy",
tag = "Pull Requests",
operation_id = "prUpdateMergeStrategy",
params(PathParams),
request_body(
content = UpdateMergeStrategyParams,
description = "Merge strategy update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Merge strategy updated successfully.", body = ApiResponse<PrMergeStrategy>),
(status = 400, description = "Invalid parameters: unsupported strategy", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_merge_strategy(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateMergeStrategyParams>,
) -> Result<HttpResponse, AppError> {
let strategy = service
.pr
.pr_update_merge_strategy(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(strategy)))
}
-161
View File
@@ -1,161 +0,0 @@
pub mod assignees;
pub mod check_runs;
pub mod close;
pub mod create;
pub mod delete;
pub mod events;
pub mod get;
pub mod get_status;
pub mod labels;
pub mod list;
pub mod list_commits;
pub mod list_files;
pub mod lock;
pub mod mark_ready;
pub mod merge;
pub mod merge_strategy;
pub mod reactions;
pub mod reopen;
pub mod reviews;
pub mod subscriptions;
pub mod update;
use actix_web::web;
/// Configure PR-level routes under `/workspaces/{workspace_name}/repos/{repo_name}/prs`
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/prs")
// Repo-level labels
.route("/labels", web::get().to(labels::list_labels))
.route("/labels", web::post().to(labels::create_label))
.route("/labels/{label_id}", web::put().to(labels::update_label))
.route("/labels/{label_id}", web::delete().to(labels::delete_label))
// Core
.route("", web::get().to(list::list))
.route("", web::post().to(create::create))
.route("/{number}", web::get().to(get::get))
.route("/{number}", web::put().to(update::update))
.route("/{number}", web::delete().to(delete::delete))
.route("/{number}/close", web::post().to(close::close))
.route("/{number}/reopen", web::post().to(reopen::reopen))
.route("/{number}/ready", web::post().to(mark_ready::mark_ready))
.route("/{number}/lock", web::put().to(lock::lock))
.route("/{number}/merge", web::post().to(merge::merge))
// Commits & Files
.route(
"/{number}/commits",
web::get().to(list_commits::list_commits),
)
.route("/{number}/files", web::get().to(list_files::list_files))
// Status & Merge Strategy
.route("/{number}/status", web::get().to(get_status::get_status))
.route(
"/{number}/merge-strategy",
web::get().to(merge_strategy::get_merge_strategy),
)
.route(
"/{number}/merge-strategy",
web::put().to(merge_strategy::update_merge_strategy),
)
// Labels (PR-level)
.route(
"/{number}/labels",
web::get().to(labels::list_label_relations),
)
.route(
"/{number}/labels/{label_id}",
web::post().to(labels::assign_label),
)
.route(
"/{number}/labels/{label_id}",
web::delete().to(labels::unassign_label),
)
// Assignees
.route(
"/{number}/assignees",
web::get().to(assignees::list_assignees),
)
.route(
"/{number}/assignees/{user_id}",
web::post().to(assignees::assign_user),
)
.route(
"/{number}/assignees/{user_id}",
web::delete().to(assignees::unassign_user),
)
// Reviews
.route("/{number}/reviews", web::get().to(reviews::list_reviews))
.route("/{number}/reviews", web::post().to(reviews::create_review))
.route(
"/{number}/reviews/{review_id}/submit",
web::post().to(reviews::submit_review),
)
.route(
"/{number}/reviews/{review_id}/dismiss",
web::post().to(reviews::dismiss_review),
)
.route(
"/{number}/reviews/{review_id}/comments",
web::get().to(reviews::list_review_comments),
)
.route(
"/{number}/comments/{comment_id}/reply",
web::post().to(reviews::add_review_reply),
)
.route(
"/{number}/comments/{comment_id}",
web::put().to(reviews::update_review_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::delete().to(reviews::delete_review_comment),
)
// Check Runs
.route(
"/{number}/check-runs",
web::get().to(check_runs::list_check_runs),
)
.route(
"/{number}/check-runs",
web::post().to(check_runs::create_check_run),
)
.route(
"/{number}/check-runs/{check_run_id}",
web::put().to(check_runs::update_check_run),
)
.route(
"/{number}/check-runs/{check_run_id}",
web::delete().to(check_runs::delete_check_run),
)
// Events
.route("/{number}/events", web::get().to(events::list_events))
// Reactions
.route(
"/{number}/reactions",
web::get().to(reactions::list_reactions),
)
.route(
"/{number}/reactions",
web::post().to(reactions::add_reaction),
)
.route(
"/{number}/reactions/{reaction_id}",
web::delete().to(reactions::remove_reaction),
)
// Subscriptions
.route(
"/{number}/subscriptions",
web::get().to(subscriptions::list_subscriptions),
)
.route(
"/{number}/subscribe",
web::post().to(subscriptions::subscribe),
)
.route(
"/{number}/subscribe",
web::delete().to(subscriptions::unsubscribe),
)
.route("/{number}/mute", web::put().to(subscriptions::mute)),
);
}
-143
View File
@@ -1,143 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrReaction;
use crate::service::AppService;
use crate::service::pr::reactions::CreateReactionParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReactionPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// Reaction ID (UUID)
pub reaction_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List reactions on a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reactions",
tag = "Pull Requests",
operation_id = "prListReactions",
params(PrPath, QP),
responses(
(status = 200, description = "Reactions listed.", body = ApiResponse<Vec<PrReaction>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_reactions(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let reactions = service
.pr
.pr_reactions(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(reactions)))
}
/// Add a reaction to a PR
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reactions",
tag = "Pull Requests",
operation_id = "prAddReaction",
params(PrPath),
request_body(content = CreateReactionParams, description = "Reaction parameters", content_type = "application/json"),
responses(
(status = 201, description = "Reaction added.", body = ApiResponse<PrReaction>),
(status = 400, description = "Invalid content or target type", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn add_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
params: web::Json<CreateReactionParams>,
) -> Result<HttpResponse, AppError> {
let reaction = service
.pr
.pr_add_reaction(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(reaction)))
}
/// Remove a reaction. Only the reaction author can remove it.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reactions/{reaction_id}",
tag = "Pull Requests",
operation_id = "prRemoveReaction",
params(ReactionPath),
responses(
(status = 200, description = "Reaction removed.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Cannot remove another user's reaction", body = ApiErrorResponse),
(status = 404, description = "Reaction not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn remove_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReactionPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_remove_reaction(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.reaction_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Reaction removed".to_string())))
}
-55
View File
@@ -1,55 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Reopen a pull request
///
/// Reopens a closed (but not merged) PR.
/// Requires write access to the PR.
///
/// Returns the reopened PR.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reopen",
tag = "Pull Requests",
operation_id = "prReopen",
params(PathParams),
responses(
(status = 200, description = "PR reopened successfully.", body = ApiResponse<PullRequest>),
(status = 400, description = "PR is not closed or already merged", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn reopen(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_reopen(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-343
View File
@@ -1,343 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::{PrReview, PrReviewComment};
use crate::service::AppService;
use crate::service::pr::reviews::{
AddReplyParams, CreateReviewParams, DismissReviewParams, SubmitReviewParams,
};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReviewPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// Review ID (UUID)
pub review_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct CommentPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
/// Comment ID (UUID)
pub comment_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List reviews on a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reviews",
tag = "Pull Requests",
operation_id = "prListReviews",
params(PrPath, QP),
responses(
(status = 200, description = "Reviews listed.", body = ApiResponse<Vec<PrReview>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_reviews(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let reviews = service
.pr
.pr_list_reviews(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(reviews)))
}
/// Create a review. States: pending, approved, changes_requested, commented. Authors cannot approve their own PRs.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reviews",
tag = "Pull Requests",
operation_id = "prCreateReview",
params(PrPath),
request_body(content = CreateReviewParams, description = "Review parameters", content_type = "application/json"),
responses(
(status = 201, description = "Review created.", body = ApiResponse<PrReview>),
(status = 400, description = "Invalid state or self-approval", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_review(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
params: web::Json<CreateReviewParams>,
) -> Result<HttpResponse, AppError> {
let review = service
.pr
.pr_create_review(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(review)))
}
/// Submit a pending review. Changes its state to approved, changes_requested, or commented.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reviews/{review_id}/submit",
tag = "Pull Requests",
operation_id = "prSubmitReview",
params(ReviewPath),
request_body(content = SubmitReviewParams, description = "Submit parameters", content_type = "application/json"),
responses(
(status = 200, description = "Review submitted.", body = ApiResponse<PrReview>),
(status = 400, description = "Invalid state or self-approval", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Review not found or already submitted", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn submit_review(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReviewPath>,
params: web::Json<SubmitReviewParams>,
) -> Result<HttpResponse, AppError> {
let review = service
.pr
.pr_submit_review(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.review_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(review)))
}
/// Dismiss a submitted review. Requires Admin role.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reviews/{review_id}/dismiss",
tag = "Pull Requests",
operation_id = "prDismissReview",
params(ReviewPath),
request_body(content = DismissReviewParams, description = "Dismiss parameters", content_type = "application/json"),
responses(
(status = 200, description = "Review dismissed.", body = ApiResponse<PrReview>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (Admin required)", body = ApiErrorResponse),
(status = 404, description = "Review not found or not submitted", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn dismiss_review(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReviewPath>,
params: web::Json<DismissReviewParams>,
) -> Result<HttpResponse, AppError> {
let review = service
.pr
.pr_dismiss_review(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.review_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(review)))
}
/// List comments for a specific review
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/reviews/{review_id}/comments",
tag = "Pull Requests",
operation_id = "prListReviewComments",
params(ReviewPath, QP),
responses(
(status = 200, description = "Review comments listed.", body = ApiResponse<Vec<PrReviewComment>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Review not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_review_comments(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReviewPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let comments = service
.pr
.pr_review_comments(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.review_id,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comments)))
}
/// Reply to a review comment
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/comments/{comment_id}/reply",
tag = "Pull Requests",
operation_id = "prAddReviewReply",
params(CommentPath),
request_body(content = AddReplyParams, description = "Reply parameters", content_type = "application/json"),
responses(
(status = 201, description = "Reply added.", body = ApiResponse<PrReviewComment>),
(status = 400, description = "Empty body", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn add_review_reply(
service: web::Data<AppService>,
session: Session,
path: web::Path<CommentPath>,
params: web::Json<AddReplyParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.pr
.pr_add_review_reply(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.comment_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(comment)))
}
/// Update a review comment (own comments only)
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/comments/{comment_id}",
tag = "Pull Requests",
operation_id = "prUpdateReviewComment",
params(CommentPath),
request_body(content = AddReplyParams, description = "Update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Comment updated.", body = ApiResponse<PrReviewComment>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Cannot edit other users' comments", body = ApiErrorResponse),
(status = 404, description = "Comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_review_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<CommentPath>,
params: web::Json<AddReplyParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.pr
.pr_update_review_comment(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.comment_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comment)))
}
/// Delete a review comment. Own comments or Admin role.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/comments/{comment_id}",
tag = "Pull Requests",
operation_id = "prDeleteReviewComment",
params(CommentPath),
responses(
(status = 200, description = "Comment deleted.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 403, description = "Cannot delete other users' comments (Admin required)", body = ApiErrorResponse),
(status = 404, description = "Comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_review_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<CommentPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_delete_review_comment(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
path.comment_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Comment deleted".to_string())))
}
-156
View File
@@ -1,156 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PrSubscription;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PrPath {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QP {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List subscriptions on a PR
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/subscriptions",
tag = "Pull Requests",
operation_id = "prListSubscriptions",
params(PrPath, QP),
responses(
(status = 200, description = "Subscriptions listed.", body = ApiResponse<Vec<PrSubscription>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_subscriptions(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
query: web::Query<QP>,
) -> Result<HttpResponse, AppError> {
let subs = service
.pr
.pr_subscriptions(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(subs)))
}
/// Subscribe to a PR to receive notifications
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/subscribe",
tag = "Pull Requests",
operation_id = "prSubscribe",
params(PrPath),
responses(
(status = 200, description = "Subscribed.", body = ApiResponse<PrSubscription>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "PR not found", body = ApiErrorResponse),
(status = 409, description = "Already subscribed", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn subscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
) -> Result<HttpResponse, AppError> {
let sub = service
.pr
.pr_subscribe(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(sub)))
}
/// Unsubscribe from a PR
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/subscribe",
tag = "Pull Requests",
operation_id = "prUnsubscribe",
params(PrPath),
responses(
(status = 200, description = "Unsubscribed.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Not subscribed", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn unsubscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_unsubscribe(&session, &path.workspace_name, &path.repo_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Unsubscribed".to_string())))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct MutePrParams {
/// Whether to mute (true) or unmute (false) notifications
pub muted: bool,
}
/// Mute or unmute notifications for a PR
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/mute",
tag = "Pull Requests",
operation_id = "prMute",
params(PrPath),
request_body(content = MutePrParams, description = "Mute/unmute parameters", content_type = "application/json"),
responses(
(status = 200, description = "Mute status updated.", body = ApiResponse<String>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Not subscribed", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn mute(
service: web::Data<AppService>,
session: Session,
path: web::Path<PrPath>,
params: web::Json<MutePrParams>,
) -> Result<HttpResponse, AppError> {
service
.pr
.pr_mute(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.muted,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Mute status updated".to_string())))
}
-69
View File
@@ -1,69 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::prs::PullRequest;
use crate::service::AppService;
use crate::service::pr::core::UpdatePrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// PR number (unique within the repository)
pub number: i64,
}
/// Update a pull request
///
/// Updates an existing pull request's metadata such as title, body, target branch, and draft status.
/// Requires write access to the PR (author or repository member).
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated PR with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}",
tag = "Pull Requests",
operation_id = "prUpdate",
params(PathParams),
request_body(
content = UpdatePrParams,
description = "PR update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "PR updated successfully. Returns the updated PR with full metadata.", body = ApiResponse<PullRequest>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this PR", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or PR not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdatePrParams>,
) -> Result<HttpResponse, AppError> {
let pr = service
.pr
.pr_update(
&session,
&path.workspace_name,
&path.repo_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(pr)))
}
-61
View File
@@ -1,61 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoInvitation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, ToSchema)]
pub struct AcceptInvitationParams {
/// Invitation token (received via email)
pub token: String,
}
/// Accept a repository invitation
///
/// Accepts a pending repository invitation using the token received via email.
/// Requires authentication and a verified email address matching the invitation.
///
/// Effects:
/// - User is added as a repository member with the invited role
/// - User is added to the workspace if not already a member
/// - Invitation is marked as accepted
///
/// Returns the accepted invitation with full metadata.
#[utoipa::path(
post,
path = "/api/v1/repos/invitations/accept",
tag = "Repos",
operation_id = "repoAcceptInvitation",
request_body(
content = AcceptInvitationParams,
description = "Invitation acceptance parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Invitation accepted successfully. User is now a member of the repository.", body = ApiResponse<RepoInvitation>),
(status = 400, description = "Invalid or expired token, or email doesn't match invitation", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Invitation not found", body = ApiErrorResponse),
(status = 409, description = "User is already a member of this repository", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn accept_invitation(
service: web::Data<AppService>,
session: Session,
params: web::Json<AcceptInvitationParams>,
) -> Result<HttpResponse, AppError> {
let invitation = service
.repo
.repo_accept_invitation(&session, &params.token)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(invitation)))
}
-77
View File
@@ -1,77 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoDeployKey;
use crate::service::AppService;
use crate::service::repo::deploy_keys::AddDeployKeyParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Add a deploy key to a repository
///
/// Adds an SSH public key for automated deployments and CI/CD access to the repository.
/// Requires Admin role or higher in the repository.
///
/// Parameters:
/// - title: Human-readable name for the deploy key (1-100 characters)
/// - key: SSH public key in OpenSSH format (e.g., "ssh-rsa AAAA...")
/// - read_only: Whether the key has read-only access (default: true)
///
/// Effects:
/// - Deploy key is added to the repository
/// - Key can be used for Git operations (clone, fetch, push if not read-only)
/// - Key fingerprint is calculated and stored
///
/// Returns the created deploy key with metadata including fingerprint.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/deploy-keys",
tag = "Repos",
operation_id = "repoAddDeployKey",
params(PathParams),
request_body(
content = AddDeployKeyParams,
description = "Deploy key addition parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Deploy key added successfully. Returns the newly created deploy key with metadata.", body = ApiResponse<RepoDeployKey>),
(status = 400, description = "Invalid parameters: title too long or invalid SSH key format", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 409, description = "Deploy key with this fingerprint already exists", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_deploy_key(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<AddDeployKeyParams>,
) -> Result<HttpResponse, AppError> {
let key = service
.repo
.repo_add_deploy_key(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(key)))
}
-72
View File
@@ -1,72 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoMember;
use crate::service::AppService;
use crate::service::repo::members::AddRepoMemberParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Add a member to a repository
///
/// Grants a user access to the repository with a specific role.
/// Requires Admin role or higher in the repository.
///
/// Requirements:
/// - User must exist in the system
/// - User must be a member of the workspace
/// - Role must be one of: "read", "write", "admin" (cannot assign "owner")
///
/// Returns the created member record with user information and role.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/members",
tag = "Repos",
operation_id = "repoAddMember",
params(PathParams),
request_body(
content = AddRepoMemberParams,
description = "Member addition parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Member added successfully. Returns the newly created member record with user information and role.", body = ApiResponse<RepoMember>),
(status = 400, description = "Invalid parameters: invalid role or user doesn't exist", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or user not found", body = ApiErrorResponse),
(status = 409, description = "User is already a member of this repository", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_member(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<AddRepoMemberParams>,
) -> Result<HttpResponse, AppError> {
let member = service
.repo
.repo_add_member(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(member)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Archive a repository
///
/// Marks a repository as archived, making it read-only. All write operations (push, create issues, etc.) are disabled.
/// Requires Owner role in the repository.
///
/// Effects:
/// - Repository status changes to "archived"
/// - All write operations are blocked
/// - Repository remains visible based on its visibility setting
/// - Can be unarchived later by repository owners
///
/// Returns success message on completion.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/archive",
tag = "Repos",
operation_id = "repoArchive",
params(PathParams),
responses(
(status = 200, description = "Repository archived successfully. Repository is now read-only.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Owner role)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 409, description = "Repository is already archived", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn archive(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.repo
.repo_archive(&session, &path.workspace_name, &path.repo_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Repository archived".to_string())))
}
-56
View File
@@ -1,56 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::repo::protection::BranchMergeCheck;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub target_branch: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub pr_number: i64,
}
/// Check if a branch meets merge requirements
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{target_branch}/merge-check",
tag = "Repos",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Merge check completed successfully", body = ApiResponse<BranchMergeCheck>),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 403, description = "Forbidden", body = ApiErrorResponse),
(status = 404, description = "Repository or pull request not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn check_branch_merge(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let check = service
.repo
.repo_check_branch_merge_allowed(
&session,
&path.workspace_name,
&path.repo_name,
&path.target_branch,
query.pr_number,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(check)))
}
-69
View File
@@ -1,69 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::Repo;
use crate::service::AppService;
use crate::service::repo::core::CreateRepoParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
/// Create a new repository
///
/// Creates a new Git repository within the specified workspace. The authenticated user becomes the repository owner.
///
/// Requirements:
/// - User must have at least Member role in the workspace
/// - Repository name must be unique within the workspace
/// - Name must be 1-100 characters, alphanumeric, hyphens, underscores, and dots allowed
///
/// Optional parameters:
/// - description: Repository description (max 500 characters)
/// - visibility: "public", "private", or "internal" (defaults to workspace setting)
/// - default_branch: Default branch name (defaults to "main")
///
/// Returns the created repository with full metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos",
tag = "Repos",
operation_id = "repoCreate",
params(PathParams),
request_body(
content = CreateRepoParams,
description = "Repository creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Repository created successfully. Returns the newly created repository with full metadata.", body = ApiResponse<Repo>),
(status = 400, description = "Invalid parameters: name too long, invalid characters, or invalid visibility", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to create repositories in this workspace", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 409, description = "Repository with this name already exists in the workspace", body = ApiErrorResponse),
(status = 500, description = "Internal server error or Git initialization failed", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateRepoParams>,
) -> Result<HttpResponse, AppError> {
let repo = service
.repo
.repo_create(&session, &path.workspace_name, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(repo)))
}
-56
View File
@@ -1,56 +0,0 @@
use crate::models::common::DEFAULT_REVISION;
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateBranchBody {
pub name: String,
pub start_point: Option<String>,
}
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches",
tag = "Repos",
operation_id = "repoCreateBranch",
params(PathParams),
request_body(content = CreateBranchBody),
responses(
(status = 201, description = "Branch created", body = ApiResponse<crate::pb::repo::Branch>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Repository not found", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_branch(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
body: web::Json<CreateBranchBody>,
) -> Result<HttpResponse, AppError> {
let start_point = body.start_point.as_deref().unwrap_or(DEFAULT_REVISION);
let result = service
.repo
.git_create_branch(
&session,
&path.workspace_name,
&path.repo_name,
&body.name,
start_point,
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-77
View File
@@ -1,77 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoCommitComment;
use crate::service::AppService;
use crate::service::repo::commit_status::CreateCommitCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a commit comment
///
/// Creates a new comment on a specific commit. Comments can be general or inline (attached to a specific file and line).
/// Requires Write role or higher in the repository.
///
/// Parameters:
/// - commit_sha: Commit SHA to comment on (must exist in repository)
/// - body: Comment body in markdown format (1-10000 characters)
/// - path: File path for inline comments (optional)
/// - line: Line number for inline comments (optional, requires path)
///
/// Effects:
/// - Comment is attached to the commit
/// - Comment author receives notifications for replies
/// - Inline comments appear in code review interfaces
///
/// Returns the created comment with metadata including ID and timestamps.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/comments",
tag = "Repos",
operation_id = "repoCreateCommitComment",
params(PathParams),
request_body(
content = CreateCommitCommentParams,
description = "Commit comment creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Commit comment created successfully. Returns the newly created comment with metadata.", body = ApiResponse<RepoCommitComment>),
(status = 400, description = "Invalid parameters: body too long, line without path, or commit doesn't exist", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Write role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, commit, or file path not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_commit_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCommitCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.repo
.repo_create_commit_comment(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(comment)))
}
-79
View File
@@ -1,79 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoCommitStatus;
use crate::service::AppService;
use crate::service::repo::commit_status::CreateCommitStatusParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a commit status
///
/// Creates a new status check for a specific commit, typically used by CI/CD systems.
/// Requires Write role or higher in the repository.
///
/// Parameters:
/// - commit_sha: Commit SHA to attach the status to (must exist in repository)
/// - state: Status state ("pending", "success", "failure", "error")
/// - context: Status context name (e.g., "ci/build", "ci/test") - must be unique per commit
/// - description: Human-readable description of the status (optional, max 500 characters)
/// - target_url: URL with detailed information about the status (optional)
///
/// Effects:
/// - Status is attached to the commit
/// - Can be used by branch protection rules to enforce status checks
/// - Multiple statuses can exist for the same commit with different contexts
///
/// Returns the created status with metadata including ID and timestamps.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/statuses",
tag = "Repos",
operation_id = "repoCreateCommitStatus",
params(PathParams),
request_body(
content = CreateCommitStatusParams,
description = "Commit status creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Commit status created successfully. Returns the newly created status with metadata.", body = ApiResponse<RepoCommitStatus>),
(status = 400, description = "Invalid parameters: invalid state, context too long, or commit doesn't exist", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Write role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or commit not found", body = ApiErrorResponse),
(status = 409, description = "Status with this context already exists for this commit (use update instead)", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_commit_status(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCommitStatusParams>,
) -> Result<HttpResponse, AppError> {
let status = service
.repo
.repo_create_commit_status(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(status)))
}
-76
View File
@@ -1,76 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::RepoInvitation;
use crate::service::AppService;
use crate::service::repo::invitations::CreateRepoInvitationParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a repository invitation
///
/// Sends an invitation email to a user to join the repository with a specific role.
/// Requires Admin role or higher in the repository.
///
/// Parameters:
/// - email: Email address of the invitee (must be a valid email)
/// - role: Role to assign when invitation is accepted ("read", "write", "admin")
///
/// Effects:
/// - Invitation email is sent to the invitee
/// - Invitation expires after 7 days
/// - Invitee must have a verified email to accept
///
/// Returns the created invitation with metadata including expiration date.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/invitations",
tag = "Repos",
operation_id = "repoCreateInvitation",
params(PathParams),
request_body(
content = CreateRepoInvitationParams,
description = "Invitation creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Invitation created successfully. Returns the newly created invitation with metadata.", body = ApiResponse<RepoInvitation>),
(status = 400, description = "Invalid parameters: invalid email format or invalid role", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 409, description = "User is already a member or has a pending invitation", body = ApiErrorResponse),
(status = 500, description = "Internal server error or email sending failed", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_invitation(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateRepoInvitationParams>,
) -> Result<HttpResponse, AppError> {
let invitation = service
.repo
.repo_create_invitation(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(invitation)))
}
-76
View File
@@ -1,76 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::repos::BranchProtectionRule;
use crate::service::AppService;
use crate::service::repo::protection::CreateProtectionRuleParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a branch protection rule
///
/// Creates a new branch protection rule that enforces policies on matching branches.
/// Requires Admin role or higher in the repository.
///
/// Parameters:
/// - pattern: Branch name pattern (supports wildcards like "feature/*", "release/**")
/// - required_approvals: Number of required approvals before merging (0-10)
/// - require_status_checks: Whether status checks must pass
/// - required_status_checks: List of required status check contexts
/// - restrict_pushes: Restrict who can push to matching branches
/// - allow_force_pushes: Allow force pushes (only if restrict_pushes is false)
/// - allow_deletions: Allow branch deletion (only if restrict_pushes is false)
///
/// Returns the created protection rule with full configuration.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/protection-rules",
tag = "Repos",
operation_id = "repoCreateProtectionRule",
params(PathParams),
request_body(
content = CreateProtectionRuleParams,
description = "Protection rule creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Protection rule created successfully. Returns the newly created protection rule with full configuration.", body = ApiResponse<BranchProtectionRule>),
(status = 400, description = "Invalid parameters: invalid pattern, negative approvals count, or conflicting settings", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 409, description = "Protection rule with this pattern already exists", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_protection_rule(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateProtectionRuleParams>,
) -> Result<HttpResponse, AppError> {
let rule = service
.repo
.repo_create_protection_rule(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(rule)))
}

Some files were not shown because too many files have changed in this diff Show More