Coding standards
Introduced via gitlab-com/support/support-ops/support-ops-project#1963
This document defines coding standards and best practices to follow for the GitLab Customer Support Operations team. By utilizing these guidelines, we ensure consistency, maintainability, simplicity, and better collaboration within the team. All contributors to our projects are expected to follow these guidelines, unless a specific exception is agreed upon.
General Principles
We believe the GitLab core values should guide all our coding decisions:
- Collaboration
- Collaborate with teammates, review each other’s code, and share knowledge.
- Always be open to feedback and improve your code based on it.
- Ensure your contributions are readable and understandable to others on the team
- Results
- Focus on the impact and effectiveness of the code.
- Prioritize working solutions over perfect solutions (but try to avoid shortcuts that undermine quality)
- Efficiency
- Write clear and maintainable code.
- Strive for simplicity without sacrificing functionality.
- Transparency
- Code should be easy to understand and accessible to all.
- Document assumptions, choices, and design decisions clearly when needed.
- Iteration
- Code should be flexible and easy to refactor.
- We aim for continuous improvement and don’t shy away from updating or reworking code when necessary.
Naming Conventions
- Variables
- Use descriptive names that clearly convey the purpose of the variable. If the name of the variable does not clearly convey the purpose, then add a comment on the code to explain it better.
- All variable names should use snake case:
- Examples:
sum_of_pairings,gitlab_user
- Examples:
- Constants
- Use descriptive names that clearly convey the purpose of the constant. If the name of the constant does not clearly convey the purpose, then add a comment on the code to explain it better.
- Constants should be written in upper snake case:
- Examples:
MAX_RETRIES,DEFAULT_TIMEOUT
- Examples:
- Functions and Methods
- Function and method names should describe the action they perform. If the name of the function/method does not clearly convey the purpose, then add a comment on the code to explain it better.
- Function and method names should be written in snake case
- Examples:
my_new_function,check_gitlab_user
- Examples:
- Classes and Modules
- Classes and modules should be written in PascalCase:
- Examples:
AccountBlocked,SupportSuperFormProcessor
- Examples:
- Classes and modules should be written in PascalCase:
- Filenames
- All filenames should use snake case:
- Examples:
process_account,compare_only
- Examples:
- All filenames should use snake case:
Code Formatting
-
Indentation
- Use 2 spaces per indentation level whenever possible.
-
Example:
def self.subscriptions @subscriptions ||= Readiness::GitLab::Namespaces.subscription(client, namespace) end
-
- Use 2 spaces per indentation level whenever possible.
-
Line length
- Keep lines of code to a maximum length of 80-120 characters (aim for 80, maximum is 120). This ensures readability and allows code to fit comfortably in most environments.
-
Blank lines
- Use blank lines to separate functions, classes, and logically related code blocks
- Use a single blank line between method definitions
-
Spacing
- Use a single space between operators (e.g.
+,<,=, etc.)
- Use a single space between operators (e.g.
Comments and Documentation
- Inline comments
- Use inline comments sparingly. Only use them to clarify complex or non-obvious code. If you need a large number of comments, you are probably overcomplicating your code!
- Keep comments up to date and relevant to the code they describe.
Code Complexity
- Avoid deeply nested code. Refactor code to use helper functions or early returns if nesting becomes too complex.
- Limit function size. Each function should have a clear, single responsibility and be relatively small (under 20 lines).
Testing
Always aim for high test coverage! When in doubt, test your code thoroughly!
Version control
-
Commit messages
-
Detail the changes being made
-
Add text to relate it to the issue you are working out of
-
Example:
Adding new attribute "title" to article object Relates to https://gitlab.com/gitlab-com/support/support-ops/support-ops-project/-/issues/1963
-
-
Branches
- The format of a branch should follow the format of
USERNAME-PROJECT-IID, where:USERNAMEis your gitlab.com usernamePROJECTis the project’s slugIIDis the issue’s IID- Example:
- For branch
jcolyeris creating for the work of issuehttps://gitlab.com/gitlab-com/support/support-ops/support-ops-project/-/issues/1963, your branch name should bejcolyer-support-ops-project-1963
- For branch
- The format of a branch should follow the format of
Use semantic versioning
Note
- This is a generalization. For more specifics on versioning, see the documentation page for the item you are working on.
Whenever you need to use version numbers, you should strive to use semantic versioning, which is in the format of MAJOR.MINOR.PATCH.
When increasing the version number, you should use the following to determine which number to increase:
- Increase the
MAJORif you do a sizable refactor - Increase the
MINORif you add or remove functionality - Increase the
PATCHif you are making small changes (wording changes, bug fixes, etc.)
Remember, when increasing a value:
- If increasing
MAJOR, the new values ofMINORandPATCHare 0 - If increasing
MINOR, the new valuePATCHare 0 (andMAJORremaining unchanged) - If increasing
PATCH, the values ofMAJORandMINORremain unchanged
To help you, here are some examples:
| Starting Version | MAJOR update |
MINOR update |
PATCH update |
|---|---|---|---|
1.0.0 |
2.0.0 |
1.1.0 |
1.0.1 |
1.9.127 |
2.0.0 |
1.10.0 |
1.9.128 |
2.99.0 |
3.0.0 |
2.100.0 |
2.99.1 |
9.99.9 |
10.0.0 |
9.100.0 |
9.99.10 |
Using semantic versioning when only two numeric values are allowed
If you are working with something only allowed two numeric values (such as 1.01 or 9.8), you would instead combine the definitions of MINOR and PATCH for the second value. This results in the needed format of xx.yy and allows you to maintain a close semblance to semantic versioning.
Thus, when increasing the version number, you should use the following to determine which number to increase:
- Increase the
xxif you do a sizable refactor - Increase the
yyif you add or remove functionality, or if you are making small changes (wording changes, bug fixes, etc.)
To help you, here are some examples:
| Starting Version | MAJOR update |
MINOR/PATCH update |
|---|---|---|
1.0 |
2.0 |
1.1 |
1.9 |
2.0 |
1.10 |
2.99 |
3.0 |
2.100 |
9.99 |
10.0 |
9.100 |
Error Handling
Whenever possible, have your code gracefully handle errors. Where this is not possible, ensure the errors clearly explain what the problem is (and bonus points if they tell you what to do about it).
Performance Considerations
- Focus on performance only after your code is functional and maintainable. Avoid premature optimization.
- Measure performance impacts using profiling tools and identify bottlenecks before optimizing.
- Use common sense to reduce API calls to our tooling.
- Example: While you could manually fetch every ticket from Zendesk one by one, there is no logical reason to when the Readiness Gem will do this efficiently using the
listmethod.
- Example: While you could manually fetch every ticket from Zendesk one by one, there is no logical reason to when the Readiness Gem will do this efficiently using the
Security
- Validate all inputs to prevent injection attacks (e.g., SQL Injection, XSS).
- Never store passwords, tokens, etc. in plaintext. Use CI/CD variables.
Code Reviews
- All code must undergo peer review before merging. Ensure reviews are thorough and address quality, consistency, and security.
- Embrace constructive feedback and improve the code collaboratively.
Language specific guidelines
This currently focuses on our primary language, ruby. This is a living section and more should be added for other languages we might use in the future.
Ruby
- Whenever possible, try to ensure your code presents no issues when run via the rubocop linter.
Examples to help you get started
Writing a ruby script
When writing a ruby script, it is recommended you start with:
Click to expand
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'base64'
require 'erb'
require 'faraday'
require 'json'
require 'yaml'
class ApiResponseError < StandardError; end
class ApiAuthenticationError < ApiResponseError; end
class ApiForbiddenError < ApiResponseError; end
class ApiNotFoundError < ApiResponseError; end
class ApiRateLimitError < ApiResponseError; end
class ApiServerError < ApiResponseError; end
def debug
ENV.fetch('DEBUG', false)
end
def sandbox?
ENV.fetch('SANDBOX', false).to_s == 'true'
end
def max_retries
@max_retries ||= ENV.fetch('MAX_RETRIES', 3).to_i
end
def base_retry_delay
@base_retry_delay ||= ENV.fetch('BASE_RETRY_DELAY', 2).to_i
end
def request_timeout
@request_timeout ||= ENV.fetch('REQUEST_TIMEOUT', 60).to_i
end
def open_timeout
@open_timeout ||= ENV.fetch('OPEN_TIMEOUT', 15).to_i
end
def page_size
@page_size ||= ENV.fetch('PAGE_SIZE', 100).to_i
end
def handle_response(response, allow404: false)
return response if success_status?(response.status)
return handle_client_error(response.status, allow404) if client_error?(response.status)
return handle_rate_limit(response) if rate_limited?(response.status)
return handle_server_error(response.status) if server_error?(response.status)
raise ApiResponseError, "Unexpected response: HTTP #{response.status}"
end
def success_status?(status)
(200..299).cover?(status)
end
def client_error?(status)
[401, 403, 404].include?(status)
end
def rate_limited?(status)
status == 429
end
def server_error?(status)
(500..599).cover?(status)
end
def handle_client_error(status, allow404)
case status
when 401
raise ApiAuthenticationError, 'Authentication failed - check your credentials'
when 403
raise ApiForbiddenError, 'Access forbidden - insufficient permissions'
when 404
return nil if allow404
raise ApiNotFoundError, 'Resource not found'
end
end
def handle_rate_limit(response)
retry_after = response.headers['retry-after']&.to_i || 60
raise ApiRateLimitError, "Rate limited - retry after #{retry_after}s"
end
def handle_server_error(status)
raise ApiServerError, "Server error: HTTP #{status}"
end
def request_with_retry(client, method, url, params = {}, allow404: false)
attempt = 0
response = nil
begin
attempt += 1
response = execute_request(client, method, url, params, allow404)
rescue ApiRateLimitError, ApiServerError, Faraday::TimeoutError, Faraday::ConnectionFailed => e
handle_retryable_error(e, attempt) && retry
rescue StandardError => e
handle_fatal_error(e, response)
end
end
def execute_request(client, method, url, params, allow404)
response = make_request(client, method, url, params)
handled_response = handle_response(response, allow404: allow404) # rubocop:disable Style/HashSyntax
return nil if handled_response.nil?
handle_body(response.body, response.headers['content-type'])
end
def handle_retryable_error(error, attempt)
return handle_rate_limit_error(error, attempt) if error.is_a? ApiRateLimitError
return retry_request(attempt, error.message) if error.is_a? ApiServerError
return retry_request(attempt, "Timeout: #{error.message}") if error.is_a? Faraday::TimeoutError
return retry_request(attempt, "Connection failed: #{error.message}") if error.is_a? Faraday::ConnectionFailed
true
end
def handle_rate_limit_error(error, attempt)
if error.message.match(/retry after (\d+)s/)
sleep_time = Regexp.last_match(1).to_i
puts "⏳ Rate limited, waiting #{sleep_time}s..."
sleep(sleep_time)
end
retry_request(attempt, error.message)
end
def handle_fatal_error(error, response)
puts "❌ #{format_error_message(error)}"
puts "[DEBUG] BODY: #{response&.body}" if debug && response
print_backtrace(error) if should_print_backtrace?(error)
exit 1
end
def format_error_message(error)
case error
when ApiResponseError
"API Error: #{error.message}"
else
"Unexpected error: #{error.message}"
end
end
NON_BACKTRACE_ERRORS = [
ArgumentError,
ApiAuthenticationError,
ApiForbiddenError,
ApiNotFoundError,
ApiResponseError
].freeze
def should_print_backtrace?(error)
NON_BACKTRACE_ERRORS.none? { |error_class| error.is_a?(error_class) }
end
def print_backtrace(error)
error.backtrace.each { |line| puts " #{line}" }
end
def handle_body(body, content_type = nil)
return body if body.nil? || body.empty?
if content_type&.include?('application/json') || content_type.nil?
JSON.parse(body)
else
body
end
rescue JSON::ParserError
body
end
def make_request(client, method, url, params)
case method.to_sym
when :get, :delete
client.public_send(method.to_sym, url, params)
when :post, :put, :patch
client.public_send(method.to_sym, url) do |r|
r.body = determine_body_type(client, params)
end
else
raise ArgumentError, "Unsupported HTTP method: #{method}"
end
end
def determine_body_type(client, params)
return params if params.is_a?(String)
return '' if params.nil?
content_type = client.headers['Content-Type']&.downcase
return URI.encode_www_form(params) if content_type&.include?('application/x-www-form-urlencoded')
params.to_json
end
def retry_request(attempt, error_msg)
if attempt < max_retries
delay = base_retry_delay * (2**(attempt - 1))
sleep(delay)
true
else
puts "❌ Failed after #{max_retries} attempts: #{error_msg}"
exit 1
end
end
def extract_next_url(next_url)
return nil if next_url.nil? || next_url.empty?
uri = URI.parse(next_url)
relative_url = uri.path
relative_url += "?#{uri.query}" if uri.query
relative_url
rescue => e # rubocop:disable Style/RescueStandardError
puts "❌ Invalid pagination URL: #{e.message}"
exit 1
end
This ensures you can start coding on the very next line and have a good starting point to work from.
Making a request
Using the starting code, you can make an external request like so:
Simple GET request
page_data = request_with_retry(client_variable, :get, url_to_use)
Request with a payload
payload = {
'text' => 'Jason is awesome',
'reason' => 'Cause I said so'
}
page_data = request_with_retry(client_variable, :post, url_to_use, payload)
Simple GET request allowing a 404 response
page_data = request_with_retry(client_variable, :get, url_to_use, allow404: true)
As a more complete example, here we have code to fetch all automations for a Zendesk instance:
Click to expand
print 'Fetching Zendesk automations'
@automations = []
url = "api/v2/automations?page[size]=#{page_size}"
loop do
print '.'
page_data = request_with_retry(zendesk_client, :get, url)
@automations += page_data['automations']
break unless page_data['meta']['has_more']
url = extract_next_url(page_data.dig('links', 'next'))
end
puts 'done! ✅ Successfully fetched Zendesk automations!'
Connecting to Zendesk via ruby
When you need to connect to Zendesk, use the starting point and then add the following:
For Zendesk Global
def base_url
sandbox? ? 'https://gitlab1707170878.zendesk.com' : 'https://gitlab.zendesk.com'
end
def username
return ENV.fetch('SB_ZD_USERNAME') if sandbox?
ENV.fetch('ZD_USERNAME')
end
def token
return ENV.fetch('SB_ZD_TOKEN') if sandbox?
ENV.fetch('ZD_TOKEN')
end
def auth_string
Base64.encode64("#{username}/token:#{token}").gsub("\n", '')
end
def setup_zendesk_client
Faraday.new(base_url) do |f|
f.options.timeout = request_timeout
f.options.open_timeout = open_timeout
f.headers['Content-Type'] = 'application/json'
f.headers['Authorization'] = "Basic #{auth_string}"
end
end
def zendesk_client
@zendesk_client ||= setup_zendesk_client
end
def find_in_collection(collection, attribute, value, collection_name)
print "[DEBUG] Locating #{collection_name} from #{value}..." if debug
item = collection.detect { |obj| obj[attribute].to_s.downcase == value.to_s.downcase }
if item.nil?
puts "❌ Unable to locate matching #{collection_name}" if debug
raise "Cannot find #{collection_name} #{value}"
end
puts "done! ✅ Successfully determined #{collection_name}!" if debug
item
end
For Zendesk US Government
def base_url
sandbox? ? 'https://gitlabfederalsupport1585318082.zendesk.com' : 'https://gitlab-federal-support.zendesk.com'
end
def username
return ENV.fetch('US_SB_ZD_USERNAME') if sandbox?
ENV.fetch('US_ZD_USERNAME')
end
def token
return ENV.fetch('US_SB_ZD_TOKEN') if sandbox?
ENV.fetch('US_ZD_TOKEN')
end
def auth_string
Base64.encode64("#{username}/token:#{token}").gsub("\n", '')
end
def setup_zendesk_client
Faraday.new(base_url) do |f|
f.options.timeout = request_timeout
f.options.open_timeout = open_timeout
f.headers['Content-Type'] = 'application/json'
f.headers['Authorization'] = "Basic #{auth_string}"
end
end
def zendesk_client
@zendesk_client ||= setup_zendesk_client
end
def find_in_collection(collection, attribute, value, collection_name)
print "[DEBUG] Locating #{collection_name} from #{value}..." if debug
item = collection.detect { |obj| obj[attribute].to_s.downcase == value.to_s.downcase }
if item.nil?
puts "❌ Unable to locate matching #{collection_name}" if debug
raise "Cannot find #{collection_name} #{value}"
end
puts "done! ✅ Successfully determined #{collection_name}!" if debug
item
end
Connecting to GitLab.com via ruby
When you need to connect to GitLab, use the starting point and then add the following:
Click to expand
def gitlab_token
ENV.fetch('GL_TOKEN')
end
def setup_gitlab_client
Faraday.new('https://gitlab.com') do |f|
f.options.timeout = request_timeout
f.options.open_timeout = open_timeout
f.headers['Content-Type'] = 'application/json'
f.headers['Authorization'] = "Bearer #{gitlab_token}"
end
end
def gitlab_client
@gitlab_client ||= setup_gitlab_client
end
Connecting to Slack via ruby
When you need to connect to Slack, use the starting point and then add the following:
Click to expand
def slack_client
@slack_client ||= Faraday.new(ENV.fetch('SLACK_URL')) do |f|
f.options.timeout = request_timeout
f.options.open_timeout = open_timeout
f.headers['Content-Type'] = 'application/json'
end
end
Connecting to Salesforce via ruby
When you need to connect to Salesforce, use the starting point and then add the following:
Click to expand
def base_salesforce_url
'https://login.salesforce.com'
end
def instance_url
'https://gitlab.my.salesforce.com'
end
def salesforce_client_id
ENV.fetch('SFDC_CLIENTID')
end
def salesforce_client_secret
ENV.fetch('SFDC_CLIENTSECRET')
end
def salesforce_username
ENV.fetch('SFDC_USERNAME')
end
def salesforce_password
ENV.fetch('SFDC_PASSWORD')
end
def salesforce_security_token
ENV.fetch('SFDC_SECURITYTOKEN')
end
def initial_connection
@initial_connection ||= Faraday.new(base_salesforce_url) do |c|
c.options.timeout = request_timeout
c.options.open_timeout = open_timeout
c.headers['Accept'] = 'application/json'
c.headers['Content-Type'] = 'application/x-www-form-urlencoded'
c.request :url_encoded
end
end
def bearer_token
@bearer_token ||= generate_token_with_retry
end
def salesforce_client
@salesforce_client ||= Faraday.new(instance_url) do |c|
c.options.timeout = request_timeout
c.options.open_timeout = open_timeout
c.headers['Content-Type'] = 'application/json'
c.headers['Authorization'] = "Bearer #{bearer_token}"
end
end
def generate_token_with_retry
data = {
'grant_type' => 'password',
'client_id' => salesforce_client_id,
'client_secret' => salesforce_client_secret,
'username' => salesforce_username,
'password' => "#{salesforce_password}#{salesforce_security_token}"
}
url = 'services/oauth2/token'
page_data = request_with_retry(initial_connection, :post, url, data)
page_data['access_token']
end
Connecting to Mailgun via ruby
When you need to connect to Mailgun, use the starting point and then add the following:
Click to expand
def mailgun_token
ENV.fetch('MAILGUN_KEY')
end
def setup_mailgun_client
Faraday.new("https://api:#{mailgun_token}@api.mailgun.net") do |f|
f.options.timeout = request_timeout
f.options.open_timeout = open_timeout
f.request :multipart
f.request :url_encoded
end
end
def mailgun_client
@mailgun_client ||= setup_mailgun_client
end
7d49549f)
