diff --git a/.editorconfig b/.editorconfig index e868be9..09975f7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,41 +1,31 @@ -# EditorConfig helps maintain consistent coding styles across different editors and IDEs -# https://editorconfig.org/ - +# EditorConfig https://editorconfig.org root = true -# Default settings — Tabs preferred, width = 2 spaces [*] -charset = utf-8 +indent_style = tab +indent_size = 2 end_of_line = lf -insert_final_newline = true +charset = utf-8 trim_trailing_whitespace = true -indent_style = tab -tab_width = 2 +insert_final_newline = true -# PowerShell scripts — tabs, 2-space visual width [*.ps1] -indent_style = tab -tab_width = 2 end_of_line = crlf -# Markdown files — keep trailing whitespace for line breaks [*.md] trim_trailing_whitespace = false -# JSON / YAML files — tabs, 2-space visual width [*.{json,yml,yaml}] -indent_style = tab -tab_width = 2 +indent_style = space +indent_size = 2 -# Makefiles — always tabs, default width -[Makefile] +[*.{mak,Makefile}] indent_style = tab -tab_width = 2 -# Windows batch scripts — keep CRLF endings -[*.{bat,cmd}] +[*.bat] end_of_line = crlf -# Shell scripts — ensure LF endings [*.sh] end_of_line = lf +indent_style = space +indent_size = 2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f3b894a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "packages/MokoSuiteClient"] + path = packages/MokoSuiteClient + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git +[submodule "packages/MokoSuiteCRM"] + path = packages/MokoSuiteCRM + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git +[submodule "packages/MokoSuiteERP"] + path = packages/MokoSuiteERP + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP.git diff --git a/.mokogitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md new file mode 100644 index 0000000..eb40760 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/adr.md @@ -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 diff --git a/.mokogitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..38a16a7 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/bug_report.md @@ -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 diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d4d49ec --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.mokogitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..ed4dabc --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,52 @@ +--- +name: Documentation Issue +about: Report an issue with documentation +title: '[DOCS] ' +labels: 'documentation' +assignees: '' + +--- + + +## Documentation Issue + +**Location**: + + +## Issue Type + +- [ ] Typo or grammar error +- [ ] Outdated information +- [ ] Missing documentation +- [ ] Unclear explanation +- [ ] Broken links +- [ ] Missing examples +- [ ] Other (specify below) + +## Description + + +## Current Content + +``` +Current text here +``` + +## Suggested Improvement + +``` +Suggested text here +``` + +## Additional Context + + +## 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) diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7b76dc9 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md @@ -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 diff --git a/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md new file mode 100644 index 0000000..d808f79 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md @@ -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 diff --git a/.mokogitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..3175013 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/question.md @@ -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) diff --git a/.mokogitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md new file mode 100644 index 0000000..6f09af7 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/rfc.md @@ -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. diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md new file mode 100644 index 0000000..f57b284 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/security.md @@ -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**: + + +## Description + + +## Affected Components + + +## Suggested Mitigation + + +## 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 + + +## Checklist +- [ ] This is NOT a critical vulnerability requiring private disclosure +- [ ] I have reviewed the SECURITY.md policy +- [ ] I have provided sufficient detail for evaluation diff --git a/.mokogitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md new file mode 100644 index 0000000..6328421 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/version.md @@ -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**: +**Requested version**: +**Change type**: + +## Reason + + + +## Checklist + +- [ ] README.md `VERSION:` field updated +- [ ] CHANGELOG.md entry added +- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: ``) +- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow diff --git a/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml new file mode 100644 index 0000000..2dff8b9 --- /dev/null +++ b/.mokogitea/branch-protection.yml @@ -0,0 +1,251 @@ +# Copyright (C) 2026 Moko Consulting +# 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 diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml index 91e16f7..6c13103 100644 --- a/.mokogitea/workflows/auto-bump.yml +++ b/.mokogitea/workflows/auto-bump.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # PATH: /.mokogitea/workflows/auto-bump.yml # VERSION: 09.02.00 # BRIEF: Auto patch-bump version on every push to dev (skips merge commits) @@ -22,7 +22,7 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} permissions: contents: write @@ -34,8 +34,7 @@ jobs: if: >- !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip bump]') && - !startsWith(github.event.head_commit.message, 'Merge pull request') && - !startsWith(github.event.repository.name, 'Template-') + !startsWith(github.event.head_commit.message, 'Merge pull request') steps: - name: Checkout @@ -53,7 +52,7 @@ jobs: echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" else git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/MokoCLI.git" \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ /tmp/mokocli cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index a7563e0..3be5d44 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -3,11 +3,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/auto-release.yml -# VERSION: 05.01.00 +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# PATH: /templates/workflows/universal/auto-release.yml.template +# VERSION: 05.00.00 # BRIEF: Universal build & release � detects platform from manifest.xml # # +=======================================================================+ @@ -27,18 +27,9 @@ name: "Universal: Build & Release" on: pull_request: - types: [opened, synchronize, closed] + types: [opened, closed] branches: - main - paths-ignore: - - '.mokogitea/workflows/**' - - '*.md' - - 'wiki/**' - - '.editorconfig' - - '.gitignore' - - '.gitattributes' - - '.gitmessage' - - 'LICENSE' workflow_dispatch: inputs: action: @@ -52,7 +43,7 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + 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 }} @@ -64,14 +55,9 @@ jobs: promote-rc: name: Promote to RC runs-on: release - # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - !startsWith(github.event.repository.name, 'Template-') && - ( - (github.event.action == 'opened' && github.event.pull_request.merged != true) || - (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') - ) + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') steps: - name: Checkout repository @@ -79,7 +65,6 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 1 - submodules: recursive - name: Setup mokocli tools env: @@ -95,7 +80,7 @@ jobs: 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 + 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 @@ -104,46 +89,18 @@ jobs: - name: Rename branch to rc run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" - FROM="${{ github.event.pull_request.head.ref || 'dev' }}" - PR="${{ github.event.pull_request.number }}" - - # Resolve the source branch HEAD commit. - SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \ - || { echo "::error::Source branch ${FROM} not found"; exit 1; } - SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true) - [ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; } - - # Point rc at the source commit. If rc already exists (a protected branch that - # cannot be deleted), force-update its ref in place instead of delete+recreate: - # deleting a protected branch fails, which then makes the recreate return HTTP 409. - if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then - echo "rc exists - force-updating to ${FROM} (${SRC_SHA})" - curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ - "${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \ - || { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; } - else - echo "Creating rc from ${FROM}" - curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ - "${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \ - || { echo "::error::Failed to create rc from ${FROM}"; exit 1; } - fi - - # Repoint the PR at rc, then delete the old source branch (non-fatal). - if [ -n "$PR" ]; then - curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ - "${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true - fi - curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true - echo "Renamed ${FROM} -> rc" + php ${MOKO_CLI}/branch_rename.php \ + --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ + --pr "${{ github.event.pull_request.number }}" - name: Checkout rc and configure git run: | git fetch origin rc git checkout rc - git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "mokogitea-actions[bot]" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - name: Publish RC release @@ -154,7 +111,7 @@ jobs: - name: Update RC release notes from CHANGELOG.md run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" # Extract [Unreleased] section from changelog @@ -196,13 +153,9 @@ jobs: release: name: Build & Release Pipeline runs-on: release - # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - !startsWith(github.event.repository.name, 'Template-') && - ( - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') - ) + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') steps: - name: Checkout repository @@ -210,12 +163,11 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 0 - submodules: recursive - name: Configure git for bot pushes run: | - git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "mokogitea-actions[bot]" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - name: Check for merge conflict markers @@ -246,7 +198,7 @@ jobs: 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 + 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 @@ -307,7 +259,7 @@ jobs: !startsWith(steps.platform.outputs.platform, 'joomla') run: | VERSION="${{ steps.version.outputs.version }}" - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" SEMVER_TAG="v${VERSION}" @@ -332,7 +284,7 @@ jobs: - name: Update release notes and promote changelog run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" # Get the stable release info (version and ID) @@ -382,7 +334,7 @@ jobs: content = open('CHANGELOG.md').read() old = '## [Unreleased]' new = f'## [Unreleased]\n\n## [{version}] --- {date}' - content = content if ('## [' + version + ']') in content else content.replace(old, new, 1) + content = content.replace(old, new, 1) open('CHANGELOG.md', 'w').write(content) " "$VERSION" "$DATE" git add CHANGELOG.md @@ -401,7 +353,7 @@ jobs: VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" RELEASE_TAG="${{ steps.version.outputs.release_tag }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" php ${MOKO_CLI}/release_mirror.php \ --version "$VERSION" --tag "$RELEASE_TAG" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ @@ -426,11 +378,11 @@ jobs: && echo "main branch pushed to GitHub mirror" \ || echo "WARNING: GitHub mirror push failed" - - name: "Step 11: Delete rc branch (dev reset moved to cascade-dev.yml)" + - name: "Step 11: Delete rc branch and recreate dev from main" if: steps.version.outputs.skip != 'true' continue-on-error: true run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" # Delete rc branch (ephemeral — created by promote-rc) @@ -438,15 +390,23 @@ jobs: "${API_BASE}/branches/rc" 2>/dev/null \ && echo "Deleted rc branch" || echo "rc branch not found" - # dev is reset from main by the dedicated "Cascade Main -> Dev" workflow - # (cascade-dev.yml), which runs after this release completes. - echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" + + # Recreate dev from main (now includes version bump + changelog promotion) + curl -sf -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" + + echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY - name: "Step 12: Create version branch from main" if: steps.version.outputs.skip != 'true' continue-on-error: true run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" BRANCH_NAME="version/${VERSION}" @@ -467,7 +427,7 @@ jobs: if: steps.version.outputs.skip != 'true' continue-on-error: true run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" php ${MOKO_CLI}/version_reset_dev.php \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ --branch dev --path . 2>&1 || true @@ -493,5 +453,5 @@ jobs: echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY + echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY fi diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml index 25ebae1..9d884e7 100644 --- a/.mokogitea/workflows/branch-cleanup.yml +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Universal -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # PATH: /.mokogitea/workflows/branch-cleanup.yml # VERSION: 01.00.00 # BRIEF: Delete feature branches after PR merge @@ -33,8 +33,7 @@ jobs: run: | BRANCH="${{ github.event.pull_request.head.ref }}" API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" - # URL-encode the branch name's slashes (no PHP dependency on the runner) - ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g') + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml index b2eabe1..5f7c1d7 100644 --- a/.mokogitea/workflows/cascade-dev.yml +++ b/.mokogitea/workflows/cascade-dev.yml @@ -1,106 +1,10 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow -# INGROUP: MokoStandards.Cascade -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/cascade-dev.yml -# VERSION: 02.00.00 -# BRIEF: Cascade main -> dev via PR; auto-merge only if conflict-free, else notify - -name: "Cascade Main -> Dev" - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -env: - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - # ntfy destination is configured via repo or org variables (org vars are inherited). - NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} - NTFY_TOPIC: ${{ vars.CASCADE_NTFY_TOPIC || vars.NTFY_TOPIC || 'gitea-releases' }} - +# 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: - cascade: - name: Cascade main -> dev + noop: runs-on: ubuntu-latest steps: - - name: Open main -> dev PR (auto-merge if clean, else notify) - env: - TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -uo pipefail - API="${MOKOGITEA_URL}/api/v1/repos/${REPO}" - AUTH="Authorization: token ${TOKEN}" - jqnum() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null; } - - # 0. dev must exist - if ! curl -sf -H "$AUTH" "${API}/branches/dev" >/dev/null 2>&1; then - echo "No dev branch - nothing to cascade."; exit 0 - fi - - # 1. is main ahead of dev? - AHEAD=$(curl -sf -H "$AUTH" "${API}/compare/dev...main" \ - | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_commits',0))" 2>/dev/null || echo 0) - if [ "${AHEAD:-0}" -eq 0 ]; then - echo "dev already up to date with main."; exit 0 - fi - echo "main is ${AHEAD} commit(s) ahead of dev." - - # 2. reuse an open main->dev PR, else create one - PR=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=dev" \ - | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='main'), ''))" 2>/dev/null || echo "") - if [ -z "$PR" ]; then - RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \ - -d '{"head":"main","base":"dev","title":"chore(sync): cascade main -> dev","body":"Automated cascade of main into dev. Auto-merges only if conflict-free; otherwise left open for manual resolution."}') - PR=$(printf '%s' "$RESP" | jqnum number) - if [ -z "$PR" ]; then - echo "::warning::Could not open cascade PR: $RESP"; exit 0 - fi - echo "Opened cascade PR #${PR}" - else - echo "Reusing open cascade PR #${PR}" - fi - - # 3. wait for MokoGitea to compute mergeability (conflict detection) - MERGEABLE="" - for _ in 1 2 3 4 5 6; do - MERGEABLE=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqnum mergeable) - case "$MERGEABLE" in True|False) break ;; esac - sleep 3 - done - echo "mergeable=${MERGEABLE}" - - notify() { - curl -sS \ - -H "Title: ${REPO}: dev cascade needs manual merge" \ - -H "Tags: warning,twisted_rightwards_arrows" \ - -H "Priority: high" \ - -H "Click: ${MOKOGITEA_URL}/${REPO}/pulls/${PR}" \ - -d "main -> dev cascade PR #${PR} $1 It was NOT auto-merged; resolve it manually." \ - "${NTFY_URL}/${NTFY_TOPIC}" || true - } - - # 4. auto-merge only if conflict-free; otherwise notify - if [ "$MERGEABLE" = "True" ]; then - CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \ - -X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true}') - if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then - echo "Cascade PR #${PR} merged (or scheduled to merge when checks pass)." - else - echo "::warning::Auto-merge returned HTTP ${CODE}: $(cat /tmp/merge.json)" - notify "could not be auto-merged (HTTP ${CODE})." - fi - else - echo "::warning::Cascade PR #${PR} has conflicts (mergeable=${MERGEABLE}); sending notification." - notify "has conflicts and cannot be merged automatically." - fi + - run: echo "Cascade disabled — auto-release handles dev recreation" diff --git a/.mokogitea/workflows/ci-generic.yml b/.mokogitea/workflows/ci-generic.yml index 14f81ba..18ae768 100644 --- a/.mokogitea/workflows/ci-generic.yml +++ b/.mokogitea/workflows/ci-generic.yml @@ -3,22 +3,16 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.CI # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/ci-generic.yml +# 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: - pull_request: - branches: - - main - - dev - - dev/** - - rc/** workflow_dispatch: permissions: @@ -32,8 +26,6 @@ jobs: lint: name: Lint & Validate runs-on: ubuntu-latest - # Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source. - if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -131,11 +123,7 @@ jobs: test: name: Tests runs-on: ubuntu-latest - # Independent job (no `needs: lint`): the Gitea Actions scheduler does not - # offer the dependent 2nd job of a needs-chain to runners, so it stalls in - # "waiting" and is reaped by ABANDONED_JOB_TIMEOUT. Guard template repos - # directly (same condition lint uses) instead of gating on lint's result. - if: ${{ !startsWith(github.event.repository.name, 'Template-') }} + needs: lint steps: - name: Checkout diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml new file mode 100644 index 0000000..727f661 --- /dev/null +++ b/.mokogitea/workflows/ci-joomla.yml @@ -0,0 +1,903 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 /dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No Joomla extension manifest found (XML file with \`> $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 ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + if ! grep -q "/dev/null; then + echo "Missing required tag: \`\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`\`" >> $GITHUB_STEP_SUMMARY + fi + else + echo "Package extension — \`\` 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 "/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 ']*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 "/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 references + FILENAMES=$(grep -oP ']*>\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 references + FOLDERS=$(grep -oP ']*>\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 references in package manifests (ZIP files won't exist in source) + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + FILES=$(grep -oP ']*>\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 '/dev/null; then + echo "- \`${FILE}\`: no \`
\`, \`\`, or \`
\` 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: 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 \`\` output(s) — use \`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 tags + MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '/dev/null || true) + + if [ -z "$MANIFESTS" ]; then + echo "No manifests with \`\` found — skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $MANIFESTS; do + NS_PATH=$(grep -oP ']*>\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 "/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 matches README VERSION + MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1) + if [ -z "$MANIFEST_VERSION" ]; then + echo "No \`\` 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 ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ -z "$EXT_TYPE" ]; then + echo "Missing \`type\` attribute on \`\` 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 \`\` or \`\` 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 ']*\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 diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index b4db32b..3a81856 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -3,10 +3,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.mokogitea/workflows/cleanup.yml +# PATH: /.gitea/workflows/cleanup.yml # VERSION: 01.00.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs @@ -21,7 +21,7 @@ permissions: contents: write env: - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} jobs: cleanup: @@ -33,30 +33,30 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.MOKOGITEA_TOKEN }} + token: ${{ secrets.GA_TOKEN }} - name: Delete merged branches env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GA_TOKEN: ${{ secrets.GA_TOKEN }} run: | echo "=== Merged Branch Cleanup ===" - API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" # List branches via API - BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \ + 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|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;; + 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 ${MOKOGITEA_TOKEN}" \ + curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ "${API}/branches/${BRANCH}" 2>/dev/null || true DELETED=$((DELETED + 1)) fi @@ -66,20 +66,20 @@ jobs: - name: Clean old workflow runs env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GA_TOKEN: ${{ secrets.GA_TOKEN }} run: | echo "=== Workflow Run Cleanup ===" - API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" + 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 ${MOKOGITEA_TOKEN}" \ + 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 ${MOKOGITEA_TOKEN}" \ + curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true DELETED=$((DELETED + 1)) done diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml index 7312838..196cf0c 100644 --- a/.mokogitea/workflows/gitleaks.yml +++ b/.mokogitea/workflows/gitleaks.yml @@ -3,10 +3,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API -# PATH: /.mokogitea/workflows/gitleaks.yml +# 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 # diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index eb67f8f..fcf42cb 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.00.22 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" @@ -19,7 +19,7 @@ permissions: issues: write env: - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} jobs: create-branch: @@ -28,8 +28,8 @@ jobs: steps: - name: Create branch and comment run: | - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" + TOKEN="${{ secrets.GA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_TITLE="${{ github.event.issue.title }}" @@ -58,7 +58,7 @@ jobs: echo "Created branch: ${BRANCH}" # Comment on issue with branch link - REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}" + 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 \ diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 5fead53..51dfcb5 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -3,10 +3,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Notifications # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.mokogitea/workflows/notify.yml +# PATH: /.gitea/workflows/notify.yml # VERSION: 01.00.00 # BRIEF: Push notifications via ntfy on release success or workflow failure @@ -15,9 +15,9 @@ name: "Universal: Notifications" on: workflow_run: workflows: - - "Universal: Build & Release" - - "Joomla: Extension CI" - - "Generic: Project CI" + - "Joomla Build & Release" + - "Joomla Extension CI" + - "Deploy" types: - completed diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index c7c2e8f..b1037e7 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -3,10 +3,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow -# INGROUP: mokocli.CI -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/pr-check.yml +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/pr-check.yml.template # VERSION: 09.23.00 # BRIEF: PR gate — branch policy + code validation before merge @@ -47,15 +47,15 @@ jobs: fi ;; fix/*|bugfix/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + if [ "$BASE" != "dev" ]; then ALLOWED=false - REASON="Fix branches must target 'dev' or 'main', not '${BASE}'" + REASON="Fix branches must target 'dev', not '${BASE}'" fi ;; patch/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then ALLOWED=false - REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'" + REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" fi ;; hotfix/*) @@ -86,8 +86,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY @@ -97,80 +96,6 @@ jobs: echo "Branch policy: OK (${HEAD} → ${BASE})" echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY - # ── Docs Update Gate (main PRs) ───────────────────────────────────────── - require-docs: - name: Require Docs Update - runs-on: ubuntu-latest - # Enforce only on PRs merging into main: README.md + CHANGELOG.md must both be updated. - if: ${{ github.base_ref == 'main' }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Require README.md and CHANGELOG.md in the PR diff - run: | - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - CHANGED="$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || true)" - if [ -z "$CHANGED" ]; then - git fetch -q origin "${{ github.base_ref }}" 2>/dev/null || true - CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" 2>/dev/null || true)" - fi - echo "Changed files in PR:" - echo "$CHANGED" - MISSING="" - echo "$CHANGED" | grep -qxE 'README\.md' || MISSING="README.md" - echo "$CHANGED" | grep -qxE 'CHANGELOG\.md' || MISSING="${MISSING:+$MISSING, }CHANGELOG.md" - if [ -n "$MISSING" ]; then - echo "::error::PRs into main must update: ${MISSING}" - { - echo "## Docs Update Required" - echo "" - echo "PRs merging into \`main\` must update both **README.md** and **CHANGELOG.md**." - echo "" - echo "Not updated in this PR: **${MISSING}**" - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - echo "Docs update present (README.md + CHANGELOG.md)" - echo "## Docs Update: Passed" >> "$GITHUB_STEP_SUMMARY" - - # ── Wiki Update Reminder (main PRs, non-blocking) ─────────────────────── - wiki-reminder: - name: Wiki Update Reminder - runs-on: ubuntu-latest - if: ${{ github.base_ref == 'main' }} - steps: - - name: Remind to update the wiki - env: - TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - run: | - set -uo pipefail - { - echo "## Wiki Update Reminder" - echo "" - echo "Docs are **wiki-first** at MokoConsulting. If this change affects behavior, usage, configuration, or standards, update the repo wiki:" - echo "" - echo "- ${SERVER}/${REPO}/wiki" - echo "" - echo "_Non-blocking reminder._" - } >> "$GITHUB_STEP_SUMMARY" - # Post a single PR comment (idempotent via hidden marker); best-effort, never fails. - API="${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" - if [ -n "${TOKEN:-}" ] && [ -n "${PR:-}" ]; then - existing="$(curl -sf -H "Authorization: token ${TOKEN}" "$API" 2>/dev/null | grep -c 'wiki-reminder' || true)" - if [ "${existing:-0}" -eq 0 ]; then - curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -X POST "$API" \ - -d '{"body":"\n\n**Wiki reminder:** docs are wiki-first -- if this PR changes behavior, usage, config, or standards, please update the repo wiki before/after merge. _(non-blocking)_"}' >/dev/null 2>&1 || true - fi - fi - echo "Wiki reminder emitted (non-blocking)." - # ── Secret Scanning ────────────────────────────────────────────────── gitleaks: name: Secret Scan @@ -201,8 +126,6 @@ jobs: validate: name: Validate PR runs-on: ubuntu-latest - # Skip on template repos (Template-*) — no real manifest/source/changelog to validate. - if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -210,7 +133,7 @@ jobs: - name: Check for merge conflict markers run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --exclude-dir='.git' --exclude-dir='.mokogitea' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) if [ -n "$CONFLICTS" ]; then echo "::error::Merge conflict markers found in source files" echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY @@ -224,12 +147,11 @@ jobs: - name: Detect platform id: platform run: | - # Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used. - API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata" - PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)" + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - echo "Detected platform: $PLATFORM" - name: Setup PHP if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' @@ -350,7 +272,7 @@ jobs: joomla) MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) if [ -z "$MANIFEST" ]; then - echo "::warning::No Joomla manifest found (MokoSuite site)" + echo "::warning::No Joomla manifest found (WaaS site)" exit 0 fi echo "Manifest: ${MANIFEST}" @@ -363,7 +285,7 @@ jobs: # Block legacy raw/branch update server URLs on MokoGitea RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) if [ -n "$RAW_URLS" ]; then - echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the MokoGitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" echo "$RAW_URLS" exit 1 fi @@ -570,33 +492,43 @@ jobs: name: Build RC Package runs-on: ubuntu-latest needs: [branch-policy, validate] - # Run only when both gates succeeded; always() forces evaluation so a skipped - # validate (e.g. template repos) skips this job cleanly instead of hanging. - if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }} steps: - name: Trigger RC pre-release env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} REPO: ${{ github.repository }} BRANCH: ${{ github.head_ref }} - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} run: | - curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_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 # ── Issue Reporter ────────────────────────────────────────────────────── report-issues: name: Report Issues + runs-on: ubuntu-latest needs: [branch-policy, validate] if: >- always() && needs.validate.result == 'failure' - uses: ./.mokogitea/workflows/ci-issue-reporter.yml - with: - gate: "PR Validation" - workflow: "PR Check" - severity: error - details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." - secrets: inherit + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/pr-metadata-check.yml b/.mokogitea/workflows/pr-metadata-check.yml index b4c9cbd..68b7589 100644 --- a/.mokogitea/workflows/pr-metadata-check.yml +++ b/.mokogitea/workflows/pr-metadata-check.yml @@ -20,7 +20,7 @@ permissions: contents: read env: - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + 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 }} @@ -55,14 +55,14 @@ jobs: - name: Validate metadata against Joomla manifest env: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | php ${MOKO_CLI}/joomla_metadata_validate.php \ --path . \ - --token "${MOKOGITEA_TOKEN}" \ + --token "${GITEA_TOKEN}" \ --org "${GITEA_ORG}" \ --repo "${GITEA_REPO}" \ - --api-base "${MOKOGITEA_URL}/api/v1" \ + --api-base "${GITEA_URL}/api/v1" \ --ci if [ $? -ne 0 ]; then diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index b212772..b34a311 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -3,11 +3,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/pre-release.yml -# VERSION: 05.02.01 +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 05.01.00 # BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches name: "Universal: Pre-Release" @@ -48,13 +48,9 @@ jobs: build: name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" runs-on: release - # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - !startsWith(github.event.repository.name, 'Template-') && - ( - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' - ) + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' steps: - name: Checkout @@ -63,11 +59,6 @@ jobs: fetch-depth: 0 token: ${{ secrets.MOKOGITEA_TOKEN }} ref: ${{ github.ref_name }} - submodules: recursive - - - name: Update submodules to main - run: | - git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true - name: Setup mokocli tools env: @@ -84,7 +75,7 @@ jobs: 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 + 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 @@ -97,20 +88,8 @@ jobs: php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/manifest_read.php --path . --github-output - - name: Check platform eligibility (Joomla only) - id: eligibility - run: | - PLATFORM="${{ steps.platform.outputs.platform }}" - if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then - echo "proceed=true" >> "$GITHUB_OUTPUT" - else - echo "proceed=false" >> "$GITHUB_OUTPUT" - echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump" - fi - - name: Resolve metadata and bump version id: meta - if: steps.eligibility.outputs.proceed == 'true' run: | # Auto-detect stability from branch name on push, or use input on dispatch if [ "${{ github.event_name }}" = "push" ]; then @@ -156,19 +135,13 @@ jobs: fi # Commit version bump - git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "mokogitea-actions[bot]" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git add -A git diff --cached --quiet || { git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" - # Push the bump commit, but do NOT fail the release if the target branch - # is protected and the release identity is not on the push allowlist. - # The build proceeds from the in-tree bumped version regardless; if the - # push is rejected, the next run simply re-bumps from the same base. - if ! git push origin HEAD 2>&1; then - echo "::warning::Version-bump commit could not be pushed (protected branch?). Building from in-tree version ${VERSION} anyway." - fi + git push origin HEAD 2>&1 } # Auto-detect element via manifest_element.php @@ -193,7 +166,6 @@ jobs: - name: Create release id: release - if: steps.eligibility.outputs.proceed == 'true' run: | TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" @@ -204,7 +176,6 @@ jobs: --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease - name: Update release notes from CHANGELOG.md - if: steps.eligibility.outputs.proceed == 'true' run: | TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" @@ -241,7 +212,6 @@ jobs: - name: Build package and upload id: package - if: steps.eligibility.outputs.proceed == 'true' run: | VERSION="${{ steps.meta.outputs.version }}" TAG="${{ steps.meta.outputs.tag }}" @@ -255,7 +225,6 @@ jobs: # No need to build, commit, or sync updates.xml from workflows - name: "Delete lesser pre-release channels (cascade)" - if: steps.eligibility.outputs.proceed == 'true' continue-on-error: true run: | API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" @@ -280,4 +249,4 @@ jobs: echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY - echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml index 57934ea..5e61de8 100644 --- a/.mokogitea/workflows/rc-revert.yml +++ b/.mokogitea/workflows/rc-revert.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Universal -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic +# 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 @@ -25,25 +25,16 @@ jobs: runs-on: ubuntu-latest if: >- github.event.pull_request.merged == false && - startsWith(github.event.pull_request.head.ref, 'rc/') && - !startsWith(github.event.repository.name, 'Template-') + startsWith(github.event.pull_request.head.ref, 'rc/') steps: - name: Rename branch - env: - BRANCH: ${{ github.event.pull_request.head.ref }} - REPO: ${{ github.repository }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | - set -euo pipefail - # BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use. - if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then - echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1 - fi + BRANCH="${{ github.event.pull_request.head.ref }}" SUFFIX="${BRANCH#rc/}" DEV_BRANCH="dev/${SUFFIX}" - API="${GITEA_URL}/api/v1/repos/${REPO}/branches" + 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 \ @@ -51,22 +42,25 @@ jobs: -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" + echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY else - echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1 + echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})" + exit 1 fi - # Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection) - ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));') + # 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" + 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" + echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY + echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml index 54d7cf9..6a25f5b 100644 --- a/.mokogitea/workflows/repo-health.yml +++ b/.mokogitea/workflows/repo-health.yml @@ -6,10 +6,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Validation -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.mokogitea/workflows/repo-health.yml +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# PATH: /templates/workflows/joomla/repo_health.yml.template # VERSION: 09.23.00 # BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts. # ============================================================================ @@ -77,7 +77,7 @@ jobs: - name: Check actor permission (admin only) id: perm env: - TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} + TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} REPO: ${{ github.repository }} ACTOR: ${{ github.actor }} run: | @@ -88,7 +88,7 @@ jobs: # Hardcoded authorized users — always allowed case "$ACTOR" in - jmiller|mokogitea-actions[bot]) + jmiller|gitea-actions[bot]) ALLOWED=true PERMISSION=admin METHOD="hardcoded allowlist" @@ -671,30 +671,42 @@ jobs: # ═══════════════════════════════════════════════════════════════════════ # Issue Reporter — file issues for failed gates # ═══════════════════════════════════════════════════════════════════════ - report-scripts: - name: "Report: Scripts Governance" - needs: [access_check, scripts_governance] + report-issues: + name: "Report Issues" + runs-on: ubuntu-latest + needs: [access_check, scripts_governance, repo_health] if: >- always() && - needs.scripts_governance.result == 'failure' - uses: ./.mokogitea/workflows/ci-issue-reporter.yml - with: - gate: "Scripts Governance" - workflow: "Repo Health" - severity: error - details: "Scripts directory policy violations detected. Review required and allowed directories." - secrets: inherit + (needs.scripts_governance.result == 'failure' || + needs.repo_health.result == 'failure') - report-health: - name: "Report: Repository Health" - needs: [access_check, repo_health] - if: >- - always() && - needs.repo_health.result == 'failure' - uses: ./.mokogitea/workflows/ci-issue-reporter.yml - with: - gate: "Repository Health" - workflow: "Repo Health" - severity: error - details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." - secrets: inherit + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issues for failed gates" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + REPORTER="./automation/ci-issue-reporter.sh" + WF="Repo Health" + + report_gate() { + local gate="$1" result="$2" details="$3" + if [ "$result" = "failure" ]; then + "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error + fi + } + + report_gate "Scripts Governance" \ + "${{ needs.scripts_governance.result }}" \ + "Scripts directory policy violations detected. Review required and allowed directories." + + report_gate "Repository Health" \ + "${{ needs.repo_health.result }}" \ + "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." diff --git a/.mokogitea/workflows/workflow-sync-trigger.yml b/.mokogitea/workflows/workflow-sync-trigger.yml index 69b00c4..371910c 100644 --- a/.mokogitea/workflows/workflow-sync-trigger.yml +++ b/.mokogitea/workflows/workflow-sync-trigger.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: MokoGitea.Workflow +# DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Universal -# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic +# 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 @@ -13,7 +13,6 @@ name: "Universal: Workflow Sync Trigger" on: - workflow_dispatch: pull_request: types: [closed] branches: @@ -27,10 +26,8 @@ jobs: name: Sync workflows to live repos runs-on: ubuntu-latest if: >- - startsWith(github.event.repository.name, 'Template-') && - (github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - !contains(github.event.pull_request.title, '[skip sync]'))) + github.event.pull_request.merged == true && + !contains(github.event.pull_request.title, '[skip sync]') steps: - name: Determine platform from repo name @@ -52,14 +49,8 @@ jobs: env: MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | - MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}" - git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli - - - name: Install PHP - run: | - if ! command -v php &> /dev/null; then - apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1 - fi + GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}" + git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6787886 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ + + +# Changelog + +## [Unreleased] + +### Added +- **Repository** — initial repo creation with dev branch, topics, workflows +- **Roadmap Issue** — implementation roadmap with full feature checklist diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0483c2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# MokoSuiteConstruction + +Construction project management for Joomla 6 — job costing, bids, change orders, RFIs, submittals, daily logs, AIA billing, subcontractors. + +## Quick Reference + +| Field | Value | +|---|---| +| **Package** | `pkg_mokosuiteconstruction` | +| **Layer** | 3 (requires: Client → CRM → ERP) | +| **Language** | PHP 8.3+ | +| **Branch** | develop on `dev`, merge to `main` (protected) | +| **Wiki** | [MokoSuiteConstruction Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteConstruction/wiki) | + +## Architecture + +Joomla **package** — Layer 3 add-on. ERP for job cost accounting, material management, and pricing. CRM contacts as owners, architects, subcontractors. + +### Key Patterns +- CSI MasterFormat cost codes for budget structure +- AIA G702/G703 for progress billing +- Retention tracking with release milestones +- Lien waiver management (conditional/unconditional) + +## Rules + +- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js` +- **Attribution**: `Authored-by: Moko Consulting` +- **Workflow directory**: `.mokogitea/` +- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoCLI/wiki) +- **Changelog**: `[Unreleased]` only — release system assigns versions + +## Coding Standards + +- PHP 8.3+ / Joomla 6 patterns +- `$this->getDatabase()` in models, `Factory::getContainer()->get(DatabaseInterface::class)` in helpers +- `Factory::getApplication()->getIdentity()` for user +- `FOR UPDATE` inside transactions for concurrent cost code updates diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 809e983..2ecc653 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,35 @@ - # 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. + +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 -- Be empathetic and kind -- Be respectful of differing opinions -- Accept constructive feedback -- Own mistakes and learn from them -Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct. +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 -Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly. -## Enforcement Guidelines -1. **Correction** — Private warning -2. **Warning** — Formal warning and limited interaction -3. **Temporary Ban** — Time-boxed exclusion -4. **Permanent Ban** — Removal from the community +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 -Adapted from the Contributor Covenant v2.1. + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf60cc5..9a91fea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,161 +1,88 @@ + + # Contributing to Moko Consulting Projects -Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. +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 ──draft PR──> (renamed to rc) ──merge──> main +feature/* ──── PR ───→ dev + │ RC cut → rc → main +fix/* ───────── PR ────────────┘ ``` -### Step by step +1. **Create a branch** from `dev`: + - `feature/` for new functionality + - `fix/` for bug fixes + - `chore/` for maintenance +2. **Open a PR** into `dev`. +3. **CI must pass** before merge. +4. **Release cuts**: `dev → rc → main` are handled by maintainers. -1. **Create a feature branch** from `dev`: - ```bash - git checkout dev && git pull - git checkout -b feature/my-change - ``` - -2. **Work and commit** on your feature branch. Push to origin. - -3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. - -4. **When ready for release**, open a **draft PR**: `dev` → `main`. - - This automatically renames the source branch to `rc` (release candidate) - - An RC pre-release is built and uploaded - -5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: - - Rename `dev` to `alpha` for early testing → alpha pre-release is built - - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built - - When the draft PR is created, the branch is renamed to `rc` - -6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. - -7. **Merging to main** triggers the stable release pipeline: - - Minor version bump (e.g., `02.09.xx` → `02.10.00`) - - Stability suffix stripped (clean version) - - Gitea release created with ZIP/tar.gz packages - - `updates.xml` updated (Joomla extensions) - - `dev` branch recreated from `main` - -### Branch summary - -| Branch | Purpose | Created by | -|--------|---------|-----------| -| `feature/*` | New features and fixes | Developer | -| `dev` | Integration branch | Auto-recreated after release | -| `alpha` | Alpha pre-release testing | Manual rename from `dev` | -| `beta` | Beta pre-release testing | Manual rename from `alpha` | -| `rc` | Release candidate | Auto-renamed on draft PR to main | -| `main` | Stable releases | Protected, merge only | -| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | - -### Protected branches - -| Branch | Direct push | Merge via | -|--------|------------|-----------| -| `main` | Blocked (CI bot whitelisted) | PR merge only | -| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | -| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | -| `alpha` | Blocked (CI bot whitelisted) | Manual rename | -| `beta` | Blocked (CI bot whitelisted) | Manual rename | -| `feature/*` | Open | N/A (source branch) | +> **Never commit directly to `main` or `dev`.** ## Version Policy -### Format +All repositories use the **XX.YY.ZZ** versioning scheme (two-digit segments): -All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: +- `XX` -- major (breaking changes) +- `YY` -- minor (new features, backward-compatible) +- `ZZ` -- patch (bug fixes, security patches) -- **XX** — Major version (breaking changes) -- **YY** — Minor version (new features, bumped on release to main) -- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) +**Stability suffixes** may be appended during pre-release: -Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. +| 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` | -### Stability suffixes +## Auto-Bump -Each branch appends a suffix to indicate stability: +Version bumps are **automated** via the `auto-bump` workflow: -| Branch | Suffix | Example | -|--------|--------|---------| -| `main` | (none) | `02.09.00` | -| `dev` | `-dev` | `02.09.01-dev` | -| `feature/*` | `-dev` | `02.09.01-dev` | -| `alpha` | `-alpha` | `02.09.01-alpha` | -| `beta` | `-beta` | `02.09.01-beta` | -| `rc` | `-rc` | `02.09.01-rc` | - -### Auto version bump - -On every push to `dev`, `feature/*`, or `patch/*`: - -1. Patch version incremented -2. Stability suffix `-dev` applied -3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) -4. Commit created with `[skip ci]` to avoid loops - -### Release version flow - -Version bumps happen at specific release events: - -| Event | Bump | Example | -|-------|------|---------| -| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | -| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | -| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | -| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | - -### Release stream copies - -When a higher-stability release is published, copies are created for all lesser streams with the same base version: - -- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` -- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` - -This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). - -### Version files - -The version tools update all files containing version stamps: - -- `.mokogitea/manifest.xml` (canonical source) -- Joomla XML manifests (`` tag) -- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) -- `package.json`, `pyproject.toml` -- Any text file with a `VERSION: XX.YY.ZZ` label - -Files synced from other repos (with a `# REPO:` header) are not touched. - -## Code Standards - -- **PHP**: PSR-12, tabs for indentation -- **Copyright**: all files must include the Moko Consulting copyright header -- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) -- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names +- 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 -Use conventional commit format: +We follow [Conventional Commits](https://www.conventionalcommits.org/): ``` -type(scope): short description +(): -Optional body with context. + -Authored-by: Moko Consulting + ``` -Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` +**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert` -Special flags in commit messages: -- `[skip ci]` — skip all CI workflows -- `[skip bump]` — skip auto version bump only +## Pull Request Checklist -## Reporting Issues +Before submitting a PR, ensure: -Use the repository's issue tracker with the appropriate template. +- [ ] 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 -*Moko Consulting * +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). diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 47fa254..aeb3e4b 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,119 +1,49 @@ -[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.00.04-blue)](https://github.com/mokoconsulting-tech/MokoStandards) +# Governance -# Project Governance +## Project Leadership -## Overview +This repository is maintained by **Moko Consulting** under a **sole operator** model. -This document defines the governance model for the `Template-Joomla` repository within the -`mokoconsulting-tech` organization. It is automatically maintained by -[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) v04.00.04. +- **Lead Maintainer**: Jonathan Miller (@jmiller) +- **Organisation**: [Moko Consulting](https://mokoconsulting.tech) -Full governance policy is defined in the MokoStandards source repository: -[docs/policy/GOVERNANCE.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md) +## 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). -## Roles and Responsibilities +## Contribution Policy -### Maintainer +- **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. -**GitHub**: @mokoconsulting-tech +## Code of Conduct -**Authority**: Final decision-making authority on all matters for this repository. +All participants must adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). -**Responsibilities**: -- Review and merge pull requests -- Maintain code quality and standards compliance -- Manage releases and versioning -- Respond to issues and security reports +## Licensing -### Contributors +All contributions are licensed under the same license as the project +(GPL-3.0-or-later unless otherwise stated in the repository root). -**Authority**: Submit changes via pull requests. +## Security -**Requirements**: -- Read and accept `CODE_OF_CONDUCT.md` -- Follow `CONTRIBUTING.md` guidelines +Security vulnerabilities should be reported privately. +See [SECURITY.md](SECURITY.md) for details. ---- +## Dispute Resolution -## Decision-Making +Disputes are resolved by the lead maintainer. For escalation, +contact [info@mokoconsulting.tech](mailto:info@mokoconsulting.tech). -All changes must be submitted as pull requests. The maintainer (@mokoconsulting-tech) -reviews and approves all changes before they are merged. +## Changes to Governance -### Sole Operator Policy - -This organization operates under a **sole operator** model. The maintainer (@mokoconsulting-tech) -is the sole employee and owner and may self-approve pull requests when no second reviewer is -available. The following requirements remain mandatory regardless: - -1. **Pull Requests Required** — all changes to protected branches go through a PR. -2. **Automated Checks** — all CI checks must pass before merging. -3. **Audit Trail** — issues, pull requests, and commit history are preserved. -4. **Documentation** — changes are documented in `CHANGELOG.md`. - -See the full policy: -[Sole Operator Policy](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md#sole-operator-policy) - ---- - -## Change Management - -| Change Type | Approval | Process | -|-------------|----------|---------| -| Routine (docs, bug fixes) | Maintainer | PR → CI pass → merge | -| Significant (new features) | Maintainer | PR with description → CI pass → merge | -| Major (breaking, architecture) | Maintainer | Issue discussion → PR → CI pass → merge | -| Emergency (security) | Maintainer | Labelled `EMERGENCY` → immediate merge → post-mortem | - ---- - -## Reporting Issues - -- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/Template-Joomla/issues) -- **Security vulnerabilities**: See [SECURITY.md](./SECURITY.md) -- **Code of Conduct**: See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) -- **Contact**: dev@mokoconsulting.tech - ---- - -## Metadata - -| Field | Value | -| ------------- | ----------------------------------------------- | -| Document Type | Policy | -| Domain | Governance | -| Applies To | mokoconsulting-tech/Template-Joomla | -| Jurisdiction | Tennessee, USA | -| Maintainer | @mokoconsulting-tech | -| Standards | MokoStandards v04.00.04 | -| Repo | https://github.com/mokoconsulting-tech/Template-Joomla | -| Path | /GOVERNANCE.md | -| Status | Active — auto-maintained by MokoStandards | +This document may be updated at any time by the lead maintainer. +Significant changes will be announced via an RFC issue. diff --git a/README.md b/README.md index 4a9eb95..7e58ac9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ -# MokoSuiteConstruction +# MokoSuite Construction -Construction project management for Joomla 6 — job costing, bids, change orders, RFIs, submittals, daily logs, progress billing, subcontractor management \ No newline at end of file +Construction project management for Joomla 6 — job costing, bids, change orders, RFIs, AIA billing, subcontractors. + +**Layer 3** add-on for [MokoSuite](https://git.mokoconsulting.tech/MokoConsulting). Requires: Client → CRM → ERP. + +## Links + +- [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteConstruction/wiki) · [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteConstruction/issues) · [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteConstruction/releases) + +GPL-3.0-or-later · Copyright © 2026 [Moko Consulting](https://mokoconsulting.tech) diff --git a/SECURITY.md b/SECURITY.md index 86b35ed..7f29d29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,241 +1,90 @@ # Security Policy -## Purpose and Scope - -This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues. - ## Supported Versions -Security updates are provided for the following versions: - -| Version | Supported | -| ------- | ------------------ | -| 01.x.x | :white_check_mark: | -| < 01.0 | :x: | - -Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches. +| Version | Supported | +|---|---| +| Latest stable | ✅ Full support | +| Previous major | ⚠️ Critical fixes only | +| Older | ❌ No support | ## Reporting a Vulnerability -### Where to Report +**Do not report security vulnerabilities via public issues.** -**DO NOT** create public GitHub issues for security vulnerabilities. +Instead, please report them privately: -Report security vulnerabilities privately to: - -**Email**: `security@mokoconsulting.tech` - -**Subject Line**: `[SECURITY] Template-Joomla - Brief Description` +1. **Email**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) +2. **Subject**: `[SECURITY] - ` ### What to Include -A complete vulnerability report should include: - -1. **Description**: Clear explanation of the vulnerability -2. **Impact**: Potential security impact and severity assessment -3. **Affected Versions**: Which versions are vulnerable -4. **Reproduction Steps**: Detailed steps to reproduce the issue -5. **Proof of Concept**: Code, configuration, or demonstration (if applicable) -6. **Suggested Fix**: Proposed remediation (if known) -7. **Disclosure Timeline**: Your expectations for public disclosure - -### Response Timeline - -* **Initial Response**: Within 3 business days -* **Assessment Complete**: Within 7 business days -* **Fix Timeline**: Depends on severity (see below) -* **Disclosure**: Coordinated with reporter +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Potential impact +- Suggested fix (if any) ## Severity Classification -Vulnerabilities are classified using the following severity levels: +| 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 | -### Critical -* Remote code execution -* Authentication bypass -* Data breach or exposure of sensitive information -* **Fix Timeline**: 7 days +## Remediation Timeline -### High -* Privilege escalation -* SQL injection or command injection -* Cross-site scripting (XSS) with significant impact -* **Fix Timeline**: 14 days - -### Medium -* Information disclosure (limited scope) -* Denial of service -* Security misconfigurations with moderate impact -* **Fix Timeline**: 30 days - -### Low -* Security best practice violations -* Minor information leaks -* Issues requiring user interaction or complex preconditions -* **Fix Timeline**: 60 days or next release - -## Remediation Process - -1. **Acknowledgment**: Security team confirms receipt and begins investigation -2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed -3. **Development**: Security patch is developed and tested -4. **Review**: Patch undergoes security review and validation -5. **Release**: Fixed version is released with security advisory -6. **Disclosure**: Public disclosure follows coordinated timeline - -## Security Advisories - -Security advisories are published via: - -* GitHub Security Advisories -* Release notes and CHANGELOG.md -* Email notification to project users (if mailing list is established) - -Advisories include: - -* CVE identifier (if applicable) -* Severity rating -* Affected versions -* Fixed versions -* Mitigation steps -* Attribution (with reporter consent) +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 projects using this template: +### For Contributors -### Required Controls +- 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 -* Enable GitHub security features (Dependabot, code scanning) -* Implement branch protection on `main` -* Require code review for all changes -* Enforce signed commits (recommended) -* Use secrets management (never commit credentials) -* Maintain security documentation -* Follow secure coding standards defined in MokoStandards +### For Users -### Joomla Plugin Security +- Keep Joomla and all extensions updated +- Use strong, unique passwords +- Enable two-factor authentication +- Review file permissions regularly +- Monitor Joomla error logs -* Follow Joomla security best practices -* Validate and sanitize all user input -* Use Joomla's database API to prevent SQL injection -* Properly escape output to prevent XSS -* Implement proper access control checks -* Use Joomla's session and authentication APIs -* Keep Joomla and dependencies up to date +## Security Updates -### CI/CD Security +Security patches are delivered through the standard update channel. +Critical fixes may receive an emergency out-of-band release. -* Validate all inputs -* Sanitize outputs -* Use least privilege access -* Pin dependencies with hash verification -* Scan for vulnerabilities in dependencies -* Audit third-party actions and tools +## Responsible Disclosure -#### Automated Security Scanning +We follow coordinated disclosure practices: -All repositories SHOULD implement: +- 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 -**CodeQL Analysis**: -* Enabled for PHP and other supported languages -* Runs on: push to main, pull requests, weekly schedule -* Query sets: `security-extended` and `security-and-quality` -* Configuration: `.github/workflows/codeql-analysis.yml` +## Contact -**Dependabot Security Updates**: -* Weekly scans for vulnerable dependencies -* Automated pull requests for security patches -* Configuration: `.github/dependabot.yml` - -**Secret Scanning**: -* Enabled by default with push protection -* Prevents accidental credential commits - -### Dependency Management - -* Keep dependencies up to date -* Monitor security advisories for dependencies -* Remove unused dependencies -* Audit new dependencies before adoption -* Document security-critical dependencies - -## Compliance and Governance - -This security policy is aligned with MokoStandards. Deviations require documented justification. - -Security policies are reviewed and updated at least annually or following significant security incidents. - -## Attribution and Recognition - -We acknowledge and appreciate responsible disclosure. With your permission, we will: - -* Credit you in security advisories -* List you in CHANGELOG.md for the fix release -* Recognize your contribution publicly (if desired) - -## Contact and Escalation - -* **Security Team**: security@mokoconsulting.tech -* **Primary Contact**: hello@mokoconsulting.tech -* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub - -## Out of Scope - -The following are explicitly out of scope: - -* Issues in third-party dependencies (report directly to maintainers) -* Social engineering attacks -* Physical security issues -* Denial of service via resource exhaustion without amplification -* Issues requiring physical access to systems -* Theoretical vulnerabilities without proof of exploitability +- **Security team**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) +- **General**: [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech) --- -## Metadata - -| Field | Value | -| ------------ | ------------------------------------------------------------------------------------------------------------ | -| Document | Security Policy | -| Path | /SECURITY.md | -| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) | -| Owner | Moko Consulting | -| Scope | Security vulnerability handling | -| Status | Active | -| Effective | 2026-01-16 | - -## Revision History - -| Date | Change Description | Author | -| ---------- | ------------------------------------------------- | --------------- | -| 2026-01-16 | Initial creation for template repository | Moko Consulting | +Thank you for helping keep Moko Consulting projects secure. diff --git a/composer.json b/composer.json index 10afe3a..d938818 100644 --- a/composer.json +++ b/composer.json @@ -1,27 +1,26 @@ { - "name": "mokoconsulting/mokojoomgallery", - "description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display", - "type": "joomla-package", - "version": "01.00.00", - "license": "GPL-3.0-or-later", - "authors": [ - { - "name": "Moko Consulting", - "email": "hello@mokoconsulting.tech", - "homepage": "https://mokoconsulting.tech" - } - ], - "require": { - "php": ">=8.1" - }, - "require-dev": { - "squizlabs/php_codesniffer": "^3.7", - "phpstan/phpstan": "^1.10", - "joomla/coding-standards": "3.0.x-dev" - }, - "minimum-stability": "dev", - "prefer-stable": true, - "config": { - "sort-packages": true + "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 + } + } } diff --git a/packages/MokoSuiteCRM b/packages/MokoSuiteCRM new file mode 160000 index 0000000..b984e1a --- /dev/null +++ b/packages/MokoSuiteCRM @@ -0,0 +1 @@ +Subproject commit b984e1aad642fae946cfd8d7e3e8a75aa6f14df1 diff --git a/packages/MokoSuiteClient b/packages/MokoSuiteClient new file mode 160000 index 0000000..a8d1e8f --- /dev/null +++ b/packages/MokoSuiteClient @@ -0,0 +1 @@ +Subproject commit a8d1e8f276c88ea72a163a65631cfdeb43052e66 diff --git a/packages/MokoSuiteERP b/packages/MokoSuiteERP new file mode 160000 index 0000000..8c76969 --- /dev/null +++ b/packages/MokoSuiteERP @@ -0,0 +1 @@ +Subproject commit 8c76969ee850c86dd9d3ccb69e86ebd108a1c919 diff --git a/phpstan.neon b/phpstan.neon index 3d4adca..d6e669c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,32 +1,20 @@ # Copyright (C) 2026 Moko Consulting # SPDX-License-Identifier: GPL-3.0-or-later -# -# PHPStan configuration for Joomla extension repositories. -# Extends the base MokoStandards config and adds Joomla framework class stubs -# so PHPStan can resolve Factory, CMSApplication, User, Table, etc. -# without requiring a full Joomla installation. +# FILE INFORMATION +# DEFGROUP: Config.StaticAnalysis +# INGROUP: Development +# BRIEF: PHPStan configuration for Joomla extension static analysis parameters: - level: 5 - - paths: - - src - - excludePaths: - - vendor - - node_modules - - # Joomla framework stubs — resolved via the enterprise package from vendor/ - stubFiles: - - vendor/mokoconsulting-tech/enterprise/templates/stubs/joomla.php - - # Suppress errors that are structural in Joomla's service-container architecture - ignoreErrors: - # Joomla's service-based dependency injection returns mixed from getApplication() - - '#Cannot call method .+ on Joomla\\CMS\\Application\\CMSApplication\|null#' - # Factory::getX() patterns are safe at runtime even when nullable in stubs - - '#Call to static method [a-zA-Z]+\(\) on an interface#' - - reportUnmatchedIgnoredErrors: false - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false + 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#' diff --git a/source/packages/com_mokosuiteconstruction/admin/access.xml b/source/packages/com_mokosuiteconstruction/admin/access.xml new file mode 100644 index 0000000..672828a --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/access.xml @@ -0,0 +1,28 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/source/packages/com_mokosuiteconstruction/admin/config.xml b/source/packages/com_mokosuiteconstruction/admin/config.xml new file mode 100644 index 0000000..49b872d --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/config.xml @@ -0,0 +1,27 @@ + + +
+ + + + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + +
+
diff --git a/source/packages/com_mokosuiteconstruction/admin/services/provider.php b/source/packages/com_mokosuiteconstruction/admin/services/provider.php new file mode 100644 index 0000000..ae2e203 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/services/provider.php @@ -0,0 +1,17 @@ +set(ComponentInterface::class, function (Container $container) { + $c = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $c->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $c; + }); + } +}; diff --git a/source/packages/com_mokosuiteconstruction/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuiteconstruction/admin/src/Controller/DisplayController.php new file mode 100644 index 0000000..ff1b90e --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/src/Controller/DisplayController.php @@ -0,0 +1,7 @@ + 'ok']; } +} diff --git a/source/packages/com_mokosuiteconstruction/admin/src/View/ConstructionBids/HtmlView.php b/source/packages/com_mokosuiteconstruction/admin/src/View/ConstructionBids/HtmlView.php new file mode 100644 index 0000000..eb3fb1c --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/src/View/ConstructionBids/HtmlView.php @@ -0,0 +1,11 @@ +

