class AsanaPlugin(CorePluginMixin, IssuePlugin2): description = DESCRIPTION slug = "asana" title = "Asana" conf_title = title conf_key = "asana" auth_provider = "asana" required_field = "workspace" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to an Asana ticket in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Asana tickets. """, IntegrationFeatures.ISSUE_BASIC, ), ] def get_group_urls(self): return super().get_group_urls() + [ url( r"^autocomplete", IssueGroupActionEndpoint.as_view(view_method_name="view_autocomplete", plugin=self), ) ] def is_configured(self, request, project, **kwargs): return bool(self.get_option("workspace", project)) def has_workspace_access(self, workspace, choices): for c, _ in choices: if workspace == c: return True return False def get_workspace_choices(self, workspaces): return [(w["gid"], w["name"]) for w in workspaces["data"]] def get_new_issue_fields(self, request, group, event, **kwargs): fields = super().get_new_issue_fields(request, group, event, **kwargs) client = self.get_client(request.user) workspaces = client.get_workspaces() workspace_choices = self.get_workspace_choices(workspaces) workspace = self.get_option("workspace", group.project) if workspace and not self.has_workspace_access(workspace, workspace_choices): workspace_choices.append((workspace, workspace)) # use labels that are more applicable to asana for field in fields: if field["name"] == "title": field["label"] = "Name" if field["name"] == "description": field["label"] = "Notes" field["required"] = False return ( [ { "name": "workspace", "label": "Asana Workspace", "default": workspace, "type": "select", "choices": workspace_choices, "readonly": True, } ] + fields + [ { "name": "project", "label": "Project", "type": "select", "has_autocomplete": True, "required": False, "placeholder": "Start typing to search for a project", }, { "name": "assignee", "label": "Assignee", "type": "select", "has_autocomplete": True, "required": False, "placeholder": "Start typing to search for a user", }, ] ) def get_link_existing_issue_fields(self, request, group, event, **kwargs): return [ { "name": "issue_id", "label": "Task", "default": "", "type": "select", "has_autocomplete": True, }, { "name": "comment", "label": "Comment", "default": absolute_uri( group.get_absolute_url(params={"referrer": "asana_plugin"}) ), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the Asana issue."), "required": False, }, ] def get_client(self, user): auth = self.get_auth_for_user(user=user) if auth is None: raise PluginIdentityRequired(ERR_AUTH_NOT_CONFIGURED) return AsanaClient(auth=auth) def error_message_from_json(self, data): errors = data.get("errors") if errors: return " ".join(e["message"] for e in errors) return "unknown error" def create_issue(self, request, group, form_data, **kwargs): client = self.get_client(request.user) try: response = client.create_issue( workspace=self.get_option("workspace", group.project), data=form_data ) except Exception as e: self.raise_error(e, identity=client.auth) return response["data"]["gid"] def link_issue(self, request, group, form_data, **kwargs): client = self.get_client(request.user) try: issue = client.get_issue(issue_id=form_data["issue_id"])["data"] except Exception as e: self.raise_error(e, identity=client.auth) comment = form_data.get("comment") if comment: try: client.create_comment(issue["gid"], {"text": comment}) except Exception as e: self.raise_error(e, identity=client.auth) return {"title": issue["name"]} def get_issue_label(self, group, issue_id, **kwargs): return "Asana Issue" def get_issue_url(self, group, issue_id, **kwargs): return "https://app.asana.com/0/0/%s" % issue_id def validate_config(self, project, config, actor): """ ``` if config['foo'] and not config['bar']: raise PluginError('You cannot configure foo with bar') return config ``` """ try: int(config["workspace"]) except ValueError as exc: self.logger.exception(str(exc)) raise PluginError("Non-numeric workspace value") return config def get_config(self, *args, **kwargs): user = kwargs["user"] try: client = self.get_client(user) except PluginIdentityRequired as e: self.raise_error(e) try: workspaces = client.get_workspaces() except HTTPError as e: if ( e.response.status_code == 400 and e.response.url == "https://app.asana.com/-/oauth_token" ): raise PluginIdentityRequired(ERR_BEARER_EXPIRED) raise workspace_choices = self.get_workspace_choices(workspaces) workspace = self.get_option("workspace", kwargs["project"]) # check to make sure the current user has access to the workspace helptext = None if workspace and not self.has_workspace_access(workspace, workspace_choices): workspace_choices.append((workspace, workspace)) helptext = ( "This plugin has been configured for an Asana workspace " "that either you don't have access to or doesn't " "exist. You can edit the configuration, but you will not " "be able to change it back to the current configuration " "unless a teammate grants you access to the workspace in Asana." ) return [ { "name": "workspace", "label": "Workspace", "type": "select", "choices": workspace_choices, "default": workspace or workspaces["data"][0]["gid"], "help": helptext, } ] def view_autocomplete(self, request, group, **kwargs): field = request.GET.get("autocomplete_field") query = request.GET.get("autocomplete_query") client = self.get_client(request.user) workspace = self.get_option("workspace", group.project) results = [] field_name = field if field == "issue_id": field_name = "task" elif field == "assignee": field_name = "user" try: response = client.search(workspace, field_name, query.encode("utf-8")) except Exception as e: return Response( {"error_type": "validation", "errors": [{"__all__": self.message_from_error(e)}]}, status=400, ) else: results = [ {"text": "(#{}) {}".format(i["gid"], i["name"]), "id": i["gid"]} for i in response.get("data", []) ] return Response({field: results})
class TwilioPlugin(CorePluginMixin, NotificationPlugin): author = "Matt Robenolt" author_url = "https://github.com/mattrobenolt" version = sentry.VERSION description = DESCRIPTION resource_links = ( ( "Documentation", "https://github.com/getsentry/sentry/blob/master/src/sentry_plugins/twilio/Twilio_Instructions.md", ), ("Report Issue", "https://github.com/getsentry/sentry/issues"), ( "View Source", "https://github.com/getsentry/sentry/tree/master/src/sentry_plugins/twilio", ), ("Twilio", "https://www.twilio.com/"), ) slug = "twilio" title = _("Twilio (SMS)") conf_title = title conf_key = "twilio" required_field = "account_sid" project_conf_form = TwilioConfigurationForm feature_descriptions = [ FeatureDescription( """ Set up SMS notifications to be sent to your mobile device via Twilio. """, IntegrationFeatures.MOBILE, ), FeatureDescription( """ Configure Sentry rules to trigger notifications based on conditions you set. """, IntegrationFeatures.ALERT_RULE, ), ] def is_configured(self, project, **kwargs): return all([ self.get_option(o, project) for o in ("account_sid", "auth_token", "sms_from", "sms_to") ]) def get_send_to(self, *args, **kwargs): # This doesn't depend on email permission... stuff. return True def error_message_from_json(self, data): code = data.get("code") message = data.get("message") more_info = data.get("more_info") error_message = "%s - %s %s" % (code, message, more_info) if message: return error_message return None def notify_users(self, group, event, **kwargs): if not self.is_configured(group.project): return project = group.project body = "Sentry [{0}] {1}: {2}".format( project.name.encode("utf-8"), event.group.get_level_display().upper().encode("utf-8"), event.title.encode("utf-8").splitlines()[0], ) body = body[:MAX_SMS_LENGTH] client = self.get_client(group.project) payload = {"From": client.sms_from, "Body": body} errors = [] for phone in client.sms_to: if not phone: continue try: # TODO: Use API client with raise_error phone = clean_phone(phone) payload = payload.copy() payload["To"] = phone client.request(payload) except Exception as e: errors.append(e) if errors: self.raise_error(errors[0]) def get_client(self, project): account_sid = self.get_option("account_sid", project) auth_token = self.get_option("auth_token", project) sms_from = clean_phone(self.get_option("sms_from", project)) sms_to = self.get_option("sms_to", project) sms_to = split_sms_to(sms_to) return TwilioApiClient(account_sid, auth_token, sms_from, sms_to)
class SplunkPlugin(CorePluginMixin, DataForwardingPlugin): title = "Splunk" slug = "splunk" description = DESCRIPTION conf_key = "splunk" resource_links = [("Splunk Setup Instructions", SETUP_URL)] + CorePluginMixin.resource_links required_field = "instance" project_token = None project_index = None project_source = None project_instance = None host = None feature_descriptions = [ FeatureDescription( """ Forward Sentry errors and events to Splunk. """, IntegrationFeatures.DATA_FORWARDING, ) ] def get_rate_limit(self): # number of requests, number of seconds (window) return (1000, 1) def get_client(self): return SplunkApiClient(self.project_instance, self.project_token) def get_config(self, project, **kwargs): return [ { "name": "instance", "label": "Instance URL", "type": "url", "required": True, "help": "The HTTP Event Collector endpoint for your Splunk instance.", "placeholder": "e.g. https://input-foo.cloud.splunk.com:8088", }, { "name": "index", "label": "Index", "type": "string", "required": True, "default": "main", }, { "name": "source", "label": "Source", "type": "string", "required": True, "default": "sentry", }, get_secret_field_config( name="token", label="Token", secret=self.get_option("token", project) ), ] def get_host_for_splunk(self, event): host = event.get_tag("server_name") if host: return host user_interface = event.interfaces.get("sentry.interfaces.User") if user_interface: host = user_interface.ip_address if host: return host return None def get_event_payload_properties(self, event): props = { "event_id": event.event_id, "issue_id": event.group_id, "project_id": event.project.slug, "transaction": event.get_tag("transaction") or "", "release": event.get_tag("sentry:release") or "", "environment": event.get_tag("environment") or "", "type": event.get_event_type(), } props["tags"] = [[k.format(tagstore.get_standardized_key(k)), v] for k, v in event.tags] for key, value in event.interfaces.items(): if key == "request": headers = value.headers if not isinstance(headers, dict): headers = dict(headers or ()) props.update( { "request_url": value.url, "request_method": value.method, "request_referer": headers.get("Referer", ""), } ) elif key == "exception": exc = value.values[0] props.update({"exception_type": exc.type, "exception_value": exc.value}) elif key == "logentry": props.update({"message": value.formatted or value.message}) elif key in ("csp", "expectct", "expectstable", "hpkp"): props.update( { "{}_{}".format(key.rsplit(".", 1)[-1].lower(), k): v for k, v in value.to_json().items() } ) elif key == "user": user_payload = {} if value.id: user_payload["user_id"] = value.id if value.email: user_payload["user_email_hash"] = md5_text(value.email).hexdigest() if value.ip_address: user_payload["user_ip_trunc"] = anonymize_ip(value.ip_address) if user_payload: props.update(user_payload) return props def initialize_variables(self, event): self.project_token = self.get_option("token", event.project) self.project_index = self.get_option("index", event.project) self.project_instance = self.get_option("instance", event.project) self.host = self.get_host_for_splunk(event) if self.project_instance and not self.project_instance.endswith("/services/collector"): self.project_instance = self.project_instance.rstrip("/") + "/services/collector" self.project_source = self.get_option("source", event.project) or "sentry" def get_rl_key(self, event): return f"{self.conf_key}:{md5_text(self.project_token).hexdigest()}" def is_ratelimited(self, event): if super().is_ratelimited(event): metrics.incr( "integrations.splunk.forward-event.rate-limited", tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, "event_type": event.get_event_type(), }, ) return True return False def get_event_payload(self, event): payload = { "time": int(event.datetime.strftime("%s")), "source": self.project_source, "index": self.project_index, "event": self.get_event_payload_properties(event), } return payload def forward_event(self, event, payload): if not (self.project_token and self.project_index and self.project_instance): metrics.incr( "integrations.splunk.forward-event.unconfigured", tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, "event_type": event.get_event_type(), }, ) return False if self.host: payload["host"] = self.host client = self.get_client() try: # https://docs.splunk.com/Documentation/Splunk/7.2.3/Data/TroubleshootHTTPEventCollector client.request(payload) metrics.incr( "integrations.splunk.forward-event.success", tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, "event_type": event.get_event_type(), }, ) return True except Exception as exc: metric = "integrations.splunk.forward-event.error" metrics.incr( metric, tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, "event_type": event.get_event_type(), }, ) logger.info( metric, extra={ "instance": self.project_instance, "project_id": event.project_id, "organization_id": event.project.organization_id, "error": str(exc), }, ) if isinstance( exc, ( ApiHostError, ApiTimeoutError, ), ): # The above errors are already handled by the API client. # Just log and return. return False if isinstance(exc, ApiError) and exc.code == 403: # 403s are not errors or actionable for us do not re-raise return False raise
def dispatch(self, request, pipeline): if "name" in request.POST: pipeline.bind_state("name", request.POST["name"]) return pipeline.next_step() return HttpResponse(self.TEMPLATE) DESCRIPTION = """ This is an example integration. Descriptions support _markdown rendering_. """ FEATURES = [ FeatureDescription( "This is a feature description. Also *supports markdown*", IntegrationFeatures.ISSUE_SYNC) ] metadata = IntegrationMetadata( description=DESCRIPTION.strip(), features=FEATURES, author="The Sentry Team", noun="example", issue_url="https://github.com/getsentry/sentry/issues/new", source_url="https://github.com/getsentry/sentry", aspects={}, ) class ExampleIntegration(IntegrationInstallation, IssueSyncMixin):
from .client import VstsApiClient from .repository import VstsRepositoryProvider from .webhooks import WorkItemWebhook DESCRIPTION = """ Connect your Sentry organization to one or more of your Azure DevOps organizations. Get started streamlining your bug squashing workflow by unifying your Sentry and Azure DevOps organization together. """ FEATURES = [ FeatureDescription( """ Authorize repositories to be added to your Sentry organization to augment sentry issues with commit data with [deployment tracking](https://docs.sentry.io/learn/releases/). """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create and link Sentry issue groups directly to a Azure DevOps work item in any of your projects, providing a quick way to jump from Sentry bug to tracked work item! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Automatically synchronize comments and assignees to and from Azure DevOps. Don't get confused who's fixing what, let us handle ensuring your issues and work
class HerokuPlugin(CorePluginMixin, ReleaseTrackingPlugin): author = "Sentry Team" author_url = "https://github.com/getsentry" title = "Heroku" slug = "heroku" description = "Integrate Heroku release tracking." required_field = "repository" feature_descriptions = [ FeatureDescription( """ Integrate Heroku release tracking. """, IntegrationFeatures.DEPLOYMENT, ) ] def configure(self, project, request): return react_plugin_config(self, project, request) def can_enable_for_projects(self): return True def can_configure_for_project(self, project): return True def has_project_conf(self): return True def get_conf_key(self): return "heroku" def get_config(self, project, **kwargs): repo_list = list(Repository.objects.filter(organization_id=project.organization_id)) if not ProjectOption.objects.get_value(project=project, key="heroku:repository"): choices = [("", "select a repo")] else: choices = [] choices.extend([(repo.name, repo.name) for repo in repo_list]) return [ { "name": "repository", "label": "Respository", "type": "select", "required": True, "choices": choices, "help": "Select which repository you would like to be associated with this project", }, { "name": "environment", "label": "Deploy Environment", "type": "text", "required": False, "default": "production", "help": "Specify an environment name for your Heroku deploys", }, ] def get_release_doc_html(self, hook_url): return """ <p>Add Sentry as a deploy hook to automatically track new releases.</p> <pre class="clippy">heroku addons:create deployhooks:http --url={hook_url}</pre> """.format( hook_url=hook_url ) def get_release_hook(self): return HerokuReleaseHook
from sentry.utils.compat import filter, map from sentry.utils import json from .client import gen_aws_client from .utils import parse_arn, get_index_of_sentry_layer, get_aws_node_arn logger = logging.getLogger("sentry.integrations.aws_lambda") DESCRIPTION = """ The AWS Lambda integration will automatically instrument your Lambda functions without any code changes. All you need to do is run a CloudFormation stack that we provide to get started. """ FEATURES = [ FeatureDescription( """ Instrument your serverless code automatically. """, IntegrationFeatures.SERVERLESS, ), ] metadata = IntegrationMetadata( description=DESCRIPTION.strip(), features=FEATURES, author="The Sentry Team", noun=_("Installation"), issue_url="https://github.com/getsentry/sentry/issues/new", source_url= "https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/aws_lambda", aspects={}, )
class JiraPlugin(CorePluginMixin, IssuePlugin2): description = "Integrate JIRA issues by linking a project." slug = "jira" title = "JIRA" conf_title = title conf_key = slug required_field = "username" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to a Jira ticket in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ) ] def get_group_urls(self): _patterns = super().get_group_urls() _patterns.append( url( r"^autocomplete", IssueGroupActionEndpoint.as_view( view_method_name="view_autocomplete", plugin=self), )) return _patterns def is_configured(self, request: Request, project, **kwargs): if not self.get_option("default_project", project): return False return True def get_group_description(self, request: Request, group, event): # mostly the same as parent class, but change ``` to {code} output = [ absolute_uri( group.get_absolute_url(params={"referrer": "jira_plugin"})) ] body = self.get_group_body(request, group, event) if body: output.extend(["", "{code}", body, "{code}"]) return "\n".join(output) def build_dynamic_field(self, group, field_meta): """ Builds a field based on JIRA's meta field information """ schema = field_meta["schema"] # set up some defaults for form fields fieldtype = "text" fkwargs = { "label": field_meta["name"], "required": field_meta["required"] } # override defaults based on field configuration if (schema["type"] in ["securitylevel", "priority"] or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["select"]): fieldtype = "select" fkwargs["choices"] = self.make_choices( field_meta.get("allowedValues")) elif field_meta.get("autoCompleteUrl") and ( schema.get("items") == "user" or schema["type"] == "user"): fieldtype = "select" sentry_url = f"/api/0/issues/{group.id}/plugins/{self.slug}/autocomplete" fkwargs["url"] = "{}?jira_url={}".format( sentry_url, quote_plus(field_meta["autoCompleteUrl"]), ) fkwargs["has_autocomplete"] = True fkwargs["placeholder"] = "Start typing to search for a user" elif schema["type"] in ["timetracking"]: # TODO: Implement timetracking (currently unsupported alltogether) return None elif schema.get("items") in ["worklog", "attachment"]: # TODO: Implement worklogs and attachments someday return None elif schema["type"] == "array" and schema["items"] != "string": fieldtype = "select" fkwargs.update({ "multiple": True, "choices": self.make_choices(field_meta.get("allowedValues")), "default": [], }) # break this out, since multiple field types could additionally # be configured to use a custom property instead of a default. if schema.get("custom"): if schema["custom"] == JIRA_CUSTOM_FIELD_TYPES["textarea"]: fieldtype = "textarea" fkwargs["type"] = fieldtype return fkwargs def get_issue_type_meta(self, issue_type, meta): issue_types = meta["issuetypes"] issue_type_meta = None if issue_type: matching_type = [t for t in issue_types if t["id"] == issue_type] issue_type_meta = matching_type[0] if len( matching_type) > 0 else None # still no issue type? just use the first one. if not issue_type_meta: issue_type_meta = issue_types[0] return issue_type_meta def get_new_issue_fields(self, request: Request, group, event, **kwargs): fields = super().get_new_issue_fields(request, group, event, **kwargs) jira_project_key = self.get_option("default_project", group.project) client = self.get_jira_client(group.project) try: meta = client.get_create_meta_for_project(jira_project_key) except ApiError as e: raise PluginError( f"JIRA responded with an error. We received a status code of {e.code}" ) except ApiUnauthorized: raise PluginError( "JIRA returned: Unauthorized. " "Please check your username, password, " "instance and project in your configuration settings.") if not meta: raise PluginError("Error in JIRA configuration, no projects " "found for user %s." % client.username) # check if the issuetype was passed as a GET parameter issue_type = None if request is not None: if request.method == "POST": issue_type = request.data.get("issuetype") else: issue_type = request.GET.get("issuetype") if issue_type is None: issue_type = self.get_option("default_issue_type", group.project) issue_type_meta = self.get_issue_type_meta(issue_type, meta) issue_type_choices = self.make_choices(meta["issuetypes"]) # make sure default issue type is actually # one that is allowed for project if issue_type: if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] fields = [ { "name": "project", "label": "Jira Project", "choices": ((meta["id"], jira_project_key), ), "default": meta["id"], "type": "select", "readonly": True, }, *fields, { "name": "issuetype", "label": "Issue Type", "default": issue_type or issue_type_meta["id"], "type": "select", "choices": issue_type_choices, }, ] # title is renamed to summary before sending to JIRA standard_fields = [f["name"] for f in fields] + ["summary"] ignored_fields = (self.get_option("ignored_fields", group.project) or "").split(",") # apply ordering to fields based on some known built-in JIRA fields. # otherwise weird ordering occurs. anti_gravity = { "priority": -150, "fixVersions": -125, "components": -100, "security": -50 } dynamic_fields = list(issue_type_meta.get("fields").keys()) dynamic_fields.sort(key=lambda f: anti_gravity.get(f) or 0) # Build up some dynamic fields based on what is required. for field in dynamic_fields: if field in standard_fields or field in [ x.strip() for x in ignored_fields ]: # don't overwrite the fixed fields for the form. continue mb_field = self.build_dynamic_field( group, issue_type_meta["fields"][field]) if mb_field: mb_field["name"] = field fields.append(mb_field) for field in fields: if field["name"] == "priority": # whenever priorities are available, put the available ones in the list. # allowedValues for some reason doesn't pass enough info. field["choices"] = self.make_choices(client.get_priorities()) field["default"] = self.get_option("default_priority", group.project) or "" elif field["name"] == "fixVersions": field["choices"] = self.make_choices( client.get_versions(jira_project_key)) return fields def get_link_existing_issue_fields(self, request: Request, group, event, **kwargs): return [ { "name": "issue_id", "label": "Issue", "default": "", "type": "select", "has_autocomplete": True, }, { "name": "comment", "label": "Comment", "default": absolute_uri( group.get_absolute_url( params={"referrer": "jira_plugin"})), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the JIRA issue."), "required": False, }, ] def link_issue(self, request: Request, group, form_data, **kwargs): client = self.get_jira_client(group.project) try: issue = client.get_issue(form_data["issue_id"]) except Exception as e: raise self.raise_error(e) comment = form_data.get("comment") if comment: try: client.create_comment(issue["key"], comment) except Exception as e: raise self.raise_error(e) return {"title": issue["fields"]["summary"]} def get_issue_label(self, group, issue_id, **kwargs): return issue_id def get_issue_url(self, group, issue_id, **kwargs): instance = self.get_option("instance_url", group.project) return f"{instance}/browse/{issue_id}" def _get_formatted_user(self, user): display = "{} {}({})".format( user.get("displayName", user["name"]), "- %s " % user.get("emailAddress") if user.get("emailAddress") else "", user["name"], ) return {"id": user["name"], "text": display} def view_autocomplete(self, request: Request, group, **kwargs): query = request.GET.get("autocomplete_query") field = request.GET.get("autocomplete_field") project = self.get_option("default_project", group.project) if field == "issue_id": client = self.get_jira_client(group.project) try: response = client.search_issues(project, query) except ApiError as e: return Response( { "error_type": "validation", "errors": [{ "__all__": self.message_from_error(e) }], }, status=400, ) else: issues = [{ "text": "({}) {}".format(i["key"], i["fields"]["summary"]), "id": i["key"] } for i in response.get("issues", [])] return Response({field: issues}) jira_url = request.GET.get("jira_url") if jira_url: jira_url = unquote_plus(jira_url) parsed = list(urlsplit(jira_url)) jira_query = parse_qs(parsed[3]) jira_client = self.get_jira_client(group.project) is_user_api = re.search("/rest/api/(latest|[0-9])/user/", jira_url) is_user_picker = "/rest/api/1.0/users/picker" in jira_url if is_user_api: # its the JSON version of the autocompleter is_xml = False jira_query["username"] = query.encode("utf8") jira_query.pop( "issueKey", False ) # some reason JIRA complains if this key is in the URL. jira_query["project"] = project.encode("utf8") elif is_user_picker: is_xml = False # for whatever reason, the create meta api returns an # invalid path, so let's just use the correct, documented one here: # https://docs.atlassian.com/jira/REST/cloud/#api/2/user # also, only pass path so saved instance url will be used parsed[0] = "" parsed[1] = "" parsed[2] = "/rest/api/2/user/picker" jira_query["query"] = query.encode("utf8") else: # its the stupid XML version of the API. is_xml = True jira_query["query"] = query.encode("utf8") if jira_query.get("fieldName"): # for some reason its a list. jira_query["fieldName"] = jira_query["fieldName"][0] parsed[3] = urlencode(jira_query) final_url = urlunsplit(parsed) autocomplete_response = jira_client.get_cached(final_url) if is_user_picker: autocomplete_response = autocomplete_response["users"] users = [] if is_xml: for userxml in autocomplete_response.xml.findAll("users"): users.append({ "id": userxml.find("name").text, "text": userxml.find("html").text }) else: for user in autocomplete_response: if user.get("name"): users.append(self._get_formatted_user(user)) # if JIRA user doesn't have proper permission for user api, # try the assignee api instead if not users and is_user_api: try: autocomplete_response = jira_client.search_users_for_project( jira_query.get("project"), jira_query.get("username")) except (ApiUnauthorized, ApiError) as e: return Response( { "error_type": "validation", "errors": [{ "__all__": self.message_from_error(e) }], }, status=400, ) for user in autocomplete_response: if user.get("name"): users.append(self._get_formatted_user(user)) return Response({field: users}) def message_from_error(self, exc): if isinstance(exc, ApiUnauthorized): return "Unauthorized: either your username and password were invalid or you do not have access" return super().message_from_error(exc) def error_message_from_json(self, data): message = "" if data.get("errorMessages"): message = " ".join(data["errorMessages"]) if data.get("errors"): if message: message += " " message += " ".join(f"{k}: {v}" for k, v in data.get("errors").items()) return message def create_issue(self, request: Request, group, form_data, **kwargs): cleaned_data = {} # protect against mis-configured plugin submitting a form without an # issuetype assigned. if not form_data.get("issuetype"): raise PluginError("Issue Type is required.") jira_project_key = self.get_option("default_project", group.project) client = self.get_jira_client(group.project) meta = client.get_create_meta_for_project(jira_project_key) if not meta: raise PluginError( "Something went wrong. Check your plugin configuration.") issue_type_meta = self.get_issue_type_meta(form_data["issuetype"], meta) fs = issue_type_meta["fields"] for field in fs.keys(): f = fs[field] if field == "description": cleaned_data[field] = form_data[field] continue elif field == "summary": cleaned_data["summary"] = form_data["title"] continue if field in form_data.keys(): v = form_data.get(field) if v: schema = f["schema"] if schema.get( "type") == "string" and not schema.get("custom"): cleaned_data[field] = v continue if schema["type"] == "user" or schema.get( "items") == "user": v = {"name": v} elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get( "multiuserpicker"): # custom multi-picker v = [{"name": v}] elif schema["type"] == "array" and schema.get( "items") != "string": v = [{"id": vx} for vx in v] elif schema["type"] == "array" and schema.get( "items") == "string": v = [v] elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get( "textarea"): v = v elif (schema["type"] == "number" or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["tempo_account"]): try: if "." in v: v = float(v) else: v = int(v) except ValueError: pass elif (schema.get("type") != "string" or (schema.get("items") and schema.get("items") != "string") or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get("select")): v = {"id": v} cleaned_data[field] = v if not (isinstance(cleaned_data["issuetype"], dict) and "id" in cleaned_data["issuetype"]): # something fishy is going on with this field, working on some JIRA # instances, and some not. # testing against 5.1.5 and 5.1.4 does not convert (perhaps is no longer included # in the projectmeta API call, and would normally be converted in the # above clean method.) cleaned_data["issuetype"] = {"id": cleaned_data["issuetype"]} try: response = client.create_issue(cleaned_data) except Exception as e: raise self.raise_error(e) return response.get("key") def get_jira_client(self, project): instance = self.get_option("instance_url", project) username = self.get_option("username", project) pw = self.get_option("password", project) return JiraClient(instance, username, pw) def make_choices(self, x): return [(y["id"], y["name"] if "name" in y else y["value"]) for y in x] if x else [] def validate_config_field(self, project, name, value, actor=None): value = super().validate_config_field(project, name, value, actor) # Don't make people update password every time if name == "password": value = value or self.get_option("password", project) return value def validate_config(self, project, config, actor=None): """ ``` if config['foo'] and not config['bar']: raise PluginError('You cannot configure foo with bar') return config ``` """ client = JiraClient(config["instance_url"], config["username"], config["password"]) try: client.get_projects_list() except ApiError as e: raise self.raise_error(e) return config def get_configure_plugin_fields(self, request: Request, project, **kwargs): instance = self.get_option("instance_url", project) username = self.get_option("username", project) pw = self.get_option("password", project) jira_project = self.get_option("default_project", project) default_priority = self.get_option("default_priority", project) default_issue_type = self.get_option("default_issue_type", project) project_choices = [] priority_choices = [] issue_type_choices = [] if instance and username and pw: client = JiraClient(instance, username, pw) try: projects = client.get_projects_list() except ApiError: projects = None else: if projects: project_choices = [ (p.get("key"), "{} ({})".format(p.get("name"), p.get("key"))) for p in projects ] jira_project = jira_project or projects[0]["key"] if jira_project: try: priorities = client.get_priorities() except ApiError: priorities = None else: if priorities: priority_choices = [(p.get("id"), "%s" % (p.get("name"))) for p in priorities] default_priority = default_priority or priorities[0][ "id"] try: meta = client.get_create_meta_for_project(jira_project) except ApiError: meta = None else: if meta: issue_type_choices = self.make_choices( meta["issuetypes"]) if issue_type_choices: default_issue_type = default_issue_type or issue_type_choices[ 0][0] secret_field = get_secret_field_config(pw, "") secret_field.update({ "name": "password", "label": "Password/API Token" }) return [ { "name": "instance_url", "label": "JIRA Instance URL", "default": instance, "type": "text", "placeholder": 'e.g. "https://jira.atlassian.com"', "help": "It must be visible to the Sentry server", }, { "name": "username", "label": "Username/Email", "default": username, "type": "text", "help": "Ensure the JIRA user has admin permissions on the project", }, secret_field, { "name": "default_project", "label": "Linked Project", "type": "select", "choices": project_choices, "default": jira_project, "required": False, }, { "name": "ignored_fields", "label": "Ignored Fields", "type": "textarea", "required": False, "placeholder": 'e.g. "components, security, customfield_10006"', "default": self.get_option("ignored_fields", project), "help": "Comma-separated list of properties that you don't want to show in the form", }, { "name": "default_priority", "label": "Default Priority", "type": "select", "choices": priority_choices, "required": False, "default": default_priority, }, { "name": "default_issue_type", "label": "Default Issue Type", "type": "select", "choices": issue_type_choices, "required": False, "default": default_issue_type, }, { "name": "auto_create", "label": "Automatically create JIRA Tickets", "default": self.get_option("auto_create", project) or False, "type": "bool", "required": False, "help": "Automatically create a JIRA ticket for EVERY new issue", }, ] def should_create(self, group, event, is_new): if not is_new: return False if not self.get_option("auto_create", group.project): return False # XXX(dcramer): Sentry doesn't expect GroupMeta referenced here so we # need to populate the cache GroupMeta.objects.populate_cache([group]) if GroupMeta.objects.get_value(group, "%s:tid" % self.get_conf_key(), None): return False return True def post_process(self, group, event, is_new, **kwargs): if not self.should_create(group, event, is_new): return fields = self.get_new_issue_fields(None, group, event, **kwargs) post_data = {} included_fields = { "priority", "issuetype", "title", "description", "project" } for field in fields: name = field["name"] if name in included_fields: post_data[name] = field.get("default") if not (post_data.get("priority") and post_data.get("issuetype") and post_data.get("project")): return interface = event.interfaces.get("sentry.interfaces.Exception") if interface: post_data[ "description"] += "\n{code}%s{code}" % interface.get_stacktrace( event, system_frames=False, max_frames=settings.SENTRY_MAX_STACKTRACE_FRAMES) try: issue_id = self.create_issue(request={}, group=group, form_data=post_data) except PluginError as e: logger.info("post_process.fail", extra={"error": str(e)}) else: prefix = self.get_conf_key() GroupMeta.objects.set_value(group, "%s:tid" % prefix, issue_id)
from sentry.pipeline import (PipelineView, NestedPipelineView) from sentry.utils.http import absolute_uri import pypd DESCRIPTION = """ Connect your Sentry organization to your Pagerduty app, and start getting alerts for errors right in front of you where all the action happens in your office! """ FEATURES = [ FeatureDescription( """ Unfurls Sentry URLs directly within pagerduty, providing you context and actionability on issues right at your fingertips. """, IntegrationFeatures.CHAT_UNFURL, ), # to be done - as this requires custom actions from Pagerduty # FeatureDescription( # """ # Resolve, ignore, and assign issues with minimal context switching. # """, # IntegrationFeatures.ACTION_NOTIFICATION, # ), FeatureDescription( """ Configure rule based Pagerduty alerts to automatically be posted to a specific service/user. Want any error that's happening more than 100 times a minute to be posted with critical severity? Setup a rule for it!
class AmazonSQSPlugin(CorePluginMixin, DataForwardingPlugin): title = "Amazon SQS" slug = "amazon-sqs" description = DESCRIPTION conf_key = "amazon-sqs" required_field = "queue_url" feature_descriptions = [ FeatureDescription( """ Forward Sentry errors and events to Amazon SQS. """, IntegrationFeatures.DATA_FORWARDING, ) ] def get_config(self, project, **kwargs): return [ { "name": "queue_url", "label": "Queue URL", "type": "url", "placeholder": "https://sqs-us-east-1.amazonaws.com/12345678/myqueue", }, { "name": "region", "label": "Region", "type": "select", "choices": tuple((z, z) for z in get_regions()), }, { "name": "access_key", "label": "Access Key", "type": "text", "placeholder": "Access Key", }, get_secret_field_config(name="secret_key", label="Secret Key", secret=self.get_option( "secret_key", project)), { "name": "message_group_id", "label": "Message Group ID", "type": "text", "required": False, "placeholder": "Required for FIFO queues, exclude for standard queues", }, { "name": "s3_bucket", "label": "S3 Bucket", "type": "text", "required": False, "placeholder": "s3-bucket", "help": ("Specify a bucket to store events in S3. The SQS message will contain a reference" " to the payload location in S3. If no S3 bucket is provided, events over the SQS" " limit of 256KB will not be forwarded."), }, ] def get_rate_limit(self): # no rate limit for SQS return (0, 0) @track_response_metric def forward_event(self, event, payload): queue_url = self.get_option("queue_url", event.project) access_key = self.get_option("access_key", event.project) secret_key = self.get_option("secret_key", event.project) region = self.get_option("region", event.project) message_group_id = self.get_option("message_group_id", event.project) # the metrics tags are a subset of logging params metric_tags = { "project_id": event.project_id, "organization_id": event.project.organization_id, } logging_params = metric_tags.copy() logging_params["event_id"] = event.event_id logging_params["issue_id"] = event.group_id if not all((queue_url, access_key, secret_key, region)): logger.info("sentry_plugins.amazon_sqs.skip_unconfigured", extra=logging_params) return boto3_args = { "aws_access_key_id": access_key, "aws_secret_access_key": secret_key, "region_name": region, } def log_and_increment(metrics_name): logger.info( metrics_name, extra=logging_params, ) metrics.incr( metrics_name, tags=metric_tags, ) def s3_put_object(*args, **kwargs): s3_client = boto3.client(service_name="s3", config=Config(signature_version="s3v4"), **boto3_args) return s3_client.put_object(*args, **kwargs) def sqs_send_message(message): client = boto3.client(service_name="sqs", **boto3_args) send_message_args = {"QueueUrl": queue_url, "MessageBody": message} # need a MessageGroupId for FIFO queues # note that if MessageGroupId is specified for non-FIFO, this will fail if message_group_id: from uuid import uuid4 send_message_args["MessageGroupId"] = message_group_id # if content based de-duplication is not enabled, we need to provide a # MessageDeduplicationId send_message_args["MessageDeduplicationId"] = uuid4().hex return client.send_message(**send_message_args) # wrap S3 put_object and and SQS send message in one try/except s3_bucket = self.get_option("s3_bucket", event.project) try: # if we have an S3 bucket, upload to S3 if s3_bucket: # we want something like 2020-08-29 so we can store it by the date date = event.datetime.strftime("%Y-%m-%d") key = "{}/{}/{}".format(event.project.slug, date, event.event_id) logger.info("sentry_plugins.amazon_sqs.s3_put_object", extra=logging_params) s3_put_object(Bucket=s3_bucket, Body=json.dumps(payload), Key=key) url = "https://{}.s3-{}.amazonaws.com/{}".format( s3_bucket, region, key) # just include the s3Url and the event ID in the payload payload = {"s3Url": url, "eventID": event.event_id} message = json.dumps(payload) if len(message) > 256 * 1024: logger.info("sentry_plugins.amazon_sqs.skip_oversized", extra=logging_params) return False sqs_send_message(message) except ClientError as e: if str(e).startswith( "An error occurred (InvalidClientTokenId)") or str( e).startswith("An error occurred (AccessDenied)"): # If there's an issue with the user's token then we can't do # anything to recover. Just log and continue. log_and_increment( "sentry_plugins.amazon_sqs.access_token_invalid") return False elif str(e).endswith("must contain the parameter MessageGroupId."): log_and_increment( "sentry_plugins.amazon_sqs.missing_message_group_id") return False elif str(e).startswith("An error occurred (NoSuchBucket)"): # If there's an issue with the user's s3 bucket then we can't do # anything to recover. Just log and continue. log_and_increment( "sentry_plugins.amazon_sqs.s3_bucket_invalid") return False raise return True
class TeamworkPlugin(CorePluginMixin, IssuePlugin): author = "Sentry Team" author_url = "https://github.com/getsentry/sentry" title = _("Teamwork") description = DESCRIPTION slug = "teamwork" required_field = "url" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to an Teamwork ticket in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Teamwork tickets. """, IntegrationFeatures.ISSUE_BASIC, ), ] conf_title = title conf_key = slug version = sentry.VERSION project_conf_form = TeamworkSettingsForm new_issue_form = TeamworkTaskForm create_issue_template = "sentry_teamwork/create_issue.html" def _get_group_description(self, request, group, event): """ Return group description in markdown-compatible format. This overrides an internal method to IssuePlugin. """ output = [absolute_uri(group.get_absolute_url())] body = self._get_group_body(request, group, event) if body: output.extend( ["", "\n".join(" " + line for line in body.splitlines())]) return "\n".join(output) def is_configured(self, request, project, **kwargs): return all(self.get_option(key, project) for key in ("url", "token")) def get_client(self, project): return TeamworkClient(base_url=self.get_option("url", project), token=self.get_option("token", project)) def get_new_issue_form(self, request, group, event, **kwargs): """ Return a Form for the "Create new issue" page. """ return self.new_issue_form( client=self.get_client(group.project), data=request.POST or None, initial=self.get_initial_form_data(request, group, event), ) def get_issue_url(self, group, issue_id, **kwargs): url = self.get_option("url", group.project) return "%s/tasks/%s" % (url.rstrip("/"), issue_id) def get_new_issue_title(self, **kwargs): return _("Create Teamwork Task") def create_issue(self, request, group, form_data, **kwargs): client = self.get_client(group.project) try: task_id = client.create_task( content=form_data["title"], description=form_data["description"], tasklist_id=form_data["tasklist"], ) except RequestException as e: raise forms.ValidationError( _("Error creating Teamwork task: %s") % str(e)) return task_id def view(self, request, group, **kwargs): op = request.GET.get("op") # TODO(dcramer): add caching if op == "getTaskLists": project_id = request.GET.get("pid") if not project_id: return HttpResponse(status=400) client = self.get_client(group.project) task_list = client.list_tasklists(project_id) return JSONResponse([{ "id": i["id"], "text": i["name"] } for i in task_list]) return super().view(request, group, **kwargs)
from sentry.utils.http import absolute_uri from .client import VstsApiClient from .repository import VstsRepositoryProvider from .webhooks import WorkItemWebhook DESCRIPTION = """ Connect your Sentry organization to one or more of your Azure DevOps organizations. Get started streamlining your bug squashing workflow by unifying your Sentry and Azure DevOps organization together. """ FEATURES = [ FeatureDescription( """ Create and link Sentry issue groups directly to a Azure DevOps work item in any of your projects, providing a quick way to jump from Sentry bug to tracked work item! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Automatically synchronize assignees to and from Azure DevOps. Don't get confused who's fixing what, let us handle ensuring your issues and work items match up to your Sentry and Azure DevOps assignees. """, IntegrationFeatures.ISSUE_SYNC, ), FeatureDescription( """ Never forget to close a resolved workitem! Resolving an issue in Sentry will resolve your linked workitems and viceversa.
class ClubhousePlugin(CorePluginMixin, IssuePlugin2): description = "Create Clubhouse Stories from a project." slug = "clubhouse" title = "Clubhouse" conf_title = title conf_key = "clubhouse" required_field = "token" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to a Clubhouse story in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Clubhouse stories. """, IntegrationFeatures.ISSUE_BASIC, ), ] issue_fields = frozenset(["id", "title", "url"]) def get_group_urls(self): return super(ClubhousePlugin, self).get_group_urls() + [ url( r"^autocomplete", IssueGroupActionEndpoint.as_view( view_method_name="view_autocomplete", plugin=self), ) ] def get_configure_plugin_fields(self, request, project, **kwargs): token = self.get_option("token", project) helptext = "Enter your API Token (found on " "your account Settings, under API Tokens)." secret_field = get_secret_field_config(token, helptext, include_prefix=True) secret_field.update({ "name": "token", "label": "API Token", "placeholder": "e.g. 12345678-1234-1234-1234-1234567890AB", }) return [ secret_field, { "name": "project", "label": "Project ID", "default": self.get_option("project", project), "type": "text", "placeholder": "e.g. 639281", "help": "Enter your project's numerical ID.", }, ] def is_configured(self, request, project, **kwargs): return all(self.get_option(k, project) for k in ("token", "project")) def get_client(self, project): token = self.get_option("token", project) return ClubhouseClient(token) def create_issue(self, request, group, form_data, **kwargs): client = self.get_client(group.project) try: response = client.create_story(project=self.get_option( "project", group.project), data=form_data) except Exception as e: self.raise_error(e) return { "id": response["id"], "title": response["name"], "url": response["app_url"] } def get_issue_label(self, group, issue, **kwargs): return "Clubhouse Story #%s" % issue["id"] def get_issue_url(self, group, issue, **kwargs): return issue["url"] def validate_config(self, project, config, actor): try: config["project"] = int(config["project"]) except ValueError as exc: self.logger.exception(six.text_type(exc)) raise PluginError( "Invalid Project ID. " "Project IDs are numbers-only, and can be found on the Project's page" ) return config # This drives the `Link` UI def get_link_existing_issue_fields(self, request, group, event, **kwargs): return [ { "name": "issue_id", "label": "Story", "default": "", "type": "select", "has_autocomplete": True, "help": ("You can use any syntax supported by Clubhouse's " '<a href="https://help.clubhouse.io/hc/en-us/articles/360000046646-Search-Operators" ' 'target="_blank">search operators.</a>'), }, { "name": "comment", "label": "Comment", "default": absolute_uri( group.get_absolute_url( params={"referrer": "clubhouse_plugin"})), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the Clubhouse story."), "required": False, }, ] # Handle the incoming search terms, make requests and build responses def view_autocomplete(self, request, group, **kwargs): field = request.GET.get("autocomplete_field") query = request.GET.get("autocomplete_query") if field != "issue_id" or not query: return Response({"issue_id": []}) project = self.get_option("project", group.project) client = self.get_client(group.project) # TODO: Something about the search API won't allow an explicit number search. # Should it switch the search mechanism from search_stories(text) to get_story(id)? try: response = client.search_stories( query=(u"project:%s %s" % (project, query)).encode("utf-8")) except Exception as e: return self.handle_api_error(e) issues = [{ "text": "(#%s) %s" % (i["id"], i["name"]), "id": i["id"] } for i in response.get("data", [])] return Response({field: issues}) def link_issue(self, request, group, form_data, **kwargs): client = self.get_client(group.project) try: story = client.get_story(story_id=form_data["issue_id"]) except Exception as e: self.raise_error(e) comment = form_data.get("comment") if comment: try: client.add_comment(story_id=story["id"], comment=comment) except Exception as e: self.raise_error(e) return { "id": story["id"], "title": story["name"], "url": story["app_url"] }
class GitHubPlugin(GitHubMixin, IssuePlugin2): description = "Integrate GitHub issues by linking a repository to a project." slug = "github" title = "GitHub" conf_title = title conf_key = "github" auth_provider = "github" required_field = "repo" logger = logging.getLogger("sentry.plugins.github") feature_descriptions = [ FeatureDescription( """ Authorize repositories to be added to your Sentry organization to augment sentry issues with commit data with [deployment tracking](https://docs.sentry.io/learn/releases/). """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create and link Sentry issue groups directly to a GitHub issue or pull request in any of your repositories, providing a quick way to jump from Sentry bug to tracked issue or PR! """, IntegrationFeatures.ISSUE_BASIC, ), ] def get_group_urls(self): return super(GitHubPlugin, self).get_group_urls() + [ url( r"^autocomplete", IssueGroupActionEndpoint.as_view( view_method_name="view_autocomplete", plugin=self), ) ] def get_url_module(self): return "sentry_plugins.github.urls" def is_configured(self, request, project, **kwargs): return bool(self.get_option("repo", project)) def get_new_issue_fields(self, request, group, event, **kwargs): fields = super(GitHubPlugin, self).get_new_issue_fields(request, group, event, **kwargs) return ([{ "name": "repo", "label": "GitHub Repository", "default": self.get_option("repo", group.project), "type": "text", "readonly": True, }] + fields + [{ "name": "assignee", "label": "Assignee", "default": "", "type": "select", "required": False, "choices": self.get_allowed_assignees(request, group), }]) def get_link_existing_issue_fields(self, request, group, event, **kwargs): return [ { "name": "issue_id", "label": "Issue", "default": "", "type": "select", "has_autocomplete": True, "help": ("You can use any syntax supported by GitHub's " '<a href="https://help.github.com/articles/searching-issues/" ' 'target="_blank">issue search.</a>'), }, { "name": "comment", "label": "Comment", "default": u"Sentry issue: [{issue_id}]({url})".format( url=absolute_uri( group.get_absolute_url( params={"referrer": "github_plugin"})), issue_id=group.qualified_short_id, ), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the GitHub issue."), "required": False, }, ] def get_allowed_assignees(self, request, group): client = self.get_client(request.user) try: response = client.list_assignees( repo=self.get_option("repo", group.project)) except Exception as e: self.raise_error(e) users = tuple((u["login"], u["login"]) for u in response) return (("", "Unassigned"), ) + users def create_issue(self, request, group, form_data, **kwargs): # TODO: support multiple identities via a selection input in the form? client = self.get_client(request.user) try: response = client.create_issue( repo=self.get_option("repo", group.project), data={ "title": form_data["title"], "body": form_data["description"], "assignee": form_data.get("assignee"), }, ) except Exception as e: self.raise_error(e) return response["number"] def link_issue(self, request, group, form_data, **kwargs): client = self.get_client(request.user) repo = self.get_option("repo", group.project) try: issue = client.get_issue(repo=repo, issue_id=form_data["issue_id"]) except Exception as e: self.raise_error(e) comment = form_data.get("comment") if comment: try: client.create_comment(repo=repo, issue_id=issue["number"], data={"body": comment}) except Exception as e: self.raise_error(e) return {"title": issue["title"]} def get_issue_label(self, group, issue_id, **kwargs): return "GH-%s" % issue_id def get_issue_url(self, group, issue_id, **kwargs): # XXX: get_option may need tweaked in Sentry so that it can be pre-fetched in bulk repo = self.get_option("repo", group.project) return "https://github.com/%s/issues/%s" % (repo, issue_id) def view_autocomplete(self, request, group, **kwargs): field = request.GET.get("autocomplete_field") query = request.GET.get("autocomplete_query") if field != "issue_id" or not query: return Response({"issue_id": []}) repo = self.get_option("repo", group.project) client = self.get_client(request.user) try: response = client.search_issues( query=(u"repo:%s %s" % (repo, query)).encode("utf-8")) except Exception as e: return self.handle_api_error(e) issues = [{ "text": "(#%s) %s" % (i["number"], i["title"]), "id": i["number"] } for i in response.get("items", [])] return Response({field: issues}) def get_configure_plugin_fields(self, request, project, **kwargs): return [{ "name": "repo", "label": "Repository Name", "default": self.get_option("repo", project), "type": "text", "placeholder": "e.g. getsentry/sentry", "help": ("Enter your repository name, including the owner. " "<p><b>Looking to integrate commit data with releases?</b> You'll need to configure this through our" '<a href="/organizations/{}/repos/" ' "> repos page</a>.</p>").format(project.organization.slug), "required": True, }] def has_apps_configured(self): return bool( options.get("github.apps-install-url") and options.get("github.integration-app-id") and options.get("github.integration-hook-secret") and options.get("github.integration-private-key")) def setup(self, bindings): bindings.add("repository.provider", GitHubRepositoryProvider, id="github") if self.has_apps_configured(): bindings.add("repository.provider", GitHubAppsRepositoryProvider, id="github_apps") else: self.logger.info("apps-not-configured")
from sentry.tasks.integrations import migrate_repo from sentry.utils.http import absolute_uri from .client import BitbucketApiClient from .issues import BitbucketIssueBasicMixin from .repository import BitbucketRepositoryProvider DESCRIPTION = """ Connect your Sentry organization to Bitbucket, enabling the following features: """ FEATURES = [ FeatureDescription( """ Track commits and releases (learn more [here](https://docs.sentry.io/learn/releases/)) """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Resolve Sentry issues via Bitbucket commits by including `Fixes PROJ-ID` in the message """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create Bitbucket issues from Sentry """, IntegrationFeatures.ISSUE_BASIC,
class PushoverPlugin(CorePluginMixin, NotifyPlugin): description = DESCRIPTION slug = "pushover" title = "Pushover" conf_title = "Pushover" conf_key = "pushover" required_field = "apikey" feature_descriptions = [ FeatureDescription( """ Have Pushover notifications get sent to your mobile device with the Pushover app. """, IntegrationFeatures.MOBILE, ), FeatureDescription( """ Configure Sentry rules to trigger notifications based on conditions you set. """, IntegrationFeatures.ALERT_RULE, ), ] def is_configured(self, project): return all( self.get_option(key, project) for key in ("userkey", "apikey")) def get_config(self, **kwargs): userkey = self.get_option("userkey", kwargs["project"]) apikey = self.get_option("apikey", kwargs["project"]) userkey_field = get_secret_field_config( userkey, "Your user key. See https://pushover.net/", include_prefix=True) userkey_field.update({"name": "userkey", "label": "User Key"}) apikey_field = get_secret_field_config( apikey, "Application API token. See https://pushover.net/apps/", include_prefix=True) apikey_field.update({"name": "apikey", "label": "API Key"}) return [ userkey_field, apikey_field, { "name": "priority", "label": "Message Priority", "type": "choice", "required": True, "choices": [ ("-2", "Lowest"), ("-1", "Low"), ("0", "Normal"), ("1", "High"), ("2", "Emergency"), ], "default": "0", }, { "name": "retry", "label": "Retry", "type": "number", "required": False, "placeholder": "e.g. 30", "help": 'How often (in seconds) you will receive the same notification. Minimum of 30 seconds. Only required for "Emergency" level priority.', }, { "name": "expire", "label": "Expire", "type": "number", "required": False, "placeholder": "e.g. 9000", "help": 'How many seconds your notification will continue to be retried for. Maximum of 10800 seconds. Only required for "Emergency" level priority.', }, ] def validate_config(self, project, config, actor): if int(config["priority"]) == 2 and config["retry"] < 30: retry = six.text_type(config["retry"]) self.logger.exception( six.text_type( "Retry not 30 or higher. It is {}.".format(retry))) raise PluginError( "Retry must be 30 or higher. It is {}.".format(retry)) return config def get_client(self, project): return PushoverClient(apikey=self.get_option("apikey", project), userkey=self.get_option("userkey", project)) def error_message_from_json(self, data): errors = data.get("errors") if errors: return " ".join(errors) return "unknown error" def notify(self, notification, **kwargs): event = notification.event group = event.group project = group.project priority = int(self.get_option("priority", project) or 0) retry = int(self.get_option("retry", project) or 30) expire = int(self.get_option("expire", project) or 90) title = "%s: %s" % (project.name, group.title) link = group.get_absolute_url(params={"referrer": "pushover_plugin"}) message = event.title[:256] tags = event.tags if tags: message += "\n\nTags: %s" % (", ".join("%s=%s" % (k, v) for (k, v) in tags)) client = self.get_client(project) try: response = client.send_message({ "message": message[:1024], "title": title[:250], "url": link, "url_title": "Issue Details", "priority": priority, "retry": retry, "expire": expire, }) except Exception as e: self.raise_error(e) assert response["status"]
class AmazonSQSPlugin(CorePluginMixin, DataForwardingPlugin): title = "Amazon SQS" slug = "amazon-sqs" description = DESCRIPTION conf_key = "amazon-sqs" required_field = "queue_url" feature_descriptions = [ FeatureDescription( """ Forward Sentry errors and events to Amazon SQS. """, IntegrationFeatures.DATA_FORWARDING, ) ] def get_config(self, project, **kwargs): return [ { "name": "queue_url", "label": "Queue URL", "type": "url", "placeholder": "https://sqs-us-east-1.amazonaws.com/12345678/myqueue", }, { "name": "region", "label": "Region", "type": "select", "choices": tuple((z, z) for z in get_regions()), }, get_secret_field_config(name="access_key", label="Access Key", secret=self.get_option( "access_key", project)), get_secret_field_config(name="secret_key", label="Secret Key", secret=self.get_option( "secret_key", project)), { "name": "message_group_id", "label": "Message Group ID", "type": "text", "required": False, "placeholder": "Required for FIFO queues, exclude for standard queues", }, ] def forward_event(self, event, payload): queue_url = self.get_option("queue_url", event.project) access_key = self.get_option("access_key", event.project) secret_key = self.get_option("secret_key", event.project) region = self.get_option("region", event.project) message_group_id = self.get_option("message_group_id", event.project) if not all((queue_url, access_key, secret_key, region)): return # TODO(dcramer): Amazon doesnt support payloads larger than 256kb # We could support this by simply trimming it and allowing upload # to S3 message = json.dumps(payload) if len(message) > 256 * 1024: return False try: client = boto3.client( service_name="sqs", aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region, ) message = {"QueueUrl": queue_url, "MessageBody": message} # need a MessageGroupId for FIFO queues # note that if MessageGroupId is specified for non-FIFO, this will fail if message_group_id: from uuid import uuid4 message["MessageGroupId"] = message_group_id # if content based de-duplication is not enabled, we need to provide a # MessageDeduplicationId message["MessageDeduplicationId"] = uuid4().hex client.send_message(**message) except ClientError as e: if six.text_type(e).startswith("An error occurred (AccessDenied)"): # If there's an issue with the user's token then we can't do # anything to recover. Just log and continue. metrics_name = "sentry_plugins.amazon_sqs.access_token_invalid" logger.info( metrics_name, extra={ "queue_url": queue_url, "access_key": access_key, "region": region, "project_id": event.project.id, "organization_id": event.project.organization_id, }, ) metrics.incr( metrics_name, tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, }, ) return False elif six.text_type(e).endswith( "must contain the parameter MessageGroupId."): metrics_name = "sentry_plugins.amazon_sqs.missing_message_group_id" logger.info( metrics_name, extra={ "queue_url": queue_url, "access_key": access_key, "region": region, "project_id": event.project.id, "organization_id": event.project.organization_id, "message_group_id": message_group_id, }, ) metrics.incr( metrics_name, tags={ "project_id": event.project_id, "organization_id": event.project.organization_id, }, ) return False raise return True
class OpsGeniePlugin(CorePluginMixin, notify.NotificationPlugin): author = "Sentry Team" author_url = "https://github.com/getsentry" title = "OpsGenie" slug = "opsgenie" description = DESCRIPTION conf_key = "opsgenie" version = sentry.VERSION project_conf_form = OpsGenieOptionsForm required_field = "api_key" feature_descriptions = [ FeatureDescription( """ Manage incidents and outages by sending Sentry notifications to OpsGenie. """, IntegrationFeatures.INCIDENT_MANAGEMENT, ), FeatureDescription( """ Configure Sentry rules to trigger notifications based on conditions you set. """, IntegrationFeatures.ALERT_RULE, ), ] logger = logging.getLogger("sentry.plugins.opsgenie") def is_configured(self, project): return all(self.get_option(k, project) for k in ("api_key", "alert_url")) def get_form_initial(self, project=None): return {"alert_url": "https://api.opsgenie.com/v2/alerts"} def build_payload(self, group, event, triggering_rules): payload = { "message": event.message or event.title, "alias": "sentry: %d" % group.id, "source": "Sentry", "details": { "Sentry ID": str(group.id), "Sentry Group": getattr(group, "title", group.message).encode("utf-8"), "Project ID": group.project.slug, "Project Name": group.project.name, "Logger": group.logger, "Level": group.get_level_display(), "URL": group.get_absolute_url(), "Triggering Rules": json.dumps(triggering_rules), }, "entity": group.culprit, } payload["tags"] = [ "{}:{}".format(str(x).replace(",", ""), str(y).replace(",", "")) for x, y in event.tags ] return payload def notify_users(self, group, event, fail_silently=False, triggering_rules=None, **kwargs): if not self.is_configured(group.project): return client = self.get_client(group.project) payload = self.build_payload(group, event, triggering_rules) try: client.trigger_incident(payload) except Exception as e: self.raise_error(e) def get_client(self, project): api_key = self.get_option("api_key", project) alert_url = self.get_option("alert_url", project) recipients = self.get_option("recipients", project) return OpsGenieApiClient(api_key, alert_url, recipients)
from .card_builder import build_installation_confirmation_message from .client import get_token_data, MsTeamsClient logger = logging.getLogger("sentry.integrations.msteams") DESCRIPTION = ( "Microsoft Teams is a hub for teamwork in Office 365. Keep all your team's chats, meetings, files, and apps together in one place." "\n\nGet [alerts](https://docs.sentry.io/product/alerts-notifications/alerts/) that let you assign, ignore, and resolve issues" " right in your Teams channels with the Sentry integration for Microsoft Teams." ) FEATURES = [ FeatureDescription( """ Interact with messages in the chat to assign, ignore, and resolve issues. """, IntegrationFeatures. CHAT_UNFURL, # not acutally using unfurl but we show this as just "chat" ), FeatureDescription( "Configure rule based Teams alerts to automatically be posted into a specific channel or user.", IntegrationFeatures.ALERT_RULE, ), ] INSTALL_NOTICE_TEXT = ( "Visit the Teams Marketplace to install this integration. After adding the integration" " to your team, you will get a welcome message in the General channel to complete installation." ) external_install = {
class BitbucketPlugin(BitbucketMixin, IssuePlugin2): description = "Integrate Bitbucket issues by linking a repository to a project." slug = "bitbucket" conf_title = BitbucketMixin.title conf_key = "bitbucket" auth_provider = "bitbucket" required_field = "repo" feature_descriptions = [ FeatureDescription( """ Track commits and releases (learn more [here](https://docs.sentry.io/learn/releases/)) """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create Bitbucket issues from Sentry """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Bitbucket issues """, IntegrationFeatures.ISSUE_BASIC, ), ] def get_group_urls(self): return super().get_group_urls() + [ url( r"^autocomplete", IssueGroupActionEndpoint.as_view( view_method_name="view_autocomplete", plugin=self), ) ] def get_url_module(self): return "sentry_plugins.bitbucket.urls" def is_configured(self, request, project, **kwargs): return bool(self.get_option("repo", project)) def get_new_issue_fields(self, request, group, event, **kwargs): fields = super().get_new_issue_fields(request, group, event, **kwargs) return ([{ "name": "repo", "label": "Bitbucket Repository", "default": self.get_option("repo", group.project), "type": "text", "readonly": True, }] + fields + [ { "name": "issue_type", "label": "Issue type", "default": ISSUE_TYPES[0][0], "type": "select", "choices": ISSUE_TYPES, }, { "name": "priority", "label": "Priority", "default": PRIORITIES[0][0], "type": "select", "choices": PRIORITIES, }, ]) def get_link_existing_issue_fields(self, request, group, event, **kwargs): return [ { "name": "issue_id", "label": "Issue", "default": "", "type": "select", "has_autocomplete": True, }, { "name": "comment", "label": "Comment", "default": absolute_uri( group.get_absolute_url( params={"referrer": "bitbucket_plugin"})), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the Bitbucket issue."), "required": False, }, ] def message_from_error(self, exc): if isinstance(exc, ApiError) and exc.code == 404: return ERR_404 return super().message_from_error(exc) def create_issue(self, request, group, form_data, **kwargs): client = self.get_client(request.user) try: response = client.create_issue(repo=self.get_option( "repo", group.project), data=form_data) except Exception as e: self.raise_error(e, identity=client.auth) return response["local_id"] def link_issue(self, request, group, form_data, **kwargs): client = self.get_client(request.user) repo = self.get_option("repo", group.project) try: issue = client.get_issue(repo=repo, issue_id=form_data["issue_id"]) except Exception as e: self.raise_error(e, identity=client.auth) comment = form_data.get("comment") if comment: try: client.create_comment(repo, issue["local_id"], {"content": comment}) except Exception as e: self.raise_error(e, identity=client.auth) return {"title": issue["title"]} def get_issue_label(self, group, issue_id, **kwargs): return "Bitbucket-%s" % issue_id def get_issue_url(self, group, issue_id, **kwargs): repo = self.get_option("repo", group.project) return f"https://bitbucket.org/{repo}/issue/{issue_id}/" def view_autocomplete(self, request, group, **kwargs): field = request.GET.get("autocomplete_field") query = request.GET.get("autocomplete_query") if field != "issue_id" or not query: return Response({"issue_id": []}) repo = self.get_option("repo", group.project) client = self.get_client(request.user) try: response = client.search_issues(repo, query.encode("utf-8")) except Exception as e: return Response( { "error_type": "validation", "errors": [{ "__all__": self.message_from_error(e) }] }, status=400, ) issues = [{ "text": "(#{}) {}".format(i["local_id"], i["title"]), "id": i["local_id"] } for i in response.get("issues", [])] return Response({field: issues}) def get_configure_plugin_fields(self, request, project, **kwargs): return [{ "name": "repo", "label": "Repository Name", "type": "text", "placeholder": "e.g. getsentry/sentry", "help": "Enter your repository name, including the owner.", "required": True, }] def setup(self, bindings): bindings.add("repository.provider", BitbucketRepositoryProvider, id="bitbucket")
class TrelloPlugin(CorePluginMixin, IssuePlugin2): description = DESCRIPTION slug = "trello" title = "Trello" conf_title = title conf_key = "trello" auth_provider = None resource_links = [("Trello Setup Instructions", SETUP_URL) ] + CorePluginMixin.resource_links required_field = "key" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to an Trello card in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Trello cards """, IntegrationFeatures.ISSUE_BASIC, ), ] def get_config(self, project, **kwargs): """ Return the configuration of the plugin. Pull the value out of our the arguments to this function or from the DB """ def get_value(field): initial_values = kwargs.get("initial", {}) return initial_values.get(field) or self.get_option(field, project) token_config = { "name": "token", "type": "secret", "label": "Trello API Token", "default": None, } token_val = get_value("token") if token_val: # The token is sensitive so we should mask the value by only sending back the first 5 characters token_config["required"] = False token_config["prefix"] = token_val[:5] token_config["has_saved_value"] = True else: token_config["required"] = True api_key = get_value("key") key_config = { "name": "key", "type": "text", "required": True, "label": "Trello API Key", "default": api_key, } config = [key_config, token_config] org_value = get_value("organization") include_org = kwargs.get("add_additial_fields", org_value) if api_key and token_val and include_org: trello_client = TrelloApiClient(api_key, token_val) try: org_options = trello_client.get_organization_options() config.append({ "name": "organization", "label": "Trello Organization", "choices": org_options, "type": "select", "required": False, "default": org_value, }) except Exception as e: self.raise_error(e) return config def validate_config(self, project, config, actor=None): """ Make sure the configuration is valid by trying to query for the organizations with the auth """ trello_client = TrelloApiClient(config["key"], config["token"]) try: trello_client.get_organization_options() except Exception as e: self.raise_error(e) return config def get_group_urls(self): """ Return the URLs and the matching views """ return super(TrelloPlugin, self).get_group_urls() + [ url( r"^options", IssueGroupActionEndpoint.as_view( view_method_name="view_options", plugin=self), ), url( r"^autocomplete", IssueGroupActionEndpoint.as_view( view_method_name="view_autocomplete", plugin=self), ), ] def is_configured(self, request, project, **kwargs): return all(self.get_option(key, project) for key in ("token", "key")) # used for boards and lists but not cards (shortLink used as ID for cards) def map_to_options(self, items): return [(item["id"], item["name"]) for item in items] def get_new_issue_fields(self, request, group, event, **kwargs): """ Return the fields needed for creating a new issue """ fields = super(TrelloPlugin, self).get_new_issue_fields(request, group, event, **kwargs) client = self.get_client(group.project) organization = self.get_option("organization", group.project) boards = client.get_boards(organization) board_choices = self.map_to_options(boards) return fields + [ { "name": "board", "label": "Board", "type": "select", "choices": board_choices, "readonly": False, "required": True, }, { "name": "list", "depends": ["board"], "label": "List", "type": "select", "has_autocomplete": False, "required": True, }, ] def get_link_existing_issue_fields(self, request, group, event, **kwargs): """ Return the fields needed for linking to an existing issue """ return [ { "name": "issue_id", "label": "Card", "type": "select", "has_autocomplete": True, "required": True, }, { "name": "comment", "label": "Comment", "default": absolute_uri( group.get_absolute_url( params={"referrer": "trello_plugin"})), "type": "textarea", "help": ("Leave blank if you don't want to " "add a comment to the Trello card."), "required": False, }, ] def get_client(self, project): return TrelloApiClient(self.get_option("key", project), token=self.get_option("token", project)) def error_message_from_json(self, data): errors = data.get("errors") if errors: return " ".join(e["message"] for e in errors) return "unknown error" def create_issue(self, request, group, form_data, **kwargs): client = self.get_client(group.project) try: response = client.new_card(id_list=form_data["list"], name=form_data["title"], desc=form_data["description"]) except Exception as e: self.raise_error(e) return response["shortLink"] def link_issue(self, request, group, form_data, **kwargs): client = self.get_client(group.project) try: card = client.get_card(form_data["issue_id"]) except Exception as e: self.raise_error(e) comment = form_data.get("comment") if comment: try: client.create_comment(card["shortLink"], comment) except Exception as e: self.raise_error(e) return {"title": card["name"], "id": card["shortLink"]} def get_issue_label(self, group, issue, **kwargs): """ Return label of the linked issue we show in the UI from the issue string """ # the old version of the plugin stores the url in the issue if LABLEX_REGEX.search(issue): short_issue = issue.split("/", 1)[0] return "Trello-%s" % short_issue return "Trello-%s" % issue def get_issue_url(self, group, issue, **kwargs): """ Return label of the url of card in Trello based off the issue object or issue ID """ # TODO(Steve): figure out why we sometimes get a string and sometimes a dict if isinstance(issue, dict): issue = issue["id"] # the old version of the plugin stores the url in the issue if LABLEX_REGEX.search(issue): return issue.split("/", 1)[1] return "https://trello.com/c/%s" % issue def view_options(self, request, group, **kwargs): """ Return the lists on a given Trello board """ field = request.GET.get("option_field") board = request.GET.get("board") results = [] if field == "list" and board: client = self.get_client(group.project) try: response = client.get_lists_of_board(board) except Exception as e: return Response( { "error_type": "validation", "errors": [{ "__all__": self.message_from_error(e) }], }, status=400, ) else: results = self.map_to_options(response) return Response({field: results}) def view_autocomplete(self, request, group, **kwargs): """ Return the cards matching a given query and the organization of the configuration """ field = request.GET.get("autocomplete_field") query = request.GET.get("autocomplete_query") output = [] if field == "issue_id" and query: organization = self.get_option("organization", group.project) client = self.get_client(group.project) cards = client.get_cards(query, organization) output = [{ "text": "(#%s) %s" % (card["idShort"], card["name"]), "id": card["shortLink"] } for card in cards] return Response({field: output})
from .client import SlackClient from .utils import get_integration_type, logger Channel = namedtuple("Channel", ["name", "id"]) DESCRIPTION = """ Connect your Sentry organization to one or more Slack workspaces, and start getting errors right in front of you where all the action happens in your office! """ FEATURES = [ FeatureDescription( """ Unfurls Sentry URLs directly within Slack, providing you context and actionability on issues right at your fingertips. Resolve, ignore, and assign issues with minimal context switching. """, IntegrationFeatures.CHAT_UNFURL, ), FeatureDescription( """ Configure rule based Slack notifications to automatically be posted into a specific channel. Want any error that's happening more than 100 times a minute to be posted in `#critical-errors`? Setup a rule for it! """, IntegrationFeatures.ALERT_RULE, ), ] setup_alert = { "type":
from sentry.utils.http import absolute_uri from sentry.web.helpers import render_to_response from .client import GitLabApiClient, GitLabSetupClient from .issues import GitlabIssueBasic from .repository import GitlabRepositoryProvider DESCRIPTION = """ Connect your Sentry organization to an organization in your GitLab instance or gitlab.com, enabling the following features: """ FEATURES = [ FeatureDescription( """ Track commits and releases (learn more [here](https://docs.sentry.io/learn/releases/)) """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Resolve Sentry issues via GitLab commits and merge requests by including `Fixes PROJ-ID` in the message """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create GitLab issues from Sentry """, IntegrationFeatures.ISSUE_BASIC,
class RedminePlugin(CorePluginMixin, IssuePlugin): author = "Sentry" author_url = "https://github.com/getsentry/sentry" version = sentry.VERSION description = DESCRIPTION slug = "redmine" title = _("Redmine") conf_title = "Redmine" conf_key = "redmine" required_field = "host" feature_descriptions = [ FeatureDescription( """ Create and link Sentry issue groups directly to an Redmine issue in any of your projects, providing a quick way to jump from a Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Link Sentry issues to existing Redmine issue. """, IntegrationFeatures.ISSUE_BASIC, ), ] new_issue_form = RedmineNewIssueForm def __init__(self): super(RedminePlugin, self).__init__() self.client_errors = [] self.fields = [] def has_project_conf(self): return True def is_configured(self, project, **kwargs): return all((self.get_option(k, project) for k in ("host", "key", "project_id"))) def get_new_issue_title(self, **kwargs): return "Create Redmine Task" def get_initial_form_data(self, request, group, event, **kwargs): return { "description": self._get_group_description(request, group, event), "title": self._get_group_title(request, group, event), } def _get_group_description(self, request, group, event): output = [absolute_uri(group.get_absolute_url())] body = self._get_group_body(request, group, event) if body: output.extend(["", "<pre>", body, "</pre>"]) return "\n".join(output) def get_client(self, project): return RedmineClient(host=self.get_option("host", project), key=self.get_option("key", project)) def create_issue(self, group, form_data, **kwargs): """ Create a Redmine issue """ client = self.get_client(group.project) default_priority = self.get_option("default_priority", group.project) if default_priority is None: default_priority = 4 issue_dict = { "project_id": self.get_option("project_id", group.project), "tracker_id": self.get_option("tracker_id", group.project), "priority_id": default_priority, "subject": form_data["title"].encode("utf-8"), "description": form_data["description"].encode("utf-8"), } extra_fields_str = self.get_option("extra_fields", group.project) if extra_fields_str: extra_fields = json.loads(extra_fields_str) else: extra_fields = {} issue_dict.update(extra_fields) response = client.create_issue(issue_dict) return response["issue"]["id"] def get_issue_url(self, group, issue_id, **kwargs): host = self.get_option("host", group.project) return "{}/issues/{}".format(host.rstrip("/"), issue_id) def build_config(self): host = { "name": "host", "label": "Host", "type": "text", "help": "e.g. http://bugs.redmine.org", "required": True, } key = { "name": "key", "label": "Key", "type": "text", "help": "Your API key is available on your account page after enabling the Rest API (Administration -> Settings -> Authentication)", "required": True, } project_id = { "name": "project_id", "label": "Project*", "type": "select", "choices": [], "required": False, } tracker_id = { "name": "tracker_id", "label": "Tracker*", "type": "select", "choices": [], "required": False, } default_priority = { "name": "default_priority", "label": "Default Priority*", "type": "select", "choices": [], "required": False, } extra_fields = { "name": "extra_fields", "label": "Extra Fields", "type": "text", "help": "Extra attributes (custom fields, status id, etc.) in JSON format", "required": False, } return [ host, key, project_id, tracker_id, default_priority, extra_fields ] def add_choices(self, field_name, choices, default): for field in self.fields: if field_name == field["name"]: field["choices"] = choices field["default"] = default return def remove_field(self, field_name): for field in self.fields: if field["name"] == field_name: self.fields.remove(field) return def build_initial(self, initial_args, project): initial = {} fields = [ "host", "key", "project_id", "tracker_id", "default_priority", "extra_fields" ] for field in fields: value = initial_args.get(field) or self.get_option(field, project) if value is not None: initial[field] = value return initial def get_config(self, project, **kwargs): self.client_errors = [] self.fields = self.build_config() initial_args = kwargs.get("initial") or {} initial = self.build_initial(initial_args, project) has_credentials = all(initial.get(k) for k in ("host", "key")) if has_credentials: client = RedmineClient(initial["host"], initial["key"]) try: projects = client.get_projects() except Exception: has_credentials = False self.client_errors.append( "There was an issue authenticating with Redmine") else: choices_value = self.get_option("project_id", project) project_choices = [("", "--")] if not choices_value else [] project_choices += [(p["id"], "%s (%s)" % (p["name"], p["identifier"])) for p in projects["projects"]] self.add_choices("project_id", project_choices, choices_value) if has_credentials: try: trackers = client.get_trackers() except Exception: self.remove_field("tracker_id") else: choices_value = self.get_option("tracker_id", project) tracker_choices = [("", "--")] if not choices_value else [] tracker_choices += [(p["id"], p["name"]) for p in trackers["trackers"]] self.add_choices("tracker_id", tracker_choices, choices_value) try: priorities = client.get_priorities() except Exception: self.remove_field("default_priority") else: choices_value = self.get_option("default_priority", project) tracker_choices = [("", "--")] if not choices_value else [] tracker_choices += [(p["id"], p["name"]) for p in priorities["issue_priorities"]] self.add_choices("default_priority", tracker_choices, choices_value) if not has_credentials: for field_name in [ "project_id", "tracker_id", "default_priority", "extra_fields" ]: self.remove_field(field_name) return self.fields def validate_config(self, project, config, actor): super(RedminePlugin, self).validate_config(project, config, actor) self.client_errors = [] for field in self.fields: if field["name"] in [ "project_id", "tracker_id", "default_priority" ]: if not config[field["name"]]: self.logger.exception( six.text_type("{} required.".format(field["name"]))) self.client_errors.append(field["name"]) if self.client_errors: raise PluginError(", ".join(self.client_errors) + " required.") return config
from .issues import GitHubIssueBasic from .repository import GitHubRepositoryProvider from .utils import get_jwt DESCRIPTION = """ Connect your Sentry organization into your GitHub organization or user account. Take a step towards augmenting your sentry issues with commits from your repositories ([using releases](https://docs.sentry.io/learn/releases/)) and linking up your GitHub issues and pull requests directly to issues in Sentry. """ FEATURES = [ FeatureDescription( """ Authorize repositories to be added to your Sentry organization to augment sentry issues with commit data with [deployment tracking](https://docs.sentry.io/learn/releases/). """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Create and link Sentry issue groups directly to a GitHub issue or pull request in any of your repositories, providing a quick way to jump from Sentry bug to tracked issue or PR! """, IntegrationFeatures.ISSUE_BASIC, ), ] metadata = IntegrationMetadata( description=DESCRIPTION.strip(),
) from sentry.integrations.repositories import RepositoryMixin from sentry.models.repository import Repository from sentry.pipeline import PipelineView from sentry.web.helpers import render_to_response from .repository import CustomSCMRepositoryProvider DESCRIPTION = """ Custom Source Control Management (SCM) """ FEATURES = [ FeatureDescription( """ Send your own commits """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Stack trace linky dink """, IntegrationFeatures.STACKTRACE_LINK, ), ] metadata = IntegrationMetadata( description=DESCRIPTION.strip(), features=FEATURES, author="The Sentry Team", noun=_("Installation"),
from sentry.mediators.sentry_apps import InternalCreator from .client import VercelClient logger = logging.getLogger("sentry.integrations.vercel") DESCRIPTION = _( """ Vercel is an all-in-one platform with Global CDN supporting static & JAMstack deployment and Serverless Functions. """ ) FEATURES = [ FeatureDescription( """ Connect your Sentry and Vercel projects to automatically upload source maps and notify Sentry of new releases being deployed. """, IntegrationFeatures.DEPLOYMENT, ) ] INSTALL_NOTICE_TEXT = _( "Visit the Vercel Marketplace to install this integration. After installing the" " Sentry integration, you'll be redirected back to Sentry to finish syncing Vercel and Sentry projects." ) external_install = { "url": "https://vercel.com/integrations/%s/add" % options.get("vercel.integration-slug"), "buttonText": _("Vercel Marketplace"), "noticeText": _(INSTALL_NOTICE_TEXT), }
from sentry.web.helpers import render_to_response from .client import BitbucketServer, BitbucketServerSetupClient from .repository import BitbucketServerRepositoryProvider logger = logging.getLogger("sentry.integrations.bitbucket_server") DESCRIPTION = """ Connect your Sentry organization to Bitbucket Server, enabling the following features: """ FEATURES = [ FeatureDescription( """ Track commits and releases (learn more [here](https://docs.sentry.io/learn/releases/)) """, IntegrationFeatures.COMMITS, ), FeatureDescription( """ Resolve Sentry issues via Bitbucket Server commits by including `Fixes PROJ-ID` in the message """, IntegrationFeatures.COMMITS, ), ] setup_alert = { "type": "warning", "icon": "icon-warning-sm",
from sentry.web.helpers import render_to_response from .client import JiraServerClient, JiraServerSetupClient logger = logging.getLogger('sentry.integrations.jira_server') DESCRIPTION = """ Connect your Sentry organization into one or more of your Jira Server instances. Get started streamlining your bug squashing workflow by unifying your Sentry and Jira instances together. """ FEATURE_DESCRIPTIONS = [ FeatureDescription( """ Create and link Sentry issue groups directly to a Jira ticket in any of your projects, providing a quick way to jump from Sentry bug to tracked ticket! """, IntegrationFeatures.ISSUE_BASIC, ), FeatureDescription( """ Automatically synchronize assignees to and from Jira. Don't get confused who's fixing what, let us handle ensuring your issues and tickets match up to your Sentry and Jira assignees. """, IntegrationFeatures.ISSUE_SYNC, ), FeatureDescription( """ Synchronize Comments on Sentry Issues directly to the linked Jira ticket. """,
class SlackPlugin(CorePluginMixin, notify.NotificationPlugin): title = "Slack" slug = "slack" description = "Post notifications to a Slack channel." conf_key = "slack" required_field = "webhook" feature_descriptions = [ FeatureDescription( """ Configure rule based Slack notifications to automatically be posted into a specific channel. Want any error that's happening more than 100 times a minute to be posted in `#critical-errors`? Setup a rule for it! """, IntegrationFeatures.ALERT_RULE, ) ] def is_configured(self, project): return bool(self.get_option("webhook", project)) def get_config(self, project, **kwargs): return [ { "name": "webhook", "label": "Webhook URL", "type": "url", "placeholder": "e.g. https://hooks.slack.com/services/000000000/000000000/00000000000000000", "required": True, "help": "Your custom Slack webhook URL.", }, { "name": "username", "label": "Bot Name", "type": "string", "placeholder": "e.g. Sentry", "default": "Sentry", "required": False, "help": "The name used when publishing messages.", }, { "name": "icon_url", "label": "Icon URL", "type": "url", "required": False, "help": ( "The url of the icon to appear beside your bot (32px png), " "leave empty for none.<br />You may use " "http://myovchev.github.io/sentry-slack/images/logo32.png" ), }, { "name": "channel", "label": "Destination", "type": "string", "placeholder": "e.g. #engineering", "required": False, "help": "Optional #channel name or @user", }, { "name": "custom_message", "label": "Custom Message", "type": "string", "placeholder": "e.g. Hey <!everyone> there is something wrong", "required": False, "help": "Optional - Slack message formatting can be used", }, { "name": "include_tags", "label": "Include Tags", "type": "bool", "required": False, "help": "Include tags with notifications", }, { "name": "included_tag_keys", "label": "Included Tags", "type": "string", "required": False, "help": ( "Only include these tags (comma separated list). " "Leave empty to include all." ), }, { "name": "excluded_tag_keys", "label": "Excluded Tags", "type": "string", "required": False, "help": "Exclude these tags (comma separated list).", }, { "name": "include_rules", "label": "Include Rules", "type": "bool", "required": False, "help": "Include triggering rules with notifications.", }, { "name": "exclude_project", "label": "Exclude Project Name", "type": "bool", "default": False, "required": False, "help": "Exclude project name with notifications.", }, { "name": "exclude_culprit", "label": "Exclude Culprit", "type": "bool", "default": False, "required": False, "help": "Exclude culprit with notifications.", }, ] def color_for_event(self, event): return "#" + LEVEL_TO_COLOR.get(event.get_tag("level"), "error") def _get_tags(self, event): tag_list = event.tags if not tag_list: return () return ( (tagstore.get_tag_key_label(k), tagstore.get_tag_value_label(k, v)) for k, v in tag_list ) def get_tag_list(self, name, project): option = self.get_option(name, project) if not option: return None return set(tag.strip().lower() for tag in option.split(",")) def notify(self, notification, raise_exception=False): event = notification.event group = event.group project = group.project if not self.is_configured(project): return webhook = self.get_option("webhook", project) username = (self.get_option("username", project) or "Sentry").strip() icon_url = self.get_option("icon_url", project) channel = (self.get_option("channel", project) or "").strip() title = event.title.encode("utf-8") # TODO(dcramer): we'd like this to be the event culprit, but Sentry # does not currently retain it if group.culprit: culprit = group.culprit.encode("utf-8") else: culprit = None project_name = project.get_full_name().encode("utf-8") fields = [] # They can be the same if there is no culprit # So we set culprit to an empty string instead of duplicating the text if not self.get_option("exclude_culprit", project) and culprit and title != culprit: fields.append({"title": "Culprit", "value": culprit, "short": False}) if not self.get_option("exclude_project", project): fields.append({"title": "Project", "value": project_name, "short": True}) if self.get_option("custom_message", project): fields.append( { "title": "Custom message", "value": self.get_option("custom_message", project), "short": False, } ) if self.get_option("include_rules", project): rules = [] for rule in notification.rules: rule_link = "/%s/%s/settings/alerts/rules/%s/" % ( group.organization.slug, project.slug, rule.id, ) # Make sure it's an absolute uri since we're sending this # outside of Sentry into Slack rule_link = absolute_uri(rule_link) rules.append((rule_link, rule.label)) if rules: value = u", ".join(u"<{} | {}>".format(*r) for r in rules) fields.append( {"title": "Triggered By", "value": value.encode("utf-8"), "short": False} ) if self.get_option("include_tags", project): included_tags = set(self.get_tag_list("included_tag_keys", project) or []) excluded_tags = set(self.get_tag_list("excluded_tag_keys", project) or []) for tag_key, tag_value in self._get_tags(event): key = tag_key.lower() std_key = tagstore.get_standardized_key(key) if included_tags and key not in included_tags and std_key not in included_tags: continue if excluded_tags and (key in excluded_tags or std_key in excluded_tags): continue fields.append( { "title": tag_key.encode("utf-8"), "value": tag_value.encode("utf-8"), "short": True, } ) payload = { "attachments": [ { "fallback": "[%s] %s" % (project_name, title), "title": title, "title_link": group.get_absolute_url(params={"referrer": "slack"}), "color": self.color_for_event(event), "fields": fields, } ] } if username: payload["username"] = username.encode("utf-8") if channel: payload["channel"] = channel if icon_url: payload["icon_url"] = icon_url values = {"payload": json.dumps(payload)} # Apparently we've stored some bad data from before we used `URLField`. webhook = webhook.strip(" ") return http.safe_urlopen(webhook, method="POST", data=values, timeout=5)