From 38f42df69b241a7c9ffa5f927d1d783c92ee6c6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:32:45 +0000 Subject: [PATCH 1/3] Initial plan From 08a1587a4a7cd203f9c789c052e0317c253631e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:43:50 +0000 Subject: [PATCH 2/3] Implement QuantEcon Style Guide Action with AI-powered analysis Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/actions/qe-style-guide/README.md | 237 +++++++++ .../process_style_check.cpython-312.pyc | Bin 0 -> 21820 bytes .github/actions/qe-style-guide/action.yml | 196 ++++++++ .../qe-style-guide/process_style_check.py | 461 ++++++++++++++++++ .github/workflows/qe-style-guide.yml | 128 +++++ .github/workflows/test-qe-style-guide.yml | 273 +++++++++++ README.md | 25 + test/README.md | 25 +- test/qe-style-guide/clean-document.md | 49 ++ test/qe-style-guide/test-basic.sh | 222 +++++++++ .../test-document-with-issues.md | 53 ++ 11 files changed, 1667 insertions(+), 2 deletions(-) create mode 100644 .github/actions/qe-style-guide/README.md create mode 100644 .github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc create mode 100644 .github/actions/qe-style-guide/action.yml create mode 100755 .github/actions/qe-style-guide/process_style_check.py create mode 100644 .github/workflows/qe-style-guide.yml create mode 100644 .github/workflows/test-qe-style-guide.yml create mode 100644 test/qe-style-guide/clean-document.md create mode 100755 test/qe-style-guide/test-basic.sh create mode 100644 test/qe-style-guide/test-document-with-issues.md diff --git a/.github/actions/qe-style-guide/README.md b/.github/actions/qe-style-guide/README.md new file mode 100644 index 0000000..e326f59 --- /dev/null +++ b/.github/actions/qe-style-guide/README.md @@ -0,0 +1,237 @@ +# QuantEcon Style Guide Action + +A GitHub Action that provides AI-powered style guide checking and suggestions for QuantEcon content. This action can be triggered via comments on issues and pull requests to automatically review and improve content according to the QuantEcon style guidelines. + +## Features + +- **Comment-triggered reviews**: Simply comment `@qe-style-check` to trigger style analysis +- **AI-powered analysis**: Uses OpenAI GPT models to analyze content against the QuantEcon style guide +- **Two modes of operation**: + - **Issue mode**: Creates a new PR with comprehensive style suggestions + - **PR mode**: Applies high-confidence changes directly to the existing PR +- **Rule-based fallback**: Works even without AI API keys using built-in style rules +- **Configurable**: Customizable style guide source, file extensions, and confidence thresholds + +## Usage + +### Triggering Style Checks + +#### For Issues +Comment on any issue with: +``` +@qe-style-check filename.md +``` + +This will: +1. Analyze the specified file against the QuantEcon style guide +2. Create a new pull request with all suggested improvements +3. Comment on the original issue with a link to the PR + +#### For Pull Requests +Comment on any pull request with: +``` +@qe-style-check +``` + +This will: +1. Analyze all changed markdown files in the PR +2. Apply high-confidence style improvements directly to the PR +3. Post a summary comment with details of changes made + +### Direct Usage in Workflows + +You can also use this action directly in your GitHub workflows: + +```yaml +- name: Check style guide compliance + uses: QuantEcon/meta/.github/actions/qe-style-guide@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + style-guide: '.github/copilot-qe-style-guide.md' + docs: 'lectures/' + extensions: 'md' + openai-api-key: ${{ secrets.OPENAI_API_KEY }} # Optional + model: 'gpt-4' + max-suggestions: '20' + confidence-threshold: '0.8' +``` + +## Configuration + +### Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token with repository access | Yes | | +| `style-guide` | Path or URL to the style guide document | No | `.github/copilot-qe-style-guide.md` | +| `docs` | Directory containing documents to check | No | `lectures/` | +| `extensions` | File extensions to check (comma-separated) | No | `md` | +| `openai-api-key` | OpenAI API key for AI-powered analysis | No | (uses rule-based fallback) | +| `model` | AI model to use for style checking | No | `gpt-4` | +| `max-suggestions` | Maximum number of suggestions per file | No | `20` | +| `confidence-threshold` | Confidence threshold for auto-applying changes (0.0-1.0) | No | `0.8` | + +### Outputs + +| Output | Description | +|--------|-------------| +| `files-processed` | Number of files processed | +| `suggestions-count` | Total number of style suggestions made | +| `pr-url` | URL of the created pull request (issue mode only) | +| `commit-sha` | SHA of the commit with style changes (PR mode only) | +| `summary` | Summary of changes made | + +## Style Guide Rules + +The action enforces the QuantEcon style guide rules including: + +### Code Style +- **Unicode Greek Letters**: Prefer `α, β, γ, δ, ε, σ, θ, ρ` over `alpha, beta, gamma, delta, epsilon, sigma, theta, rho` in Python code +- **PEP8 Compliance**: Follow Python style guidelines +- **Operator Spacing**: Use spaces around operators (`a * b`, `a + b`) but not for exponentiation (`a**b`) + +### Writing Conventions +- **Bold for Definitions**: Use `**bold**` for new term definitions +- **Italics for Emphasis**: Use `*italics*` for emphasis +- **Heading Capitalization**: Only capitalize first word and proper nouns in headings (except main titles) + +### Math Notation +- Use `\\top` for transpose: $A^\\top$ +- Use `\\mathbb{1}` for vectors/matrices of ones +- Use square brackets for matrices: `\\begin{bmatrix} ... \\end{bmatrix}` +- Use curly brackets for sequences: `\\{ x_t \\}_{t=0}^{\\infty}` + +### Figure Guidelines +- No embedded titles in matplotlib plots +- Use lowercase captions except first letter and proper nouns +- Set descriptive `name` attributes for cross-references +- Use `lw=2` for matplotlib line charts + +## Setup + +### Prerequisites + +1. **Repository Access**: The action requires a GitHub token with repository access +2. **OpenAI API Key** (Optional): For AI-powered analysis, set up an OpenAI API key as a repository secret + +### Installation + +1. **Copy the action files** to your repository: + ``` + .github/actions/qe-style-guide/ + ├── action.yml + ├── process-style-check.py + └── README.md + ``` + +2. **Add the workflow file**: + ``` + .github/workflows/qe-style-guide.yml + ``` + +3. **Set up secrets** (optional): + - `OPENAI_API_KEY`: Your OpenAI API key for enhanced AI analysis + +4. **Customize the style guide**: + - Place your style guide document at `.github/copilot-qe-style-guide.md` + - Or specify a different path/URL in the action configuration + +## Permissions + +Only users with write access to the repository can trigger style guide checks. The action will automatically check permissions and reject requests from unauthorized users. + +## Examples + +### Basic Issue Usage +``` +I'd like to review the style of the Aiyagari model lecture. + +@qe-style-check aiyagari.md +``` + +### PR Usage +``` +Please check the style of the files I've modified in this PR. + +@qe-style-check +``` + +### Custom Workflow Integration +```yaml +name: Weekly Style Review +on: + schedule: + - cron: '0 10 * * 1' # Every Monday at 10 AM + +jobs: + style-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Review recent changes + uses: QuantEcon/meta/.github/actions/qe-style-guide@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + docs: 'lectures/' + extensions: 'md,rst' + confidence-threshold: '0.9' +``` + +## AI vs Rule-Based Analysis + +### AI-Powered Analysis (with OpenAI API key) +- Comprehensive understanding of context and style nuances +- Natural language explanations of issues +- Confidence scoring for each suggestion +- Advanced pattern recognition + +### Rule-Based Analysis (fallback) +- Fast, deterministic checking +- Focuses on clear, codifiable rules: + - Greek letter usage in code blocks + - Heading capitalization patterns + - Basic formatting conventions +- No external API dependencies + +## Troubleshooting + +### Common Issues + +1. **"User does not have sufficient permissions"** + - Only repository collaborators with write access can trigger style checks + - Contact a repository maintainer for access + +2. **"No files to process"** + - Ensure the specified file exists in the `docs` directory + - Check that file extensions match the configured `extensions` input + +3. **"AI analysis failed"** + - Check that `OPENAI_API_KEY` is correctly set + - Verify API key has sufficient credits + - The action will fall back to rule-based analysis + +4. **"Could not load style guide"** + - Verify the style guide path is correct + - For URL-based style guides, ensure the URL is accessible + - Check repository permissions for local files + +### Getting Help + +- Check the [workflow logs](../../actions) for detailed error messages +- Review the [QuantEcon style guide](../../blob/main/.github/copilot-qe-style-guide.md) +- Open an issue in the [meta repository](https://github.com/QuantEcon/meta/issues) + +## Contributing + +This action is part of the QuantEcon meta repository. Contributions are welcome! + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Submit a pull request + +## License + +This action is released under the same license as the QuantEcon meta repository. \ No newline at end of file diff --git a/.github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc b/.github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e03050f5b52a4d9e7dfa87579eac89308196578e GIT binary patch literal 21820 zcmd6PdvqIDdfyD*009Cd_y7q$h7XAZpQ1#)s0YQDDC$8yNXZs02?9Ao5)=r~Gk_#Q zpzUmS8+xn6R5quQvUWu$o0_VAD`u^n0$0+K5;*D}>k{8eZ z4_w@)SZah~X;$TZe%kHz`*4u1cn0B#+By^;haCig&0eF7MqW zVIy|VKIKZ@WSo?InwnM3s%j|CT1)X-phPv2d!D+v>l$@keV)2TZ^*Gh&Os%|We9Vo z(;<&P*f$pNGef~yAIJ2Eye!A`OmbsaINsDX&^!~k#_=4>2qb%gWRGFC*FV8{{E!iv znBatZoIhy#7|9-V(URtrCphWSN}A(dAt-5jy< z0*_D94u@uZ+*rnkQe^Pp!80?BL&Q-qG!zcYHz^nt6{i_jvFdMXU_exyj?;6R5-6;k z$5nU-B{WdN040oaK0MLF6BDcBG^7p)&TcVCi&dVX*f%=4g+g~61iVmA_q!X zNeMHwWX85rEf`n}^ju_ki-3iZGc zDKi~vQdZrsLXn2mX4I6T`B$Jx$Lf`urYy?0rO&KE`SxDw4SGy9L5-;{=2O%h9igYJ z>2k`IvLbJ(ZWd^uER>_%1||1bpwGyf6#Aeh_tsk;KmD@xr_aF-e<7VjKN-UQwuk0Hk_%sHCO zyK9uIa8Sx07(8`$*xf%ceB^Amd-&wBzCkHZel|3G{&=6e|Lj0-U%0Ae!W*0njkb;j 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 str: + """Load the style guide content from file or URL""" + try: + if self.style_guide_path.startswith('http'): + # Load from URL + response = requests.get(self.style_guide_path) + response.raise_for_status() + return response.text + else: + # Load from local file + with open(self.style_guide_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + print(f"Error loading style guide: {e}") + sys.exit(1) + + def parse_trigger_comment(self) -> Tuple[Optional[str], Optional[str]]: + """Parse the trigger comment to determine mode and target file""" + event_name = self.github_context.get('event_name') + + if event_name == 'issue_comment': + comment_body = self.github_context.get('event', {}).get('comment', {}).get('body', '') + + # Check if this is an issue or PR comment + if 'pull_request' in self.github_context.get('event', {}).get('issue', {}): + # This is a PR comment + if '@qe-style-check' in comment_body and comment_body.strip() == '@qe-style-check': + return 'pr', None + else: + # This is an issue comment + match = re.search(r'@qe-style-check\s+(\S+)', comment_body) + if match: + return 'issue', match.group(1) + + return None, None + + def get_changed_files_in_pr(self) -> List[str]: + """Get list of changed markdown files in the current PR""" + try: + repo_name = self.github_context['repository'] + pr_number = self.github_context['event']['issue']['number'] + + url = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}/files" + headers = { + 'Authorization': f'token {self.github_token}', + 'Accept': 'application/vnd.github.v3+json' + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + files = response.json() + changed_md_files = [] + + for file in files: + filename = file['filename'] + # Check if file is in docs directory and has correct extension + if filename.startswith(self.docs_dir): + for ext in self.extensions: + if filename.endswith(f'.{ext}'): + changed_md_files.append(filename) + break + + return changed_md_files + except Exception as e: + print(f"Error getting changed files: {e}") + return [] + + def get_file_content(self, file_path: str) -> str: + """Get file content from GitHub repository""" + try: + repo_name = self.github_context['repository'] + url = f"https://api.github.com/repos/{repo_name}/contents/{file_path}" + headers = { + 'Authorization': f'token {self.github_token}', + 'Accept': 'application/vnd.github.v3+json' + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + content_data = response.json() + if content_data['encoding'] == 'base64': + import base64 + return base64.b64decode(content_data['content']).decode('utf-8') + else: + return content_data['content'] + except Exception as e: + print(f"Error getting file content for {file_path}: {e}") + return "" + + def analyze_with_ai(self, content: str, style_guide: str, file_path: str) -> Dict[str, Any]: + """Use AI to analyze content against style guide""" + if not self.openai_api_key or not openai: + return self.analyze_with_rules(content, style_guide, file_path) + + try: + prompt = f""" +You are a QuantEcon style guide checker. Please analyze the following markdown content against the QuantEcon style guide and provide specific, actionable suggestions. + +STYLE GUIDE: +{style_guide} + +CONTENT TO ANALYZE: +{content} + +Please provide your response in the following JSON format: +{{ + "suggestions": [ + {{ + "line_number": , + "rule_id": "", + "severity": "high|medium|low", + "confidence": , + "description": "", + "suggestion": "", + "original_text": "", + "suggested_text": "" + }} + ], + "summary": "" +}} + +Focus on: +1. Code style rules (especially Unicode Greek letters in code) +2. Writing conventions (bold for definitions, italics for emphasis) +3. Math notation standards +4. Figure and heading conventions +5. Only suggest changes that clearly violate the style guide +""" + + # Use the newer client interface + from openai import OpenAI + client = OpenAI(api_key=self.openai_api_key) + + response = client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a precise technical writing editor focused on QuantEcon style guidelines."}, + {"role": "user", "content": prompt} + ], + temperature=0.1, + max_tokens=4000 + ) + + result_text = response.choices[0].message.content + + # Try to parse JSON response + try: + result = json.loads(result_text) + return result + except json.JSONDecodeError: + # Fallback if AI doesn't return valid JSON + return { + "suggestions": [], + "summary": "AI analysis completed but could not parse structured response" + } + except Exception as e: + print(f"Error with AI analysis: {e}") + return self.analyze_with_rules(content, style_guide, file_path) + + def analyze_with_rules(self, content: str, style_guide: str, file_path: str) -> Dict[str, Any]: + """Fallback rule-based analysis when AI is not available""" + suggestions = [] + lines = content.split('\n') + + # Rule 1: Check for Greek letters in code blocks + in_code_block = False + for i, line in enumerate(lines): + if line.strip().startswith('```'): + in_code_block = not in_code_block + continue + + if in_code_block and 'python' in lines[max(0, i-1)]: + # Check for spelled-out Greek letters + greek_replacements = { + 'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'delta': 'δ', + 'epsilon': 'ε', 'sigma': 'σ', 'theta': 'θ', 'rho': 'ρ' + } + + for spelled, unicode_char in greek_replacements.items(): + if re.search(rf'\b{spelled}\b', line): + suggestions.append({ + "line_number": i + 1, + "rule_id": "CODE_RULE_4", + "severity": "medium", + "confidence": 0.9, + "description": f"Use Unicode Greek letter instead of '{spelled}'", + "suggestion": f"Replace '{spelled}' with '{unicode_char}'", + "original_text": spelled, + "suggested_text": unicode_char + }) + + # Rule 2: Check heading capitalization + for i, line in enumerate(lines): + if line.startswith('#'): + # Skip lecture titles (single #) + if line.startswith('# '): + continue + + # Check other headings for proper capitalization + heading_text = line.lstrip('# ').strip() + words = heading_text.split() + if len(words) > 1: + # Check if more than first word is capitalized + capitalized_count = sum(1 for word in words[1:] if word[0].isupper() and word.lower() not in ['and', 'or', 'the', 'of', 'in', 'on', 'at', 'to', 'for']) + if capitalized_count > 0: + suggestions.append({ + "line_number": i + 1, + "rule_id": "HEADING_RULE", + "severity": "low", + "confidence": 0.7, + "description": "Heading should only capitalize first word and proper nouns", + "suggestion": "Use sentence case for headings", + "original_text": heading_text, + "suggested_text": heading_text.capitalize() + }) + + summary = f"Found {len(suggestions)} potential style issues using rule-based analysis" + return {"suggestions": suggestions, "summary": summary} + + def apply_suggestions(self, content: str, suggestions: List[Dict], file_path: str) -> str: + """Apply high-confidence suggestions to content""" + modified_content = content + lines = content.split('\n') + + # Sort suggestions by line number in reverse order to avoid offset issues + high_confidence_suggestions = [ + s for s in suggestions + if s.get('confidence', 0) >= self.confidence_threshold + ] + + applied_count = 0 + for suggestion in sorted(high_confidence_suggestions, key=lambda x: x.get('line_number', 0), reverse=True): + if suggestion.get('original_text') and suggestion.get('suggested_text'): + original = suggestion['original_text'] + replacement = suggestion['suggested_text'] + + # Apply replacement + modified_content = modified_content.replace(original, replacement) + applied_count += 1 + + if applied_count >= self.max_suggestions: + break + + return modified_content + + def generate_summary(self, suggestions: List[Dict], file_path: str, mode: str) -> str: + """Generate a summary of changes made""" + high_conf = [s for s in suggestions if s.get('confidence', 0) >= self.confidence_threshold] + low_conf = [s for s in suggestions if s.get('confidence', 0) < self.confidence_threshold] + + if mode == 'pr': + summary = f"## 🎨 QuantEcon Style Guide Review\n\n" + summary += f"Automatically applied **{len(high_conf)}** high-confidence style improvements to `{file_path}`.\n\n" + + if high_conf: + summary += "### Changes Applied:\n" + for suggestion in high_conf[:5]: # Show first 5 + summary += f"- **{suggestion.get('rule_id', 'STYLE')}**: {suggestion.get('description', 'Style improvement')}\n" + + if len(high_conf) > 5: + summary += f"- ... and {len(high_conf) - 5} more improvements\n" + + if low_conf: + summary += f"\n### Additional Suggestions (manual review needed):\n" + for suggestion in low_conf[:3]: # Show first 3 + summary += f"- {suggestion.get('description', 'Style suggestion')}\n" + + else: # issue mode + summary = f"## 📝 QuantEcon Style Guide Review for `{file_path}`\n\n" + summary += f"Found **{len(suggestions)}** style suggestions.\n\n" + + if suggestions: + summary += "### Suggestions:\n" + for suggestion in suggestions[:10]: # Show first 10 + conf_emoji = "🔴" if suggestion.get('confidence', 0) < 0.5 else "🟡" if suggestion.get('confidence', 0) < 0.8 else "🟢" + summary += f"{conf_emoji} **{suggestion.get('rule_id', 'STYLE')}** (Line {suggestion.get('line_number', '?')}): {suggestion.get('description', 'Style suggestion')}\n" + + if len(suggestions) > 10: + summary += f"\n*... and {len(suggestions) - 10} more suggestions in the full review.*\n" + + summary += f"\n\n*Generated by [QuantEcon Style Guide Action](https://github.com/QuantEcon/meta/.github/actions/qe-style-guide)*" + return summary + + def process_file(self, file_path: str, style_guide: str) -> Dict[str, Any]: + """Process a single file for style checking""" + print(f"Processing file: {file_path}") + + content = self.get_file_content(file_path) + if not content: + return {"suggestions": [], "summary": f"Could not load content for {file_path}"} + + analysis = self.analyze_with_ai(content, style_guide, file_path) + suggestions = analysis.get('suggestions', []) + + self.files_processed += 1 + self.suggestions_count += len(suggestions) + + result = { + "file_path": file_path, + "suggestions": suggestions, + "original_content": content + } + + # Apply changes if in PR mode and have high-confidence suggestions + if self.mode == 'pr': + modified_content = self.apply_suggestions(content, suggestions, file_path) + if modified_content != content: + result["modified_content"] = modified_content + self.changes_made = True + self.file_changes.append({ + "path": file_path, + "content": modified_content + }) + elif self.mode == 'issue': + # For issue mode, apply all suggestions to create comprehensive PR + modified_content = self.apply_suggestions(content, suggestions, file_path) + if modified_content != content: + result["modified_content"] = modified_content + self.changes_made = True + self.file_changes.append({ + "path": file_path, + "content": modified_content + }) + + return result + + def run(self): + """Main execution method""" + print("QuantEcon Style Guide Checker starting...") + + # Parse the trigger comment + mode, target_file = self.parse_trigger_comment() + if not mode: + print("No valid trigger comment found") + sys.exit(0) + + self.mode = mode + self.target_file = target_file + + print(f"Mode: {mode}, Target file: {target_file}") + + # Load style guide + style_guide = self.load_style_guide() + print(f"Loaded style guide from: {self.style_guide_path}") + + # Determine files to process + files_to_process = [] + + if mode == 'issue' and target_file: + # Process specific file mentioned in issue + file_path = target_file + if not file_path.startswith(self.docs_dir): + file_path = os.path.join(self.docs_dir, file_path) + files_to_process = [file_path] + elif mode == 'pr': + # Process changed files in PR + files_to_process = self.get_changed_files_in_pr() + + if not files_to_process: + print("No files to process") + self.output_results() + return + + print(f"Processing {len(files_to_process)} files...") + + # Process each file + all_results = [] + for file_path in files_to_process: + result = self.process_file(file_path, style_guide) + all_results.append(result) + + # Generate summary + all_suggestions = [] + for result in all_results: + all_suggestions.extend(result.get('suggestions', [])) + + summary = self.generate_summary(all_suggestions, target_file or "changed files", mode) + + # Save results for GitHub Actions + changes_data = { + "mode": mode, + "targetFile": target_file, + "fileChanges": self.file_changes, + "summary": summary, + "allResults": all_results + } + + with open('/tmp/style_check_changes.json', 'w') as f: + json.dump(changes_data, f, indent=2) + + self.output_results() + + def output_results(self): + """Output results for GitHub Actions""" + github_output = os.environ.get('GITHUB_OUTPUT', '/dev/stdout') + + with open(github_output, 'a') as f: + f.write(f"files-processed={self.files_processed}\n") + f.write(f"suggestions-count={self.suggestions_count}\n") + f.write(f"changes-made={'true' if self.changes_made else 'false'}\n") + f.write(f"mode={self.mode or ''}\n") + f.write(f"changes-file=/tmp/style_check_changes.json\n") + + if self.mode and self.target_file: + f.write(f"target-file={self.target_file}\n") + +if __name__ == "__main__": + checker = StyleGuideChecker() + checker.run() \ No newline at end of file diff --git a/.github/workflows/qe-style-guide.yml b/.github/workflows/qe-style-guide.yml new file mode 100644 index 0000000..b3227dc --- /dev/null +++ b/.github/workflows/qe-style-guide.yml @@ -0,0 +1,128 @@ +name: QuantEcon Style Guide Check + +on: + issue_comment: + types: [created] + +jobs: + style-check: + # Only run on comments that contain the trigger phrase + if: contains(github.event.comment.body, '@qe-style-check') + runs-on: ubuntu-latest + name: Process style guide check request + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Need full history for PR processing + fetch-depth: 0 + # Use the head ref for PR comments, default branch for issue comments + ref: ${{ github.event.issue.pull_request && github.event.pull_request.head.ref || github.sha }} + + - name: Check if user has permissions + id: check-permissions + uses: actions/github-script@v7 + with: + script: | + const { data: permissions } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login + }); + + const hasPermissions = ['write', 'admin', 'maintain'].includes(permissions.permission); + + if (!hasPermissions) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '⚠️ Sorry, only collaborators with write access can trigger style guide checks.' + }); + core.setFailed('User does not have sufficient permissions'); + return; + } + + core.setOutput('has-permissions', 'true'); + + - name: React to comment + if: steps.check-permissions.outputs.has-permissions == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + - name: Run style guide check + if: steps.check-permissions.outputs.has-permissions == 'true' + id: style-check + uses: .//.github/actions/qe-style-guide + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + style-guide: '.github/copilot-qe-style-guide.md' + docs: 'lectures/' + extensions: 'md' + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + model: 'gpt-4' + max-suggestions: '20' + confidence-threshold: '0.8' + + - name: React with success/failure + if: always() && steps.check-permissions.outputs.has-permissions == 'true' + uses: actions/github-script@v7 + with: + script: | + const success = '${{ steps.style-check.outcome }}' === 'success'; + const reaction = success ? 'hooray' : 'confused'; + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: reaction + }); + + if (!success) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '❌ Style guide check failed. Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.' + }); + } + + - name: Post summary comment + if: steps.check-permissions.outputs.has-permissions == 'true' && steps.style-check.outputs.files-processed > 0 + uses: actions/github-script@v7 + with: + script: | + const filesProcessed = '${{ steps.style-check.outputs.files-processed }}'; + const suggestionsCount = '${{ steps.style-check.outputs.suggestions-count }}'; + const changesMade = '${{ steps.style-check.outputs.changes-made }}'; + + let summaryMessage = `## 📊 Style Guide Check Summary\n\n`; + summaryMessage += `- **Files processed:** ${filesProcessed}\n`; + summaryMessage += `- **Suggestions found:** ${suggestionsCount}\n`; + summaryMessage += `- **Changes made:** ${changesMade === 'true' ? 'Yes' : 'No'}\n\n`; + + if (changesMade === 'true') { + summaryMessage += `✅ Style guide processing completed successfully!\n\n`; + } else if (suggestionsCount > 0) { + summaryMessage += `ℹ️ Found suggestions but no automatic changes were applied.\n\n`; + } else { + summaryMessage += `✨ No style issues found - great work!\n\n`; + } + + summaryMessage += `*Check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for detailed information.*`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: summaryMessage + }); \ No newline at end of file diff --git a/.github/workflows/test-qe-style-guide.yml b/.github/workflows/test-qe-style-guide.yml new file mode 100644 index 0000000..caf3477 --- /dev/null +++ b/.github/workflows/test-qe-style-guide.yml @@ -0,0 +1,273 @@ +name: Test QuantEcon Style Guide Action + +on: + push: + branches: [ main ] + paths: + - '.github/actions/qe-style-guide/**' + - 'test/qe-style-guide/**' + pull_request: + branches: [ main ] + paths: + - '.github/actions/qe-style-guide/**' + - 'test/qe-style-guide/**' + workflow_dispatch: + +jobs: + test-basic-functionality: + runs-on: ubuntu-latest + name: Test basic functionality without AI + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run basic tests + run: ./test/qe-style-guide/test-basic.sh + + test-with-mock-context: + runs-on: ubuntu-latest + name: Test with mock GitHub context + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test issue mode parsing + run: | + # Test parsing of issue comment + export INPUT_GITHUB_TOKEN="fake-token" + export INPUT_STYLE_GUIDE=".github/copilot-qe-style-guide.md" + export INPUT_DOCS="test/qe-style-guide/" + export INPUT_EXTENSIONS="md" + export INPUT_OPENAI_API_KEY="" + export GITHUB_OUTPUT="/tmp/test_output" + export GITHUB_CONTEXT='{ + "event_name": "issue_comment", + "repository": "QuantEcon/test-repo", + "event": { + "comment": { + "body": "@qe-style-check test-document-with-issues.md" + }, + "issue": { + "number": 123 + } + } + }' + + echo "" > /tmp/test_output + + # Mock the GitHub API calls by creating a test version + cat > /tmp/test_mock_style_check.py << 'EOF' + import os + import sys + sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + + from process_style_check import StyleGuideChecker + + class MockStyleGuideChecker(StyleGuideChecker): + def get_file_content(self, file_path): + # Read from local test files instead of GitHub API + local_path = file_path + if not os.path.exists(local_path): + local_path = f"test/qe-style-guide/{os.path.basename(file_path)}" + + if os.path.exists(local_path): + with open(local_path, 'r') as f: + return f.read() + return "" + + # Run the mock checker + checker = MockStyleGuideChecker() + + # Test that we can parse the trigger comment correctly + mode, target_file = checker.parse_trigger_comment() + print(f"Mode: {mode}, Target file: {target_file}") + + assert mode == 'issue', f"Expected 'issue', got '{mode}'" + assert target_file == 'test-document-with-issues.md', f"Expected 'test-document-with-issues.md', got '{target_file}'" + + # Test loading style guide + style_guide = checker.load_style_guide() + assert len(style_guide) > 100, "Style guide should be loaded" + + # Test file processing + content = checker.get_file_content(target_file) + assert len(content) > 0, "Should be able to get file content" + + # Test analysis + result = checker.analyze_with_rules(content, style_guide, target_file) + suggestions = result.get('suggestions', []) + print(f"Found {len(suggestions)} suggestions") + + # Output results + checker.files_processed = 1 + checker.suggestions_count = len(suggestions) + checker.mode = mode + checker.target_file = target_file + checker.output_results() + + print("✅ Mock test passed!") + EOF + + python3 /tmp/test_mock_style_check.py + + # Verify outputs were written + if [ -f "/tmp/test_output" ]; then + echo "✅ GitHub outputs were written:" + cat /tmp/test_output + else + echo "❌ No GitHub outputs found" + exit 1 + fi + + test-pr-mode-parsing: + runs-on: ubuntu-latest + name: Test PR mode comment parsing + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test PR mode parsing + run: | + export INPUT_GITHUB_TOKEN="fake-token" + export GITHUB_CONTEXT='{ + "event_name": "issue_comment", + "repository": "QuantEcon/test-repo", + "event": { + "comment": { + "body": "@qe-style-check" + }, + "issue": { + "number": 123, + "pull_request": { + "url": "https://api.github.com/repos/test/test/pulls/123" + } + } + } + }' + + cat > /tmp/test_pr_parsing.py << 'EOF' + import os + import sys + sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + + from process_style_check import StyleGuideChecker + + checker = StyleGuideChecker() + mode, target_file = checker.parse_trigger_comment() + + print(f"PR mode test: mode={mode}, target_file={target_file}") + + assert mode == 'pr', f"Expected 'pr', got '{mode}'" + assert target_file is None, f"Expected None for target_file in PR mode, got '{target_file}'" + + print("✅ PR mode parsing test passed!") + EOF + + python3 /tmp/test_pr_parsing.py + + test-rule-based-analysis: + runs-on: ubuntu-latest + name: Test rule-based analysis + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test Greek letter detection + run: | + cat > /tmp/test_rules.py << 'EOF' + import os + import sys + sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + + from process_style_check import StyleGuideChecker + + checker = StyleGuideChecker() + + # Test content with Greek letter issues + test_content = ''' + # Test Document + + Here is some Python code: + + ```python + def utility(c, alpha=0.5, beta=0.95): + return c * alpha + beta + + gamma = 0.02 + delta = 0.1 + ``` + + ## This Heading Has Too Many Capitals + + Some text. + ''' + + style_guide = checker.load_style_guide() + result = checker.analyze_with_rules(test_content, style_guide, "test.md") + suggestions = result.get('suggestions', []) + + print(f"Found {len(suggestions)} suggestions:") + for i, suggestion in enumerate(suggestions): + print(f" {i+1}. {suggestion.get('rule_id', 'UNKNOWN')}: {suggestion.get('description', 'No description')}") + + # Check for Greek letter suggestions + greek_suggestions = [s for s in suggestions if 'alpha' in s.get('original_text', '') or 'beta' in s.get('original_text', '') or 'gamma' in s.get('original_text', '') or 'delta' in s.get('original_text', '')] + + if len(greek_suggestions) > 0: + print(f"✅ Found {len(greek_suggestions)} Greek letter suggestions as expected") + else: + print("⚠️ No Greek letter suggestions found") + + # Check for heading suggestions + heading_suggestions = [s for s in suggestions if s.get('rule_id') == 'HEADING_RULE'] + + if len(heading_suggestions) > 0: + print(f"✅ Found {len(heading_suggestions)} heading suggestions as expected") + else: + print("⚠️ No heading suggestions found") + + print("✅ Rule-based analysis test completed!") + EOF + + python3 /tmp/test_rules.py + + test-clean-document: + runs-on: ubuntu-latest + name: Test with clean document + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test clean document analysis + run: | + cat > /tmp/test_clean.py << 'EOF' + import os + import sys + sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + + from process_style_check import StyleGuideChecker + + checker = StyleGuideChecker() + + # Read the clean test document + with open('test/qe-style-guide/clean-document.md', 'r') as f: + content = f.read() + + style_guide = checker.load_style_guide() + result = checker.analyze_with_rules(content, style_guide, "clean-document.md") + suggestions = result.get('suggestions', []) + + print(f"Clean document analysis found {len(suggestions)} suggestions:") + for suggestion in suggestions: + print(f" - {suggestion.get('description', 'No description')}") + + # Clean document should have minimal suggestions + if len(suggestions) <= 2: + print("✅ Clean document test passed - minimal suggestions as expected") + else: + print(f"⚠️ Clean document had {len(suggestions)} suggestions, expected <= 2") + + print("✅ Clean document test completed!") + EOF + + python3 /tmp/test_clean.py \ No newline at end of file diff --git a/README.md b/README.md index 8cef683..76ac80e 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,28 @@ A GitHub Action that generates a weekly report summarizing issues and PR activit **Use case**: Automated weekly reporting on repository activity including opened/closed issues and merged PRs. Runs automatically every Saturday and creates an issue with the report. See the [action documentation](./.github/actions/weekly-report/README.md) for detailed usage instructions and examples. + +### QuantEcon Style Guide Action + +An AI-powered GitHub Action that checks QuantEcon content for style guide compliance and provides automated suggestions and improvements. + +**Location**: `.github/actions/qe-style-guide` + +**Usage**: +Comment-triggered style checks: +- **Issues**: `@qe-style-check filename.md` - Creates a PR with comprehensive style review +- **Pull Requests**: `@qe-style-check` - Applies high-confidence style improvements to the PR + +**Direct workflow usage**: +```yaml +- name: Check style guide compliance + uses: QuantEcon/meta/.github/actions/qe-style-guide@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} # Optional + style-guide: '.github/copilot-qe-style-guide.md' +``` + +**Use case**: Automated style guide enforcement with AI-powered analysis, ensuring consistent writing style, code formatting, mathematical notation, and document structure across QuantEcon content. + +See the [action documentation](./.github/actions/qe-style-guide/README.md) for detailed usage instructions and examples. diff --git a/test/README.md b/test/README.md index bbeab05..74a2b81 100644 --- a/test/README.md +++ b/test/README.md @@ -13,9 +13,30 @@ Each GitHub Action has its own test subdirectory: - `weekly-report/` - Tests for the `.github/actions/weekly-report` action - `test-basic.sh` - Basic functionality test for the weekly report action +- `qe-style-guide/` - Tests for the `.github/actions/qe-style-guide` action + - `test-basic.sh` - Basic functionality test for the style guide action + - `test-document-with-issues.md` - Test document with various style issues + - `clean-document.md` - Test document following style guidelines + ## Running Tests -Tests are automatically run by the GitHub Actions workflows in `.github/workflows/`. +Tests are automatically run by the GitHub Actions workflows in `.github/workflows/`: - For the `check-warnings` action, tests are run by the `test-warning-check.yml` workflow. -- For the `weekly-report` action, tests are run by the `test-weekly-report.yml` workflow. \ No newline at end of file +- For the `weekly-report` action, tests are run by the `test-weekly-report.yml` workflow. +- For the `qe-style-guide` action, tests are run by the `test-qe-style-guide.yml` workflow. + +### Manual Test Execution + +You can also run tests manually: + +```bash +# Test check-warnings action +cd .github/actions/check-warnings && # follow test instructions in README + +# Test weekly-report action +./test/weekly-report/test-basic.sh + +# Test qe-style-guide action +./test/qe-style-guide/test-basic.sh +``` \ No newline at end of file diff --git a/test/qe-style-guide/clean-document.md b/test/qe-style-guide/clean-document.md new file mode 100644 index 0000000..a63569a --- /dev/null +++ b/test/qe-style-guide/clean-document.md @@ -0,0 +1,49 @@ +# Clean Test Document + +This document follows QuantEcon style guidelines and should have minimal issues. + +## Introduction + +This is a **clean document** that demonstrates proper QuantEcon style formatting. + +Use *italics* for emphasis when needed. + +## Code example + +Here's properly formatted Python code: + +```python +# Proper Unicode Greek letters +def utility_function(c, α=0.5, β=0.95): + return (c**(1-α) - 1) / (1-α) * β + +# More proper Greek letters +γ = 0.02 +δ = 0.1 +σ = 2.0 +θ = 0.8 +ρ = 0.9 +ε = 1e-6 +``` + +## Proper heading style + +### This heading follows proper capitalization rules + +Another section with correct formatting. + +## Mathematical notation + +Proper transpose notation: $A^\top$ + +Matrix with square brackets: +$$ +\begin{bmatrix} +1 & 2 \\ +3 & 4 +\end{bmatrix} +$$ + +## Conclusion + +This document should pass style checks with minimal suggestions. \ No newline at end of file diff --git a/test/qe-style-guide/test-basic.sh b/test/qe-style-guide/test-basic.sh new file mode 100755 index 0000000..69085b3 --- /dev/null +++ b/test/qe-style-guide/test-basic.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Test script for the QuantEcon Style Guide Action + +set -e + +echo "Testing QuantEcon Style Guide Action..." + +# Mock environment variables for testing +export INPUT_GITHUB_TOKEN="fake-token-for-testing" +export INPUT_STYLE_GUIDE="/home/runner/work/meta/meta/.github/copilot-qe-style-guide.md" +export INPUT_DOCS="/home/runner/work/meta/meta/test/qe-style-guide/" +export INPUT_EXTENSIONS="md" +export INPUT_OPENAI_API_KEY="" # Test without AI first +export INPUT_MODEL="gpt-4" +export INPUT_MAX_SUGGESTIONS="20" +export INPUT_CONFIDENCE_THRESHOLD="0.8" +export GITHUB_OUTPUT="/tmp/github_output_test" + +# Create a temporary GitHub output file +echo "" > "$GITHUB_OUTPUT" + +# Test 1: Basic functionality test without AI +echo "=== Test 1: Basic rule-based analysis ===" + +# Mock GitHub context for issue comment +export GITHUB_CONTEXT='{ + "event_name": "issue_comment", + "repository": "QuantEcon/test-repo", + "event": { + "comment": { + "body": "@qe-style-check test-document-with-issues.md" + }, + "issue": { + "number": 123 + } + } +}' + +# Create a simple Python test that imports and tests basic functionality +cat > /tmp/test_style_checker.py << 'EOF' +#!/usr/bin/env python3 +import os +import sys +import json + +# Add the action path to sys.path so we can import the module +action_path = '/home/runner/work/meta/meta/.github/actions/qe-style-guide' +sys.path.insert(0, action_path) + +# Change to the action directory so relative imports work +import os +os.chdir(action_path) + +try: + from process_style_check import StyleGuideChecker + + # Test basic initialization + checker = StyleGuideChecker() + print("✅ StyleGuideChecker initialized successfully") + + # Test style guide loading + style_guide = checker.load_style_guide() + print(f"✅ Style guide loaded: {len(style_guide)} characters") + + # Test comment parsing + mode, target_file = checker.parse_trigger_comment() + print(f"✅ Comment parsed: mode={mode}, target_file={target_file}") + + if mode != 'issue' or target_file != 'test-document-with-issues.md': + print(f"❌ Expected mode='issue', target_file='test-document-with-issues.md', got mode='{mode}', target_file='{target_file}'") + sys.exit(1) + + # Test rule-based analysis + test_content = """ +def test_function(alpha, beta): + return alpha + beta +""" + + result = checker.analyze_with_rules(test_content, style_guide, "test.md") + suggestions = result.get('suggestions', []) + print(f"✅ Rule-based analysis found {len(suggestions)} suggestions") + + # Check that Greek letter suggestions were found + greek_suggestions = [s for s in suggestions if 'alpha' in s.get('original_text', '') or 'beta' in s.get('original_text', '')] + if len(greek_suggestions) >= 1: + print("✅ Greek letter suggestions found as expected") + else: + print("⚠️ No Greek letter suggestions found (this might be expected)") + + print("✅ All basic tests passed!") + +except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +EOF + +# Run the basic test +cd /home/runner/work/meta/meta +python3 /tmp/test_style_checker.py + +echo "" +echo "=== Test 2: File content processing ===" + +# Test reading local file content (since we can't actually call GitHub API) +cat > /tmp/test_file_processing.py << 'EOF' +#!/usr/bin/env python3 +import os +import sys +sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + +try: + from process_style_check import StyleGuideChecker + + checker = StyleGuideChecker() + + # Read a local test file directly (simulate get_file_content) + test_file_path = "/home/runner/work/meta/meta/test/qe-style-guide/test-document-with-issues.md" + + if os.path.exists(test_file_path): + with open(test_file_path, 'r') as f: + content = f.read() + print(f"✅ Successfully read test file: {len(content)} characters") + + # Test analysis + style_guide = checker.load_style_guide() + result = checker.analyze_with_rules(content, style_guide, test_file_path) + suggestions = result.get('suggestions', []) + + print(f"✅ Analysis completed: {len(suggestions)} suggestions found") + + # Print some suggestions for verification + for i, suggestion in enumerate(suggestions[:3]): + print(f" Suggestion {i+1}: {suggestion.get('description', 'No description')}") + + if len(suggestions) > 0: + print("✅ Style issues detected as expected") + else: + print("⚠️ No style issues detected (might indicate test file is too clean)") + else: + print(f"❌ Test file not found: {test_file_path}") + sys.exit(1) + +except Exception as e: + print(f"❌ File processing test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +EOF + +python3 /tmp/test_file_processing.py + +echo "" +echo "=== Test 3: Clean file test ===" + +# Test with clean file +export GITHUB_CONTEXT='{ + "event_name": "issue_comment", + "repository": "QuantEcon/test-repo", + "event": { + "comment": { + "body": "@qe-style-check clean-document.md" + }, + "issue": { + "number": 123 + } + } +}' + +cat > /tmp/test_clean_file.py << 'EOF' +#!/usr/bin/env python3 +import os +import sys +sys.path.insert(0, '/home/runner/work/meta/meta/.github/actions/qe-style-guide') + +try: + from process_style_check import StyleGuideChecker + + checker = StyleGuideChecker() + + # Parse comment for clean file + mode, target_file = checker.parse_trigger_comment() + print(f"✅ Clean file test: mode={mode}, target_file={target_file}") + + # Read clean test file + test_file_path = "/home/runner/work/meta/meta/test/qe-style-guide/clean-document.md" + + if os.path.exists(test_file_path): + with open(test_file_path, 'r') as f: + content = f.read() + + style_guide = checker.load_style_guide() + result = checker.analyze_with_rules(content, style_guide, test_file_path) + suggestions = result.get('suggestions', []) + + print(f"✅ Clean file analysis: {len(suggestions)} suggestions found") + + if len(suggestions) <= 2: # Allow for minor suggestions + print("✅ Clean file test passed - minimal suggestions as expected") + else: + print(f"⚠️ Clean file had more suggestions than expected: {len(suggestions)}") + for suggestion in suggestions: + print(f" - {suggestion.get('description', 'No description')}") + else: + print(f"❌ Clean test file not found: {test_file_path}") + sys.exit(1) + +except Exception as e: + print(f"❌ Clean file test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +EOF + +python3 /tmp/test_clean_file.py + +# Clean up +rm -f /tmp/test_style_checker.py /tmp/test_file_processing.py /tmp/test_clean_file.py /tmp/github_output_test + +echo "" +echo "✅ All QuantEcon Style Guide Action tests completed successfully!" \ No newline at end of file diff --git a/test/qe-style-guide/test-document-with-issues.md b/test/qe-style-guide/test-document-with-issues.md new file mode 100644 index 0000000..8792c54 --- /dev/null +++ b/test/qe-style-guide/test-document-with-issues.md @@ -0,0 +1,53 @@ +# Test Document with Style Issues + +This document contains various style issues that should be detected by the QuantEcon style guide checker. + +## Code Issues + +Here's some Python code with style problems: + +```python +# Greek letters spelled out instead of Unicode +def utility_function(c, alpha=0.5, beta=0.95): + return (c**(1-alpha) - 1) / (1-alpha) * beta + +# More Greek letters +gamma = 0.02 +delta = 0.1 +sigma = 2.0 +theta = 0.8 +rho = 0.9 +epsilon = 1e-6 +``` + +## Writing Issues + +This is a **definition** but should use bold formatting. + +This text has *emphasis* correctly formatted. + +Some more text with emphasis but using _underscores_ instead of asterisks. + +## Heading Issues + +### This Heading Has Too Many Capital Letters And Should Be Fixed + +### another heading with wrong capitalization + +## Math Issues + +Some inline math: $A^T$ should be $A^\top$. + +Matrix notation: +$$ +\begin{pmatrix} +1 & 2 \\ +3 & 4 +\end{pmatrix} +$$ + +Should use square brackets instead. + +## Figure Issues + +Some text describing a figure that should have proper captions and naming. \ No newline at end of file From 31e4d8a30dcd3203d892d073598e5702d40656ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:47:13 +0000 Subject: [PATCH 3/3] Finalize QuantEcon Style Guide Action with comprehensive testing Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .../process_style_check.cpython-312.pyc | Bin 21820 -> 0 bytes .github/workflows/qe-style-guide.yml | 2 +- .github/workflows/test-qe-style-guide.yml | 2 +- .gitignore | 58 ++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) delete mode 100644 .github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc create mode 100644 .gitignore diff --git a/.github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc b/.github/actions/qe-style-guide/__pycache__/process_style_check.cpython-312.pyc deleted file mode 100644 index e03050f5b52a4d9e7dfa87579eac89308196578e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21820 zcmd6PdvqIDdfyD*009Cd_y7q$h7XAZpQ1#)s0YQDDC$8yNXZs02?9Ao5)=r~Gk_#Q zpzUmS8+xn6R5quQvUWu$o0_VAD`u^n0$0+K5;*D}>k{8eZ z4_w@)SZah~X;$TZe%kHz`*4u1cn0B#+By^;haCig&0eF7MqW zVIy|VKIKZ@WSo?InwnM3s%j|CT1)X-phPv2d!D+v>l$@keV)2TZ^*Gh&Os%|We9Vo z(;<&P*f$pNGef~yAIJ2Eye!A`OmbsaINsDX&^!~k#_=4>2qb%gWRGFC*FV8{{E!iv znBatZoIhy#7|9-V(URtrCphWSN}A(dAt-5jy< z0*_D94u@uZ+*rnkQe^Pp!80?BL&Q-qG!zcYHz^nt6{i_jvFdMXU_exyj?;6R5-6;k z$5nU-B{WdN040oaK0MLF6BDcBG^7p)&TcVCi&dVX*f%=4g+g~61iVmA_q!X zNeMHwWX85rEf`n}^ju_ki-3iZGc zDKi~vQdZrsLXn2mX4I6T`B$Jx$Lf`urYy?0rO&KE`SxDw4SGy9L5-;{=2O%h9igYJ z>2k`IvLbJ(ZWd^uER>_%1||1bpwGyf6#Aeh_tsk;KmD@xr_aF-e<7VjKN-UQwuk0Hk_%sHCO zyK9uIa8Sx07(8`$*xf%ceB^Amd-&wBzCkHZel|3G{&=6e|Lj0-U%0Ae!W*0njkb;j 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