ConstructionBids

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondailylogs/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondailylogs/default.php new file mode 100644 index 0000000..a2be0e9 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondailylogs/default.php @@ -0,0 +1 @@ +

ConstructionDailyLogs

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondashboard/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondashboard/default.php new file mode 100644 index 0000000..28c1759 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructiondashboard/default.php @@ -0,0 +1 @@ +

ConstructionDashboard

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionjobs/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionjobs/default.php new file mode 100644 index 0000000..e137f26 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionjobs/default.php @@ -0,0 +1 @@ +

ConstructionJobs

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionpayapps/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionpayapps/default.php new file mode 100644 index 0000000..e7bed61 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionpayapps/default.php @@ -0,0 +1 @@ +

ConstructionPayApps

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionrfis/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionrfis/default.php new file mode 100644 index 0000000..fb87f9a --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionrfis/default.php @@ -0,0 +1 @@ +

ConstructionRfis

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionsubcontractors/default.php b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionsubcontractors/default.php new file mode 100644 index 0000000..1333a3d --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/admin/tmpl/constructionsubcontractors/default.php @@ -0,0 +1 @@ +

ConstructionSubcontractors

Coming soon.

diff --git a/source/packages/com_mokosuiteconstruction/mokosuiteconstruction.xml b/source/packages/com_mokosuiteconstruction/mokosuiteconstruction.xml new file mode 100644 index 0000000..508b844 --- /dev/null +++ b/source/packages/com_mokosuiteconstruction/mokosuiteconstruction.xml @@ -0,0 +1,14 @@ + + + MokoSuite Construction + Moko Consulting + 2026-06-23 + Copyright (C) 2026 Moko Consulting. + GPL-3.0-or-later + 01.00.22 + Moko\Component\MokoSuiteConstruction + + servicessrctmpl + COM_MOKOSUITECONSTRUCTION + + diff --git a/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.ini b/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.ini new file mode 100644 index 0000000..c774970 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITECONSTRUCTION="System - MokoSuite Construction" +PLG_SYSTEM_MOKOSUITECONSTRUCTION_DESC="Construction project management — job costing, bids, change orders, RFIs, AIA billing, subcontractors, SAM.gov." diff --git a/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.sys.ini b/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.sys.ini new file mode 100644 index 0000000..c774970 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/language/en-GB/plg_system_mokosuiteconstruction.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITECONSTRUCTION="System - MokoSuite Construction" +PLG_SYSTEM_MOKOSUITECONSTRUCTION_DESC="Construction project management — job costing, bids, change orders, RFIs, AIA billing, subcontractors, SAM.gov." diff --git a/source/packages/plg_system_mokosuiteconstruction/mokosuiteconstruction.xml b/source/packages/plg_system_mokosuiteconstruction/mokosuiteconstruction.xml new file mode 100644 index 0000000..55982b7 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/mokosuiteconstruction.xml @@ -0,0 +1,45 @@ + + + System - MokoSuite Construction + mokosuiteconstruction + Moko Consulting + 2026-06-23 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 01.00.22 + 8.3 + PLG_SYSTEM_MOKOSUITECONSTRUCTION_DESC + Moko\Plugin\System\MokoSuiteConstruction + + src + services + language + sql + + + en-GB/plg_system_mokosuiteconstruction.ini + en-GB/plg_system_mokosuiteconstruction.sys.ini + + sql/install.mysql.sql + sql/uninstall.mysql.sql + + +
+ + + + + + + + + +
+
+ +
+
+
+
diff --git a/source/packages/plg_system_mokosuiteconstruction/services/provider.php b/source/packages/plg_system_mokosuiteconstruction/services/provider.php new file mode 100644 index 0000000..b72aca8 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/services/provider.php @@ -0,0 +1,27 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Construction($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteconstruction')); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuiteconstruction/sql/install.mysql.sql b/source/packages/plg_system_mokosuiteconstruction/sql/install.mysql.sql new file mode 100644 index 0000000..c8bd844 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/sql/install.mysql.sql @@ -0,0 +1,222 @@ +-- +-- MokoSuite Construction Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_jobs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_number` VARCHAR(20) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `address` TEXT, + `city` VARCHAR(100) NOT NULL DEFAULT '', + `state` VARCHAR(50) NOT NULL DEFAULT '', + `postal_code` VARCHAR(20) NOT NULL DEFAULT '', + `owner_contact_id` INT DEFAULT NULL, + `architect_contact_id` INT DEFAULT NULL, + `superintendent_id` INT DEFAULT NULL, + `contract_value` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `approved_changes` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `revised_value` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `retention_pct` DECIMAL(5,2) NOT NULL DEFAULT 10.00, + `phase` ENUM('preconstruction','active','punch_list','closeout','warranty','completed') NOT NULL DEFAULT 'preconstruction', + `start_date` DATE DEFAULT NULL, + `end_date` DATE DEFAULT NULL, + `actual_start` DATE DEFAULT NULL, + `actual_end` DATE DEFAULT NULL, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_job_number` (`job_number`), + KEY `idx_owner` (`owner_contact_id`), + KEY `idx_phase` (`phase`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_cost_codes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `division` VARCHAR(10) NOT NULL, + `section` VARCHAR(20) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `budget_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `committed_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `actual_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_division` (`division`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_change_orders` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `co_number` INT UNSIGNED NOT NULL DEFAULT 1, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `days_impact` INT NOT NULL DEFAULT 0, + `status` ENUM('draft','submitted','approved','rejected') NOT NULL DEFAULT 'draft', + `submitted_at` DATETIME DEFAULT NULL, + `approved_at` DATETIME DEFAULT NULL, + `approved_by` INT DEFAULT NULL, + `created` DATETIME NOT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_rfis` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `rfi_number` INT UNSIGNED NOT NULL DEFAULT 1, + `subject` VARCHAR(255) NOT NULL, + `question` TEXT NOT NULL, + `spec_section` VARCHAR(50) NOT NULL DEFAULT '', + `drawing_ref` VARCHAR(100) NOT NULL DEFAULT '', + `ball_in_court` VARCHAR(100) NOT NULL DEFAULT '', + `answer` TEXT, + `status` ENUM('open','answered','closed') NOT NULL DEFAULT 'open', + `submitted_by` INT DEFAULT NULL, + `answered_by` INT DEFAULT NULL, + `submitted_at` DATETIME NOT NULL, + `answered_at` DATETIME DEFAULT NULL, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_submittals` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `submittal_number` INT UNSIGNED NOT NULL DEFAULT 1, + `title` VARCHAR(255) NOT NULL, + `spec_section` VARCHAR(50) NOT NULL DEFAULT '', + `subcontractor_contact_id` INT DEFAULT NULL, + `status` ENUM('pending','in_review','approved','approved_as_noted','rejected','resubmit') NOT NULL DEFAULT 'pending', + `submitted_at` DATETIME DEFAULT NULL, + `reviewed_at` DATETIME DEFAULT NULL, + `reviewed_by` INT DEFAULT NULL, + `notes` TEXT, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_daily_logs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `log_date` DATE NOT NULL, + `weather` VARCHAR(100) NOT NULL DEFAULT '', + `temperature_high` INT DEFAULT NULL, + `temperature_low` INT DEFAULT NULL, + `crew_count` INT UNSIGNED NOT NULL DEFAULT 0, + `work_performed` TEXT, + `equipment_used` TEXT, + `visitors` TEXT, + `safety_incidents` TEXT, + `delays` TEXT, + `photos_count` INT UNSIGNED NOT NULL DEFAULT 0, + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_job_date` (`job_id`, `log_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_pay_applications` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `app_number` INT UNSIGNED NOT NULL DEFAULT 1, + `period_from` DATE NOT NULL, + `period_to` DATE NOT NULL, + `contract_sum` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `change_orders_total` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `revised_contract` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `work_completed` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `materials_stored` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `total_earned` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `retention_held` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `previous_payments` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `current_due` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `status` ENUM('draft','submitted','approved','paid') NOT NULL DEFAULT 'draft', + `submitted_at` DATETIME DEFAULT NULL, + `approved_at` DATETIME DEFAULT NULL, + `paid_at` DATETIME DEFAULT NULL, + `created` DATETIME NOT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_pay_app_lines` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `pay_app_id` INT UNSIGNED NOT NULL, + `cost_code_id` INT UNSIGNED NOT NULL, + `scheduled_value` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `previous_work` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `current_work` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `materials_stored` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `total_completed` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `pct_complete` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `balance_to_finish` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (`id`), + KEY `idx_pay_app` (`pay_app_id`), + KEY `idx_cost_code` (`cost_code_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_lien_waivers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `vendor_contact_id` INT NOT NULL, + `type` ENUM('conditional_partial','unconditional_partial','conditional_final','unconditional_final') NOT NULL, + `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `through_date` DATE DEFAULT NULL, + `received` TINYINT NOT NULL DEFAULT 0, + `received_at` DATETIME DEFAULT NULL, + `document_path` VARCHAR(500) NOT NULL DEFAULT '', + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_vendor` (`vendor_contact_id`), + KEY `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_punch_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `location` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + `responsible_contact_id` INT DEFAULT NULL, + `priority` ENUM('high','medium','low') NOT NULL DEFAULT 'medium', + `status` ENUM('open','in_progress','completed','verified') NOT NULL DEFAULT 'open', + `photo_path` VARCHAR(500) NOT NULL DEFAULT '', + `completed_at` DATETIME DEFAULT NULL, + `verified_at` DATETIME DEFAULT NULL, + `created` DATETIME NOT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteconstruction_subcontracts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_id` INT UNSIGNED NOT NULL, + `subcontractor_contact_id` INT NOT NULL, + `trade` VARCHAR(100) NOT NULL DEFAULT '', + `scope` TEXT, + `contract_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00, + `retention_pct` DECIMAL(5,2) NOT NULL DEFAULT 10.00, + `status` ENUM('draft','executed','in_progress','completed','terminated') NOT NULL DEFAULT 'draft', + `executed_at` DATETIME DEFAULT NULL, + `insurance_gl_expires` DATE DEFAULT NULL, + `insurance_wc_expires` DATE DEFAULT NULL, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_job` (`job_id`), + KEY `idx_sub` (`subcontractor_contact_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuiteconstruction/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuiteconstruction/sql/uninstall.mysql.sql new file mode 100644 index 0000000..ee8f008 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/sql/uninstall.mysql.sql @@ -0,0 +1,15 @@ +-- +-- MokoSuite Construction — Uninstall +-- + +DROP TABLE IF EXISTS `#__mokosuiteconstruction_subcontracts`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_punch_items`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_lien_waivers`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_pay_app_lines`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_pay_applications`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_daily_logs`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_submittals`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_rfis`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_change_orders`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_cost_codes`; +DROP TABLE IF EXISTS `#__mokosuiteconstruction_jobs`; diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Extension/Construction.php b/source/packages/plg_system_mokosuiteconstruction/src/Extension/Construction.php new file mode 100644 index 0000000..ba1f93d --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Extension/Construction.php @@ -0,0 +1,18 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $bid = (object) [ + 'job_id' => (int) $jobId, + 'subcontractor_contact_id' => (int) ($data['subcontractor_contact_id'] ?? 0), + 'trade' => $data['trade'] ?? '', + 'scope' => $data['scope'] ?? null, + 'contract_amount' => (float) ($data['amount'] ?? 0), + 'retention_pct' => (float) ($data['retention_pct'] ?? 10.00), + 'status' => 'draft', + 'insurance_gl_expires' => $data['insurance_gl_expires'] ?? null, + 'insurance_wc_expires' => $data['insurance_wc_expires'] ?? null, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuiteconstruction_subcontracts', $bid, 'id'); + + // Create cost code line items if provided + if (!empty($data['line_items']) && is_array($data['line_items'])) { + foreach ($data['line_items'] as $item) { + $line = (object) [ + 'job_id' => (int) $jobId, + 'division' => $item['division'] ?? '', + 'section' => $item['section'] ?? '', + 'title' => $item['title'] ?? '', + 'budget_amount' => (float) ($item['amount'] ?? 0), + 'committed_amount' => 0.00, + 'actual_amount' => 0.00, + 'ordering' => (int) ($item['ordering'] ?? 0), + ]; + $db->insertObject('#__mokosuiteconstruction_cost_codes', $line, 'id'); + } + } + + return $bid; + } + + /** + * Side-by-side subcontractor bid comparison for a trade on a given job. + * + * Returns all draft subcontracts for the specified trade, sorted by amount, + * allowing the GC to level bids and compare scope/pricing. + */ + public static function levelSubs(int $jobId, string $trade): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('sc.*') + ->select('c.name AS subcontractor_name') + ->from($db->quoteName('#__mokosuiteconstruction_subcontracts', 'sc')) + ->join('LEFT', $db->quoteName('#__contact_details', 'c') + . ' ON c.id = sc.subcontractor_contact_id') + ->where($db->quoteName('sc.job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('sc.trade') . ' = ' . $db->quote($trade)) + ->where($db->quoteName('sc.status') . ' = ' . $db->quote('draft')) + ->order('sc.contract_amount ASC'); + + $db->setQuery($query); + $bids = $db->loadObjectList() ?: []; + + if (count($bids) > 1) { + $lowest = (float) $bids[0]->contract_amount; + foreach ($bids as &$bid) { + $bid->delta_from_low = round((float) $bid->contract_amount - $lowest, 2); + $bid->pct_above_low = $lowest > 0 + ? round($bid->delta_from_low / $lowest * 100, 1) + : 0; + } + } + + return $bids; + } + + /** + * Win rate analytics across all bids (subcontracts). + * + * draft = pending, executed/in_progress/completed = won, terminated = lost. + */ + public static function getWinRate(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*) AS total') + ->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' IN (' + . $db->quote('executed') . ',' . $db->quote('in_progress') . ',' + . $db->quote('completed') . ') THEN 1 ELSE 0 END) AS won') + ->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' + . $db->quote('terminated') . ' THEN 1 ELSE 0 END) AS lost') + ->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' + . $db->quote('draft') . ' THEN 1 ELSE 0 END) AS pending') + ->from($db->quoteName('#__mokosuiteconstruction_subcontracts')) + ); + $result = $db->loadObject(); + + $total = (int) $result->total; + $won = (int) $result->won; + $lost = (int) $result->lost; + $pending = (int) $result->pending; + $decided = $won + $lost; + + return (object) [ + 'total' => $total, + 'won' => $won, + 'lost' => $lost, + 'pending' => $pending, + 'win_pct' => $decided > 0 ? round($won / $decided * 100, 1) : 0, + ]; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php new file mode 100644 index 0000000..d7a52fa --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php @@ -0,0 +1,165 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Get next CO number for this job + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(MAX(' . $db->quoteName('co_number') . '), 0) + 1') + ->from($db->quoteName('#__mokosuiteconstruction_change_orders')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ); + $coNumber = (int) $db->loadResult(); + + $co = (object) [ + 'job_id' => (int) $jobId, + 'co_number' => $coNumber, + 'title' => $title, + 'description' => $description, + 'amount' => $amount, + 'days_impact' => $daysImpact, + 'status' => 'submitted', + 'submitted_at' => $now, + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuiteconstruction_change_orders', $co, 'id'); + + return $co; + } + + /** + * Approve a change order and atomically update the job's revised value. + * + * Uses SELECT ... FOR UPDATE to prevent concurrent budget corruption. + */ + public static function approve(int $changeOrderId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->transactionStart(); + + try { + // Lock and load the change order + $db->setQuery( + 'SELECT co.* FROM ' . $db->quoteName('#__mokosuiteconstruction_change_orders', 'co') + . ' WHERE co.id = ' . (int) $changeOrderId + . ' FOR UPDATE' + ); + $co = $db->loadObject(); + + if (!$co) { + throw new \RuntimeException('Change order not found: ' . $changeOrderId); + } + + if ($co->status === 'approved') { + throw new \RuntimeException('Change order already approved: ' . $changeOrderId); + } + + // Lock and load the job + $db->setQuery( + 'SELECT j.* FROM ' . $db->quoteName('#__mokosuiteconstruction_jobs', 'j') + . ' WHERE j.id = ' . (int) $co->job_id + . ' FOR UPDATE' + ); + $job = $db->loadObject(); + + if (!$job) { + throw new \RuntimeException('Job not found for change order: ' . $co->job_id); + } + + // Update change order status + $coUpdate = (object) [ + 'id' => (int) $changeOrderId, + 'status' => 'approved', + 'approved_at' => $now, + 'approved_by' => Factory::getApplication()->getIdentity()->id, + ]; + $db->updateObject('#__mokosuiteconstruction_change_orders', $coUpdate, 'id'); + + // Update job budget atomically + $newApproved = (float) $job->approved_changes + (float) $co->amount; + $newRevised = (float) $job->contract_value + $newApproved; + + $jobUpdate = (object) [ + 'id' => (int) $job->id, + 'approved_changes' => round($newApproved, 2), + 'revised_value' => round($newRevised, 2), + 'modified' => $now, + ]; + $db->updateObject('#__mokosuiteconstruction_jobs', $jobUpdate, 'id'); + + // Adjust end date if there is a schedule impact + if ((int) $co->days_impact !== 0 && $job->end_date) { + $newEnd = date('Y-m-d', strtotime($job->end_date . ' + ' . (int) $co->days_impact . ' days')); + $endUpdate = (object) [ + 'id' => (int) $job->id, + 'end_date' => $newEnd, + ]; + $db->updateObject('#__mokosuiteconstruction_jobs', $endUpdate, 'id'); + } + + $db->transactionCommit(); + } catch (\Throwable $e) { + $db->transactionRollback(); + throw $e; + } + + // Return refreshed CO + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteconstruction_change_orders')) + ->where('id = ' . (int) $changeOrderId) + ); + + return $db->loadObject(); + } + + /** + * Get all change orders for a job with running total. + */ + public static function getForJob(int $jobId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('co.*') + ->from($db->quoteName('#__mokosuiteconstruction_change_orders', 'co')) + ->where($db->quoteName('co.job_id') . ' = ' . (int) $jobId) + ->order('co.co_number ASC') + ); + $orders = $db->loadObjectList() ?: []; + + $runningTotal = 0.00; + foreach ($orders as &$order) { + if ($order->status === 'approved') { + $runningTotal += (float) $order->amount; + } + $order->running_total = round($runningTotal, 2); + } + + return $orders; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php new file mode 100644 index 0000000..0605314 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php @@ -0,0 +1,214 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Auto-generate job number: YYMM-NNN + $prefix = date('ym'); + $db->setQuery( + $db->getQuery(true) + ->select('MAX(' . $db->quoteName('job_number') . ')') + ->from($db->quoteName('#__mokosuiteconstruction_jobs')) + ->where($db->quoteName('job_number') . ' LIKE ' . $db->quote($prefix . '-%')) + ); + $last = $db->loadResult(); + + if ($last) { + $seq = (int) substr($last, strrpos($last, '-') + 1) + 1; + } else { + $seq = 1; + } + + $jobNumber = $prefix . '-' . str_pad($seq, 3, '0', STR_PAD_LEFT); + + $job = (object) [ + 'job_number' => $jobNumber, + 'title' => $data['title'] ?? '', + 'description' => $data['description'] ?? null, + 'address' => $data['address'] ?? null, + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'postal_code' => $data['postal_code'] ?? '', + 'owner_contact_id' => isset($data['owner_contact_id']) ? (int) $data['owner_contact_id'] : null, + 'architect_contact_id' => isset($data['architect_contact_id']) ? (int) $data['architect_contact_id'] : null, + 'superintendent_id' => isset($data['superintendent_id']) ? (int) $data['superintendent_id'] : null, + 'contract_value' => (float) ($data['contract_value'] ?? 0), + 'approved_changes' => 0.00, + 'revised_value' => (float) ($data['contract_value'] ?? 0), + 'retention_pct' => (float) ($data['retention_pct'] ?? 10.00), + 'phase' => $data['phase'] ?? 'preconstruction', + 'start_date' => $data['start_date'] ?? null, + 'end_date' => $data['end_date'] ?? null, + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuiteconstruction_jobs', $job, 'id'); + + return $job; + } + + /** + * Get job dashboard with P&L summary. + */ + public static function getDashboard(int $jobId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Load job + $db->setQuery( + $db->getQuery(true) + ->select('j.*') + ->from($db->quoteName('#__mokosuiteconstruction_jobs', 'j')) + ->where('j.id = ' . (int) $jobId) + ); + $job = $db->loadObject(); + + if (!$job) { + throw new \RuntimeException('Job not found: ' . $jobId); + } + + // Total billed (sum of pay applications) + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('total_earned') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')') + ); + $totalBilled = (float) $db->loadResult(); + + // Total paid + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('current_due') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' = ' . $db->quote('paid')) + ); + $totalPaid = (float) $db->loadResult(); + + // Retention held + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')') + ); + $retentionHeld = (float) $db->loadResult(); + + // Cost to date (actual from cost codes) + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('actual_amount') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_cost_codes')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ); + $costToDate = (float) $db->loadResult(); + + $revisedValue = (float) $job->revised_value; + $projectedProfit = $revisedValue - $costToDate; + + return (object) [ + 'job_id' => $jobId, + 'job_number' => $job->job_number, + 'title' => $job->title, + 'contract_value' => (float) $job->contract_value, + 'approved_changes' => (float) $job->approved_changes, + 'revised_value' => $revisedValue, + 'total_billed' => $totalBilled, + 'total_paid' => $totalPaid, + 'retention_held' => $retentionHeld, + 'cost_to_date' => $costToDate, + 'projected_profit' => round($projectedProfit, 2), + ]; + } + + /** + * Get all active jobs with phase, % complete, and days remaining. + */ + public static function getActive(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('j.*') + ->select('DATEDIFF(j.end_date, CURDATE()) AS days_remaining') + ->from($db->quoteName('#__mokosuiteconstruction_jobs', 'j')) + ->where($db->quoteName('j.phase') . ' IN (' + . $db->quote('preconstruction') . ',' + . $db->quote('active') . ',' + . $db->quote('punch_list') . ',' + . $db->quote('closeout') . ')') + ->order('j.end_date ASC') + ); + $jobs = $db->loadObjectList() ?: []; + + foreach ($jobs as &$job) { + $budget = (float) $job->revised_value; + if ($budget > 0) { + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('actual_amount') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_cost_codes')) + ->where($db->quoteName('job_id') . ' = ' . (int) $job->id) + ); + $actual = (float) $db->loadResult(); + $job->pct_complete = round($actual / $budget * 100, 1); + } else { + $job->pct_complete = 0; + } + } + + return $jobs; + } + + /** + * Get cost report — cost codes with budget vs actual vs committed. + */ + public static function getCostReport(int $jobId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('cc.*') + ->from($db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc')) + ->where($db->quoteName('cc.job_id') . ' = ' . (int) $jobId) + ->order('cc.division ASC, cc.section ASC') + ); + $codes = $db->loadObjectList() ?: []; + + foreach ($codes as &$code) { + $budget = (float) $code->budget_amount; + $actual = (float) $code->actual_amount; + $committed = (float) $code->committed_amount; + + $code->variance = round($budget - $actual, 2); + $code->projected_cost = $actual + $committed; + $code->over_budget = $actual > $budget; + $code->pct_used = $budget > 0 ? round($actual / $budget * 100, 1) : 0; + } + + return $codes; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php new file mode 100644 index 0000000..be3d7e2 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php @@ -0,0 +1,240 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Load job + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteconstruction_jobs')) + ->where('id = ' . (int) $jobId) + ); + $job = $db->loadObject(); + + if (!$job) { + throw new \RuntimeException('Job not found: ' . $jobId); + } + + // Get next app number + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(MAX(' . $db->quoteName('app_number') . '), 0) + 1') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ); + $appNumber = (int) $db->loadResult(); + + // Get previous payments total + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('current_due') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')') + ); + $previousPayments = (float) $db->loadResult(); + + // Load cost codes as schedule of values + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteconstruction_cost_codes')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->order('division ASC, section ASC') + ); + $costCodes = $db->loadObjectList() ?: []; + + // Calculate totals from cost codes + $workCompleted = 0.00; + $materialsStored = 0.00; + + foreach ($costCodes as $cc) { + $workCompleted += (float) $cc->actual_amount; + } + + $totalEarned = $workCompleted + $materialsStored; + $retentionPct = (float) $job->retention_pct; + $retentionHeld = round($totalEarned * ($retentionPct / 100), 2); + $currentDue = round($totalEarned - $retentionHeld - $previousPayments, 2); + + // Approved change orders total + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('amount') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_change_orders')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' = ' . $db->quote('approved')) + ); + $changeOrdersTotal = (float) $db->loadResult(); + + // Create pay application header (G702) + $payApp = (object) [ + 'job_id' => (int) $jobId, + 'app_number' => $appNumber, + 'period_from' => $periodFrom, + 'period_to' => $periodTo, + 'contract_sum' => (float) $job->contract_value, + 'change_orders_total' => $changeOrdersTotal, + 'revised_contract' => (float) $job->revised_value, + 'work_completed' => round($workCompleted, 2), + 'materials_stored' => round($materialsStored, 2), + 'total_earned' => round($totalEarned, 2), + 'retention_held' => $retentionHeld, + 'previous_payments' => round($previousPayments, 2), + 'current_due' => $currentDue, + 'status' => 'draft', + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuiteconstruction_pay_applications', $payApp, 'id'); + + // Create G703 line items from cost codes + // Get previous work per cost code + foreach ($costCodes as $cc) { + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('current_work') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_app_lines', 'pal')) + ->join('INNER', $db->quoteName('#__mokosuiteconstruction_pay_applications', 'pa') + . ' ON pa.id = pal.pay_app_id') + ->where($db->quoteName('pal.cost_code_id') . ' = ' . (int) $cc->id) + ->where($db->quoteName('pa.status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')') + ); + $previousWork = (float) $db->loadResult(); + + $scheduledValue = (float) $cc->budget_amount; + $currentWork = (float) $cc->actual_amount - $previousWork; + $totalCompleted = $previousWork + max($currentWork, 0); + $pctComplete = $scheduledValue > 0 ? round($totalCompleted / $scheduledValue * 100, 2) : 0; + $balanceToFinish = round($scheduledValue - $totalCompleted, 2); + + $line = (object) [ + 'pay_app_id' => (int) $payApp->id, + 'cost_code_id' => (int) $cc->id, + 'scheduled_value' => $scheduledValue, + 'previous_work' => round($previousWork, 2), + 'current_work' => round(max($currentWork, 0), 2), + 'materials_stored' => 0.00, + 'total_completed' => round($totalCompleted, 2), + 'pct_complete' => $pctComplete, + 'balance_to_finish' => max($balanceToFinish, 0), + ]; + + $db->insertObject('#__mokosuiteconstruction_pay_app_lines', $line, 'id'); + } + + return $payApp; + } + + /** + * Get the G703 schedule of values — line items with cost codes, % complete, balance to finish. + */ + public static function getScheduleOfValues(int $jobId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Get the latest pay application for this job + $db->setQuery( + $db->getQuery(true) + ->select('id') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->order('app_number DESC') + ->setLimit(1) + ); + $latestPayAppId = (int) $db->loadResult(); + + if (!$latestPayAppId) { + // No pay app yet — return cost codes as base schedule + $db->setQuery( + $db->getQuery(true) + ->select('cc.id AS cost_code_id') + ->select('cc.division, cc.section, cc.title') + ->select('cc.budget_amount AS scheduled_value') + ->select('0.00 AS previous_work') + ->select('0.00 AS current_work') + ->select('0.00 AS materials_stored') + ->select('0.00 AS total_completed') + ->select('0.00 AS pct_complete') + ->select('cc.budget_amount AS balance_to_finish') + ->from($db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc')) + ->where($db->quoteName('cc.job_id') . ' = ' . (int) $jobId) + ->order('cc.division ASC, cc.section ASC') + ); + + return $db->loadObjectList() ?: []; + } + + $db->setQuery( + $db->getQuery(true) + ->select('pal.*') + ->select('cc.division, cc.section, cc.title') + ->from($db->quoteName('#__mokosuiteconstruction_pay_app_lines', 'pal')) + ->join('LEFT', $db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc') + . ' ON cc.id = pal.cost_code_id') + ->where($db->quoteName('pal.pay_app_id') . ' = ' . (int) $latestPayAppId) + ->order('cc.division ASC, cc.section ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Calculate retention summary for a job. + */ + public static function calculateRetention(int $jobId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('total_earned') . '), 0) AS total_earned') + ->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0) AS retention_held') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')') + ); + $totals = $db->loadObject(); + + $totalEarned = (float) $totals->total_earned; + $retentionHeld = (float) $totals->retention_held; + + // Retention released = retention from paid apps that have been fully released + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0)') + ->from($db->quoteName('#__mokosuiteconstruction_pay_applications')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('status') . ' = ' . $db->quote('paid')) + ->where($db->quoteName('retention_held') . ' = 0') + ); + $retentionReleased = (float) $db->loadResult(); + + return (object) [ + 'total_earned' => round($totalEarned, 2), + 'retention_held' => round($retentionHeld, 2), + 'retention_released' => round($retentionReleased, 2), + 'retention_balance' => round($retentionHeld - $retentionReleased, 2), + ]; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php new file mode 100644 index 0000000..6b85596 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php @@ -0,0 +1,141 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Get next RFI number for this job + $db->setQuery( + $db->getQuery(true) + ->select('COALESCE(MAX(' . $db->quoteName('rfi_number') . '), 0) + 1') + ->from($db->quoteName('#__mokosuiteconstruction_rfis')) + ->where($db->quoteName('job_id') . ' = ' . (int) $jobId) + ); + $rfiNumber = (int) $db->loadResult(); + + $rfi = (object) [ + 'job_id' => (int) $jobId, + 'rfi_number' => $rfiNumber, + 'subject' => $subject, + 'question' => $question, + 'spec_section' => $specSection, + 'drawing_ref' => '', + 'ball_in_court' => '', + 'status' => 'open', + 'submitted_by' => Factory::getApplication()->getIdentity()->id, + 'submitted_at' => $now, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuiteconstruction_rfis', $rfi, 'id'); + + return $rfi; + } + + /** + * Record a response to an RFI and set answered_at timestamp. + */ + public static function respond(int $rfiId, string $answer): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Verify RFI exists + $db->setQuery( + $db->getQuery(true) + ->select('id') + ->from($db->quoteName('#__mokosuiteconstruction_rfis')) + ->where('id = ' . (int) $rfiId) + ); + + if (!$db->loadResult()) { + throw new \RuntimeException('RFI not found: ' . $rfiId); + } + + $update = (object) [ + 'id' => (int) $rfiId, + 'answer' => $answer, + 'status' => 'answered', + 'answered_by' => Factory::getApplication()->getIdentity()->id, + 'answered_at' => $now, + ]; + + $db->updateObject('#__mokosuiteconstruction_rfis', $update, 'id'); + + // Return refreshed record + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteconstruction_rfis')) + ->where('id = ' . (int) $rfiId) + ); + + return $db->loadObject(); + } + + /** + * Get open RFIs for a job with days aging (DATEDIFF from submitted_at). + */ + public static function getOpen(int $jobId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select('r.*') + ->select('DATEDIFF(CURDATE(), r.submitted_at) AS days_aging') + ->from($db->quoteName('#__mokosuiteconstruction_rfis', 'r')) + ->where($db->quoteName('r.job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('r.status') . ' = ' . $db->quote('open')) + ->order('r.submitted_at ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get RFIs grouped by ball_in_court party for a job. + */ + public static function getBallInCourt(int $jobId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('r.ball_in_court')) + ->select('COUNT(*) AS rfi_count') + ->select('SUM(CASE WHEN r.status = ' . $db->quote('open') + . ' THEN 1 ELSE 0 END) AS open_count') + ->select('AVG(DATEDIFF(CURDATE(), r.submitted_at)) AS avg_days_aging') + ->from($db->quoteName('#__mokosuiteconstruction_rfis', 'r')) + ->where($db->quoteName('r.job_id') . ' = ' . (int) $jobId) + ->where($db->quoteName('r.ball_in_court') . ' != ' . $db->quote('')) + ->group($db->quoteName('r.ball_in_court')) + ->order('open_count DESC') + ); + + $groups = $db->loadObjectList() ?: []; + + foreach ($groups as &$group) { + $group->avg_days_aging = round((float) $group->avg_days_aging, 1); + } + + return $groups; + } +} diff --git a/source/packages/plg_webservices_mokosuiteconstruction/mokosuiteconstruction.xml b/source/packages/plg_webservices_mokosuiteconstruction/mokosuiteconstruction.xml new file mode 100644 index 0000000..9a2b8a7 --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteconstruction/mokosuiteconstruction.xml @@ -0,0 +1,10 @@ + + + Web Services - MokoSuite Construction + mokosuiteconstruction + Moko Consulting + 01.00.22 + GPL-3.0-or-later + Moko\Plugin\WebServices\MokoSuiteConstruction + srcservices + diff --git a/source/packages/plg_webservices_mokosuiteconstruction/services/provider.php b/source/packages/plg_webservices_mokosuiteconstruction/services/provider.php new file mode 100644 index 0000000..adc8b34 --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteconstruction/services/provider.php @@ -0,0 +1,15 @@ +set(PluginInterface::class, function (Container $container) { + return new MokoSuiteConstruction($container->get(DispatcherInterface::class), (array) PluginHelper::getPlugin('webservices', 'mokosuiteconstruction')); + }); + } +}; diff --git a/source/packages/plg_webservices_mokosuiteconstruction/src/Extension/MokoSuiteConstruction.php b/source/packages/plg_webservices_mokosuiteconstruction/src/Extension/MokoSuiteConstruction.php new file mode 100644 index 0000000..38f370d --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteconstruction/src/Extension/MokoSuiteConstruction.php @@ -0,0 +1,21 @@ + 'onBeforeApiRoute']; + } + public function onBeforeApiRoute(&$event): void { + $router = $event->getArgument('router'); + $router->createCRUDRoutes('v1/mokosuiteconstruction/jobs', 'jobs', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/bids', 'bids', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/change_orders', 'change_orders', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/rfis', 'rfis', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/submittals', 'submittals', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/pay_apps', 'pay_apps', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/daily_logs', 'daily_logs', ['component' => 'com_mokosuiteconstruction']); + $router->createCRUDRoutes('v1/mokosuiteconstruction/subcontracts', 'subcontracts', ['component' => 'com_mokosuiteconstruction']); + } +} diff --git a/source/pkg_mokosuiteconstruction.xml b/source/pkg_mokosuiteconstruction.xml new file mode 100644 index 0000000..2a7a489 --- /dev/null +++ b/source/pkg_mokosuiteconstruction.xml @@ -0,0 +1,23 @@ + + + Package - MokoSuite Construction + mokosuiteconstruction + 01.00.22 + 2026-06-23 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + Construction project management — job costing, bids, change orders, RFIs, AIA billing + 8.3 + + true + script.php + + plg_system_mokosuiteconstruction.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteConstruction/updates.xml + + diff --git a/source/script.php b/source/script.php new file mode 100644 index 0000000..d316e4c --- /dev/null +++ b/source/script.php @@ -0,0 +1,74 @@ +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('%MokoSuiteConstruction%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteConstruction%') . ')') + ->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( + 'Moko Consulting License Key Required — ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . 'Enter License Key', + 'warning' + ); + } + catch (\Throwable $e) + { + // Silent — avoid breaking install if update_sites query fails + } + } +}