diff --git a/erpnext_github_integration/api.py b/erpnext_github_integration/api.py index ccebce7..07158c3 100644 --- a/erpnext_github_integration/api.py +++ b/erpnext_github_integration/api.py @@ -20,9 +20,10 @@ def validate_repository(doc, method): def get_repository_dashboard_data(data): """Get dashboard data for Repository doctype""" return { - "heatmap": True, - "heatmap_message": _("This is based on the commits in the repository"), - "fieldname": "repository", # This is the default fieldname + "fieldname": "repository", # main link field for connections + "non_standard_fieldnames": { + "Task": "github_repo" + }, "transactions": [ { "label": _("Issues & PRs"), @@ -30,10 +31,7 @@ def get_repository_dashboard_data(data): }, { "label": _("Project Management"), - "items": [ - {"item": "Project", "fieldname": "repository"}, # Project uses 'repository' field - {"item": "Task", "fieldname": "github_repo"} # Task uses 'github_repo' field - ] + "items": ["Project", "Task"] } ] } @@ -310,9 +308,16 @@ def link_github_user_to_erp(github_username, erp_user): user_doc = frappe.get_doc('User', erp_user) user_doc.github_username = github_username user_doc.save(ignore_permissions=True) - return {'success': True, 'message': _('GitHub username linked successfully')} - except Exception as e: + return { + 'success': True, + 'message': _('GitHub username {0} linked successfully to user {1}').format( + github_username, erp_user) + } + except frappe.ValidationError as e: return {'success': False, 'error': str(e)} + except Exception as e: + frappe.log_error(f'Error linking GitHub user: {str(e)}') + return {'success': False, 'error': _('An error occurred while updating the user')} @frappe.whitelist() def get_repository_statistics(repo_full_name): diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.js b/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.js index f16f3f4..058a240 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.js +++ b/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.js @@ -3,6 +3,8 @@ frappe.ui.form.on("GitHub Settings", { refresh(frm) { + // Update on form refresh + update_auth_fields_visibility(frm); // Test Connection button frm.add_custom_button(__('Test Connection'), function() { if (!frm.doc.personal_access_token) { @@ -22,6 +24,31 @@ frappe.ui.form.on("GitHub Settings", { }); }, __('Actions')); + frm.add_custom_button(__('Bulk Update Github Username'), function() { + if (!frm.doc.personal_access_token) { + frappe.msgprint({ + title: __('Personal Access Token Required'), + indicator: 'red', + message: __('Please set Personal Access Token first in the Personal Access Token field.') + }); + return; + } + + frappe.confirm( + __('This will update GitHub usernames for all users based on their email addresses. This may take several minutes. Continue?'), + function() { + // Proceed with bulk update + update_all_users_github_usernames(); + }, + function() { + frappe.show_alert({ + message: __('Bulk update cancelled'), + indicator: 'blue' + }); + } + ); + }, __('Actions')); + // Fetch All Repositories button frm.add_custom_button(__('Fetch All Repositories'), function() { frappe.call({ @@ -225,6 +252,9 @@ frappe.ui.form.on("GitHub Settings", { // Manage Repository Access button frm.add_custom_button(__('Manage Repository Access'), function() { let repos = []; + let users = []; + + // Fetch repositories first frappe.call({ method: 'frappe.client.get_list', args: { @@ -234,66 +264,88 @@ frappe.ui.form.on("GitHub Settings", { callback: function(r) { if (r.message && r.message.length) { r.message.forEach(repo => { - repos.push({label: repo.full_name, value: repo.full_name}); + repos.push({ label: repo.full_name, value: repo.full_name }); }); - - let d = new frappe.ui.Dialog({ - title: __('Manage Repository Access'), - fields: [ - { - fieldname: 'repository', - fieldtype: 'Select', - label: 'Repository', - options: repos, - reqd: 1 - }, - { - fieldname: 'action', - fieldtype: 'Select', - label: 'Action', - options: 'Add Collaborator\nRemove Collaborator\nAdd Team\nRemove Team', - reqd: 1 - }, - { - fieldname: 'identifier', - fieldtype: 'Data', - label: 'Username/Team Name', - reqd: 1 - }, - { - fieldname: 'permission', - fieldtype: 'Select', - label: 'Permission Level', - options: 'pull\npush\nadmin\nmaintain\ntriage', - default: 'push' - } - ], - primary_action_label: __('Execute'), - primary_action: function(values) { - let action_map = { - 'Add Collaborator': 'add_collaborator', - 'Remove Collaborator': 'remove_collaborator', - 'Add Team': 'add_team', - 'Remove Team': 'remove_team' - }; - - frappe.call({ - method: 'erpnext_github_integration.github_api.manage_repo_access', - args: { - repo_full_name: values.repository, - action: action_map[values.action], - identifier: values.identifier, - permission: values.permission - }, - callback: function(r) { - frappe.msgprint(__('Repository access updated successfully')); - d.hide(); - } + } + + // Fetch users after repos + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'User', + filters: { enabled: 1 }, + fields: ['name', 'full_name'] + }, + callback: function(r2) { + if (r2.message && r2.message.length) { + r2.message.forEach(user => { + users.push({ + label: user.full_name || user.name, + value: user.name + }); }); } - }); - d.show(); - } + + // Now show the dialog + let d = new frappe.ui.Dialog({ + title: __('Manage Repository Access'), + fields: [ + { + fieldname: 'repository', + fieldtype: 'Select', + label: 'Repository', + options: repos, + reqd: 1 + }, + { + fieldname: 'action', + fieldtype: 'Select', + label: 'Action', + options: 'Add Collaborator\nRemove Collaborator\nAdd Team\nRemove Team', + reqd: 1 + }, + { + fieldname: 'identifier', + fieldtype: 'Select', + label: 'Username', + options: users, + reqd: 1 + }, + { + fieldname: 'permission', + fieldtype: 'Select', + label: 'Permission Level', + options: 'pull\npush\nadmin\nmaintain\ntriage', + default: 'push' + } + ], + primary_action_label: __('Execute'), + primary_action: function(values) { + let action_map = { + 'Add Collaborator': 'add_collaborator', + 'Remove Collaborator': 'remove_collaborator', + 'Add Team': 'add_team', + 'Remove Team': 'remove_team' + }; + + frappe.call({ + method: 'erpnext_github_integration.github_api.manage_repo_access', + args: { + repo_full_name: values.repository, + action: action_map[values.action], + identifier: values.identifier, + permission: values.permission + }, + callback: function(r) { + frappe.msgprint(__('Repository access updated successfully')); + d.hide(); + } + }); + } + }); + d.show(); + } + }); } }); }, __('Actions')); @@ -359,17 +411,146 @@ frappe.ui.form.on("GitHub Settings", { }, __('Statistics')); } }, + onload: function(frm) { + // Set initial state + update_auth_fields_visibility(frm); + }, - auth_type(frm) { - // Toggle field visibility based on auth type - if (frm.doc.auth_type === 'Personal Access Token') { - frm.toggle_display('personal_access_token', true); - frm.toggle_display('oauth_client_id', false); - frm.toggle_display('oauth_client_secret', false); - } else if (frm.doc.auth_type === 'OAuth App') { - frm.toggle_display('personal_access_token', false); - frm.toggle_display('oauth_client_id', true); - frm.toggle_display('oauth_client_secret', true); - } + auth_type: function(frm) { + // Update when auth_type changes + update_auth_fields_visibility(frm); } -}); \ No newline at end of file +}); + +function update_auth_fields_visibility(frm) { + // Default to PAT if not set + const auth_type = frm.doc.auth_type || 'Personal Access Token'; + + if (auth_type === 'Personal Access Token') { + frm.set_df_property('personal_access_token', 'hidden', false); + frm.set_df_property('oauth_client_id', 'hidden', true); + frm.set_df_property('oauth_client_secret', 'hidden', true); + } else if (auth_type === 'OAuth App') { + frm.set_df_property('personal_access_token', 'hidden', true); + frm.set_df_property('oauth_client_id', 'hidden', false); + frm.set_df_property('oauth_client_secret', 'hidden', false); + } + + // Refresh the form to apply changes + frm.refresh_fields(); +} + +// Script to bulk update GitHub usernames for all users +function update_all_users_github_usernames() { + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'User', + fields: ['name', 'email', 'github_username', 'full_name'], + filters: [ + ['email', '!=', ''], + ['github_username', '=', ''], + ['enabled', '=', 1] + ], + limit_page_length: 0 + }, + callback: function(r) { + if (r.message && r.message.length > 0) { + let users = r.message; + let processed = 0; + let successCount = 0; + let errorCount = 0; + + // Show progress dialog + let progress_dialog = new frappe.ui.Dialog({ + title: __('Updating GitHub Usernames'), + fields: [ + { + fieldname: 'progress', + fieldtype: 'HTML', + options: `
+
${__('Processing 0 of ' + users.length + ' users...')}
+
+
+
+
` + } + ] + }); + + progress_dialog.show(); + + // Process users with delay to avoid rate limiting + users.forEach((user, index) => { + setTimeout(() => { + frappe.call({ + method: 'erpnext_github_integration.github_api.get_github_username_by_email', + args: { + email: user.email + }, + callback: function(response) { + processed++; + + // Update progress + let progressPercent = (processed / users.length) * 100; + progress_dialog.fields_dict.progress.$wrapper.html(` +
+
${__('Processing ' + processed + ' of ' + users.length + ' users...')}
+
+
+
+
+ ${__('Success:')} ${successCount} | ${__('Errors:')} ${errorCount} +
+
+ `); + + if (response.message && response.message.success) { + frappe.call({ + method: 'erpnext_github_integration.api.link_github_user_to_erp', + args: { + erp_user: user.name, + github_username: response.message.github_username + }, + callback: function(linkResponse) { + if (linkResponse.message && linkResponse.message.success) { + console.log(`Updated ${user.name} with GitHub username: ${response.message.github_username}`); + successCount++; + } else { + console.error(__('Failed to update {0}: {1}', [user.name, linkResponse.message?.error || 'Unknown error'])); + errorCount++; + } + + checkCompletion(); + } + }); + } else { + console.error(__('Failed to fetch GitHub username for {0}: {1}', [user.email, response.message?.error || 'Unknown error'])); + errorCount++; + checkCompletion(); + } + + function checkCompletion() { + if (processed === users.length) { + progress_dialog.hide(); + frappe.msgprint({ + title: __('Update Complete'), + indicator: 'green', + message: __(` + Processed: ${processed} users
+ Success: ${successCount}
+ Errors: ${errorCount} + `) + }); + } + } + } + }); + }, index * 1500); // 1.5 second delay between requests to avoid GitHub rate limiting + }); + } else { + frappe.msgprint(__('No users found without GitHub usernames')); + } + } + }); +} \ No newline at end of file diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.json b/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.json index a42a5c8..38e7f3b 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.json +++ b/erpnext_github_integration/erpnext_github_integration/doctype/github_settings/github_settings.json @@ -12,8 +12,6 @@ "column_break_nwhs", "default_organization", "default_visibility", - "sync_interval_minutes", - "rate_limit_threshold", "last_sync", "enabled" ], @@ -59,16 +57,6 @@ "label": "Default Visibility", "options": "Public\nPrivate" }, - { - "fieldname": "sync_interval_minutes", - "fieldtype": "Int", - "label": "Sync Interval Minutes" - }, - { - "fieldname": "rate_limit_threshold", - "fieldtype": "Int", - "label": "Rate Limit Threshold" - }, { "fieldname": "last_sync", "fieldtype": "Datetime", @@ -83,7 +71,7 @@ ], "issingle": 1, "links": [], - "modified": "2025-08-20 11:42:40.710361", + "modified": "2025-08-21 14:54:53.501895", "modified_by": "Administrator", "module": "Erpnext Github Integration", "name": "GitHub Settings", diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.js b/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.js index 63a9436..2e2a229 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.js +++ b/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.js @@ -149,26 +149,52 @@ frappe.ui.form.on("Repository", { frm.add_custom_button(__('Show Activity'), function() { frappe.call({ method: 'erpnext_github_integration.github_api.get_repository_activity', - args: {repo_full_name: frm.doc.full_name, days: 30}, + args: {repository: frm.doc.full_name, days: 30}, callback: function(r) { - if (r.message) { + if (r.message && !r.message.error) { let activity = r.message; let html = '
'; - if (activity.commits && activity.commits.length) { + // Summary statistics + html += `
+ Activity Summary (Last ${activity.period_days} days):
+ Commits: ${activity.commits} | Issues: ${activity.issues} | Pull Requests: ${activity.pulls} +
`; + + // Recent commits + if (activity.details && activity.details.commits && activity.details.commits.length) { html += '
Recent Commits
'; + } else { + html += '

No recent commits

'; + } + + // Recent issues + if (activity.details && activity.details.issues && activity.details.issues.length) { + html += '
Recent Issues
'; } - if (activity.events && activity.events.length) { - html += '
Recent Events
'; let d = new frappe.ui.Dialog({ - title: __('Repository Activity (Last 30 days)'), + title: __('Repository Activity (Last {0} days)', [activity.period_days]), fields: [{ fieldname: 'activity', fieldtype: 'HTML', @@ -185,6 +211,8 @@ frappe.ui.form.on("Repository", { size: 'large' }); d.show(); + } else { + frappe.msgprint(__('Failed to load activity: {0}', [r.message ? r.message.error : 'Unknown error'])); } } }); diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.json b/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.json index 98abc4b..90c49e0 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.json +++ b/erpnext_github_integration/erpnext_github_integration/doctype/repository/repository.json @@ -10,6 +10,7 @@ "project", "is_synced", "last_synced", + "enabled", "column_break_dsmh", "repo_name", "repo_owner", @@ -103,10 +104,16 @@ "fieldname": "member_section", "fieldtype": "Section Break", "label": "Member" + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" } ], "links": [], - "modified": "2025-08-20 15:52:23.049815", + "modified": "2025-08-21 11:56:17.540867", "modified_by": "Administrator", "module": "Erpnext Github Integration", "name": "Repository", diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/repository_issue/repository_issue.json b/erpnext_github_integration/erpnext_github_integration/doctype/repository_issue/repository_issue.json index 656a244..c7686eb 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/repository_issue/repository_issue.json +++ b/erpnext_github_integration/erpnext_github_integration/doctype/repository_issue/repository_issue.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "field:title", + "autoname": "format:{repository}-#{issue_number}", "creation": "2025-08-12 18:03:45.384921", "doctype": "DocType", "engine": "InnoDB", @@ -31,15 +31,16 @@ { "fieldname": "issue_number", "fieldtype": "Int", + "in_filter": 1, "in_list_view": 1, + "in_standard_filter": 1, "label": "Issue Number", "reqd": 1 }, { "fieldname": "title", "fieldtype": "Data", - "label": "Title", - "unique": 1 + "label": "Title" }, { "fieldname": "body", @@ -94,11 +95,11 @@ } ], "links": [], - "modified": "2025-08-20 15:55:01.486955", + "modified": "2025-08-21 14:06:23.824247", "modified_by": "Administrator", "module": "Erpnext Github Integration", "name": "Repository Issue", - "naming_rule": "By fieldname", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext_github_integration/erpnext_github_integration/doctype/repository_pull_request/repository_pull_request.json b/erpnext_github_integration/erpnext_github_integration/doctype/repository_pull_request/repository_pull_request.json index 284d158..53bdd42 100644 --- a/erpnext_github_integration/erpnext_github_integration/doctype/repository_pull_request/repository_pull_request.json +++ b/erpnext_github_integration/erpnext_github_integration/doctype/repository_pull_request/repository_pull_request.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "field:title", + "autoname": "format:{repository}-#{pr_number}", "creation": "2025-08-12 18:03:44.866127", "doctype": "DocType", "engine": "InnoDB", @@ -26,8 +26,8 @@ { "fieldname": "title", "fieldtype": "Data", - "label": "Title", - "unique": 1 + "in_list_view": 1, + "label": "Title" }, { "fieldname": "repository", @@ -40,7 +40,9 @@ { "fieldname": "pr_number", "fieldtype": "Int", + "in_filter": 1, "in_list_view": 1, + "in_standard_filter": 1, "label": "PR Number", "reqd": 1 }, @@ -112,11 +114,11 @@ } ], "links": [], - "modified": "2025-08-20 15:54:43.666661", + "modified": "2025-08-21 14:46:22.038600", "modified_by": "Administrator", "module": "Erpnext Github Integration", "name": "Repository Pull Request", - "naming_rule": "By fieldname", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext_github_integration/github_api.py b/erpnext_github_integration/github_api.py index 60ea711..deb5cdd 100644 --- a/erpnext_github_integration/github_api.py +++ b/erpnext_github_integration/github_api.py @@ -1,6 +1,7 @@ import frappe, json from frappe import _ import datetime +from datetime import datetime, timedelta from dateutil import parser from .github_client import github_request @@ -63,6 +64,35 @@ def test_connection(): except Exception as e: return {'success': False, 'error': str(e)} +@frappe.whitelist() +def get_github_username_by_email(email): + """Fetch GitHub username from GitHub API using email""" + settings = frappe.get_single('GitHub Settings') + token = settings.get_password('personal_access_token') + + if not token: + return {'success': False, 'error': 'GitHub Personal Access Token not configured'} + + try: + # Use github_request to search for users by email + search_results = github_request('GET', f'/search/users?q={email}+in:email', token) + + if search_results and search_results.get('total_count', 0) > 0 and search_results.get('items'): + # Return the first matching username + return { + 'success': True, + 'github_username': search_results['items'][0]['login'], + 'total_results': search_results['total_count'] + } + else: + return { + 'success': False, + 'error': f'No GitHub user found with email: {email}' + } + + except Exception as e: + return {'success': False, 'error': str(e)} + @frappe.whitelist() def fetch_all_repositories(organization=None): """Fetch all repositories from GitHub and create/update them in ERPNext""" @@ -302,12 +332,14 @@ def sync_repo(repository): # Clear and update members repo_doc.set('members_table', []) for m in members: + user_info = github_request('GET', f"/users/{m.get('login')}", token) + m_email = user_info.get("email") or "" repo_doc.append('members_table', { 'repo_full_name': repo_full, 'github_username': m.get('login'), 'github_id': str(m.get('id', '')), 'role': 'maintainer' if m.get('permissions', {}).get('admin') else 'member', - 'email': m.get('email') or '' + 'email': m_email or '' }) repo_doc.save(ignore_permissions=True) @@ -572,14 +604,15 @@ def sync_repo_members(repo_full_name): try: repo_doc = frappe.get_doc('Repository', {'full_name': repo_full_name}) repo_doc.set('members_table', []) - for m in members or []: + user_info = github_request('GET', f"/users/{m.get('login')}", token) + m_email = user_info.get("email") or "" repo_doc.append('members_table', { 'repo_full_name': repo_full_name, 'github_username': m.get('login'), 'github_id': str(m.get('id', '')), 'role': 'maintainer' if m.get('permissions', {}).get('admin') else 'member', - 'email': m.get('email') or '' + 'email': m_email or '' }) repo_doc.save(ignore_permissions=True) @@ -594,19 +627,24 @@ def sync_repo_members(repo_full_name): proj.set('project_users', []) for m in members or []: + user_info = github_request('GET', f"/users/{m.get('login')}", token) + m_email = user_info.get("email") or "" username = m.get('login') erp_user = None # Try to find matching ERP user try: - user_doc = frappe.get_doc('User', {'github_username': username}) - erp_user = user_doc.name + user_name = frappe.db.get_value("User", {"github_username": username}, "name") + erp_user = user_name except Exception: # Try to find by email if available - if m.get('email'): + if m_email: try: - user_doc = frappe.get_doc('User', m.get('email')) - erp_user = user_doc.name + user_name = frappe.db.get_value("User", {"email": m_email}, "name") + if user_name: + user_doc = frappe.get_doc("User", user_name) + user_doc.github_username = username + user_doc.save(ignore_permissions=True) except Exception: pass @@ -667,33 +705,72 @@ def sync_all_repositories(): except Exception as e: results['failed'] += 1 frappe.log_error(message=str(e), title=f'GitHub Sync Error - {r.get("full_name")}') - + + settings = frappe.get_single("GitHub Settings") + settings.last_sync = frappe.utils.now() + settings.save(ignore_permissions=True) + return results @frappe.whitelist() -def get_repository_activity(repo_full_name, days=30): +def get_repository_activity(repository, days=30): """Get recent activity for a repository""" - settings = frappe.get_single('GitHub Settings') - token = settings.get_password('personal_access_token') - if not token: - frappe.throw(_('GitHub Personal Access Token not configured in GitHub Settings')) - - from datetime import datetime, timedelta - since = (datetime.now() - timedelta(days=days)).isoformat() - try: - commits = github_request('GET', f"/repos/{repo_full_name}/commits", token, - params={'since': since, 'per_page': 50}) - events = github_request('GET', f"/repos/{repo_full_name}/events", token, - params={'per_page': 50}) + settings = frappe.get_single('GitHub Settings') + token = settings.get_password('personal_access_token') + + # Validate and convert days + try: + days_int = int(days) + except (ValueError, TypeError): + days_int = 30 # Default fallback + + # Ensure days is within reasonable bounds + days_int = max(1, min(days_int, 365)) # Between 1 and 365 days + + # Calculate since date + since = (datetime.now() - timedelta(days=days_int)).isoformat() + + # Get activity data + commits = github_request( + 'GET', + f'/repos/{repository}/commits', + token, + params={'since': since, 'per_page': 50} + ) or [] + + issues = github_request( + 'GET', + f'/repos/{repository}/issues', + token, + params={'since': since, 'state': 'all', 'per_page': 20} + ) or [] + + pulls = github_request( + 'GET', + f'/repos/{repository}/pulls', + token, + params={'since': since, 'state': 'all', 'per_page': 20} + ) or [] + + # Filter out pull requests from issues (GitHub API returns PRs in issues) + actual_issues = [issue for issue in issues if 'pull_request' not in issue] return { - 'commits': commits or [], - 'events': events or [], - 'period_days': days + 'commits': len(commits), + 'issues': len(actual_issues), + 'pulls': len(pulls), + 'period_days': days_int, + 'details': { + 'commits': commits[:10], # Return first 10 for preview + 'issues': actual_issues[:10], + 'pulls': pulls[:10] + } } + except Exception as e: - frappe.throw(_('Failed to get repository activity: {0}').format(str(e))) + frappe.logger().error(f"Error getting repository activity: {str(e)}") + return {'error': str(e)} @frappe.whitelist() def create_repository_webhook(repo_full_name, webhook_url=None, events=None):