Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d4f06b28 | |||
| 705d40d50f | |||
| 40c09a3dc2 | |||
| cc4479bf06 | |||
| 22000c099e | |||
| e8e7c9aa36 | |||
| fdb25cf999 | |||
| 455707591a | |||
| 4a3277471c | |||
| 3113d684a0 | |||
| aba3d30153 | |||
| 040eeac850 | |||
| 8bba084ee0 | |||
| 89ce3d0bf2 | |||
| f6c1710f97 | |||
| f5492fcf3a | |||
| 3152ade88f | |||
| 435fe28f0f | |||
| 1eed18ac0b | |||
| ba5bef267a | |||
| 4f79486cc7 | |||
| 9082563055 | |||
| 2faefb4138 | |||
| ac8f53327b | |||
| 1a71d4d413 | |||
| ac4acd81ac | |||
| ed90019f79 | |||
| 7b75aa239c | |||
| dc457e6744 | |||
| 99f71d0d5f | |||
| d143275f30 | |||
| cbdb35a76d | |||
| 8e99096941 | |||
| 51afb62d8c | |||
| ebe419852f | |||
| 303d0c7b6a | |||
| 4c3479e822 | |||
| a02c7e3e58 | |||
| 71ee3afceb | |||
| 95c3bbf8c5 | |||
| 4a570e54c9 | |||
| 22cf1aab38 | |||
| d24364113b | |||
| b214119c3d | |||
| 537edf94c4 | |||
| f3d7233ffd | |||
| 3f0327454b | |||
| 2811d50305 | |||
| 9666a874ca | |||
| 2a724446ce | |||
| a4d1850c58 | |||
| 8befe3a64a | |||
| a8b202108b | |||
| 8f7f8c5fa6 | |||
| d02f51e1e1 | |||
| 435ef08ca7 | |||
| d2af38e7d9 | |||
| bb33d294d5 | |||
| 40b85b533b | |||
| 194cb41371 | |||
| e75b257818 | |||
| 34774148f0 | |||
| 0a8095bf0c | |||
| 0ecb311894 | |||
| e751e124b1 | |||
| ff9288d93b | |||
| 7435ebc62e | |||
| c3a333f4c1 | |||
| b27fcdb7ce | |||
| 74edae4d4d | |||
| 4cf53595fd | |||
| 5aaa60adb6 | |||
| 67cf8ad771 | |||
| 61cd784f57 | |||
| 2c10a70b59 | |||
| c7b6803c24 | |||
| c056878e29 | |||
| 3057235b0d | |||
| c8c93fa10f | |||
| 60c570c5fb | |||
| 3f75d06efc | |||
| 98a0bd0637 | |||
| 3d443b3092 | |||
| 032a1f3bdc | |||
| 6687db05c4 | |||
| e475ab24ae | |||
| ad26508b82 | |||
| 30b995bf2b | |||
| 63c4e832e8 | |||
| 5ca3c80114 | |||
| a4c8488781 | |||
| 0d900b50d3 | |||
| e95e612a44 | |||
| 37debe909c | |||
| df55a2c7c5 | |||
| 379b262f90 | |||
| d1b7f9787f | |||
| 8f25cdcc98 | |||
| cc1485d8c1 | |||
| 759af569d1 | |||
| ba779a8fc1 | |||
| 1bff03696c | |||
| bb77c65244 | |||
| fbccca11bb | |||
| c5c492463e | |||
| 9af651d2be | |||
| 502dfa40d9 | |||
| d831d01240 | |||
| f05f0e08a8 | |||
| 3c4962c368 | |||
| edc3d0582d | |||
| 45bfdb1232 | |||
| 0075c616d9 | |||
| f4644826cb | |||
| 7d12b42408 | |||
| dcb02ce52c | |||
| daec39b756 | |||
| 5c799e8fb1 | |||
| f1bbfd064a | |||
| 774fee24fd | |||
| 69b554f4a6 |
@@ -0,0 +1,31 @@
|
|||||||
|
# EditorConfig https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.ps1]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{mak,Makefile}]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.bat]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
[*.sh]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: Architecture Decision Record (ADR)
|
||||||
|
about: Propose or document an architectural decision
|
||||||
|
title: '[ADR] '
|
||||||
|
labels: 'architecture, decision'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## ADR Number
|
||||||
|
ADR-XXXX
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- [ ] Proposed
|
||||||
|
- [ ] Accepted
|
||||||
|
- [ ] Deprecated
|
||||||
|
- [ ] Superseded by ADR-XXXX
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Describe the issue or problem that motivates this decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
State the architecture decision and provide rationale.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
### Positive
|
||||||
|
- List positive consequences
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- List negative consequences or trade-offs
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- List neutral aspects
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
### Alternative 1
|
||||||
|
- Description
|
||||||
|
- Pros
|
||||||
|
- Cons
|
||||||
|
- Why not chosen
|
||||||
|
|
||||||
|
### Alternative 2
|
||||||
|
- Description
|
||||||
|
- Pros
|
||||||
|
- Cons
|
||||||
|
- Why not chosen
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
1. Step 1
|
||||||
|
2. Step 2
|
||||||
|
3. Step 3
|
||||||
|
|
||||||
|
## Stakeholders
|
||||||
|
- **Decision Makers**: @user1, @user2
|
||||||
|
- **Consulted**: @user3, @user4
|
||||||
|
- **Informed**: team-name
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
### Architecture Diagram
|
||||||
|
```
|
||||||
|
[Add diagram or link]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Dependency 1
|
||||||
|
- Dependency 2
|
||||||
|
|
||||||
|
### Impact Analysis
|
||||||
|
- **Performance**: [Impact description]
|
||||||
|
- **Security**: [Impact description]
|
||||||
|
- **Scalability**: [Impact description]
|
||||||
|
- **Maintainability**: [Impact description]
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- [ ] Unit tests
|
||||||
|
- [ ] Integration tests
|
||||||
|
- [ ] Performance tests
|
||||||
|
- [ ] Security tests
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- [ ] Architecture documentation updated
|
||||||
|
- [ ] API documentation updated
|
||||||
|
- [ ] Developer guide updated
|
||||||
|
- [ ] Runbook created
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
Describe how to migrate from current state to new architecture.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
Describe how to rollback if issues occur.
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
- **Proposal Date**:
|
||||||
|
- **Decision Date**:
|
||||||
|
- **Implementation Start**:
|
||||||
|
- **Expected Completion**:
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Related ADRs:
|
||||||
|
- External resources:
|
||||||
|
- RFCs:
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] Aligns with enterprise architecture principles
|
||||||
|
- [ ] Security implications reviewed
|
||||||
|
- [ ] Performance implications reviewed
|
||||||
|
- [ ] Cost implications reviewed
|
||||||
|
- [ ] Compliance requirements met
|
||||||
|
- [ ] Team consensus achieved
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a bug or issue with the project
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: 'bug'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Bug Description
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
A clear and concise description of what actually happened.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- **Project**: [e.g., MokoDoliTools, moko-cassiopeia]
|
||||||
|
- **Version**: [e.g., 1.2.3]
|
||||||
|
- **Platform**: [e.g., Dolibarr 18.0, Joomla 5.0]
|
||||||
|
- **PHP Version**: [e.g., 8.1]
|
||||||
|
- **Database**: [e.g., MySQL 8.0, PostgreSQL 14]
|
||||||
|
- **Browser** (if applicable): [e.g., Chrome 120, Firefox 121]
|
||||||
|
- **OS**: [e.g., Ubuntu 22.04, Windows 11]
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
## Possible Solution
|
||||||
|
If you have suggestions on how to fix the issue, please describe them here.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have searched for similar issues before creating this one
|
||||||
|
- [ ] I have provided all the requested information
|
||||||
|
- [ ] I have tested this on the latest stable version
|
||||||
|
- [ ] I have checked the documentation and couldn't find a solution
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: 💼 Enterprise Support
|
||||||
|
url: https://mokoconsulting.tech/enterprise
|
||||||
|
about: Enterprise-level support and consultation services
|
||||||
|
- name: 💬 Ask a Question
|
||||||
|
url: https://mokoconsulting.tech/
|
||||||
|
about: Get help or ask questions through our website
|
||||||
|
- name: 📚 MokoStandards Documentation
|
||||||
|
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
about: View our coding standards and best practices
|
||||||
|
- name: 🔒 Report a Security Vulnerability
|
||||||
|
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
|
||||||
|
about: Report security vulnerabilities privately (for critical issues)
|
||||||
|
- name: 💡 Community Discussions
|
||||||
|
url: https://github.com/orgs/mokoconsulting-tech/discussions
|
||||||
|
about: Join community discussions and Q&A
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: Documentation Issue
|
||||||
|
about: Report an issue with documentation
|
||||||
|
title: '[DOCS] '
|
||||||
|
labels: 'documentation'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Documentation Issue
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
<!-- Specify the file, page, or section with the issue -->
|
||||||
|
|
||||||
|
## Issue Type
|
||||||
|
<!-- Mark the relevant option with an "x" -->
|
||||||
|
- [ ] Typo or grammar error
|
||||||
|
- [ ] Outdated information
|
||||||
|
- [ ] Missing documentation
|
||||||
|
- [ ] Unclear explanation
|
||||||
|
- [ ] Broken links
|
||||||
|
- [ ] Missing examples
|
||||||
|
- [ ] Other (specify below)
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Clearly describe the documentation issue -->
|
||||||
|
|
||||||
|
## Current Content
|
||||||
|
<!-- Quote or describe the current documentation (if applicable) -->
|
||||||
|
```
|
||||||
|
Current text here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suggested Improvement
|
||||||
|
<!-- Provide your suggestion for how to improve the documentation -->
|
||||||
|
```
|
||||||
|
Suggested text here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
<!-- Add any other context, screenshots, or references -->
|
||||||
|
|
||||||
|
## Standards Alignment
|
||||||
|
- [ ] Follows MokoStandards documentation guidelines
|
||||||
|
- [ ] Uses en_US/en_GB localization
|
||||||
|
- [ ] Includes proper SPDX headers where applicable
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have searched for similar documentation issues
|
||||||
|
- [ ] I have provided a clear description
|
||||||
|
- [ ] I have suggested an improvement (if applicable)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or enhancement
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: 'enhancement'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Feature Description
|
||||||
|
A clear and concise description of the feature you'd like to see.
|
||||||
|
|
||||||
|
## Problem or Use Case
|
||||||
|
Describe the problem this feature would solve or the use case it addresses.
|
||||||
|
Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
## Alternative Solutions
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
Describe how this feature would benefit users:
|
||||||
|
- Who would use this feature?
|
||||||
|
- What problems does it solve?
|
||||||
|
- What value does it add?
|
||||||
|
|
||||||
|
## Implementation Details (Optional)
|
||||||
|
If you have ideas about how this could be implemented, share them here:
|
||||||
|
- Technical approach
|
||||||
|
- Files/components that might need changes
|
||||||
|
- Any concerns or challenges you foresee
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
Add any other context, mockups, or screenshots about the feature request here.
|
||||||
|
|
||||||
|
## Relevant Standards
|
||||||
|
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||||
|
- [ ] Accessibility (WCAG 2.1 AA)
|
||||||
|
- [ ] Localization (en_US/en_GB)
|
||||||
|
- [ ] Security best practices
|
||||||
|
- [ ] Code quality standards
|
||||||
|
- [ ] Other: [specify]
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have searched for similar feature requests before creating this one
|
||||||
|
- [ ] I have clearly described the use case and benefits
|
||||||
|
- [ ] I have considered alternative solutions
|
||||||
|
- [ ] This feature aligns with the project's goals and scope
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: Joomla Extension Issue
|
||||||
|
about: Report an issue with a Joomla extension
|
||||||
|
title: '[JOOMLA] '
|
||||||
|
labels: 'joomla'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Issue Type
|
||||||
|
- [ ] Component Issue
|
||||||
|
- [ ] Module Issue
|
||||||
|
- [ ] Plugin Issue
|
||||||
|
- [ ] Template Issue
|
||||||
|
|
||||||
|
## Extension Details
|
||||||
|
- **Extension Name**: [e.g., moko-cassiopeia]
|
||||||
|
- **Extension Version**: [e.g., 1.2.3]
|
||||||
|
- **Extension Type**: [Component / Module / Plugin / Template]
|
||||||
|
|
||||||
|
## Joomla Environment
|
||||||
|
- **Joomla Version**: [e.g., 4.4.0, 5.0.0]
|
||||||
|
- **PHP Version**: [e.g., 8.1.0]
|
||||||
|
- **Database**: [MySQL / PostgreSQL / MariaDB]
|
||||||
|
- **Database Version**: [e.g., 8.0]
|
||||||
|
- **Server**: [Apache / Nginx / IIS]
|
||||||
|
- **Hosting**: [Shared / VPS / Dedicated / Cloud]
|
||||||
|
|
||||||
|
## Issue Description
|
||||||
|
Provide a clear and detailed description of the issue.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Configure '...'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
What you expected to happen.
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
What actually happened.
|
||||||
|
|
||||||
|
## Error Messages
|
||||||
|
```
|
||||||
|
# Paste any error messages from Joomla error logs
|
||||||
|
# Location: administrator/logs/error.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Console Errors
|
||||||
|
```javascript
|
||||||
|
// Paste any JavaScript console errors (F12 in browser)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
Add screenshots to help explain the issue.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
```ini
|
||||||
|
# Paste extension configuration (sanitize sensitive data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installed Extensions
|
||||||
|
List other installed extensions that might conflict:
|
||||||
|
- Extension 1 (version)
|
||||||
|
- Extension 2 (version)
|
||||||
|
|
||||||
|
## Template Overrides
|
||||||
|
- [ ] Using template overrides
|
||||||
|
- [ ] Custom CSS
|
||||||
|
- [ ] Custom JavaScript
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
- **Multilingual Site**: [Yes / No]
|
||||||
|
- **Cache Enabled**: [Yes / No]
|
||||||
|
- **Debug Mode**: [Yes / No]
|
||||||
|
- **SEF URLs**: [Yes / No]
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have cleared Joomla cache
|
||||||
|
- [ ] I have disabled other extensions to test for conflicts
|
||||||
|
- [ ] I have checked Joomla error logs
|
||||||
|
- [ ] I have tested with a default Joomla template
|
||||||
|
- [ ] I have checked browser console for JavaScript errors
|
||||||
|
- [ ] I have searched for similar issues
|
||||||
|
- [ ] I am using a supported Joomla version
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: Question
|
||||||
|
about: Ask a question about usage, features, or best practices
|
||||||
|
title: '[QUESTION] '
|
||||||
|
labels: ['question']
|
||||||
|
assignees: ['jmiller']
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
**Your question:**
|
||||||
|
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
**What are you trying to accomplish?**
|
||||||
|
|
||||||
|
|
||||||
|
**What have you already tried?**
|
||||||
|
|
||||||
|
|
||||||
|
**Category**:
|
||||||
|
- [ ] Script usage
|
||||||
|
- [ ] Configuration
|
||||||
|
- [ ] Workflow setup
|
||||||
|
- [ ] Documentation interpretation
|
||||||
|
- [ ] Best practices
|
||||||
|
- [ ] Integration
|
||||||
|
- [ ] Other: __________
|
||||||
|
|
||||||
|
## Environment (if relevant)
|
||||||
|
|
||||||
|
**Your setup**:
|
||||||
|
- Operating System:
|
||||||
|
- Version:
|
||||||
|
|
||||||
|
## What You've Researched
|
||||||
|
|
||||||
|
**Documentation reviewed**:
|
||||||
|
- [ ] README.md
|
||||||
|
- [ ] Project documentation
|
||||||
|
- [ ] Other (specify): __________
|
||||||
|
|
||||||
|
**Similar issues/questions found**:
|
||||||
|
- #
|
||||||
|
- #
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
**What result are you hoping for?**
|
||||||
|
|
||||||
|
|
||||||
|
## Code/Configuration Samples
|
||||||
|
|
||||||
|
**Relevant code or configuration** (if applicable):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Your code here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
**Any other relevant information:**
|
||||||
|
|
||||||
|
|
||||||
|
**Screenshots** (if helpful):
|
||||||
|
|
||||||
|
|
||||||
|
## Urgency
|
||||||
|
|
||||||
|
- [ ] Urgent (blocking work)
|
||||||
|
- [ ] Normal (can work on other things meanwhile)
|
||||||
|
- [ ] Low priority (just curious)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have searched existing issues and discussions
|
||||||
|
- [ ] I have reviewed relevant documentation
|
||||||
|
- [ ] I have provided sufficient context
|
||||||
|
- [ ] I have included code/configuration samples if relevant
|
||||||
|
- [ ] This is a genuine question (not a bug report or feature request)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
name: Request for Comments (RFC)
|
||||||
|
about: Propose a significant change for community discussion
|
||||||
|
title: '[RFC] '
|
||||||
|
labels: 'rfc, discussion'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## RFC Summary
|
||||||
|
One-paragraph summary of the proposal.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Why are we doing this? What use cases does it support? What is the expected outcome?
|
||||||
|
|
||||||
|
## Detailed Design
|
||||||
|
### Overview
|
||||||
|
Provide a detailed explanation of the proposed change.
|
||||||
|
|
||||||
|
### API Changes (if applicable)
|
||||||
|
```php
|
||||||
|
// Before
|
||||||
|
function oldApi($param1) { }
|
||||||
|
|
||||||
|
// After
|
||||||
|
function newApi($param1, $param2) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Experience Changes
|
||||||
|
Describe how users will interact with this change.
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
High-level implementation strategy.
|
||||||
|
|
||||||
|
## Drawbacks
|
||||||
|
Why should we *not* do this?
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
What other designs have been considered? What is the impact of not doing this?
|
||||||
|
|
||||||
|
### Alternative 1
|
||||||
|
- Description
|
||||||
|
- Trade-offs
|
||||||
|
|
||||||
|
### Alternative 2
|
||||||
|
- Description
|
||||||
|
- Trade-offs
|
||||||
|
|
||||||
|
## Adoption Strategy
|
||||||
|
How will existing users adopt this? Is this a breaking change?
|
||||||
|
|
||||||
|
### Migration Guide
|
||||||
|
```bash
|
||||||
|
# Steps to migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deprecation Timeline
|
||||||
|
- **Announcement**:
|
||||||
|
- **Deprecation**:
|
||||||
|
- **Removal**:
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
- Question 1
|
||||||
|
- Question 2
|
||||||
|
|
||||||
|
## Future Possibilities
|
||||||
|
What future work does this enable?
|
||||||
|
|
||||||
|
## Impact Assessment
|
||||||
|
### Performance
|
||||||
|
Expected performance impact.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
Security considerations and implications.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- **Backward Compatible**: [Yes / No]
|
||||||
|
- **Breaking Changes**: [List]
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
Long-term maintenance considerations.
|
||||||
|
|
||||||
|
## Community Input
|
||||||
|
### Stakeholders
|
||||||
|
- [ ] Core team
|
||||||
|
- [ ] Module developers
|
||||||
|
- [ ] End users
|
||||||
|
- [ ] Enterprise customers
|
||||||
|
|
||||||
|
### Feedback Period
|
||||||
|
**Duration**: [e.g., 2 weeks]
|
||||||
|
**Deadline**: [date]
|
||||||
|
|
||||||
|
## Implementation Timeline
|
||||||
|
### Phase 1: Design
|
||||||
|
- [ ] RFC discussion
|
||||||
|
- [ ] Design finalization
|
||||||
|
- [ ] Approval
|
||||||
|
|
||||||
|
### Phase 2: Implementation
|
||||||
|
- [ ] Core implementation
|
||||||
|
- [ ] Tests
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
|
### Phase 3: Release
|
||||||
|
- [ ] Beta release
|
||||||
|
- [ ] Feedback collection
|
||||||
|
- [ ] Stable release
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
How will we measure success?
|
||||||
|
- Metric 1
|
||||||
|
- Metric 2
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Related RFCs:
|
||||||
|
- External documentation:
|
||||||
|
- Prior art:
|
||||||
|
|
||||||
|
## Open Questions for Community
|
||||||
|
1. Question 1?
|
||||||
|
2. Question 2?
|
||||||
|
|
||||||
|
---
|
||||||
|
**Note**: This RFC is open for community discussion. Please provide feedback in the comments below.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: Security Vulnerability Report
|
||||||
|
about: Report a security vulnerability (use only for non-critical issues)
|
||||||
|
title: '[SECURITY] '
|
||||||
|
labels: 'security'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## ⚠️ IMPORTANT: Private Disclosure Required
|
||||||
|
|
||||||
|
**For critical security vulnerabilities, DO NOT use this template.**
|
||||||
|
Follow the process in [SECURITY.md](../SECURITY.md) for responsible disclosure.
|
||||||
|
|
||||||
|
Use this template only for:
|
||||||
|
- Security improvements
|
||||||
|
- Non-critical security suggestions
|
||||||
|
- Security documentation updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Issue
|
||||||
|
|
||||||
|
**Severity**:
|
||||||
|
<!-- Low, Medium, or informational only -->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Describe the security concern or improvement suggestion -->
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
<!-- List the affected files, features, or components -->
|
||||||
|
|
||||||
|
## Suggested Mitigation
|
||||||
|
<!-- Describe how this could be addressed -->
|
||||||
|
|
||||||
|
## Standards Reference
|
||||||
|
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||||
|
- [ ] SPDX license identifiers
|
||||||
|
- [ ] Secret management
|
||||||
|
- [ ] Dependency security
|
||||||
|
- [ ] Access control
|
||||||
|
- [ ] Other: [specify]
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
<!-- Add any other context about the security concern -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] This is NOT a critical vulnerability requiring private disclosure
|
||||||
|
- [ ] I have reviewed the SECURITY.md policy
|
||||||
|
- [ ] I have provided sufficient detail for evaluation
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: Version Bump
|
||||||
|
about: Request or track a version change
|
||||||
|
title: '[VERSION] '
|
||||||
|
labels: 'version, type: version'
|
||||||
|
assignees: 'jmiller'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Change
|
||||||
|
|
||||||
|
**Current version**: <!-- e.g., 01.02.03 -->
|
||||||
|
**Requested version**: <!-- e.g., 01.03.00 -->
|
||||||
|
**Change type**: <!-- patch / minor / major -->
|
||||||
|
|
||||||
|
## Reason
|
||||||
|
|
||||||
|
<!-- Why is this version bump needed? -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] README.md `VERSION:` field updated
|
||||||
|
- [ ] CHANGELOG.md entry added
|
||||||
|
- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: `<version>`)
|
||||||
|
- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Automation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.gitea/workflows/branch-protection.yml
|
||||||
|
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||||
|
#
|
||||||
|
# +========================================================================+
|
||||||
|
# | BRANCH PROTECTION SETUP |
|
||||||
|
# +========================================================================+
|
||||||
|
# | |
|
||||||
|
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||||
|
# | |
|
||||||
|
# | main — Require PR, block rejected reviews, no force push |
|
||||||
|
# | dev — Allow push, no force push, no delete |
|
||||||
|
# | rc — Allow push, no force push, no delete |
|
||||||
|
# | beta — Allow push, no force push, no delete |
|
||||||
|
# | alpha — Allow push, no force push, no delete |
|
||||||
|
# | |
|
||||||
|
# | jmiller has override authority on all branches. |
|
||||||
|
# | |
|
||||||
|
# +========================================================================+
|
||||||
|
|
||||||
|
name: Branch Protection Setup
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: 'Preview mode (no changes)'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
repos:
|
||||||
|
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.mokoconsulting.tech
|
||||||
|
GITEA_ORG: MokoConsulting
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
protect:
|
||||||
|
name: Apply Branch Protection Rules
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Determine target repos
|
||||||
|
id: repos
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
|
# Platform/standards/infra repos to exclude
|
||||||
|
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||||
|
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||||
|
|
||||||
|
if [ -n "${{ inputs.repos }}" ]; then
|
||||||
|
# User-specified repos
|
||||||
|
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||||
|
else
|
||||||
|
# Fetch all org repos
|
||||||
|
PAGE=1
|
||||||
|
REPOS=""
|
||||||
|
while true; do
|
||||||
|
BATCH=$(curl -sS \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||||
|
| jq -r '.[].name // empty')
|
||||||
|
[ -z "$BATCH" ] && break
|
||||||
|
REPOS="$REPOS $BATCH"
|
||||||
|
PAGE=$((PAGE + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Filter out excluded repos
|
||||||
|
FILTERED=""
|
||||||
|
for REPO in $REPOS; do
|
||||||
|
SKIP=false
|
||||||
|
for EX in $EXCLUDE; do
|
||||||
|
if [ "$REPO" = "$EX" ]; then
|
||||||
|
SKIP=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$SKIP" = "false" ]; then
|
||||||
|
FILTERED="$FILTERED $REPO"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
REPOS="$FILTERED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||||
|
COUNT=$(echo "$REPOS" | wc -w)
|
||||||
|
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||||
|
|
||||||
|
- name: Apply protection rules
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||||
|
run: |
|
||||||
|
API="${GITEA_URL}/api/v1"
|
||||||
|
REPOS="${{ steps.repos.outputs.repos }}"
|
||||||
|
|
||||||
|
SUCCESS=0
|
||||||
|
FAILED=0
|
||||||
|
SKIPPED=0
|
||||||
|
|
||||||
|
# ── Rule definitions ──────────────────────────────────────
|
||||||
|
# Only the CI bot (jmiller token) can push directly.
|
||||||
|
# All human contributors must use PRs.
|
||||||
|
# Force push disabled on all branches.
|
||||||
|
|
||||||
|
RULE_MAIN='{
|
||||||
|
"rule_name": "main",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"dismiss_stale_approvals": true,
|
||||||
|
"block_on_rejected_reviews": true,
|
||||||
|
"block_on_outdated_branch": false,
|
||||||
|
"priority": 1
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_DEV='{
|
||||||
|
"rule_name": "dev",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 2
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_RC='{
|
||||||
|
"rule_name": "rc",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 3
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_BETA='{
|
||||||
|
"rule_name": "beta",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 4
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_ALPHA='{
|
||||||
|
"rule_name": "alpha",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 5
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||||
|
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||||
|
|
||||||
|
# ── Apply rules to each repo ──────────────────────────────
|
||||||
|
for REPO in $REPOS; do
|
||||||
|
echo ""
|
||||||
|
echo "═══ ${REPO} ═══"
|
||||||
|
|
||||||
|
for i in "${!RULES[@]}"; do
|
||||||
|
RULE="${RULES[$i]}"
|
||||||
|
NAME="${RULE_NAMES[$i]}"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete existing rule if present (idempotent recreate)
|
||||||
|
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||||
|
curl -sS -o /dev/null -w "" \
|
||||||
|
-X DELETE \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create rule
|
||||||
|
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$RULE" \
|
||||||
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||||
|
|
||||||
|
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP" = "201" ]; then
|
||||||
|
echo " ✅ ${NAME}"
|
||||||
|
SUCCESS=$((SUCCESS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo " ✅ Success: ${SUCCESS}"
|
||||||
|
echo " ❌ Failed: ${FAILED}"
|
||||||
|
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
|
||||||
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
|
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||||
|
fi
|
||||||
@@ -205,6 +205,12 @@ jobs:
|
|||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: "Detect platform"
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||||
|
|
||||||
- name: "Determine version bump level"
|
- name: "Determine version bump level"
|
||||||
id: bump
|
id: bump
|
||||||
run: |
|
run: |
|
||||||
@@ -228,6 +234,18 @@ jobs:
|
|||||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
- name: "Read published version"
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||||
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
|
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Published version: ${VERSION}"
|
||||||
|
|
||||||
- name: Update release notes and promote changelog
|
- name: Update release notes and promote changelog
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||||
|
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||||
|
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||||
|
name: "Cascade Main → Dev (DISABLED)"
|
||||||
|
on: workflow_dispatch
|
||||||
|
jobs:
|
||||||
|
noop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
|
# PATH: /.gitea/workflows/ci-generic.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||||
|
|
||||||
|
name: "Generic: Project CI"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Lint & Validate ───────────────────────────────────────────────────
|
||||||
|
lint:
|
||||||
|
name: Lint & Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Detect toolchain
|
||||||
|
id: detect
|
||||||
|
run: |
|
||||||
|
HAS_PHP=false
|
||||||
|
HAS_NODE=false
|
||||||
|
[ -f "composer.json" ] && HAS_PHP=true
|
||||||
|
[ -f "package.json" ] && HAS_NODE=true
|
||||||
|
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "package.json" ]; then
|
||||||
|
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHP syntax check
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
|
echo "::error file=${file}::PHP syntax error"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
|
||||||
|
|
||||||
|
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$ERRORS" -eq 0 ]; then
|
||||||
|
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: TypeScript/JavaScript lint
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "node_modules/.bin/eslint" ]; then
|
||||||
|
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
|
||||||
|
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
|
||||||
|
echo "::warning::ESLint config found but eslint not installed"
|
||||||
|
else
|
||||||
|
echo "No ESLint configured — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: TypeScript compile check
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
|
||||||
|
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
|
||||||
|
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHPStan static analysis
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
|
||||||
|
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Detect toolchain
|
||||||
|
id: detect
|
||||||
|
run: |
|
||||||
|
HAS_PHP=false
|
||||||
|
HAS_NODE=false
|
||||||
|
[ -f "composer.json" ] && HAS_PHP=true
|
||||||
|
[ -f "package.json" ] && HAS_NODE=true
|
||||||
|
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||||
|
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
|
||||||
|
|
||||||
|
- name: Run PHP tests
|
||||||
|
if: steps.detect.outputs.has_php == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -f "vendor/bin/phpunit" ]; then
|
||||||
|
vendor/bin/phpunit --testdox 2>&1
|
||||||
|
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||||
|
echo "::warning::PHPUnit config found but phpunit not installed"
|
||||||
|
else
|
||||||
|
echo "No PHPUnit configured — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Node.js tests
|
||||||
|
if: steps.detect.outputs.has_node == 'true'
|
||||||
|
run: |
|
||||||
|
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
|
||||||
|
npm test 2>&1
|
||||||
|
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "No test script in package.json — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build check
|
||||||
|
run: |
|
||||||
|
if [ -f "Makefile" ]; then
|
||||||
|
make build 2>&1 || echo "::warning::Build failed or not configured"
|
||||||
|
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
|
||||||
|
npm run build 2>&1 || echo "::warning::Build failed"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# This file is part of a Moko Consulting project.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow.Template
|
||||||
|
# INGROUP: MokoStandards.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||||
|
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
|
||||||
|
# VERSION: 04.06.00
|
||||||
|
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
|
||||||
|
|
||||||
|
name: "Joomla: Extension CI"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dev/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-validate:
|
||||||
|
name: Lint & Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Setup mokocli tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||||
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
|
run: |
|
||||||
|
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||||
|
echo "mokocli already available on runner — skipping clone"
|
||||||
|
else
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||||
|
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--prefer-dist \
|
||||||
|
--optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "No composer.json found — skipping dependency install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHP syntax check
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
for DIR in src/ htdocs/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
FOUND=1
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
OUTPUT=$(php -l "$FILE" 2>&1)
|
||||||
|
if echo "$OUTPUT" | grep -q "Parse error"; then
|
||||||
|
echo "::error file=${FILE}::${OUTPUT}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -name "*.php" -print0)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: XML manifest validation
|
||||||
|
run: |
|
||||||
|
echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find the extension manifest (XML with <extension tag)
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Validate well-formed XML
|
||||||
|
php -r "
|
||||||
|
\$xml = @simplexml_load_file('$MANIFEST');
|
||||||
|
if (\$xml === false) {
|
||||||
|
echo 'INVALID';
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'VALID';
|
||||||
|
" > /tmp/xml_result 2>&1
|
||||||
|
XML_RESULT=$(cat /tmp/xml_result)
|
||||||
|
if [ "$XML_RESULT" != "VALID" ]; then
|
||||||
|
echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required tags: name, version, author
|
||||||
|
for TAG in name version author; do
|
||||||
|
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
|
||||||
|
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Namespace is required for components/plugins but not packages
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
|
||||||
|
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check language files referenced in manifest
|
||||||
|
run: |
|
||||||
|
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$MANIFEST" ]; then
|
||||||
|
# Extract language file references from manifest
|
||||||
|
LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
if [ -z "$LANG_FILES" ]; then
|
||||||
|
echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
while IFS= read -r LANG_FILE; do
|
||||||
|
LANG_FILE=$(echo "$LANG_FILE" | xargs)
|
||||||
|
if [ -z "$LANG_FILE" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Check in common locations
|
||||||
|
FOUND=0
|
||||||
|
for BASE in "." "src" "htdocs"; do
|
||||||
|
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
||||||
|
FOUND=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$FOUND" -eq 0 ]; then
|
||||||
|
echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
done <<< "$LANG_FILES"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check index.html files in directories
|
||||||
|
run: |
|
||||||
|
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=0
|
||||||
|
CHECKED=0
|
||||||
|
|
||||||
|
for DIR in src/ htdocs/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
while IFS= read -r -d '' SUBDIR; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if [ ! -f "${SUBDIR}/index.html" ]; then
|
||||||
|
echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -type d -print0)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${CHECKED}" -eq 0 ]; then
|
||||||
|
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "${MISSING}" -gt 0 ]; then
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check config.xml and access.xml for components
|
||||||
|
run: |
|
||||||
|
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find all component manifests (XML with type="component")
|
||||||
|
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$COMP_MANIFESTS" ]; then
|
||||||
|
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $COMP_MANIFESTS; do
|
||||||
|
COMP_DIR=$(dirname "$MANIFEST")
|
||||||
|
COMP_NAME=$(basename "$COMP_DIR")
|
||||||
|
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check access.xml exists
|
||||||
|
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$ACCESS_FILE" ]; then
|
||||||
|
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
for ACTION in core.admin core.manage; do
|
||||||
|
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check config.xml exists
|
||||||
|
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$CONFIG_FILE" ]; then
|
||||||
|
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SQL schema validation
|
||||||
|
run: |
|
||||||
|
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find SQL files in source/htdocs
|
||||||
|
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$SQL_FILES" ]; then
|
||||||
|
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $SQL_FILES; do
|
||||||
|
# Basic syntax check: balanced parentheses, no empty files
|
||||||
|
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||||
|
if [ "$SIZE" -eq 0 ]; then
|
||||||
|
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for common SQL errors
|
||||||
|
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||||
|
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check update SQL files follow version numbering pattern
|
||||||
|
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$UPDATE_DIR" ]; then
|
||||||
|
BAD_NAMES=0
|
||||||
|
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||||
|
[ ! -f "$UFILE" ] && continue
|
||||||
|
BASENAME=$(basename "$UFILE" .sql)
|
||||||
|
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||||
|
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
BAD_NAMES=$((BAD_NAMES + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||||
|
ERRORS=$((ERRORS + BAD_NAMES))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Manifest file references check
|
||||||
|
run: |
|
||||||
|
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
# Check <filename> references
|
||||||
|
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILENAMES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <folder> references
|
||||||
|
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FOLDERS; do
|
||||||
|
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Form XML validation
|
||||||
|
run: |
|
||||||
|
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$FORM_FILES" ]; then
|
||||||
|
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
for FILE in $FORM_FILES; do
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
# Check for valid Joomla form structure
|
||||||
|
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deprecated Joomla API check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||||
|
PATTERNS=(
|
||||||
|
'JFactory::'
|
||||||
|
'JText::'
|
||||||
|
'JHtml::'
|
||||||
|
'JRoute::'
|
||||||
|
'JUri::'
|
||||||
|
'JLog::'
|
||||||
|
'JTable::'
|
||||||
|
'JInput'
|
||||||
|
'CMSFactory::\$application'
|
||||||
|
'JApplicationCms'
|
||||||
|
)
|
||||||
|
|
||||||
|
for PATTERN in "${PATTERNS[@]}"; do
|
||||||
|
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
COUNT=$(echo "$HITS" | wc -l)
|
||||||
|
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + COUNT))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Template output escaping check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$TMPL_FILES" ]; then
|
||||||
|
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $TMPL_FILES; do
|
||||||
|
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||||
|
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$UNESCAPED" ]; then
|
||||||
|
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for echo without escaping in template context
|
||||||
|
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$RAW_ECHO" ]; then
|
||||||
|
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Namespace consistency check
|
||||||
|
run: |
|
||||||
|
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find component/plugin manifests with <namespace> tags
|
||||||
|
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$MANIFESTS" ]; then
|
||||||
|
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $MANIFESTS; do
|
||||||
|
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||||
|
[ -z "$NS_PATH" ] && continue
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check PHP files have matching namespace
|
||||||
|
while IFS= read -r -d '' PHP_FILE; do
|
||||||
|
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||||
|
[ -z "$FILE_NS" ] && continue
|
||||||
|
|
||||||
|
# Namespace should start with the manifest namespace path
|
||||||
|
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||||
|
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SPDX license header check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
TOTAL=0
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||||
|
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$MISSING" -gt 0 ]; then
|
||||||
|
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Service provider check
|
||||||
|
run: |
|
||||||
|
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$PROVIDERS" ]; then
|
||||||
|
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for FILE in $PROVIDERS; do
|
||||||
|
# Must return a ServiceProviderInterface
|
||||||
|
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must have return statement
|
||||||
|
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
release-readiness:
|
||||||
|
name: Release Readiness Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate release readiness
|
||||||
|
run: |
|
||||||
|
echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Extract version from README.md
|
||||||
|
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||||
|
if [ -z "$README_VERSION" ]; then
|
||||||
|
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the extension manifest
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check <version> matches README VERSION
|
||||||
|
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$MANIFEST_VERSION" ]; then
|
||||||
|
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
|
||||||
|
echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check extension type, element, client attributes
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$EXT_TYPE" ]; then
|
||||||
|
echo "Missing \`type\` attribute on \`<extension>\` tag." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Element check (component/module/plugin name)
|
||||||
|
HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
|
||||||
|
if [ "$HAS_ELEMENT" -eq 0 ]; then
|
||||||
|
echo "Missing \`<element>\` or \`<name>\` in manifest." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Client attribute for site/admin modules and plugins
|
||||||
|
if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
|
||||||
|
HAS_CLIENT=$(grep -cP '<extension[^>]*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
|
||||||
|
if [ "$HAS_CLIENT" -eq 0 ]; then
|
||||||
|
echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check updates.xml exists
|
||||||
|
if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
|
||||||
|
echo "Update XML present." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check CHANGELOG.md exists
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ $ERRORS -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests (PHP ${{ matrix.php }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-validate
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php: ['8.2', '8.3']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP ${{ matrix.php }}
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--prefer-dist \
|
||||||
|
--optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "No composer.json found — skipping dependency install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||||
|
vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
|
||||||
|
EXIT=${PIPESTATUS[0]}
|
||||||
|
if [ $EXIT -eq 0 ]; then
|
||||||
|
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
exit $EXIT
|
||||||
|
else
|
||||||
|
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
static-analysis:
|
||||||
|
name: PHPStan Analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-validate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install PHPStan
|
||||||
|
run: |
|
||||||
|
if ! command -v vendor/bin/phpstan &> /dev/null; then
|
||||||
|
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
|
||||||
|
composer global require phpstan/phpstan --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run PHPStan
|
||||||
|
run: |
|
||||||
|
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
|
||||||
|
PHPSTAN="vendor/bin/phpstan"
|
||||||
|
if [ ! -f "$PHPSTAN" ]; then
|
||||||
|
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine source directory
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in src/ htdocs/ lib/; do
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
SRC_DIR="$DIR"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use repo phpstan.neon if present, otherwise use baseline config
|
||||||
|
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
|
||||||
|
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
|
||||||
|
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
ARGS="$ARGS --level=3"
|
||||||
|
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
|
||||||
|
EXIT=${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
if [ $EXIT -eq 0 ]; then
|
||||||
|
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
|
||||||
|
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
exit $EXIT
|
||||||
|
|
||||||
|
pre-release:
|
||||||
|
name: Build RC Pre-Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-and-validate, test]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger pre-release build
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.head_ref }}
|
||||||
|
run: |
|
||||||
|
curl -s -X POST \
|
||||||
|
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||||
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Maintenance
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||||
|
# PATH: /.gitea/workflows/cleanup.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||||
|
|
||||||
|
name: "Universal: Repository Cleanup"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
name: Clean Merged Branches
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Delete merged branches
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "=== Merged Branch Cleanup ==="
|
||||||
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
|
# List branches via API
|
||||||
|
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
|
DELETED=0
|
||||||
|
for BRANCH in $BRANCHES; do
|
||||||
|
# Skip protected branches
|
||||||
|
case "$BRANCH" in
|
||||||
|
main|master|develop|release/*|hotfix/*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Check if branch is merged into main
|
||||||
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
|
echo " Deleting merged branch: ${BRANCH}"
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
|
DELETED=$((DELETED + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Deleted ${DELETED} merged branch(es)"
|
||||||
|
|
||||||
|
- name: Clean old workflow runs
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "=== Workflow Run Cleanup ==="
|
||||||
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# Get old completed runs
|
||||||
|
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
|
DELETED=0
|
||||||
|
for RUN_ID in $RUNS; do
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
|
DELETED=$((DELETED + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Deleted ${DELETED} old workflow run(s)"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Security
|
||||||
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||||
|
# PATH: /templates/workflows/gitleaks.yml.template
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||||
|
#
|
||||||
|
# +========================================================================+
|
||||||
|
# | SECRET SCANNING |
|
||||||
|
# +========================================================================+
|
||||||
|
# | |
|
||||||
|
# | Scans commits for leaked secrets using Gitleaks. |
|
||||||
|
# | |
|
||||||
|
# | - PR scan: only new commits in the PR |
|
||||||
|
# | - Scheduled: full repo scan weekly |
|
||||||
|
# | - Alerts via ntfy on findings |
|
||||||
|
# | |
|
||||||
|
# +========================================================================+
|
||||||
|
|
||||||
|
name: "Universal: Secret Scanning"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||||
|
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Gitleaks Secret Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Gitleaks
|
||||||
|
run: |
|
||||||
|
GITLEAKS_VERSION="8.21.2"
|
||||||
|
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin gitleaks
|
||||||
|
gitleaks version
|
||||||
|
|
||||||
|
- name: Scan for secrets
|
||||||
|
id: scan
|
||||||
|
run: |
|
||||||
|
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
# Scan only PR commits
|
||||||
|
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
||||||
|
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gitleaks detect $ARGS 2>&1; then
|
||||||
|
echo "result=clean" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "result=found" >> "$GITHUB_OUTPUT"
|
||||||
|
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
|
||||||
|
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on findings
|
||||||
|
if: failure() && steps.scan.outputs.result == 'found'
|
||||||
|
run: |
|
||||||
|
REPO="${{ github.event.repository.name }}"
|
||||||
|
curl -sS \
|
||||||
|
-H "Title: ${REPO} — secrets detected in code" \
|
||||||
|
-H "Tags: rotating_light,key" \
|
||||||
|
-H "Priority: urgent" \
|
||||||
|
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
|
||||||
|
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Automation
|
||||||
|
# VERSION: 01.07.23
|
||||||
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
|
name: "Universal: Issue Branch"
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-branch:
|
||||||
|
name: Create feature branch
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create branch and comment
|
||||||
|
run: |
|
||||||
|
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||||
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|
||||||
|
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||||
|
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||||
|
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||||
|
|
||||||
|
# Check dev branch exists
|
||||||
|
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||||
|
echo "No dev branch -- skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create branch from dev
|
||||||
|
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/branches" \
|
||||||
|
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "${HTTP}" = "201" ]; then
|
||||||
|
echo "Created branch: ${BRANCH}"
|
||||||
|
|
||||||
|
# Comment on issue with branch link
|
||||||
|
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||||
|
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||||
|
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||||
|
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||||
|
|
||||||
|
echo "Commented on issue #${ISSUE_NUM}"
|
||||||
|
else
|
||||||
|
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Notifications
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||||
|
# PATH: /.gitea/workflows/notify.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||||
|
|
||||||
|
name: "Universal: Notifications"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- "Joomla Build & Release"
|
||||||
|
- "Joomla Extension CI"
|
||||||
|
- "Deploy"
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||||
|
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
name: Send Notification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' ||
|
||||||
|
github.event.workflow_run.conclusion == 'failure'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Notify on success (releases only)
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
contains(github.event.workflow_run.name, 'Release')
|
||||||
|
run: |
|
||||||
|
REPO="${{ github.event.repository.name }}"
|
||||||
|
WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
|
URL="${{ github.event.workflow_run.html_url }}"
|
||||||
|
|
||||||
|
curl -sS \
|
||||||
|
-H "Title: ${REPO} released" \
|
||||||
|
-H "Tags: white_check_mark,package" \
|
||||||
|
-H "Priority: default" \
|
||||||
|
-H "Click: ${URL}" \
|
||||||
|
-d "${WORKFLOW} completed successfully." \
|
||||||
|
"${NTFY_URL}/${NTFY_TOPIC}"
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: github.event.workflow_run.conclusion == 'failure'
|
||||||
|
run: |
|
||||||
|
REPO="${{ github.event.repository.name }}"
|
||||||
|
WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
|
URL="${{ github.event.workflow_run.html_url }}"
|
||||||
|
|
||||||
|
curl -sS \
|
||||||
|
-H "Title: ${REPO} workflow failed" \
|
||||||
|
-H "Tags: x,warning" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-H "Click: ${URL}" \
|
||||||
|
-d "${WORKFLOW} failed. Check the run for details." \
|
||||||
|
"${NTFY_URL}/${NTFY_TOPIC}"
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.CI
|
# INGROUP: moko-platform.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: PR gate — branch policy + code validation before merge
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
@@ -96,6 +96,32 @@ jobs:
|
|||||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||||
|
gitleaks:
|
||||||
|
name: Secret Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Gitleaks
|
||||||
|
run: |
|
||||||
|
GITLEAKS_VERSION="8.21.2"
|
||||||
|
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin gitleaks
|
||||||
|
|
||||||
|
- name: Scan PR commits for secrets
|
||||||
|
run: |
|
||||||
|
if gitleaks detect --source . --verbose \
|
||||||
|
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
||||||
|
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::error::Potential secrets detected in PR commits"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Code Validation ────────────────────────────────────────────────────
|
# ── Code Validation ────────────────────────────────────────────────────
|
||||||
validate:
|
validate:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Validation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||||
|
|
||||||
|
name: "Joomla: Metadata Validation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-metadata:
|
||||||
|
name: "Validate Joomla Metadata"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup mokocli tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/mokocli
|
||||||
|
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/mokocli
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate metadata against Joomla manifest
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||||
|
--path . \
|
||||||
|
--token "${GITEA_TOKEN}" \
|
||||||
|
--org "${GITEA_ORG}" \
|
||||||
|
--repo "${GITEA_REPO}" \
|
||||||
|
--api-base "${GITEA_URL}/api/v1" \
|
||||||
|
--ci
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
|
|
||||||
|
name: "RC Revert"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
revert:
|
||||||
|
name: Rename rc/ back to dev/
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == false &&
|
||||||
|
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Rename branch
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
|
SUFFIX="${BRANCH#rc/}"
|
||||||
|
DEV_BRANCH="dev/${SUFFIX}"
|
||||||
|
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
# Create dev/ branch from rc/ branch
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||||
|
"${API}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "201" ]; then
|
||||||
|
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete rc/ branch
|
||||||
|
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "204" ]; then
|
||||||
|
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||||
|
# VERSION: 01.01.00
|
||||||
|
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||||
|
|
||||||
|
name: "Universal: Workflow Sync Trigger"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
name: Sync workflows to live repos
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
!contains(github.event.pull_request.title, '[skip sync]')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Determine platform from repo name
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
REPO="${{ github.event.repository.name }}"
|
||||||
|
case "$REPO" in
|
||||||
|
Template-Joomla) PLATFORM="joomla" ;;
|
||||||
|
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||||
|
Template-Go) PLATFORM="go" ;;
|
||||||
|
Template-MCP) PLATFORM="mcp" ;;
|
||||||
|
Template-Generic) PLATFORM="" ;;
|
||||||
|
*) PLATFORM="" ;;
|
||||||
|
esac
|
||||||
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Platform: ${PLATFORM:-all}"
|
||||||
|
|
||||||
|
- name: Clone mokocli
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
|
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd /tmp/mokocli
|
||||||
|
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run workflow sync
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||||
|
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||||
|
ARGS="${ARGS} --phase repos"
|
||||||
|
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [ -n "$PLATFORM" ]; then
|
||||||
|
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: CC-BY-4.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, colour, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behaviour that contributes to a positive environment:
|
||||||
|
|
||||||
|
- Using welcoming and inclusive language
|
||||||
|
- Being respectful of differing viewpoints and experiences
|
||||||
|
- Accepting constructive criticism gracefully
|
||||||
|
- Focusing on what is best for the community
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
|
||||||
|
reported by contacting the project team at
|
||||||
|
[info@mokoconsulting.tech](mailto:info@mokoconsulting.tech).
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the
|
||||||
|
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Contributing to Moko Consulting Projects
|
||||||
|
|
||||||
|
Thank you for your interest in contributing! This guide explains our workflow,
|
||||||
|
conventions, and how to get your changes merged.
|
||||||
|
|
||||||
|
## Branching Workflow
|
||||||
|
|
||||||
|
We use a **stability-gated** branching model:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/* ──── PR ───→ dev
|
||||||
|
│ RC cut → rc → main
|
||||||
|
fix/* ───────── PR ────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Create a branch** from `dev`:
|
||||||
|
- `feature/<short-name>` for new functionality
|
||||||
|
- `fix/<short-name>` for bug fixes
|
||||||
|
- `chore/<short-name>` for maintenance
|
||||||
|
2. **Open a PR** into `dev`.
|
||||||
|
3. **CI must pass** before merge.
|
||||||
|
4. **Release cuts**: `dev → rc → main` are handled by maintainers.
|
||||||
|
|
||||||
|
> **Never commit directly to `main` or `dev`.**
|
||||||
|
|
||||||
|
## Version Policy
|
||||||
|
|
||||||
|
All repositories use the **XX.YY.ZZ** versioning scheme (two-digit segments):
|
||||||
|
|
||||||
|
- `XX` -- major (breaking changes)
|
||||||
|
- `YY` -- minor (new features, backward-compatible)
|
||||||
|
- `ZZ` -- patch (bug fixes, security patches)
|
||||||
|
|
||||||
|
**Stability suffixes** may be appended during pre-release:
|
||||||
|
|
||||||
|
| Suffix | Meaning | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `-alpha.N` | Early testing | `06.01.00-alpha.1` |
|
||||||
|
| `-beta.N` | Feature complete | `06.01.00-beta.2` |
|
||||||
|
| `-rc.N` | Release candidate | `06.01.00-rc.1` |
|
||||||
|
| *(none)* | Stable release | `06.01.00` |
|
||||||
|
|
||||||
|
## Auto-Bump
|
||||||
|
|
||||||
|
Version bumps are **automated** via the `auto-bump` workflow:
|
||||||
|
|
||||||
|
- Merges into `dev` trigger a minor/patch bump.
|
||||||
|
- The workflow updates all version references (manifests, changelog, etc.).
|
||||||
|
- **Do not manually edit version numbers** -- let the workflow handle it.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short description>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
<optional footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`
|
||||||
|
|
||||||
|
## Pull Request Checklist
|
||||||
|
|
||||||
|
Before submitting a PR, ensure:
|
||||||
|
|
||||||
|
- [ ] Branch is based on latest `dev`
|
||||||
|
- [ ] Commit messages follow conventional commits
|
||||||
|
- [ ] CHANGELOG.md updated under `[Unreleased]`
|
||||||
|
- [ ] No `TODO.md`, `.claude/`, `.mcp.json`, or minified files included
|
||||||
|
- [ ] Code follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)
|
||||||
|
- [ ] All CI checks pass
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a [Question issue](../../issues/new?template=question.md) or contact
|
||||||
|
us at [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Governance
|
||||||
|
|
||||||
|
## Project Leadership
|
||||||
|
|
||||||
|
This repository is maintained by **Moko Consulting** under a **sole operator** model.
|
||||||
|
|
||||||
|
- **Lead Maintainer**: Jonathan Miller (@jmiller)
|
||||||
|
- **Organisation**: [Moko Consulting](https://mokoconsulting.tech)
|
||||||
|
|
||||||
|
## Decision Making
|
||||||
|
|
||||||
|
- All architectural decisions are made by the lead maintainer.
|
||||||
|
- Community feedback is welcome via [RFC issues](../../issues/new?template=rfc.md).
|
||||||
|
- Breaking changes are documented via [ADR issues](../../issues/new?template=adr.md).
|
||||||
|
|
||||||
|
## Contribution Policy
|
||||||
|
|
||||||
|
- **All changes** must go through a pull request (PR).
|
||||||
|
- **CI checks** are mandatory before merge.
|
||||||
|
- **Direct push** to `main` and `dev` is restricted to automated workflows.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
All participants must adhere to our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
All contributions are licensed under the same license as the project
|
||||||
|
(GPL-3.0-or-later unless otherwise stated in the repository root).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Security vulnerabilities should be reported privately.
|
||||||
|
See [SECURITY.md](SECURITY.md) for details.
|
||||||
|
|
||||||
|
## Dispute Resolution
|
||||||
|
|
||||||
|
Disputes are resolved by the lead maintainer. For escalation,
|
||||||
|
contact [info@mokoconsulting.tech](mailto:info@mokoconsulting.tech).
|
||||||
|
|
||||||
|
## Changes to Governance
|
||||||
|
|
||||||
|
This document may be updated at any time by the lead maintainer.
|
||||||
|
Significant changes will be announced via an RFC issue.
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| Latest stable | ✅ Full support |
|
||||||
|
| Previous major | ⚠️ Critical fixes only |
|
||||||
|
| Older | ❌ No support |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
**Do not report security vulnerabilities via public issues.**
|
||||||
|
|
||||||
|
Instead, please report them privately:
|
||||||
|
|
||||||
|
1. **Email**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech)
|
||||||
|
2. **Subject**: `[SECURITY] <Repository Name> - <Brief Description>`
|
||||||
|
|
||||||
|
### What to Include
|
||||||
|
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Affected versions
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
## Severity Classification
|
||||||
|
|
||||||
|
| Severity | Description | Response Time |
|
||||||
|
|---|---|---|
|
||||||
|
| **Critical** | Remote code execution, SQL injection, auth bypass | 24 hours |
|
||||||
|
| **High** | XSS, CSRF, privilege escalation | 48 hours |
|
||||||
|
| **Medium** | Information disclosure, path traversal | 72 hours |
|
||||||
|
| **Low** | Best practice violation, hardening suggestion | Next release |
|
||||||
|
|
||||||
|
## Remediation Timeline
|
||||||
|
|
||||||
|
1. **Acknowledgement**: Within 24 hours of report
|
||||||
|
2. **Assessment**: Within 72 hours
|
||||||
|
3. **Fix development**: Based on severity
|
||||||
|
4. **Release**: Patch release with security advisory
|
||||||
|
5. **Disclosure**: Coordinated disclosure after fix is available
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### For Contributors
|
||||||
|
|
||||||
|
- Never commit secrets, credentials, or API keys
|
||||||
|
- Use parameterised queries (no raw SQL concatenation)
|
||||||
|
- Validate and sanitise all user input
|
||||||
|
- Follow Joomla API for access control checks
|
||||||
|
- Use Joomla's `HTMLHelper` for output escaping
|
||||||
|
- Include SPDX license headers in all source files
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
- Keep Joomla and all extensions updated
|
||||||
|
- Use strong, unique passwords
|
||||||
|
- Enable two-factor authentication
|
||||||
|
- Review file permissions regularly
|
||||||
|
- Monitor Joomla error logs
|
||||||
|
|
||||||
|
## Security Updates
|
||||||
|
|
||||||
|
Security patches are delivered through the standard update channel.
|
||||||
|
Critical fixes may receive an emergency out-of-band release.
|
||||||
|
|
||||||
|
## Responsible Disclosure
|
||||||
|
|
||||||
|
We follow coordinated disclosure practices:
|
||||||
|
|
||||||
|
- We will work with reporters to understand and reproduce the issue
|
||||||
|
- We will develop and test a fix
|
||||||
|
- We will credit reporters (with permission) in security advisories
|
||||||
|
- We ask that reporters allow reasonable time for a fix before public disclosure
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- **Security team**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech)
|
||||||
|
- **General**: [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping keep Moko Consulting projects secure.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "mokoconsulting/mokojoomgallery",
|
||||||
|
"description": "Joomla extension by Moko Consulting",
|
||||||
|
"type": "joomla-package",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"homepage": "https://mokoconsulting.tech",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Moko Consulting",
|
||||||
|
"email": "hello@mokoconsulting.tech"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"squizlabs/php_codesniffer": "^3.0",
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"joomla/coding-standards": "dev-main"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Config.StaticAnalysis
|
||||||
|
# INGROUP: Development
|
||||||
|
# BRIEF: PHPStan configuration for Joomla extension static analysis
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 5
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
scanDirectories:
|
||||||
|
# Joomla framework stubs (if available)
|
||||||
|
- vendor/joomla
|
||||||
|
ignoreErrors:
|
||||||
|
# Joomla service-container architecture: Factory/Container returns mixed
|
||||||
|
- '#Call to an undefined method Joomla\\CMS\\Application\\.*::get#i'
|
||||||
|
- '#Call to method .* on an unknown class Joomla\\Cms\\Extension\\.*#'
|
||||||
|
# Joomla MVC pattern: Table::getInstance returns Table|bool
|
||||||
|
- '#Method Joomla\\CMS\\Table\\Table::getInstance#'
|
||||||
@@ -85,11 +85,30 @@ class NpoReportsController extends BaseController
|
|||||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
|
// Verify pledge exists and is active before cancelling
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('id, status')
|
||||||
|
->from('#__mokosuitenpo_pledges')
|
||||||
|
->where('id = ' . (int) $id));
|
||||||
|
$pledge = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$pledge) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->sendJson(['success' => false, 'error' => 'Pledge not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($pledge->status !== 'active') {
|
||||||
|
$this->sendJson(['success' => false, 'error' => 'Pledge is not active']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$db->setQuery($db->getQuery(true)
|
$db->setQuery($db->getQuery(true)
|
||||||
->update('#__mokosuitenpo_pledges')
|
->update('#__mokosuitenpo_pledges')
|
||||||
->set($db->quoteName('status') . ' = ' . $db->quote('cancelled'))
|
->set($db->quoteName('status') . ' = ' . $db->quote('cancelled'))
|
||||||
->set($db->quoteName('cancelled_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
->set($db->quoteName('cancelled_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||||
->where('id = ' . (int) $id));
|
->where('id = ' . (int) $id)
|
||||||
|
->where($db->quoteName('status') . ' = ' . $db->quote('active')));
|
||||||
$db->execute();
|
$db->execute();
|
||||||
|
|
||||||
$this->sendJson(['success' => true]);
|
$this->sendJson(['success' => true]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>01.02.00</version>
|
<version>01.07.23</version>
|
||||||
<php_minimum>8.3</php_minimum>
|
<php_minimum>8.3</php_minimum>
|
||||||
<description>MokoSuite NPO component</description>
|
<description>MokoSuite NPO component</description>
|
||||||
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
|
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Component\MokoSuiteNpo\Site\View\ThankYou;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donation thank-you page — displays after successful donation with receipt info.
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
public ?object $donation = null;
|
||||||
|
public ?object $receipt = null;
|
||||||
|
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$donationId = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$token = Factory::getApplication()->getInput()->getString('token', '');
|
||||||
|
|
||||||
|
if (!$donationId || !$token) {
|
||||||
|
parent::display($tpl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token matches donation (prevents enumeration)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('d.*, cd.name AS donor_name, f.name AS fund_name, c.title AS campaign_title')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = d.fund_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = d.campaign_id')
|
||||||
|
->where('d.id = ' . (int) $donationId)
|
||||||
|
->where($db->quoteName('d.confirmation_token') . ' = ' . $db->quote($token)));
|
||||||
|
$this->donation = $db->loadObject();
|
||||||
|
|
||||||
|
if ($this->donation) {
|
||||||
|
// Get tax receipt if generated
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('receipt_number, issued_date, amount')
|
||||||
|
->from('#__mokosuitenpo_tax_receipts')
|
||||||
|
->where('donation_id = ' . (int) $donationId));
|
||||||
|
$this->receipt = $db->loadObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board of directors management — member terms, committees, meeting minutes, attendance.
|
||||||
|
*/
|
||||||
|
class BoardManagementHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get current board members with term status.
|
||||||
|
*/
|
||||||
|
public static function getCurrentMembers(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('bm.*, cd.name, cd.email_to, cd.telephone')
|
||||||
|
->select('CASE WHEN bm.term_end < NOW() THEN ' . $db->quote('expired')
|
||||||
|
. ' WHEN bm.term_end < DATE_ADD(NOW(), INTERVAL 90 DAY) THEN ' . $db->quote('expiring_soon')
|
||||||
|
. ' ELSE ' . $db->quote('active') . ' END AS term_status')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_board_members', 'bm'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = bm.contact_id')
|
||||||
|
->where($db->quoteName('bm.status') . ' = ' . $db->quote('active'))
|
||||||
|
->order('bm.role ASC, cd.name ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get committee assignments.
|
||||||
|
*/
|
||||||
|
public static function getCommittees(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('c.id, c.name AS committee_name, c.description')
|
||||||
|
->select('(SELECT COUNT(*) FROM #__mokosuitenpo_committee_members cm WHERE cm.committee_id = c.id AND cm.status = ' . $db->quote('active') . ') AS member_count')
|
||||||
|
->select('(SELECT cd2.name FROM #__mokosuitenpo_committee_members cm2'
|
||||||
|
. ' JOIN #__contact_details cd2 ON cd2.id = cm2.contact_id'
|
||||||
|
. ' WHERE cm2.committee_id = c.id AND cm2.role = ' . $db->quote('chair')
|
||||||
|
. ' AND cm2.status = ' . $db->quote('active') . ' LIMIT 1) AS chair_name')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_committees', 'c'))
|
||||||
|
->where($db->quoteName('c.status') . ' = ' . $db->quote('active'))
|
||||||
|
->order('c.name ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meeting attendance rate for a board member.
|
||||||
|
*/
|
||||||
|
public static function getAttendanceRate(int $contactId, int $months = 12): object
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$since = date('Y-m-d', strtotime("-{$months} months"));
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COUNT(*) AS total_meetings')
|
||||||
|
->select('SUM(CASE WHEN ma.status = ' . $db->quote('present') . ' THEN 1 ELSE 0 END) AS attended')
|
||||||
|
->select('SUM(CASE WHEN ma.status = ' . $db->quote('absent') . ' THEN 1 ELSE 0 END) AS absent')
|
||||||
|
->select('SUM(CASE WHEN ma.status = ' . $db->quote('excused') . ' THEN 1 ELSE 0 END) AS excused')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_meeting_attendance', 'ma'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitenpo_meetings', 'm') . ' ON m.id = ma.meeting_id')
|
||||||
|
->where('ma.contact_id = ' . (int) $contactId)
|
||||||
|
->where('m.meeting_date >= ' . $db->quote($since)));
|
||||||
|
|
||||||
|
$stats = $db->loadObject();
|
||||||
|
$total = (int) ($stats->total_meetings ?? 0);
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'contact_id' => $contactId,
|
||||||
|
'total_meetings' => $total,
|
||||||
|
'attended' => (int) ($stats->attended ?? 0),
|
||||||
|
'absent' => (int) ($stats->absent ?? 0),
|
||||||
|
'excused' => (int) ($stats->excused ?? 0),
|
||||||
|
'attendance_pct' => $total > 0 ? round((int) $stats->attended / $total * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terms expiring within N days.
|
||||||
|
*/
|
||||||
|
public static function getExpiringTerms(int $days = 90): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$cutoff = date('Y-m-d', strtotime("+{$days} days"));
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('bm.id, bm.role, bm.term_start, bm.term_end')
|
||||||
|
->select('cd.name, cd.email_to')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_board_members', 'bm'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = bm.contact_id')
|
||||||
|
->where($db->quoteName('bm.status') . ' = ' . $db->quote('active'))
|
||||||
|
->where('bm.term_end BETWEEN ' . $db->quote(date('Y-m-d')) . ' AND ' . $db->quote($cutoff))
|
||||||
|
->order('bm.term_end ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donor retention analysis — LYBUNT/SYBUNT detection, retention rates, lapsed outreach lists.
|
||||||
|
*/
|
||||||
|
class DonorRetentionHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get LYBUNT donors — gave Last Year But Unfortunately Not This year.
|
||||||
|
*/
|
||||||
|
public static function getLybunt(int $currentYear = 0): array
|
||||||
|
{
|
||||||
|
$currentYear = $currentYear ?: (int) date('Y');
|
||||||
|
$lastYear = $currentYear - 1;
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('cd.id AS contact_id, cd.name, cd.email_to, cd.telephone')
|
||||||
|
->select('MAX(d.donation_date) AS last_donation_date')
|
||||||
|
->select('SUM(CASE WHEN YEAR(d.donation_date) = ' . $lastYear . ' THEN d.amount ELSE 0 END) AS last_year_total')
|
||||||
|
->from($db->quoteName('#__contact_details', 'cd'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitenpo_donations', 'd') . ' ON d.contact_id = cd.id')
|
||||||
|
->where('YEAR(d.donation_date) = ' . $lastYear)
|
||||||
|
->where('cd.id NOT IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $currentYear . ')')
|
||||||
|
->group('cd.id, cd.name, cd.email_to, cd.telephone')
|
||||||
|
->order('last_year_total DESC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SYBUNT donors — gave Some Year But Unfortunately Not This year.
|
||||||
|
*/
|
||||||
|
public static function getSybunt(int $currentYear = 0, int $lookbackYears = 3): array
|
||||||
|
{
|
||||||
|
$currentYear = $currentYear ?: (int) date('Y');
|
||||||
|
$lastYear = $currentYear - 1;
|
||||||
|
$startYear = $currentYear - $lookbackYears;
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('cd.id AS contact_id, cd.name, cd.email_to')
|
||||||
|
->select('COUNT(DISTINCT YEAR(d.donation_date)) AS years_donated')
|
||||||
|
->select('MAX(d.donation_date) AS last_donation_date')
|
||||||
|
->select('SUM(d.amount) AS lifetime_total')
|
||||||
|
->from($db->quoteName('#__contact_details', 'cd'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitenpo_donations', 'd') . ' ON d.contact_id = cd.id')
|
||||||
|
->where('YEAR(d.donation_date) BETWEEN ' . $startYear . ' AND ' . $lastYear)
|
||||||
|
->where('cd.id NOT IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $currentYear . ')')
|
||||||
|
->group('cd.id, cd.name, cd.email_to')
|
||||||
|
->order('lifetime_total DESC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate retention rate — percentage of donors who gave again this year.
|
||||||
|
*/
|
||||||
|
public static function getRetentionRate(int $currentYear = 0): object
|
||||||
|
{
|
||||||
|
$currentYear = $currentYear ?: (int) date('Y');
|
||||||
|
$lastYear = $currentYear - 1;
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COUNT(DISTINCT d.contact_id) AS last_year_donors')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
|
||||||
|
->where('YEAR(d.donation_date) = ' . $lastYear));
|
||||||
|
$lastYearDonors = (int) $db->loadResult();
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COUNT(DISTINCT d.contact_id) AS retained')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
|
||||||
|
->where('YEAR(d.donation_date) = ' . $currentYear)
|
||||||
|
->where('d.contact_id IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $lastYear . ')'));
|
||||||
|
$retained = (int) $db->loadResult();
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'last_year_donors' => $lastYearDonors,
|
||||||
|
'retained' => $retained,
|
||||||
|
'lapsed' => $lastYearDonors - $retained,
|
||||||
|
'retention_rate' => $lastYearDonors > 0 ? round($retained / $lastYearDonors * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get donor giving trends — year-over-year comparison.
|
||||||
|
*/
|
||||||
|
public static function getGivingTrends(int $years = 5): array
|
||||||
|
{
|
||||||
|
$currentYear = (int) date('Y');
|
||||||
|
$startYear = $currentYear - $years + 1;
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('YEAR(donation_date) AS year')
|
||||||
|
->select('COUNT(*) AS donation_count')
|
||||||
|
->select('COUNT(DISTINCT contact_id) AS unique_donors')
|
||||||
|
->select('SUM(amount) AS total_amount')
|
||||||
|
->select('AVG(amount) AS avg_donation')
|
||||||
|
->from('#__mokosuitenpo_donations')
|
||||||
|
->where('YEAR(donation_date) BETWEEN ' . $startYear . ' AND ' . $currentYear)
|
||||||
|
->group('YEAR(donation_date)')
|
||||||
|
->order('year ASC'));
|
||||||
|
|
||||||
|
$trends = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($trends as &$t) {
|
||||||
|
$t->avg_donation = round((float) $t->avg_donation, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trends;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IRS Form 990 data preparation — revenue/expense classification, program services, compensation reporting.
|
||||||
|
*/
|
||||||
|
class Form990Helper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get Part VIII revenue breakdown (contributions, program service, investment, other).
|
||||||
|
*/
|
||||||
|
public static function getRevenueBreakdown(int $fiscalYear = 0): object
|
||||||
|
{
|
||||||
|
$fiscalYear = $fiscalYear ?: (int) date('Y');
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
// Contributions (Line 1)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE(SUM(amount), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_donations')
|
||||||
|
->where('YEAR(donation_date) = ' . $fiscalYear));
|
||||||
|
$contributions = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// Membership dues (Line 1b)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE(SUM(amount), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_membership_payments')
|
||||||
|
->where('YEAR(payment_date) = ' . $fiscalYear));
|
||||||
|
$memberDues = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// Grant revenue (Line 1f)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE(SUM(amount_received), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_grant_payments')
|
||||||
|
->where('YEAR(received_date) = ' . $fiscalYear));
|
||||||
|
$grants = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// Program service revenue (Line 2)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE(SUM(amount), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_program_revenue')
|
||||||
|
->where('YEAR(revenue_date) = ' . $fiscalYear));
|
||||||
|
$programRevenue = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// In-kind donations (Line 1g)
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE(SUM(fair_market_value), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_inkind_donations')
|
||||||
|
->where('YEAR(received_at) = ' . $fiscalYear));
|
||||||
|
$inKind = (float) $db->loadResult();
|
||||||
|
|
||||||
|
$totalRevenue = $contributions + $memberDues + $grants + $programRevenue + $inKind;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'fiscal_year' => $fiscalYear,
|
||||||
|
'contributions' => round($contributions, 2),
|
||||||
|
'membership_dues' => round($memberDues, 2),
|
||||||
|
'grants' => round($grants, 2),
|
||||||
|
'program_revenue' => round($programRevenue, 2),
|
||||||
|
'in_kind' => round($inKind, 2),
|
||||||
|
'total_revenue' => round($totalRevenue, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Part IX expense breakdown (program services, management, fundraising).
|
||||||
|
*/
|
||||||
|
public static function getExpenseBreakdown(int $fiscalYear = 0): object
|
||||||
|
{
|
||||||
|
$fiscalYear = $fiscalYear ?: (int) date('Y');
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('classification')
|
||||||
|
->select('SUM(amount) AS total')
|
||||||
|
->from('#__mokosuitenpo_expenses')
|
||||||
|
->where('YEAR(expense_date) = ' . $fiscalYear)
|
||||||
|
->group('classification'));
|
||||||
|
|
||||||
|
$rows = $db->loadObjectList('classification') ?: [];
|
||||||
|
|
||||||
|
$program = round((float) ($rows['program_services']->total ?? 0), 2);
|
||||||
|
$management = round((float) ($rows['management_general']->total ?? 0), 2);
|
||||||
|
$fundraising = round((float) ($rows['fundraising']->total ?? 0), 2);
|
||||||
|
$total = $program + $management + $fundraising;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'fiscal_year' => $fiscalYear,
|
||||||
|
'program_services' => $program,
|
||||||
|
'management_general' => $management,
|
||||||
|
'fundraising' => $fundraising,
|
||||||
|
'total_expenses' => $total,
|
||||||
|
'program_pct' => $total > 0 ? round($program / $total * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Part VII compensation data for officers/key employees.
|
||||||
|
*/
|
||||||
|
public static function getCompensationSchedule(int $fiscalYear = 0): array
|
||||||
|
{
|
||||||
|
$fiscalYear = $fiscalYear ?: (int) date('Y');
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('cd.name, bm.role AS title, bm.weekly_hours')
|
||||||
|
->select('COALESCE(c.base_compensation, 0) AS compensation')
|
||||||
|
->select('COALESCE(c.benefits, 0) AS benefits')
|
||||||
|
->select('COALESCE(c.other_compensation, 0) AS other')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_board_members', 'bm'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = bm.contact_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitenpo_compensation', 'c')
|
||||||
|
. ' ON c.contact_id = bm.contact_id AND c.fiscal_year = ' . $fiscalYear)
|
||||||
|
->where($db->quoteName('bm.status') . ' = ' . $db->quote('active'))
|
||||||
|
->order('compensation DESC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fund accounting — restricted vs unrestricted funds, fund balances, GAAP compliance.
|
||||||
|
*/
|
||||||
|
class FundAccountingHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get fund balances summary.
|
||||||
|
*/
|
||||||
|
public static function getFundBalances(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('f.id, f.name, f.type, f.description')
|
||||||
|
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.fund_id = f.id), 0) AS total_received')
|
||||||
|
->select('COALESCE((SELECT SUM(e.amount) FROM #__mokosuitenpo_fund_expenses e WHERE e.fund_id = f.id), 0) AS total_spent')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_funds', 'f'))
|
||||||
|
->where($db->quoteName('f.status') . ' = ' . $db->quote('active'))
|
||||||
|
->order('f.type ASC, f.name ASC'));
|
||||||
|
|
||||||
|
$funds = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($funds as &$f) {
|
||||||
|
$f->balance = round((float) $f->total_received - (float) $f->total_spent, 2);
|
||||||
|
$f->is_restricted = ($f->type === 'restricted' || $f->type === 'temporarily_restricted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $funds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an expense against a fund.
|
||||||
|
*/
|
||||||
|
public static function recordExpense(int $fundId, float $amount, string $description, string $category = 'program'): int
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$now = Factory::getDate()->toSql();
|
||||||
|
|
||||||
|
// Verify fund exists and has sufficient balance
|
||||||
|
$db->setQuery($db->getQuery(true)->select('type')->from('#__mokosuitenpo_funds')->where('id = ' . (int) $fundId));
|
||||||
|
$fundType = $db->loadResult();
|
||||||
|
|
||||||
|
if (!$fundType) throw new \RuntimeException('Fund not found');
|
||||||
|
|
||||||
|
// Enforce balance check on restricted funds (GAAP compliance)
|
||||||
|
if ($fundType === 'restricted' || $fundType === 'temporarily_restricted') {
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.fund_id = ' . (int) $fundId . '), 0)'
|
||||||
|
. ' - COALESCE((SELECT SUM(e.amount) FROM #__mokosuitenpo_fund_expenses e WHERE e.fund_id = ' . (int) $fundId . '), 0) AS balance')
|
||||||
|
->from('DUAL'));
|
||||||
|
$balance = (float) $db->loadResult();
|
||||||
|
|
||||||
|
if ($amount > $balance) {
|
||||||
|
throw new \RuntimeException('Insufficient balance in restricted fund (available: $' . number_format($balance, 2) . ', requested: $' . number_format($amount, 2) . ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$expense = (object) [
|
||||||
|
'fund_id' => $fundId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'description' => $description,
|
||||||
|
'category' => $category, // program, admin, fundraising
|
||||||
|
'recorded_by' => Factory::getApplication()->getIdentity()->id,
|
||||||
|
'recorded_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuitenpo_fund_expenses', $expense, 'id');
|
||||||
|
return (int) $expense->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Statement of Financial Position (nonprofit balance sheet).
|
||||||
|
*/
|
||||||
|
public static function getFinancialPosition(): object
|
||||||
|
{
|
||||||
|
$funds = self::getFundBalances();
|
||||||
|
|
||||||
|
$unrestricted = 0;
|
||||||
|
$tempRestricted = 0;
|
||||||
|
$permRestricted = 0;
|
||||||
|
|
||||||
|
foreach ($funds as $f) {
|
||||||
|
switch ($f->type) {
|
||||||
|
case 'unrestricted': $unrestricted += $f->balance; break;
|
||||||
|
case 'temporarily_restricted': $tempRestricted += $f->balance; break;
|
||||||
|
case 'permanently_restricted': case 'restricted': $permRestricted += $f->balance; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'unrestricted' => $unrestricted,
|
||||||
|
'temporarily_restricted' => $tempRestricted,
|
||||||
|
'permanently_restricted' => $permRestricted,
|
||||||
|
'total_net_assets' => $unrestricted + $tempRestricted + $permRestricted,
|
||||||
|
'fund_count' => count($funds),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expense breakdown by category (program vs admin vs fundraising).
|
||||||
|
*/
|
||||||
|
public static function getExpenseBreakdown(string $from = '', string $to = ''): object
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$from = $from ?: date('Y-01-01');
|
||||||
|
$to = $to ?: date('Y-12-31');
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('category')
|
||||||
|
->select('COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total')
|
||||||
|
->from('#__mokosuitenpo_fund_expenses')
|
||||||
|
->where('DATE(recorded_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))
|
||||||
|
->group('category'));
|
||||||
|
|
||||||
|
$byCategory = $db->loadObjectList('category') ?: [];
|
||||||
|
|
||||||
|
$program = (float) ($byCategory['program']->total ?? 0);
|
||||||
|
$admin = (float) ($byCategory['admin']->total ?? 0);
|
||||||
|
$fundraising = (float) ($byCategory['fundraising']->total ?? 0);
|
||||||
|
$totalExpenses = $program + $admin + $fundraising;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'program' => $program,
|
||||||
|
'admin' => $admin,
|
||||||
|
'fundraising' => $fundraising,
|
||||||
|
'total' => $totalExpenses,
|
||||||
|
'program_pct' => $totalExpenses > 0 ? round($program / $totalExpenses * 100, 1) : 0,
|
||||||
|
'overhead_pct' => $totalExpenses > 0 ? round(($admin + $fundraising) / $totalExpenses * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant reporting — deliverable tracking, spending reports, funder compliance.
|
||||||
|
*/
|
||||||
|
class GrantReportingHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get grant spending report — budgeted vs actual by category.
|
||||||
|
*/
|
||||||
|
public static function getSpendingReport(int $grantId): object
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('g.*, cd.name AS funder_name')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = g.funder_contact_id')
|
||||||
|
->where('g.id = ' . (int) $grantId));
|
||||||
|
$grant = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$grant) return (object) ['found' => false];
|
||||||
|
|
||||||
|
// Get expenses charged to this grant's fund
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('category, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS spent')
|
||||||
|
->from('#__mokosuitenpo_fund_expenses')
|
||||||
|
->where('fund_id = ' . (int) ($grant->fund_id ?? 0))
|
||||||
|
->group('category')
|
||||||
|
->order('spent DESC'));
|
||||||
|
$grant->spending_by_category = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
$grant->total_spent = array_sum(array_column($grant->spending_by_category, 'spent'));
|
||||||
|
$grant->remaining = max(0, (float) ($grant->amount ?? 0) - (float) $grant->total_spent);
|
||||||
|
$grant->utilization_pct = (float) ($grant->amount ?? 0) > 0
|
||||||
|
? round((float) $grant->total_spent / (float) $grant->amount * 100, 1) : 0;
|
||||||
|
|
||||||
|
return $grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get grants requiring reports soon.
|
||||||
|
*/
|
||||||
|
public static function getUpcomingReportDeadlines(int $days = 30): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('g.id, g.title, g.funder, g.report_due_date, g.amount')
|
||||||
|
->select('DATEDIFF(g.report_due_date, CURDATE()) AS days_until_due')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
|
||||||
|
->where($db->quoteName('g.status') . ' IN (' . $db->quote('active') . ',' . $db->quote('reporting') . ')')
|
||||||
|
->where($db->quoteName('g.report_due_date') . ' IS NOT NULL')
|
||||||
|
->where($db->quoteName('g.report_due_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . (int) $days . ' DAY)')
|
||||||
|
->order('g.report_due_date ASC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-kind donation tracking — non-cash gifts, fair market valuation, category reporting.
|
||||||
|
*/
|
||||||
|
class InKindDonationHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Record an in-kind donation.
|
||||||
|
*/
|
||||||
|
public static function record(int $contactId, string $description, float $fairMarketValue, string $category = 'goods'): object
|
||||||
|
{
|
||||||
|
if ($fairMarketValue <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Fair market value must be positive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedCategories = ['goods', 'services', 'equipment', 'real_estate', 'securities', 'vehicles', 'other'];
|
||||||
|
if (!in_array($category, $allowedCategories, true)) {
|
||||||
|
$category = 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
$filter = \Joomla\Filter\InputFilter::getInstance();
|
||||||
|
|
||||||
|
$donation = (object) [
|
||||||
|
'contact_id' => $contactId,
|
||||||
|
'description' => $filter->clean($description, 'STRING'),
|
||||||
|
'fair_market_value'=> $fairMarketValue,
|
||||||
|
'category' => $category,
|
||||||
|
'status' => 'received',
|
||||||
|
'received_at' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuitenpo_inkind_donations', $donation, 'id');
|
||||||
|
|
||||||
|
return (object) ['success' => true, 'donation_id' => (int) $donation->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get in-kind donation summary by category for a period.
|
||||||
|
*/
|
||||||
|
public static function getSummary(string $from = '', string $to = ''): array
|
||||||
|
{
|
||||||
|
$from = $from ?: date('Y-01-01');
|
||||||
|
$to = $to ?: date('Y-m-d');
|
||||||
|
|
||||||
|
if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) {
|
||||||
|
throw new \InvalidArgumentException('Date parameters must be Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('ik.category')
|
||||||
|
->select('COUNT(*) AS donation_count')
|
||||||
|
->select('SUM(ik.fair_market_value) AS total_value')
|
||||||
|
->select('AVG(ik.fair_market_value) AS avg_value')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_inkind_donations', 'ik'))
|
||||||
|
->where('DATE(ik.received_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))
|
||||||
|
->group('ik.category')
|
||||||
|
->order('total_value DESC'));
|
||||||
|
|
||||||
|
$results = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($results as &$r) {
|
||||||
|
$r->avg_value = round((float) $r->avg_value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get donations needing appraisal (over $5,000 threshold per IRS rules).
|
||||||
|
*/
|
||||||
|
public static function getNeedingAppraisal(): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('ik.*, cd.name AS donor_name')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_inkind_donations', 'ik'))
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = ik.contact_id')
|
||||||
|
->where('ik.fair_market_value > 5000')
|
||||||
|
->where('ik.appraisal_date IS NULL')
|
||||||
|
->where($db->quoteName('ik.category') . ' NOT IN (' . $db->quote('securities') . ')')
|
||||||
|
->order('ik.fair_market_value DESC'));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Database\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pledge reminders — notify donors of unfulfilled pledges, track fulfillment progress.
|
||||||
|
*/
|
||||||
|
class PledgeReminderHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get unfulfilled pledges that need reminders.
|
||||||
|
*/
|
||||||
|
public static function getUnfulfilled(int $overdueDays = 30): array
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('p.*, cd.name AS donor_name, cd.email_to')
|
||||||
|
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.pledge_id = p.id), 0) AS amount_fulfilled')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
|
||||||
|
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = p.donor_id')
|
||||||
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
|
||||||
|
->where($db->quoteName('p.status') . ' = ' . $db->quote('active'))
|
||||||
|
->where('p.due_date IS NOT NULL')
|
||||||
|
->where('p.due_date < DATE_SUB(CURDATE(), INTERVAL ' . (int) $overdueDays . ' DAY)')
|
||||||
|
->having('amount_fulfilled < p.amount')
|
||||||
|
->order('p.due_date ASC'));
|
||||||
|
|
||||||
|
$pledges = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($pledges as &$p) {
|
||||||
|
$p->remaining = round((float) $p->amount - (float) $p->amount_fulfilled, 2);
|
||||||
|
$p->fulfillment_pct = (float) $p->amount > 0 ? round((float) $p->amount_fulfilled / (float) $p->amount * 100, 1) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pledges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pledge fulfillment summary.
|
||||||
|
*/
|
||||||
|
public static function getFulfillmentSummary(): object
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||||
|
|
||||||
|
$db->setQuery($db->getQuery(true)
|
||||||
|
->select('COUNT(*) AS total_pledges')
|
||||||
|
->select('COALESCE(SUM(amount), 0) AS total_pledged')
|
||||||
|
->select('COALESCE(SUM((SELECT COALESCE(SUM(d.amount), 0) FROM #__mokosuitenpo_donations d WHERE d.pledge_id = p.id)), 0) AS total_received')
|
||||||
|
->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
|
||||||
|
->where($db->quoteName('p.status') . ' IN (' . $db->quote('active') . ',' . $db->quote('completed') . ')'));
|
||||||
|
|
||||||
|
$stats = $db->loadObject() ?: (object) ['total_pledges' => 0, 'total_pledged' => 0, 'total_received' => 0];
|
||||||
|
$stats->outstanding = round((float) $stats->total_pledged - (float) $stats->total_received, 2);
|
||||||
|
$stats->fulfillment_rate = (float) $stats->total_pledged > 0
|
||||||
|
? round((float) $stats->total_received / (float) $stats->total_pledged * 100, 1) : 0;
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,10 +89,10 @@ class RecurringDonationHelper
|
|||||||
DonorHelper::recordDonation(
|
DonorHelper::recordDonation(
|
||||||
(int) $pledge->donor_id,
|
(int) $pledge->donor_id,
|
||||||
(float) $pledge->amount,
|
(float) $pledge->amount,
|
||||||
'card',
|
|
||||||
(int) $pledge->fund_id,
|
(int) $pledge->fund_id,
|
||||||
0,
|
'card',
|
||||||
'Recurring pledge #' . $pledge->id
|
null,
|
||||||
|
['notes' => 'Recurring pledge #' . $pledge->id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Advance next charge date
|
// Advance next charge date
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuite NPO</name>
|
<name>Package - MokoSuite NPO</name>
|
||||||
<packagename>mokosuitenpo</packagename>
|
<packagename>mokosuitenpo</packagename>
|
||||||
<version>01.02.00</version>
|
<version>01.07.23</version>
|
||||||
<creationDate>2026-06-11</creationDate>
|
<creationDate>2026-06-11</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
@@ -23,6 +23,6 @@
|
|||||||
</files>
|
</files>
|
||||||
|
|
||||||
<updateservers>
|
<updateservers>
|
||||||
<server type="extension" priority="1" name="Package - MokoSuite NPO">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/updates.xml</server>
|
<server type="extension" name="MokoSuiteNPO Updates">https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/MokoSuiteNPO/latest/updates.xml</server>
|
||||||
</updateservers>
|
</updateservers>
|
||||||
</extension>
|
</extension>
|
||||||
|
|||||||
+69
-3
@@ -1,8 +1,74 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteNPO
|
||||||
|
* @subpackage pkg_mokosuitenpo
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Installer\InstallerAdapter;
|
use Joomla\CMS\Installer\InstallerAdapter;
|
||||||
class Pkg_mokosuitenpoInstallerScript
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package installation script for MokoSuiteNPO.
|
||||||
|
*/
|
||||||
|
class Pkg_MokoSuiteNPOInstallerScript
|
||||||
{
|
{
|
||||||
public function preflight(string $type, InstallerAdapter $adapter): bool { return true; }
|
public function postflight(string $type, InstallerAdapter $adapter): void
|
||||||
public function postflight(string $type, InstallerAdapter $adapter): void {}
|
{
|
||||||
|
$this->warnMissingLicenseKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warnMissingLicenseKey(): void
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
|
||||||
|
->from($db->quoteName('#__update_sites'))
|
||||||
|
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteNPO%')
|
||||||
|
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteNPO%') . ')')
|
||||||
|
->setLimit(1);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$site = $db->loadObject();
|
||||||
|
|
||||||
|
if ($site)
|
||||||
|
{
|
||||||
|
$extraQuery = (string) ($site->extra_query ?? '');
|
||||||
|
|
||||||
|
if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false)
|
||||||
|
{
|
||||||
|
parse_str($extraQuery, $parsed);
|
||||||
|
|
||||||
|
if (!empty($parsed['dlid']))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$editUrl = 'index.php?option=com_installer&view=updatesites';
|
||||||
|
}
|
||||||
|
|
||||||
|
$app->enqueueMessage(
|
||||||
|
'<strong>Moko Consulting License Key Required</strong> — '
|
||||||
|
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||||
|
. '<a href="' . $editUrl . '" class="btn btn-sm btn-warning ms-2">Enter License Key</a>',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
// Silent — avoid breaking install if update_sites query fails
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<updates><update>
|
|
||||||
<name>Package - MokoSuite NPO</name>
|
|
||||||
<element>pkg_mokosuitenpo</element>
|
|
||||||
<type>package</type>
|
|
||||||
<version>01.01.00</version>
|
|
||||||
<targetplatform name="joomla" version="6.[0-9]" />
|
|
||||||
<php_minimum>8.3</php_minimum>
|
|
||||||
</update></updates>
|
|
||||||
Reference in New Issue
Block a user