You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by li...@apache.org on 2022/11/22 15:42:02 UTC

[incubator-devlake] branch main updated: [feat-3498][backend] Pagerduty plugin implementation (#3641)

This is an automated email from the ASF dual-hosted git repository.

likyh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new eef06e96e [feat-3498][backend] Pagerduty plugin implementation (#3641)
eef06e96e is described below

commit eef06e96e95ad9e95fd9bc4dde27cc55123e0740
Author: Keon Amini <ke...@merico.dev>
AuthorDate: Tue Nov 22 09:41:56 2022 -0600

    [feat-3498][backend] Pagerduty plugin implementation (#3641)
    
    * feat: Pagerduty plugin implementation
    
    * refactor: change endpoint for token validation
    
    * fix: added domain types to subtasks
    
    * fix: removed deploymentId assignment from converter
    
    * refactor: pluralized database table names
---
 .env.example                                       |    4 +-
 .licenserc.yaml                                    |    1 +
 Dockerfile                                         |    8 +
 Makefile                                           |    5 +
 README.md                                          |   24 +-
 config/config.go                                   |    1 +
 config/tap/pagerduty.json                          | 1884 ++++++++++++++++++++
 helpers/pluginhelper/tap/singer_models.go          |    2 +-
 helpers/pluginhelper/tap/singer_tap_client.go      |   47 -
 helpers/pluginhelper/tap/singer_tap_impl.go        |   47 +-
 helpers/pluginhelper/tap/tap_collector.go          |    6 +-
 plugins/pagerduty/api/blueprint.go                 |   64 +
 plugins/pagerduty/api/connection.go                |  150 ++
 plugins/pagerduty/api/init.go                      |   39 +
 plugins/pagerduty/e2e/incident_test.go             |   86 +
 .../e2e/raw_tables/_raw_pagerduty_incidents.csv    |    4 +
 .../_tool_pagerduty_assignments.csv                |    4 +
 .../snapshot_tables/_tool_pagerduty_incidents.csv  |    4 +
 .../snapshot_tables/_tool_pagerduty_services.csv   |    2 +
 .../e2e/snapshot_tables/_tool_pagerduty_users.csv  |    3 +
 plugins/pagerduty/e2e/snapshot_tables/issues.csv   |    4 +
 plugins/pagerduty/impl/impl.go                     |  148 ++
 plugins/pagerduty/models/assignment.go             |   35 +
 plugins/pagerduty/models/config.go                 |   34 +
 plugins/pagerduty/models/connection.go             |   52 +
 plugins/pagerduty/models/consts.go                 |   25 +
 plugins/pagerduty/models/generated/incidents.go    |  491 +++++
 .../pagerduty/models/generated/notifications.go    |   55 +
 plugins/pagerduty/models/generated/services.go     |  205 +++
 plugins/pagerduty/models/incident.go               |   51 +
 .../migrationscripts/20221115_add_init_tables.go   |   46 +
 .../models/migrationscripts/archived/assignment.go |   35 +
 .../models/migrationscripts/archived/connection.go |   30 +
 .../models/migrationscripts/archived/incident.go   |   40 +
 .../models/migrationscripts/archived/service.go    |   34 +
 .../models/migrationscripts/archived/user.go       |   32 +
 .../pagerduty/models/migrationscripts/register.go  |   29 +
 plugins/pagerduty/models/service.go                |   32 +
 plugins/pagerduty/models/user.go                   |   32 +
 plugins/pagerduty/pager_duty.go                    |   43 +
 plugins/pagerduty/tasks/incidents_collector.go     |   62 +
 plugins/pagerduty/tasks/incidents_converter.go     |  143 ++
 plugins/pagerduty/tasks/incidents_extractor.go     |  107 ++
 plugins/pagerduty/tasks/task_data.go               |   52 +
 requirements.txt                                   |    3 +
 scripts/singer-model-generator.sh                  |  107 +-
 utils/ipc.go                                       |   18 +
 47 files changed, 4198 insertions(+), 132 deletions(-)

diff --git a/.env.example b/.env.example
index 19b43e579..4d71e61ed 100644
--- a/.env.example
+++ b/.env.example
@@ -31,8 +31,8 @@ LOGGING_DIR=
 ENABLE_STACKTRACE=false
 FORCE_MIGRATION=false
 
-# Lake SINGER API
-SINGER_PROPERTIES_DIR=
+# Lake TAP API
+TAP_PROPERTIES_DIR=
 
 ##########################
 # Sensitive information encryption key
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 3f79f5dc0..bc33837f8 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -44,6 +44,7 @@ header:
     - 'DISCLAIMER'
     - 'go.mod'
     - 'go.sum'
+    - 'requirements.txt'
     - '**/.babelrc'
     - '**/empty'
     - '**/*.conf'
diff --git a/Dockerfile b/Dockerfile
index e3aeb0dd5..1ee0ecd35 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -45,6 +45,14 @@ WORKDIR /app
 
 COPY --from=builder /app/bin /app/bin
 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
+COPY --from=builder /app/requirements.txt /app/requirements.txt
+COPY --from=builder /app/config/tap /app/config/tap
+
+# Setup Python
+RUN python -m venv /app/.venv
+RUN echo "source /app/.venv/bin/activate" >> ~/.profile
+RUN source ~/.profile
+RUN pip install --upgrade pip -r requirements.txt
 
 ENV PATH="/app/bin:${PATH}"
 
diff --git a/Makefile b/Makefile
index 6bd35581b..e818a7349 100644
--- a/Makefile
+++ b/Makefile
@@ -25,6 +25,8 @@ VERSION = $(TAG)@$(SHA)
 dep:
 	go install github.com/vektra/mockery/v2@latest
 	go install github.com/swaggo/swag/cmd/swag@v1.8.4
+	go install github.com/atombender/go-jsonschema/cmd/gojsonschema@latest
+	pip install -r requirements.txt
 
 swag:
 	swag init --parseDependency --parseInternal -o ./api/docs -g ./api/api.go -g plugins/*/api/*.go
@@ -57,6 +59,9 @@ build-grafana-image:
 
 build-images: build-server-image build-config-ui-image build-grafana-image
 
+tap-models:
+	chmod +x ./scripts/singer-model-generator.sh
+	@sh scripts/singer-model-generator.sh config/singer/pagerduty.json plugins/pagerduty --all
 
 push-server-image: build-server-image
 	docker push $(IMAGE_REPO)/devlake:$(TAG)
diff --git a/README.md b/README.md
index d62b338f9..d40edab0f 100644
--- a/README.md
+++ b/README.md
@@ -33,18 +33,18 @@ Apache DevLake is designed for developer teams looking to make better sense of t
 
 ## 💪 Supported Data Sources
 
-| Data Source                   | Domain(s)                                                  | Supported Versions                   | Config UI Availability | Triggered Plugins           | Collection Mode       |
-|-------------------------------|------------------------------------------------------------|--------------------------------------|------------------------|---------------------------- | --------------------- |
-| GitHub (include GitHub Action)| Source Code Management, Code Review, Issue Tracking, CI/CD | Cloud                                |Available               |`github`, `gitextractor`     | Full Refresh, Incremental Sync(for `issues`, `PRs`) |
-| GitLab (include GitLabCI)     | Source Code Management, Code Review, Issue Tracking, CI/CD | Cloud, Community Edition 13.x+       |Available               |`gitlab`, `gitextractor`     | Full Refresh, Incremental Sync(for `issues`)|
-| Gitee                         | Source Code Management, Code Review, Issue Tracking        | Cloud                                |Not Available           |`gitee`, `gitextractor`      | Incremental Sync      |
-| BitBucket                     | Source Code Management, Code Review                        | Cloud                                |Not Available           |`bitbucket`, `gitextractor`  | Full Refresh          |
-| Jira                          | Issue Tracking                                             | Cloud, Server 8.x+, Data Center 8.x+ |Available               |`jira`                       | Full Refresh, Incremental Sync(for `issues`, `changelogs`, `worklogs`) |
-| TAPD                          | Issue Tracking                                             | Cloud                                |Not Available           |`tapd`                       | Full Refresh, Incremental Sync(for `stories`, `bugs`, `tasks`)          |
-| Jenkins                       | CI/CD                                                      | 2.263.x+                             |Available               |`jenkins`                    | Full Refresh          |
-| Feishu                        | Calendar                                                   | Cloud                                |Not Available           |`feishu`                     | Full Refresh          |
-| AE                            | Source Code Management                                     |                                      |Not Available           | `ae`                        | Full Refresh          |
-
+| Data Source                   | Domain(s)                                                  | Supported Versions                                          | Config UI Availability | Triggered Plugins           | Collection Mode       |
+|-------------------------------|------------------------------------------------------------|-------------------------------------------------------------|------------------------|---------------------------- | --------------------- |
+| GitHub (include GitHub Action)| Source Code Management, Code Review, Issue Tracking, CI/CD | Cloud                                                       |Available               |`github`, `gitextractor`     | Full Refresh, Incremental Sync(for `issues`, `PRs`) |
+| GitLab (include GitLabCI)     | Source Code Management, Code Review, Issue Tracking, CI/CD | Cloud, Community Edition 13.x+                              |Available               |`gitlab`, `gitextractor`     | Full Refresh, Incremental Sync(for `issues`)|
+| Gitee                         | Source Code Management, Code Review, Issue Tracking        | Cloud                                                       |Not Available           |`gitee`, `gitextractor`      | Incremental Sync      |
+| BitBucket                     | Source Code Management, Code Review                        | Cloud                                                       |Not Available           |`bitbucket`, `gitextractor`  | Full Refresh          |
+| Jira                          | Issue Tracking                                             | Cloud, Server 8.x+, Data Center 8.x+                        |Available               |`jira`                       | Full Refresh, Incremental Sync(for `issues`, `changelogs`, `worklogs`) |
+| TAPD                          | Issue Tracking                                             | Cloud                                                       |Not Available           |`tapd`                       | Full Refresh, Incremental Sync(for `stories`, `bugs`, `tasks`)          |
+| Jenkins                       | CI/CD                                                      | 2.263.x+                                                    |Available               |`jenkins`                    | Full Refresh          |
+| Feishu                        | Calendar                                                   | Cloud                                                       |Not Available           |`feishu`                     | Full Refresh          |
+| AE                            | Source Code Management                                     |                                                             |Not Available           | `ae`                        | Full Refresh          |
+| Pagerduty                     | Issue Tracking                                             | [Singer-tap](https://github.com/singer-io/tap-pagerduty)    |Not Available           | `pagerduty`                 | Full Refresh          |
 
 ## 🚀 Getting Started
 
diff --git a/config/config.go b/config/config.go
index d83d28396..282e86e6c 100644
--- a/config/config.go
+++ b/config/config.go
@@ -70,6 +70,7 @@ func setDefaultValue(v *viper.Viper) {
 	v.SetDefault("PORT", ":8080")
 	v.SetDefault("PLUGIN_DIR", "bin/plugins")
 	v.SetDefault("TEMPORAL_TASK_QUEUE", "DEVLAKE_TASK_QUEUE")
+	v.SetDefault("TAP_PROPERTIES_DIR", "config/tap")
 }
 
 // replaceNewEnvItemInOldContent replace old config to new config in env file content
diff --git a/config/tap/pagerduty.json b/config/tap/pagerduty.json
new file mode 100644
index 000000000..e76c54ed1
--- /dev/null
+++ b/config/tap/pagerduty.json
@@ -0,0 +1,1884 @@
+{
+  "streams": [
+    {
+      "tap_stream_id": "incidents",
+      "replication_key": "last_status_change_at",
+      "replication_method": "FULL_TABLE",
+      "key_properties": "id",
+      "schema": {
+        "properties": {
+          "id": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "type": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "summary": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "self": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "html_url": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "incident_number": {
+            "type": [
+              "null",
+              "integer"
+            ]
+          },
+          "created_at": {
+            "format": "date-time",
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "status": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "title": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "incident_key": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "service": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "priority": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "assignments": {
+            "items": {
+              "properties": {
+                "at": {
+                  "format": "date-time",
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "assignee": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "acknowledgements": {
+            "items": {
+              "properties": {
+                "at": {
+                  "format": "date-time",
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "acknowledger": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "last_status_change_at": {
+            "format": "date-time",
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "last_status_change_by": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "first_trigger_log_entry": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "escalation_policy": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "teams": {
+            "items": {
+              "properties": {
+                "id": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "summary": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "self": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "html_url": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "urgency": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "conference_bridge": {
+            "properties": {
+              "conference_number": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "conference_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "log_entries": {
+            "items": {
+              "properties": {
+                "id": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "summary": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "self": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "html_url": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "created_at": {
+                  "format": "date-time",
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "agent": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "channel": {
+                  "properties": {
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "incident": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "teams": {
+                  "items": {
+                    "properties": {
+                      "id": {
+                        "type": [
+                          "null",
+                          "string"
+                        ]
+                      },
+                      "type": {
+                        "type": [
+                          "null",
+                          "string"
+                        ]
+                      },
+                      "summary": {
+                        "type": [
+                          "null",
+                          "string"
+                        ]
+                      },
+                      "self": {
+                        "type": [
+                          "null",
+                          "string"
+                        ]
+                      },
+                      "html_url": {
+                        "type": [
+                          "null",
+                          "string"
+                        ]
+                      }
+                    },
+                    "type": [
+                      "null",
+                      "object"
+                    ]
+                  },
+                  "type": [
+                    "null",
+                    "array"
+                  ]
+                },
+                "event_details": {
+                  "properties": {
+                    "description": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "alerts": {
+            "items": {
+              "properties": {
+                "id": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "summary": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "self": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "html_url": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "created_at": {
+                  "format": "date-time",
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "status": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "alert_key": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "service": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "body": {
+                  "properties": {
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "contexts": {
+                      "items": {
+                        "properties": {
+                          "type": {
+                            "type": [
+                              "null",
+                              "string"
+                            ]
+                          },
+                          "href": {
+                            "type": [
+                              "null",
+                              "string"
+                            ]
+                          },
+                          "src": {
+                            "type": [
+                              "null",
+                              "string"
+                            ]
+                          },
+                          "text": {
+                            "type": [
+                              "null",
+                              "string"
+                            ]
+                          }
+                        },
+                        "type": [
+                          "null",
+                          "object"
+                        ]
+                      },
+                      "type": [
+                        "null",
+                        "array"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "incident": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "suppressed": {
+                  "type": [
+                    "null",
+                    "boolean"
+                  ]
+                },
+                "severity": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "integration": {
+                  "properties": {
+                    "id": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "summary": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "self": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "html_url": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "name": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "service": {
+                      "properties": {
+                        "id": {
+                          "type": [
+                            "null",
+                            "string"
+                          ]
+                        },
+                        "summary": {
+                          "type": [
+                            "null",
+                            "string"
+                          ]
+                        },
+                        "type": {
+                          "type": [
+                            "null",
+                            "string"
+                          ]
+                        },
+                        "self": {
+                          "type": [
+                            "null",
+                            "string"
+                          ]
+                        },
+                        "html_url": {
+                          "type": [
+                            "null",
+                            "string"
+                          ]
+                        }
+                      },
+                      "type": [
+                        "null",
+                        "object"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          }
+        },
+        "type": [
+          "null",
+          "object"
+        ],
+        "additionalProperties": false
+      },
+      "stream": "incidents",
+      "metadata": [
+        {
+          "breadcrumb": [],
+          "metadata": {
+            "table-key-properties": "id",
+            "forced-replication-method": "FULL_TABLE",
+            "valid-replication-keys": [
+              "last_status_change_at"
+            ],
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "id"
+          ],
+          "metadata": {
+            "inclusion": "automatic"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "type"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "summary"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "self"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "html_url"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "incident_number"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "created_at"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "status"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "title"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "incident_key"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "service"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "priority"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "assignments"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "acknowledgements"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "last_status_change_at"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "last_status_change_by"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "first_trigger_log_entry"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "escalation_policy"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "teams"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "urgency"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "conference_bridge"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "log_entries"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "alerts"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        }
+      ]
+    },
+    {
+      "tap_stream_id": "services",
+      "replication_method": "FULL_TABLE",
+      "key_properties": "id",
+      "schema": {
+        "properties": {
+          "id": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "type": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "summary": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "self": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "html_url": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "name": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "description": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "auto_resolve_timeout": {
+            "type": [
+              "null",
+              "integer"
+            ]
+          },
+          "acknowledgement_timeout": {
+            "type": [
+              "null",
+              "integer"
+            ]
+          },
+          "created_at": {
+            "format": "date-time",
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "status": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "last_incident_timestamp": {
+            "format": "date-time",
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "alert_creation": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "alert_grouping": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "alert_grouping_timeout": {
+            "type": [
+              "null",
+              "integer"
+            ]
+          },
+          "integrations": {
+            "items": {
+              "properties": {
+                "id": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "summary": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "self": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "html_url": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "escalation_policy": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "teams": {
+            "items": {
+              "properties": {
+                "id": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "summary": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "self": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "html_url": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          },
+          "incident_urgency_rule": {
+            "properties": {
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "during_support_hours": {
+                "properties": {
+                  "type": {
+                    "type": [
+                      "null",
+                      "string"
+                    ]
+                  },
+                  "urgency": {
+                    "type": [
+                      "null",
+                      "string"
+                    ]
+                  }
+                },
+                "type": [
+                  "null",
+                  "object"
+                ]
+              },
+              "outside_support_hours": {
+                "properties": {
+                  "type": {
+                    "type": [
+                      "null",
+                      "string"
+                    ]
+                  },
+                  "urgency": {
+                    "type": [
+                      "null",
+                      "string"
+                    ]
+                  }
+                },
+                "type": [
+                  "null",
+                  "object"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "support_hours": {
+            "properties": {
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "time_zone": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "start_time": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "end_time": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "days_of_week": {
+                "items": {
+                  "type": [
+                    "null",
+                    "integer"
+                  ]
+                },
+                "type": [
+                  "null",
+                  "array"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          },
+          "scheduled_actions": {
+            "items": {
+              "properties": {
+                "type": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                },
+                "at": {
+                  "properties": {
+                    "type": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    },
+                    "name": {
+                      "type": [
+                        "null",
+                        "string"
+                      ]
+                    }
+                  },
+                  "type": [
+                    "null",
+                    "object"
+                  ]
+                },
+                "to_urgency": {
+                  "type": [
+                    "null",
+                    "string"
+                  ]
+                }
+              },
+              "type": [
+                "null",
+                "object"
+              ]
+            },
+            "type": [
+              "null",
+              "array"
+            ]
+          }
+        },
+        "type": [
+          "null",
+          "object"
+        ],
+        "additionalProperties": false
+      },
+      "stream": "services",
+      "metadata": [
+        {
+          "breadcrumb": [],
+          "metadata": {
+            "table-key-properties": "id",
+            "forced-replication-method": "FULL_TABLE",
+            "valid-replication-keys": [],
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "id"
+          ],
+          "metadata": {
+            "inclusion": "automatic"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "type"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "summary"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "self"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "html_url"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "name"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "description"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "auto_resolve_timeout"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "acknowledgement_timeout"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "created_at"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "status"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "last_incident_timestamp"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "alert_creation"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "alert_grouping"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "alert_grouping_timeout"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "integrations"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "escalation_policy"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "teams"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "incident_urgency_rule"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "support_hours"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "scheduled_actions"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        }
+      ]
+    },
+    {
+      "tap_stream_id": "notifications",
+      "replication_key": "started_at",
+      "replication_method": "INCREMENTAL",
+      "key_properties": "id",
+      "schema": {
+        "properties": {
+          "id": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "type": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "started_at": {
+            "format": "date-time",
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "address": {
+            "type": [
+              "null",
+              "string"
+            ]
+          },
+          "user": {
+            "properties": {
+              "id": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "type": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "summary": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "self": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              },
+              "html_url": {
+                "type": [
+                  "null",
+                  "string"
+                ]
+              }
+            },
+            "type": [
+              "null",
+              "object"
+            ]
+          }
+        },
+        "type": [
+          "null",
+          "object"
+        ],
+        "additionalProperties": false
+      },
+      "stream": "notifications",
+      "metadata": [
+        {
+          "breadcrumb": [],
+          "metadata": {
+            "table-key-properties": "id",
+            "forced-replication-method": "INCREMENTAL",
+            "valid-replication-keys": [
+              "started_at"
+            ],
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "id"
+          ],
+          "metadata": {
+            "inclusion": "automatic"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "type"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "started_at"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "address"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        },
+        {
+          "breadcrumb": [
+            "properties",
+            "user"
+          ],
+          "metadata": {
+            "inclusion": "available"
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/helpers/pluginhelper/tap/singer_models.go b/helpers/pluginhelper/tap/singer_models.go
index f6eebdd2c..ad8a0f565 100644
--- a/helpers/pluginhelper/tap/singer_models.go
+++ b/helpers/pluginhelper/tap/singer_models.go
@@ -33,7 +33,7 @@ type (
 	}
 	// SingerTapConfig the set of variables needed to initialize a SingerTap
 	SingerTapConfig struct {
-		Cmd                  string
+		TapExecutable        string
 		StreamPropertiesFile string
 		IsLegacy             bool
 	}
diff --git a/helpers/pluginhelper/tap/singer_tap_client.go b/helpers/pluginhelper/tap/singer_tap_client.go
deleted file mode 100644
index 452f5ebcb..000000000
--- a/helpers/pluginhelper/tap/singer_tap_client.go
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
-Licensed to the Apache Software Foundation (ASF) under one or more
-contributor license agreements.  See the NOTICE file distributed with
-this work for additional information regarding copyright ownership.
-The ASF licenses this file to You under the Apache License, Version 2.0
-(the "License"); you may not use this file except in compliance with
-the License.  You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package tap
-
-import (
-	"github.com/apache/incubator-devlake/config"
-	"github.com/apache/incubator-devlake/errors"
-)
-
-// SingerTapArgs the args needed to instantiate tap.Tap for singer-taps
-type SingerTapArgs struct {
-	// Name of the env variable that expands to the tap binary path
-	TapClass string
-	// The name of the properties/catalog JSON file of the tap
-	StreamPropertiesFile string
-	// IsLegacy - set to true if this is an old tap that uses the "--properties" flag
-	IsLegacy bool
-}
-
-// NewSingerTapClient returns an instance of tap.Tap for singer-taps
-func NewSingerTapClient(args *SingerTapArgs) (*SingerTap, errors.Error) {
-	env := config.GetConfig()
-	cmd := env.GetString(args.TapClass)
-	if cmd == "" {
-		return nil, errors.Default.New("singer tap command not provided")
-	}
-	return NewSingerTap(&SingerTapConfig{
-		Cmd:                  cmd,
-		StreamPropertiesFile: args.StreamPropertiesFile,
-		IsLegacy:             args.IsLegacy,
-	})
-}
diff --git a/helpers/pluginhelper/tap/singer_tap_impl.go b/helpers/pluginhelper/tap/singer_tap_impl.go
index 4786443a0..3a2c681ba 100644
--- a/helpers/pluginhelper/tap/singer_tap_impl.go
+++ b/helpers/pluginhelper/tap/singer_tap_impl.go
@@ -25,11 +25,10 @@ import (
 	"github.com/apache/incubator-devlake/utils"
 	"github.com/mitchellh/hashstructure"
 	"os"
-	"os/exec"
 	"path/filepath"
 )
 
-const singerPropertiesDir = "SINGER_PROPERTIES_DIR"
+const singerPropertiesDir = "TAP_PROPERTIES_DIR"
 
 type (
 	// SingerTap the Singer implementation of Tap
@@ -58,12 +57,14 @@ func NewSingerTap(cfg *SingerTapConfig) (*SingerTap, errors.Error) {
 	if err != nil {
 		return nil, err
 	}
-	tapName := filepath.Base(cfg.Cmd)
+	tapName := filepath.Base(cfg.TapExecutable)
 	return &SingerTap{
-		cmd:             cfg.Cmd,
+		cmd:             cfg.TapExecutable,
 		name:            tapName,
 		tempLocation:    tempDir,
 		propertiesFile:  propsFile,
+		stateFile:       new(fileData[[]byte]),
+		configFile:      new(fileData[[]byte]),
 		SingerTapConfig: cfg,
 	}, nil
 }
@@ -127,15 +128,14 @@ func (t *SingerTap) GetName() string {
 
 // Run implements Tap.Run
 func (t *SingerTap) Run() (<-chan *utils.ProcessResponse[Output[json.RawMessage]], errors.Error) {
-	catalogCmd := "--catalog"
-	if t.IsLegacy {
-		catalogCmd = "--properties"
-	}
-	args := []string{"--config", t.configFile.path, catalogCmd, t.propertiesFile.path}
-	if t.stateFile != nil {
-		args = append(args, []string{"--state", t.stateFile.path}...)
-	}
-	cmd := exec.Command(t.cmd, args...)
+	cmd := utils.CreateCmd(
+		t.cmd,
+		"--config",
+		t.configFile.path,
+		ifElse(t.IsLegacy, "--properties", "--catalog"),
+		t.propertiesFile.path,
+		ifElse(t.stateFile.path != "", "--state "+t.stateFile.path, ""),
+	)
 	stream, err := utils.StreamProcess(cmd, func(b []byte) (Output[json.RawMessage], error) {
 		var output Output[json.RawMessage]
 		output, err := NewSingerTapOutput(b)
@@ -196,15 +196,14 @@ func hash(x any) (uint64, errors.Error) {
 	return version, nil
 }
 
-var _ Tap[SingerTapStream] = (*SingerTap)(nil)
-
 func (t *SingerTap) modifyProperties(streamName string, propsModifier func(props *SingerTapStream) bool) *SingerTapStream {
 	properties := t.propertiesFile.content
 	for i := 0; i < len(properties.Streams); i++ {
 		stream := properties.Streams[i]
-		if !defaultSingerStreamSetter(streamName, stream) {
+		if stream.Stream != streamName {
 			continue
 		}
+		setSingerStream(stream)
 		if propsModifier != nil && propsModifier(stream) {
 			return stream
 		}
@@ -212,13 +211,19 @@ func (t *SingerTap) modifyProperties(streamName string, propsModifier func(props
 	return nil
 }
 
-func defaultSingerStreamSetter(name string, stream *SingerTapStream) bool {
-	if stream.Stream != name {
-		return false
-	}
+func setSingerStream(stream *SingerTapStream) {
 	for _, meta := range stream.Metadata {
 		innerMeta := meta["metadata"].(map[string]any)
 		innerMeta["selected"] = true
 	}
-	return true
 }
+
+// ternary if-else so we can inline
+func ifElse(cond bool, onTrue string, onFalse string) string {
+	if cond {
+		return onTrue
+	}
+	return onFalse
+}
+
+var _ Tap[SingerTapStream] = (*SingerTap)(nil)
diff --git a/helpers/pluginhelper/tap/tap_collector.go b/helpers/pluginhelper/tap/tap_collector.go
index aac67fdef..99aba819d 100644
--- a/helpers/pluginhelper/tap/tap_collector.go
+++ b/helpers/pluginhelper/tap/tap_collector.go
@@ -141,8 +141,10 @@ func (c *Collector[Stream]) Execute() (err errors.Error) {
 	ctx := c.ctx.GetContext()
 	var batchedResults []json.RawMessage
 	defer func() {
-		// push whatever is left
-		err = c.pushResults(batchedResults)
+		if err == nil {
+			// push whatever is left
+			err = c.pushResults(batchedResults)
+		}
 	}()
 	for result := range resultStream {
 		if result.Err != nil {
diff --git a/plugins/pagerduty/api/blueprint.go b/plugins/pagerduty/api/blueprint.go
new file mode 100644
index 000000000..636e430bb
--- /dev/null
+++ b/plugins/pagerduty/api/blueprint.go
@@ -0,0 +1,64 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+	"encoding/json"
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/tasks"
+)
+
+func MakePipelinePlan(subtaskMetas []core.SubTaskMeta, connectionId uint64, scope []*core.BlueprintScopeV100) (core.PipelinePlan, errors.Error) {
+	var err errors.Error
+	plan := make(core.PipelinePlan, len(scope))
+	for i, scopeElem := range scope {
+		taskOptions := make(map[string]interface{})
+		err = errors.Convert(json.Unmarshal(scopeElem.Options, &taskOptions))
+		if err != nil {
+			return nil, errors.Default.Wrap(err, "error unmarshalling task options")
+		}
+		var transformationRules tasks.TransformationRules
+		if len(scopeElem.Transformation) > 0 {
+			err = errors.Convert(json.Unmarshal(scopeElem.Transformation, &transformationRules))
+			if err != nil {
+				return nil, errors.Default.Wrap(err, "unable to unmarshal transformation rule")
+			}
+		}
+		taskOptions["connectionId"] = connectionId
+		taskOptions["transformationRules"] = transformationRules
+		_, err = tasks.DecodeAndValidateTaskOptions(taskOptions)
+		if err != nil {
+			return nil, err
+		}
+		// subtasks
+		subtasks, err := helper.MakePipelinePlanSubtasks(subtaskMetas, scopeElem.Entities)
+		if err != nil {
+			return nil, err
+		}
+		plan[i] = core.PipelineStage{
+			{
+				Plugin:   "pagerduty",
+				Subtasks: subtasks,
+				Options:  taskOptions,
+			},
+		}
+	}
+	return plan, nil
+}
diff --git a/plugins/pagerduty/api/connection.go b/plugins/pagerduty/api/connection.go
new file mode 100644
index 000000000..560235397
--- /dev/null
+++ b/plugins/pagerduty/api/connection.go
@@ -0,0 +1,150 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+	"context"
+	"fmt"
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+	"net/http"
+	"time"
+)
+
+// @Summary test pagerduty connection
+// @Description Test Pagerduty Connection
+// @Tags plugins/pagerduty
+// @Param body body models.TestConnectionRequest true "json body"
+// @Success 200  {object} shared.ApiBody "Success"
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/test [POST]
+func TestConnection(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	var params models.TestConnectionRequest
+	err := helper.Decode(input.Body, &params, vld)
+	if err != nil {
+		return nil, err
+	}
+	apiClient, err := helper.NewApiClient(
+		context.TODO(),
+		params.Endpoint,
+		map[string]string{
+			"Authorization": fmt.Sprintf("Token token=%s", params.Token),
+		},
+		3*time.Second,
+		params.Proxy,
+		basicRes,
+	)
+	if err != nil {
+		return nil, err
+	}
+	response, err := apiClient.Get("licenses", nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	if response.StatusCode == http.StatusOK {
+		return &core.ApiResourceOutput{Body: nil, Status: http.StatusOK}, nil
+	}
+	return &core.ApiResourceOutput{Body: nil, Status: response.StatusCode}, errors.HttpStatus(response.StatusCode).Wrap(err, "could not validate connection")
+}
+
+// @Summary create pagerduty connection
+// @Description Create Pagerduty connection
+// @Tags plugins/pagerduty
+// @Param body body models.PagerDutyConnection true "json body"
+// @Success 200  {object} models.PagerDutyConnection
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/connections [POST]
+func PostConnections(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	connection := &models.PagerDutyConnection{}
+	err := connectionHelper.Create(connection, input)
+	if err != nil {
+		return nil, err
+	}
+	return &core.ApiResourceOutput{Body: connection, Status: http.StatusOK}, nil
+}
+
+// @Summary patch pagerduty connection
+// @Description Patch Pagerduty connection
+// @Tags plugins/pagerduty
+// @Param body body models.PagerDutyConnection true "json body"
+// @Success 200  {object} models.PagerDutyConnection
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/connections/{connectionId} [PATCH]
+func PatchConnection(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	connection := &models.PagerDutyConnection{}
+	err := connectionHelper.Patch(connection, input)
+	if err != nil {
+		return nil, err
+	}
+	return &core.ApiResourceOutput{Body: connection, Status: http.StatusOK}, nil
+}
+
+// @Summary delete pagerduty connection
+// @Description Delete Pagerduty connection
+// @Tags plugins/pagerduty
+// @Success 200  {object} models.PagerDutyConnection
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/connections/{connectionId} [DELETE]
+func DeleteConnection(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	connection := &models.PagerDutyConnection{}
+	err := connectionHelper.First(connection, input.Params)
+	if err != nil {
+		return nil, err
+	}
+	err = connectionHelper.Delete(connection)
+	return &core.ApiResourceOutput{Body: connection}, err
+}
+
+// @Summary list pagerduty connections
+// @Description List Pagerduty connections
+// @Tags plugins/pagerduty
+// @Success 200  {object} models.PagerDutyConnection
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/connections [GET]
+func ListConnections(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	var connections []models.PagerDutyConnection
+	err := connectionHelper.List(&connections)
+	if err != nil {
+		return nil, err
+	}
+
+	return &core.ApiResourceOutput{Body: connections}, nil
+}
+
+// @Summary get pagerduty connection
+// @Description Get Pagerduty connection
+// @Tags plugins/pagerduty
+// @Success 200  {object} models.PagerDutyConnection
+// @Failure 400  {string} errcode.Error "Bad Request"
+// @Failure 500  {string} errcode.Error "Internal Error"
+// @Router /plugins/pagerduty/connections/{connectionId} [GET]
+func GetConnection(input *core.ApiResourceInput) (*core.ApiResourceOutput, errors.Error) {
+	connection := &models.PagerDutyConnection{}
+	err := connectionHelper.First(connection, input.Params)
+	if err != nil {
+		return nil, err
+	}
+	return &core.ApiResourceOutput{Body: connection}, nil
+}
diff --git a/plugins/pagerduty/api/init.go b/plugins/pagerduty/api/init.go
new file mode 100644
index 000000000..6774e1482
--- /dev/null
+++ b/plugins/pagerduty/api/init.go
@@ -0,0 +1,39 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/go-playground/validator/v10"
+	"github.com/spf13/viper"
+	"gorm.io/gorm"
+)
+
+var vld *validator.Validate
+var connectionHelper *helper.ConnectionApiHelper
+var basicRes core.BasicRes
+
+func Init(config *viper.Viper, logger core.Logger, database *gorm.DB) {
+	basicRes = helper.NewDefaultBasicRes(config, logger, database)
+	vld = validator.New()
+	connectionHelper = helper.NewConnectionHelper(
+		basicRes,
+		vld,
+	)
+}
diff --git a/plugins/pagerduty/e2e/incident_test.go b/plugins/pagerduty/e2e/incident_test.go
new file mode 100644
index 000000000..08c2667a4
--- /dev/null
+++ b/plugins/pagerduty/e2e/incident_test.go
@@ -0,0 +1,86 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+	"github.com/apache/incubator-devlake/helpers/e2ehelper"
+	"github.com/apache/incubator-devlake/models/common"
+	"github.com/apache/incubator-devlake/models/domainlayer/ticket"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/impl"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/tasks"
+	"testing"
+)
+
+func TestIncidentDataFlow(t *testing.T) {
+	var plugin impl.PagerDuty
+	dataflowTester := e2ehelper.NewDataFlowTester(t, "pagerduty", plugin)
+
+	taskData := &tasks.PagerDutyTaskData{
+		Options: &tasks.PagerDutyOptions{
+			ConnectionId: 1,
+		},
+	}
+
+	// import raw data table
+	dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_pagerduty_incidents.csv", "_raw_pagerduty_incidents")
+
+	// verify worklog extraction
+	dataflowTester.FlushTabler(&models.Incident{})
+	dataflowTester.FlushTabler(&models.User{})
+	dataflowTester.FlushTabler(&models.Service{})
+	dataflowTester.FlushTabler(&models.Assignment{})
+	dataflowTester.Subtask(tasks.ExtractIncidentsMeta, taskData)
+	dataflowTester.VerifyTableWithOptions(
+		models.Incident{},
+		e2ehelper.TableOptions{
+			CSVRelPath:  "./snapshot_tables/_tool_pagerduty_incidents.csv",
+			IgnoreTypes: []any{common.Model{}},
+		},
+	)
+	dataflowTester.VerifyTableWithOptions(
+		models.User{},
+		e2ehelper.TableOptions{
+			CSVRelPath:  "./snapshot_tables/_tool_pagerduty_users.csv",
+			IgnoreTypes: []any{common.Model{}},
+		},
+	)
+	dataflowTester.VerifyTableWithOptions(
+		models.Assignment{},
+		e2ehelper.TableOptions{
+			CSVRelPath:  "./snapshot_tables/_tool_pagerduty_assignments.csv",
+			IgnoreTypes: []any{common.Model{}},
+		},
+	)
+	dataflowTester.VerifyTableWithOptions(
+		models.Service{},
+		e2ehelper.TableOptions{
+			CSVRelPath:  "./snapshot_tables/_tool_pagerduty_services.csv",
+			IgnoreTypes: []any{common.Model{}},
+		},
+	)
+	dataflowTester.FlushTabler(&ticket.Issue{})
+	dataflowTester.Subtask(tasks.ConvertIncidentsMeta, taskData)
+	dataflowTester.VerifyTableWithOptions(
+		ticket.Issue{},
+		e2ehelper.TableOptions{
+			CSVRelPath:  "./snapshot_tables/issues.csv",
+			IgnoreTypes: []any{common.NoPKModel{}},
+		},
+	)
+}
diff --git a/plugins/pagerduty/e2e/raw_tables/_raw_pagerduty_incidents.csv b/plugins/pagerduty/e2e/raw_tables/_raw_pagerduty_incidents.csv
new file mode 100644
index 000000000..19989b120
--- /dev/null
+++ b/plugins/pagerduty/e2e/raw_tables/_raw_pagerduty_incidents.csv
@@ -0,0 +1,4 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""Stream"":""incidents""}","{""incident_number"": 4, ""title"": ""Crash reported"", ""created_at"": ""2022-11-03T06:23:06.000000Z"", ""status"": ""triggered"", ""incident_key"": ""bb60942875634ee6a7fe94ddb51c3a09"", ""service"": {""id"": ""PIKL83L"", ""type"": ""service_reference"", ""summary"": ""DevService"", ""self"": ""https://api.pagerduty.com/services/PIKL83L"", ""html_url"": ""https://keon-test.pagerduty.com/service-directory/PIKL83L""}, ""assignments"": [{" [...]
+2,"{""ConnectionId"":1,""Stream"":""incidents""}","{""incident_number"": 5, ""title"": ""Slow startup"", ""created_at"": ""2022-11-03T06:44:28.000000Z"", ""status"": ""acknowledged"", ""incident_key"": ""d7bc6d39c37e4af8b206a12ff6b05793"", ""service"": {""id"": ""PIKL83L"", ""type"": ""service_reference"", ""summary"": ""DevService"", ""self"": ""https://api.pagerduty.com/services/PIKL83L"", ""html_url"": ""https://keon-test.pagerduty.com/service-directory/PIKL83L""}, ""assignments"": [{ [...]
+3,"{""ConnectionId"":1,""Stream"":""incidents""}","{""incident_number"": 6, ""title"": ""Spamming logs"", ""created_at"": ""2022-11-03T06:45:36.000000Z"", ""status"": ""resolved"", ""incident_key"": ""9f5acd07975e4c57bc717d8d9e066785"", ""service"": {""id"": ""PIKL83L"", ""type"": ""service_reference"", ""summary"": ""DevService"", ""self"": ""https://api.pagerduty.com/services/PIKL83L"", ""html_url"": ""https://keon-test.pagerduty.com/service-directory/PIKL83L""}, ""assignments"": [], " [...]
diff --git a/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_assignments.csv b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_assignments.csv
new file mode 100644
index 000000000..6acfc18e5
--- /dev/null
+++ b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_assignments.csv
@@ -0,0 +1,4 @@
+incident_number,user_id,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,connection_id,assigned_at
+4,P25K520,2022-11-03T07:11:37.415+00:00,2022-11-03T07:11:37.415+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,1,,1,2022-11-03T07:02:36.000+00:00
+4,PQYACO3,2022-11-03T07:11:37.415+00:00,2022-11-03T07:11:37.415+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,1,,1,2022-11-03T06:23:06.000+00:00
+5,PQYACO3,2022-11-03T07:11:37.415+00:00,2022-11-03T07:11:37.415+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,2,,1,2022-11-03T06:44:37.000+00:00
diff --git a/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_incidents.csv b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_incidents.csv
new file mode 100644
index 000000000..920ff1fe5
--- /dev/null
+++ b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_incidents.csv
@@ -0,0 +1,4 @@
+connection_id,number,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,url,service_id,summary,status,urgency,created_date,updated_date
+1,4,2022-11-03T07:11:37.422+00:00,2022-11-03T07:11:37.422+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,1,,https://keon-test.pagerduty.com/incidents/Q3YON8WNWTZMRQ,PIKL83L,[#4] Crash reported,triggered,high,2022-11-03T06:23:06.000+00:00,2022-11-03T07:02:36.000+00:00
+1,5,2022-11-03T07:11:37.422+00:00,2022-11-03T07:11:37.422+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,2,,https://keon-test.pagerduty.com/incidents/Q3CZAU7Q4008QD,PIKL83L,[#5] Slow startup,acknowledged,high,2022-11-03T06:44:28.000+00:00,2022-11-03T06:44:37.000+00:00
+1,6,2022-11-03T07:11:37.422+00:00,2022-11-03T07:11:37.422+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,3,,https://keon-test.pagerduty.com/incidents/Q1OHFWFP3GPXOG,PIKL83L,[#6] Spamming logs,resolved,low,2022-11-03T06:45:36.000+00:00,2022-11-03T06:51:44.000+00:00
diff --git a/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_services.csv b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_services.csv
new file mode 100644
index 000000000..8f5225257
--- /dev/null
+++ b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_services.csv
@@ -0,0 +1,2 @@
+connection_id,id,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,url,name
+1,PIKL83L,2022-11-03T07:11:37.411+00:00,2022-11-03T07:11:37.411+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,3,,https://keon-test.pagerduty.com/service-directory/PIKL83L,DevService
diff --git a/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_users.csv b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_users.csv
new file mode 100644
index 000000000..25ad6a710
--- /dev/null
+++ b/plugins/pagerduty/e2e/snapshot_tables/_tool_pagerduty_users.csv
@@ -0,0 +1,3 @@
+connection_id,id,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,url,name
+1,P25K520,2022-11-03T07:11:37.418+00:00,2022-11-03T07:11:37.418+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,1,,https://keon-test.pagerduty.com/users/P25K520,Kian Amini
+1,PQYACO3,2022-11-03T07:11:37.418+00:00,2022-11-03T07:11:37.418+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,2,,https://keon-test.pagerduty.com/users/PQYACO3,Keon Amini
diff --git a/plugins/pagerduty/e2e/snapshot_tables/issues.csv b/plugins/pagerduty/e2e/snapshot_tables/issues.csv
new file mode 100644
index 000000000..e923dbcfe
--- /dev/null
+++ b/plugins/pagerduty/e2e/snapshot_tables/issues.csv
@@ -0,0 +1,4 @@
+id,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,url,icon_url,issue_key,title,description,epic_key,type,status,original_status,story_point,resolution_date,created_date,updated_date,parent_issue_id,priority,original_estimate_minutes,time_spent_minutes,time_remaining_minutes,creator_id,creator_name,assignee_id,assignee_name,severity,component,deployment_id,lead_time_minutes
+pagerduty:Incident:1:4,2022-11-03T07:11:37.437+00:00,2022-11-03T07:11:37.437+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,1,,https://keon-test.pagerduty.com/incidents/Q3YON8WNWTZMRQ,,4,,[#4] Crash reported,,INCIDENT,TODO,triggered,0,,2022-11-03T06:23:06.000+00:00,2022-11-03T07:02:36.000+00:00,,high,0,0,0,,,P25K520,Kian Amini,,,"",0
+pagerduty:Incident:1:5,2022-11-03T07:11:37.437+00:00,2022-11-03T07:11:37.437+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,2,,https://keon-test.pagerduty.com/incidents/Q3CZAU7Q4008QD,,5,,[#5] Slow startup,,INCIDENT,IN_PROGRESS,acknowledged,0,,2022-11-03T06:44:28.000+00:00,2022-11-03T06:44:37.000+00:00,,high,0,0,0,,,PQYACO3,Keon Amini,,,"",0
+pagerduty:Incident:1:6,2022-11-03T07:11:37.437+00:00,2022-11-03T07:11:37.437+00:00,"{""ConnectionId"":1,""Stream"":""incidents""}",_raw_pagerduty_incidents,3,,https://keon-test.pagerduty.com/incidents/Q1OHFWFP3GPXOG,,6,,[#6] Spamming logs,,INCIDENT,DONE,resolved,0,2022-11-03T06:51:44.000+00:00,2022-11-03T06:45:36.000+00:00,2022-11-03T06:51:44.000+00:00,,low,0,0,0,,,,,,,"",6
diff --git a/plugins/pagerduty/impl/impl.go b/plugins/pagerduty/impl/impl.go
new file mode 100644
index 000000000..dcaabb3e8
--- /dev/null
+++ b/plugins/pagerduty/impl/impl.go
@@ -0,0 +1,148 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package impl
+
+import (
+	"fmt"
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/helpers/pluginhelper/tap"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/api"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models/migrationscripts"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/tasks"
+	"github.com/spf13/viper"
+	"gorm.io/gorm"
+	"time"
+)
+
+// make sure interface is implemented
+var _ core.PluginMeta = (*PagerDuty)(nil)
+var _ core.PluginInit = (*PagerDuty)(nil)
+var _ core.PluginTask = (*PagerDuty)(nil)
+var _ core.PluginApi = (*PagerDuty)(nil)
+var _ core.PluginBlueprintV100 = (*PagerDuty)(nil)
+var _ core.CloseablePluginTask = (*PagerDuty)(nil)
+
+type PagerDuty struct{}
+
+func (plugin PagerDuty) Description() string {
+	return "collect some PagerDuty data"
+}
+
+func (plugin PagerDuty) Init(config *viper.Viper, logger core.Logger, db *gorm.DB) errors.Error {
+	api.Init(config, logger, db)
+	return nil
+}
+
+func (plugin PagerDuty) SubTaskMetas() []core.SubTaskMeta {
+	return []core.SubTaskMeta{
+		tasks.CollectIncidentsMeta,
+		tasks.ExtractIncidentsMeta,
+		tasks.ConvertIncidentsMeta,
+	}
+}
+
+func (plugin PagerDuty) PrepareTaskData(taskCtx core.TaskContext, options map[string]interface{}) (interface{}, errors.Error) {
+	op, err := tasks.DecodeAndValidateTaskOptions(options)
+	if err != nil {
+		return nil, err
+	}
+	connectionHelper := helper.NewConnectionHelper(
+		taskCtx,
+		nil,
+	)
+	connection := &models.PagerDutyConnection{}
+	err = connectionHelper.FirstById(connection, op.ConnectionId)
+	if err != nil {
+		return nil, errors.Default.Wrap(err, "unable to get Pagerduty connection by the given connection ID")
+	}
+	startDate, err := parseTime("start_date", options)
+	if err != nil {
+		return nil, err
+	}
+	config := &models.PagerDutyConfig{
+		Token:     connection.Token,
+		Email:     "", // ignore, works without it too
+		StartDate: startDate,
+	}
+	tapClient, err := tap.NewSingerTap(&tap.SingerTapConfig{
+		TapExecutable:        models.TapExecutable,
+		StreamPropertiesFile: models.StreamPropertiesFile,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &tasks.PagerDutyTaskData{
+		Options: op,
+		Config:  config,
+		Client:  tapClient,
+	}, nil
+}
+
+// PkgPath information lost when compiled as plugin(.so)
+func (plugin PagerDuty) RootPkgPath() string {
+	return "github.com/apache/incubator-devlake/plugins/pagerduty"
+}
+
+func (plugin PagerDuty) MigrationScripts() []core.MigrationScript {
+	return migrationscripts.All()
+}
+
+func (plugin PagerDuty) ApiResources() map[string]map[string]core.ApiResourceHandler {
+	return map[string]map[string]core.ApiResourceHandler{
+		"test": {
+			"POST": api.TestConnection,
+		},
+		"connections": {
+			"POST": api.PostConnections,
+			"GET":  api.ListConnections,
+		},
+		"connections/:connectionId": {
+			"GET":    api.GetConnection,
+			"PATCH":  api.PatchConnection,
+			"DELETE": api.DeleteConnection,
+		},
+	}
+}
+
+func (plugin PagerDuty) MakePipelinePlan(connectionId uint64, scope []*core.BlueprintScopeV100) (core.PipelinePlan, errors.Error) {
+	return api.MakePipelinePlan(plugin.SubTaskMetas(), connectionId, scope)
+}
+
+func (plugin PagerDuty) Close(taskCtx core.TaskContext) errors.Error {
+	_, ok := taskCtx.GetData().(*tasks.PagerDutyTaskData)
+	if !ok {
+		return errors.Default.New(fmt.Sprintf("GetData failed when try to close %+v", taskCtx))
+	}
+	return nil
+}
+
+func parseTime(key string, opts map[string]any) (time.Time, errors.Error) {
+	var date time.Time
+	dateRaw, ok := opts[key]
+	if !ok {
+		return date, errors.BadInput.New("time input not provided")
+	}
+	date, err := time.Parse("2006-01-02T15:04:05Z", dateRaw.(string))
+	if err != nil {
+		return date, errors.BadInput.Wrap(err, "bad type input provided")
+	}
+	return date, nil
+}
diff --git a/plugins/pagerduty/models/assignment.go b/plugins/pagerduty/models/assignment.go
new file mode 100644
index 000000000..60d1875ce
--- /dev/null
+++ b/plugins/pagerduty/models/assignment.go
@@ -0,0 +1,35 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+	"github.com/apache/incubator-devlake/models/common"
+	"time"
+)
+
+type Assignment struct {
+	common.NoPKModel
+	ConnectionId   uint64
+	UserId         string `gorm:"primaryKey"`
+	IncidentNumber int    `gorm:"primaryKey"`
+	AssignedAt     time.Time
+}
+
+func (Assignment) TableName() string {
+	return "_tool_pagerduty_assignments"
+}
diff --git a/plugins/pagerduty/models/config.go b/plugins/pagerduty/models/config.go
new file mode 100644
index 000000000..c1818dc92
--- /dev/null
+++ b/plugins/pagerduty/models/config.go
@@ -0,0 +1,34 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+	"time"
+)
+
+// PagerDutyConfig model corresponds to docs here https://github.com/singer-io/tap-pagerduty
+type PagerDutyConfig struct {
+	Token     string    `json:"token"`
+	Email     string    `json:"email"` // Seems to be an inconsequential field
+	StartDate time.Time `json:"start_date"`
+}
+
+type PagerDutyParams struct {
+	ConnectionId uint64
+	Stream       string
+}
diff --git a/plugins/pagerduty/models/connection.go b/plugins/pagerduty/models/connection.go
new file mode 100644
index 000000000..82e79823c
--- /dev/null
+++ b/plugins/pagerduty/models/connection.go
@@ -0,0 +1,52 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+	"github.com/apache/incubator-devlake/plugins/helper"
+)
+
+// TODO Please modify the following code to fit your needs
+// This object conforms to what the frontend currently sends.
+type PagerDutyConnection struct {
+	helper.BaseConnection `mapstructure:",squash"`
+	helper.AccessToken    `mapstructure:",squash"`
+}
+
+type TestConnectionRequest struct {
+	Endpoint string `json:"endpoint" validate:"required,url"`
+	Token    string `json:"token" validate:"required"`
+	Proxy    string `json:"proxy"`
+}
+
+// This object conforms to what the frontend currently expects.
+type PagerDutyResponse struct {
+	Name string `json:"name"`
+	ID   int    `json:"id"`
+	PagerDutyConnection
+}
+
+// Using User because it requires authentication.
+type ApiUserResponse struct {
+	Id   int
+	Name string `json:"name"`
+}
+
+func (PagerDutyConnection) TableName() string {
+	return "_tool_pagerduty_connections"
+}
diff --git a/plugins/pagerduty/models/consts.go b/plugins/pagerduty/models/consts.go
new file mode 100644
index 000000000..2d2c658c5
--- /dev/null
+++ b/plugins/pagerduty/models/consts.go
@@ -0,0 +1,25 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+// The consts that this plugin needs
+const (
+	TapExecutable        = "tap-pagerduty"
+	StreamPropertiesFile = "pagerduty.json"
+	IncidentStream       = "incidents"
+)
diff --git a/plugins/pagerduty/models/generated/incidents.go b/plugins/pagerduty/models/generated/incidents.go
new file mode 100644
index 000000000..04e0941d8
--- /dev/null
+++ b/plugins/pagerduty/models/generated/incidents.go
@@ -0,0 +1,491 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
+
+package generated
+
+import "time"
+
+type Incidents struct {
+	// Acknowledgements corresponds to the JSON schema field "acknowledgements".
+	Acknowledgements []IncidentsAcknowledgementsElem `json:"acknowledgements,omitempty"`
+
+	// Alerts corresponds to the JSON schema field "alerts".
+	Alerts []IncidentsAlertsElem `json:"alerts,omitempty"`
+
+	// Assignments corresponds to the JSON schema field "assignments".
+	Assignments []IncidentsAssignmentsElem `json:"assignments,omitempty"`
+
+	// ConferenceBridge corresponds to the JSON schema field "conference_bridge".
+	ConferenceBridge *IncidentsConferenceBridge `json:"conference_bridge,omitempty"`
+
+	// CreatedAt corresponds to the JSON schema field "created_at".
+	CreatedAt *time.Time `json:"created_at,omitempty"`
+
+	// EscalationPolicy corresponds to the JSON schema field "escalation_policy".
+	EscalationPolicy *IncidentsEscalationPolicy `json:"escalation_policy,omitempty"`
+
+	// FirstTriggerLogEntry corresponds to the JSON schema field
+	// "first_trigger_log_entry".
+	FirstTriggerLogEntry *IncidentsFirstTriggerLogEntry `json:"first_trigger_log_entry,omitempty"`
+
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// IncidentKey corresponds to the JSON schema field "incident_key".
+	IncidentKey *string `json:"incident_key,omitempty"`
+
+	// IncidentNumber corresponds to the JSON schema field "incident_number".
+	IncidentNumber *int `json:"incident_number,omitempty"`
+
+	// LastStatusChangeAt corresponds to the JSON schema field
+	// "last_status_change_at".
+	LastStatusChangeAt *time.Time `json:"last_status_change_at,omitempty"`
+
+	// LastStatusChangeBy corresponds to the JSON schema field
+	// "last_status_change_by".
+	LastStatusChangeBy *IncidentsLastStatusChangeBy `json:"last_status_change_by,omitempty"`
+
+	// LogEntries corresponds to the JSON schema field "log_entries".
+	LogEntries []IncidentsLogEntriesElem `json:"log_entries,omitempty"`
+
+	// Priority corresponds to the JSON schema field "priority".
+	Priority *IncidentsPriority `json:"priority,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Service corresponds to the JSON schema field "service".
+	Service *IncidentsService `json:"service,omitempty"`
+
+	// Status corresponds to the JSON schema field "status".
+	Status *string `json:"status,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Teams corresponds to the JSON schema field "teams".
+	Teams []IncidentsTeamsElem `json:"teams,omitempty"`
+
+	// Title corresponds to the JSON schema field "title".
+	Title *string `json:"title,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+
+	// Urgency corresponds to the JSON schema field "urgency".
+	Urgency *string `json:"urgency,omitempty"`
+}
+
+type IncidentsAcknowledgementsElem struct {
+	// Acknowledger corresponds to the JSON schema field "acknowledger".
+	Acknowledger *IncidentsAcknowledgementsElemAcknowledger `json:"acknowledger,omitempty"`
+
+	// At corresponds to the JSON schema field "at".
+	At *time.Time `json:"at,omitempty"`
+}
+
+type IncidentsAcknowledgementsElemAcknowledger struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElem struct {
+	// AlertKey corresponds to the JSON schema field "alert_key".
+	AlertKey *string `json:"alert_key,omitempty"`
+
+	// Body corresponds to the JSON schema field "body".
+	Body *IncidentsAlertsElemBody `json:"body,omitempty"`
+
+	// CreatedAt corresponds to the JSON schema field "created_at".
+	CreatedAt *time.Time `json:"created_at,omitempty"`
+
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Incident corresponds to the JSON schema field "incident".
+	Incident *IncidentsAlertsElemIncident `json:"incident,omitempty"`
+
+	// Integration corresponds to the JSON schema field "integration".
+	Integration *IncidentsAlertsElemIntegration `json:"integration,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Service corresponds to the JSON schema field "service".
+	Service *IncidentsAlertsElemService `json:"service,omitempty"`
+
+	// Severity corresponds to the JSON schema field "severity".
+	Severity *string `json:"severity,omitempty"`
+
+	// Status corresponds to the JSON schema field "status".
+	Status *string `json:"status,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Suppressed corresponds to the JSON schema field "suppressed".
+	Suppressed *bool `json:"suppressed,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemBody struct {
+	// Contexts corresponds to the JSON schema field "contexts".
+	Contexts []IncidentsAlertsElemBodyContextsElem `json:"contexts,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemBodyContextsElem struct {
+	// Href corresponds to the JSON schema field "href".
+	Href *string `json:"href,omitempty"`
+
+	// Src corresponds to the JSON schema field "src".
+	Src *string `json:"src,omitempty"`
+
+	// Text corresponds to the JSON schema field "text".
+	Text *string `json:"text,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemIncident struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemIntegration struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Name corresponds to the JSON schema field "name".
+	Name *string `json:"name,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Service corresponds to the JSON schema field "service".
+	Service *IncidentsAlertsElemIntegrationService `json:"service,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemIntegrationService struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAlertsElemService struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsAssignmentsElem struct {
+	// Assignee corresponds to the JSON schema field "assignee".
+	Assignee *IncidentsAssignmentsElemAssignee `json:"assignee,omitempty"`
+
+	// At corresponds to the JSON schema field "at".
+	At *time.Time `json:"at,omitempty"`
+}
+
+type IncidentsAssignmentsElemAssignee struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsConferenceBridge struct {
+	// ConferenceNumber corresponds to the JSON schema field "conference_number".
+	ConferenceNumber *string `json:"conference_number,omitempty"`
+
+	// ConferenceUrl corresponds to the JSON schema field "conference_url".
+	ConferenceUrl *string `json:"conference_url,omitempty"`
+}
+
+type IncidentsEscalationPolicy struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsFirstTriggerLogEntry struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLastStatusChangeBy struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLogEntriesElem struct {
+	// Agent corresponds to the JSON schema field "agent".
+	Agent *IncidentsLogEntriesElemAgent `json:"agent,omitempty"`
+
+	// Channel corresponds to the JSON schema field "channel".
+	Channel *IncidentsLogEntriesElemChannel `json:"channel,omitempty"`
+
+	// CreatedAt corresponds to the JSON schema field "created_at".
+	CreatedAt *time.Time `json:"created_at,omitempty"`
+
+	// EventDetails corresponds to the JSON schema field "event_details".
+	EventDetails *IncidentsLogEntriesElemEventDetails `json:"event_details,omitempty"`
+
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Incident corresponds to the JSON schema field "incident".
+	Incident *IncidentsLogEntriesElemIncident `json:"incident,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Teams corresponds to the JSON schema field "teams".
+	Teams []IncidentsLogEntriesElemTeamsElem `json:"teams,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLogEntriesElemAgent struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLogEntriesElemChannel struct {
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLogEntriesElemEventDetails struct {
+	// Description corresponds to the JSON schema field "description".
+	Description *string `json:"description,omitempty"`
+}
+
+type IncidentsLogEntriesElemIncident struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsLogEntriesElemTeamsElem struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsPriority struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsService struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type IncidentsTeamsElem struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
diff --git a/plugins/pagerduty/models/generated/notifications.go b/plugins/pagerduty/models/generated/notifications.go
new file mode 100644
index 000000000..721ea5479
--- /dev/null
+++ b/plugins/pagerduty/models/generated/notifications.go
@@ -0,0 +1,55 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
+
+package generated
+
+import "time"
+
+type Notifications struct {
+	// Address corresponds to the JSON schema field "address".
+	Address *string `json:"address,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// StartedAt corresponds to the JSON schema field "started_at".
+	StartedAt *time.Time `json:"started_at,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+
+	// User corresponds to the JSON schema field "user".
+	User *NotificationsUser `json:"user,omitempty"`
+}
+
+type NotificationsUser struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
diff --git a/plugins/pagerduty/models/generated/services.go b/plugins/pagerduty/models/generated/services.go
new file mode 100644
index 000000000..0823548a1
--- /dev/null
+++ b/plugins/pagerduty/models/generated/services.go
@@ -0,0 +1,205 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
+
+package generated
+
+import "time"
+
+type Services struct {
+	// AcknowledgementTimeout corresponds to the JSON schema field
+	// "acknowledgement_timeout".
+	AcknowledgementTimeout *int `json:"acknowledgement_timeout,omitempty"`
+
+	// AlertCreation corresponds to the JSON schema field "alert_creation".
+	AlertCreation *string `json:"alert_creation,omitempty"`
+
+	// AlertGrouping corresponds to the JSON schema field "alert_grouping".
+	AlertGrouping *string `json:"alert_grouping,omitempty"`
+
+	// AlertGroupingTimeout corresponds to the JSON schema field
+	// "alert_grouping_timeout".
+	AlertGroupingTimeout *int `json:"alert_grouping_timeout,omitempty"`
+
+	// AutoResolveTimeout corresponds to the JSON schema field "auto_resolve_timeout".
+	AutoResolveTimeout *int `json:"auto_resolve_timeout,omitempty"`
+
+	// CreatedAt corresponds to the JSON schema field "created_at".
+	CreatedAt *time.Time `json:"created_at,omitempty"`
+
+	// Description corresponds to the JSON schema field "description".
+	Description *string `json:"description,omitempty"`
+
+	// EscalationPolicy corresponds to the JSON schema field "escalation_policy".
+	EscalationPolicy *ServicesEscalationPolicy `json:"escalation_policy,omitempty"`
+
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// IncidentUrgencyRule corresponds to the JSON schema field
+	// "incident_urgency_rule".
+	IncidentUrgencyRule *ServicesIncidentUrgencyRule `json:"incident_urgency_rule,omitempty"`
+
+	// Integrations corresponds to the JSON schema field "integrations".
+	Integrations []ServicesIntegrationsElem `json:"integrations,omitempty"`
+
+	// LastIncidentTimestamp corresponds to the JSON schema field
+	// "last_incident_timestamp".
+	LastIncidentTimestamp *time.Time `json:"last_incident_timestamp,omitempty"`
+
+	// Name corresponds to the JSON schema field "name".
+	Name *string `json:"name,omitempty"`
+
+	// ScheduledActions corresponds to the JSON schema field "scheduled_actions".
+	ScheduledActions []ServicesScheduledActionsElem `json:"scheduled_actions,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Status corresponds to the JSON schema field "status".
+	Status *string `json:"status,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// SupportHours corresponds to the JSON schema field "support_hours".
+	SupportHours *ServicesSupportHours `json:"support_hours,omitempty"`
+
+	// Teams corresponds to the JSON schema field "teams".
+	Teams []ServicesTeamsElem `json:"teams,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesEscalationPolicy struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesIncidentUrgencyRule struct {
+	// DuringSupportHours corresponds to the JSON schema field "during_support_hours".
+	DuringSupportHours *ServicesIncidentUrgencyRuleDuringSupportHours `json:"during_support_hours,omitempty"`
+
+	// OutsideSupportHours corresponds to the JSON schema field
+	// "outside_support_hours".
+	OutsideSupportHours *ServicesIncidentUrgencyRuleOutsideSupportHours `json:"outside_support_hours,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesIncidentUrgencyRuleDuringSupportHours struct {
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+
+	// Urgency corresponds to the JSON schema field "urgency".
+	Urgency *string `json:"urgency,omitempty"`
+}
+
+type ServicesIncidentUrgencyRuleOutsideSupportHours struct {
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+
+	// Urgency corresponds to the JSON schema field "urgency".
+	Urgency *string `json:"urgency,omitempty"`
+}
+
+type ServicesIntegrationsElem struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesScheduledActionsElem struct {
+	// At corresponds to the JSON schema field "at".
+	At *ServicesScheduledActionsElemAt `json:"at,omitempty"`
+
+	// ToUrgency corresponds to the JSON schema field "to_urgency".
+	ToUrgency *string `json:"to_urgency,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesScheduledActionsElemAt struct {
+	// Name corresponds to the JSON schema field "name".
+	Name *string `json:"name,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesSupportHours struct {
+	// DaysOfWeek corresponds to the JSON schema field "days_of_week".
+	DaysOfWeek []int `json:"days_of_week,omitempty"`
+
+	// EndTime corresponds to the JSON schema field "end_time".
+	EndTime *string `json:"end_time,omitempty"`
+
+	// StartTime corresponds to the JSON schema field "start_time".
+	StartTime *string `json:"start_time,omitempty"`
+
+	// TimeZone corresponds to the JSON schema field "time_zone".
+	TimeZone *string `json:"time_zone,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
+
+type ServicesTeamsElem struct {
+	// HtmlUrl corresponds to the JSON schema field "html_url".
+	HtmlUrl *string `json:"html_url,omitempty"`
+
+	// Id corresponds to the JSON schema field "id".
+	Id *string `json:"id,omitempty"`
+
+	// Self corresponds to the JSON schema field "self".
+	Self *string `json:"self,omitempty"`
+
+	// Summary corresponds to the JSON schema field "summary".
+	Summary *string `json:"summary,omitempty"`
+
+	// Type corresponds to the JSON schema field "type".
+	Type *string `json:"type,omitempty"`
+}
diff --git a/plugins/pagerduty/models/incident.go b/plugins/pagerduty/models/incident.go
new file mode 100644
index 000000000..182fa4fab
--- /dev/null
+++ b/plugins/pagerduty/models/incident.go
@@ -0,0 +1,51 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+	"github.com/apache/incubator-devlake/models/common"
+	"time"
+)
+
+const (
+	IncidentStatusAcknowledged IncidentStatus = "acknowledged"
+	IncidentStatusTriggered    IncidentStatus = "triggered"
+	IncidentStatusResolved     IncidentStatus = "resolved"
+)
+
+type (
+	IncidentUrgency string
+	IncidentStatus  string
+
+	Incident struct {
+		common.NoPKModel
+		ConnectionId uint64 `gorm:"primaryKey"`
+		Number       int    `gorm:"primaryKey"`
+		Url          string
+		ServiceId    string
+		Summary      string
+		Status       IncidentStatus  //acknowledged, triggered, resolved
+		Urgency      IncidentUrgency //high or low
+		CreatedDate  time.Time
+		UpdatedDate  time.Time
+	}
+)
+
+func (Incident) TableName() string {
+	return "_tool_pagerduty_incidents"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/20221115_add_init_tables.go b/plugins/pagerduty/models/migrationscripts/20221115_add_init_tables.go
new file mode 100644
index 000000000..3e228fed1
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/20221115_add_init_tables.go
@@ -0,0 +1,46 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/helpers/migrationhelper"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models/migrationscripts/archived"
+)
+
+type addInitTables struct{}
+
+func (*addInitTables) Up(baseRes core.BasicRes) errors.Error {
+	err := migrationhelper.AutoMigrateTables(baseRes,
+		&archived.Connection{},
+		&archived.Incident{},
+		&archived.User{},
+		&archived.Assignment{},
+		&archived.Service{},
+	)
+	return err
+}
+
+func (*addInitTables) Version() uint64 {
+	return 20221115000001
+}
+
+func (*addInitTables) Name() string {
+	return "PagerDuty init schemas"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/archived/assignment.go b/plugins/pagerduty/models/migrationscripts/archived/assignment.go
new file mode 100644
index 000000000..bda07fa2a
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/archived/assignment.go
@@ -0,0 +1,35 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package archived
+
+import (
+	"github.com/apache/incubator-devlake/models/common"
+	"time"
+)
+
+type Assignment struct {
+	common.NoPKModel
+	ConnectionId   uint64
+	IncidentNumber int    `gorm:"primaryKey"`
+	UserId         string `gorm:"primaryKey"`
+	AssignedAt     time.Time
+}
+
+func (Assignment) TableName() string {
+	return "_tool_pagerduty_assignments"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/archived/connection.go b/plugins/pagerduty/models/migrationscripts/archived/connection.go
new file mode 100644
index 000000000..edc752a2d
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/archived/connection.go
@@ -0,0 +1,30 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package archived
+
+import "github.com/apache/incubator-devlake/models/migrationscripts/archived"
+
+type Connection struct {
+	archived.Model
+	Name  string `gorm:"type:varchar(100);uniqueIndex" json:"name" validate:"required"`
+	Token string `mapstructure:"token" env:"PAGERDUTY_AUTH" validate:"required" encrypt:"yes"`
+}
+
+func (Connection) TableName() string {
+	return "_tool_pagerduty_connections"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/archived/incident.go b/plugins/pagerduty/models/migrationscripts/archived/incident.go
new file mode 100644
index 000000000..5419c3490
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/archived/incident.go
@@ -0,0 +1,40 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package archived
+
+import (
+	"github.com/apache/incubator-devlake/models/common"
+	"time"
+)
+
+type Incident struct {
+	common.NoPKModel
+	ConnectionId uint64 `gorm:"primaryKey"`
+	Number       int    `gorm:"primaryKey"`
+	Url          string
+	ServiceId    string
+	Summary      string
+	Status       string
+	Urgency      string
+	CreatedDate  time.Time
+	UpdatedDate  time.Time
+}
+
+func (Incident) TableName() string {
+	return "_tool_pagerduty_incidents"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/archived/service.go b/plugins/pagerduty/models/migrationscripts/archived/service.go
new file mode 100644
index 000000000..759f0a3fa
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/archived/service.go
@@ -0,0 +1,34 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package archived
+
+import "github.com/apache/incubator-devlake/models/common"
+
+type (
+	Service struct {
+		common.NoPKModel
+		ConnectionId uint64 `gorm:"primaryKey"`
+		Id           string `gorm:"primaryKey"`
+		Url          string
+		Name         string
+	}
+)
+
+func (Service) TableName() string {
+	return "_tool_pagerduty_services"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/archived/user.go b/plugins/pagerduty/models/migrationscripts/archived/user.go
new file mode 100644
index 000000000..b67771e06
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/archived/user.go
@@ -0,0 +1,32 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package archived
+
+import "github.com/apache/incubator-devlake/models/common"
+
+type User struct {
+	common.NoPKModel
+	ConnectionId uint64 `gorm:"primaryKey"`
+	Id           string `gorm:"primaryKey"`
+	Url          string
+	Name         string
+}
+
+func (User) TableName() string {
+	return "_tool_pagerduty_users"
+}
diff --git a/plugins/pagerduty/models/migrationscripts/register.go b/plugins/pagerduty/models/migrationscripts/register.go
new file mode 100644
index 000000000..c0c766cd6
--- /dev/null
+++ b/plugins/pagerduty/models/migrationscripts/register.go
@@ -0,0 +1,29 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+	"github.com/apache/incubator-devlake/plugins/core"
+)
+
+// All return all the migration scripts
+func All() []core.MigrationScript {
+	return []core.MigrationScript{
+		new(addInitTables),
+	}
+}
diff --git a/plugins/pagerduty/models/service.go b/plugins/pagerduty/models/service.go
new file mode 100644
index 000000000..f09dfbe30
--- /dev/null
+++ b/plugins/pagerduty/models/service.go
@@ -0,0 +1,32 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import "github.com/apache/incubator-devlake/models/common"
+
+type Service struct {
+	common.NoPKModel
+	ConnectionId uint64 `gorm:"primaryKey"`
+	Url          string
+	Id           string `gorm:"primaryKey"`
+	Name         string
+}
+
+func (Service) TableName() string {
+	return "_tool_pagerduty_services"
+}
diff --git a/plugins/pagerduty/models/user.go b/plugins/pagerduty/models/user.go
new file mode 100644
index 000000000..4af5e059a
--- /dev/null
+++ b/plugins/pagerduty/models/user.go
@@ -0,0 +1,32 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import "github.com/apache/incubator-devlake/models/common"
+
+type User struct {
+	common.NoPKModel
+	ConnectionId uint64 `gorm:"primaryKey"`
+	Id           string `gorm:"primaryKey"`
+	Url          string
+	Name         string
+}
+
+func (User) TableName() string {
+	return "_tool_pagerduty_users"
+}
diff --git a/plugins/pagerduty/pager_duty.go b/plugins/pagerduty/pager_duty.go
new file mode 100644
index 000000000..a4bade7f3
--- /dev/null
+++ b/plugins/pagerduty/pager_duty.go
@@ -0,0 +1,43 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"github.com/apache/incubator-devlake/plugins/pagerduty/impl"
+	"github.com/apache/incubator-devlake/runner"
+	"github.com/spf13/cobra"
+)
+
+// PluginEntry Export a variable named PluginEntry for Framework to search and load
+var PluginEntry impl.PagerDuty //nolint
+
+// standalone mode for debugging
+func main() {
+	cmd := &cobra.Command{Use: "pagerduty"}
+
+	// TODO add your cmd flag if necessary
+	// yourFlag := cmd.Flags().IntP("yourFlag", "y", 8, "TODO add description here")
+	// _ = cmd.MarkFlagRequired("yourFlag")
+
+	cmd.Run = func(cmd *cobra.Command, args []string) {
+		runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{
+			// TODO add more custom params here
+		})
+	}
+	runner.RunCmd(cmd)
+}
diff --git a/plugins/pagerduty/tasks/incidents_collector.go b/plugins/pagerduty/tasks/incidents_collector.go
new file mode 100644
index 000000000..48006c2f7
--- /dev/null
+++ b/plugins/pagerduty/tasks/incidents_collector.go
@@ -0,0 +1,62 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/helpers/pluginhelper/tap"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+)
+
+const RAW_INCIDENTS_TABLE = "pagerduty_incidents"
+
+var _ core.SubTaskEntryPoint = CollectIncidents
+
+func CollectIncidents(taskCtx core.SubTaskContext) errors.Error {
+	data := taskCtx.GetData().(*PagerDutyTaskData)
+	collector, err := tap.NewTapCollector(
+		&tap.CollectorArgs[tap.SingerTapStream]{
+			RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+				Ctx:   taskCtx,
+				Table: RAW_INCIDENTS_TABLE,
+				Params: models.PagerDutyParams{
+					Stream:       models.IncidentStream,
+					ConnectionId: data.Options.ConnectionId,
+				},
+			},
+			TapClient:    data.Client,
+			TapConfig:    data.Config,
+			ConnectionId: data.Options.ConnectionId, // Seems to be an inconsequential field
+			StreamName:   models.IncidentStream,
+		},
+	)
+	if err != nil {
+		return err
+	}
+	return collector.Execute()
+}
+
+var CollectIncidentsMeta = core.SubTaskMeta{
+	Name:             "collectIncidents",
+	EntryPoint:       CollectIncidents,
+	EnabledByDefault: true,
+	Description:      "Collect PagerDuty incidents",
+	DomainTypes:      []string{core.DOMAIN_TYPE_TICKET},
+}
diff --git a/plugins/pagerduty/tasks/incidents_converter.go b/plugins/pagerduty/tasks/incidents_converter.go
new file mode 100644
index 000000000..ec13ec2aa
--- /dev/null
+++ b/plugins/pagerduty/tasks/incidents_converter.go
@@ -0,0 +1,143 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+	"fmt"
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/models/common"
+	"github.com/apache/incubator-devlake/models/domainlayer"
+	"github.com/apache/incubator-devlake/models/domainlayer/didgen"
+	"github.com/apache/incubator-devlake/models/domainlayer/ticket"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/core/dal"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+	"reflect"
+	"time"
+)
+
+var ConvertIncidentsMeta = core.SubTaskMeta{
+	Name:             "convertIncidents",
+	EntryPoint:       ConvertIncidents,
+	EnabledByDefault: true,
+	Description:      "Convert incidents into domain layer table issues",
+	DomainTypes:      []string{core.DOMAIN_TYPE_TICKET},
+}
+
+type (
+	// IncidentWithUser struct that represents the joined query result
+	IncidentWithUser struct {
+		common.NoPKModel
+		*models.Incident
+		*models.User
+		AssignedAt time.Time
+	}
+)
+
+func ConvertIncidents(taskCtx core.SubTaskContext) errors.Error {
+	db := taskCtx.GetDal()
+	data := taskCtx.GetData().(*PagerDutyTaskData)
+	cursor, err := db.Cursor(
+		dal.Select("pi.*, pu.*, pa.assigned_at"),
+		dal.From("_tool_pagerduty_incidents AS pi"),
+		dal.Join(`LEFT JOIN _tool_pagerduty_assignments AS pa ON pa.incident_number = pi.number`),
+		dal.Join(`LEFT JOIN _tool_pagerduty_users AS pu ON pa.user_id = pu.id`),
+		dal.Where("pi.connection_id = ?", data.Options.ConnectionId),
+	)
+	if err != nil {
+		return err
+	}
+	defer cursor.Close()
+	seenIncidents := map[int]*IncidentWithUser{}
+	idGen := didgen.NewDomainIdGenerator(&models.Incident{})
+	converter, err := helper.NewDataConverter(helper.DataConverterArgs{
+		RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+			Ctx: taskCtx,
+			Params: models.PagerDutyParams{
+				ConnectionId: data.Options.ConnectionId,
+				Stream:       models.IncidentStream,
+			},
+			Table: RAW_INCIDENTS_TABLE,
+		},
+		InputRowType: reflect.TypeOf(IncidentWithUser{}),
+		Input:        cursor,
+		Convert: func(inputRow interface{}) ([]interface{}, errors.Error) {
+			combined := inputRow.(*IncidentWithUser)
+			incident := combined.Incident
+			user := combined.User
+			if seen, ok := seenIncidents[incident.Number]; ok {
+				if combined.AssignedAt.Before(seen.AssignedAt) {
+					// skip this one (it's an older assignee)
+					return nil, nil
+				}
+			}
+			status := getStatus(incident)
+			leadTime, resolutionDate := getTimes(incident)
+			domainIssue := &ticket.Issue{
+				DomainEntity: domainlayer.DomainEntity{
+					Id: idGen.Generate(data.Options.ConnectionId, incident.Number),
+				},
+				Url:             incident.Url,
+				IssueKey:        fmt.Sprintf("%d", incident.Number),
+				Description:     incident.Summary,
+				Type:            ticket.INCIDENT,
+				Status:          status,
+				OriginalStatus:  string(incident.Status),
+				ResolutionDate:  resolutionDate,
+				CreatedDate:     &incident.CreatedDate,
+				UpdatedDate:     &incident.UpdatedDate,
+				LeadTimeMinutes: leadTime,
+				Priority:        string(incident.Urgency),
+				AssigneeId:      user.Id,
+				AssigneeName:    user.Name,
+			}
+			seenIncidents[incident.Number] = combined
+			return []interface{}{
+				domainIssue,
+			}, nil
+		},
+	})
+	if err != nil {
+		return err
+	}
+	return converter.Execute()
+}
+
+func getStatus(incident *models.Incident) string {
+	if incident.Status == models.IncidentStatusTriggered {
+		return ticket.TODO
+	}
+	if incident.Status == models.IncidentStatusAcknowledged {
+		return ticket.IN_PROGRESS
+	}
+	if incident.Status == models.IncidentStatusResolved {
+		return ticket.DONE
+	}
+	panic("unknown incident status encountered")
+}
+
+func getTimes(incident *models.Incident) (int64, *time.Time) {
+	var leadTime int64
+	var resolutionDate *time.Time
+	if incident.Status == models.IncidentStatusResolved {
+		resolutionDate = &incident.UpdatedDate
+		leadTime = int64(resolutionDate.Sub(incident.CreatedDate).Minutes())
+	}
+	return leadTime, resolutionDate
+}
diff --git a/plugins/pagerduty/tasks/incidents_extractor.go b/plugins/pagerduty/tasks/incidents_extractor.go
new file mode 100644
index 000000000..58152b1a3
--- /dev/null
+++ b/plugins/pagerduty/tasks/incidents_extractor.go
@@ -0,0 +1,107 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+	"encoding/json"
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models/generated"
+)
+
+var _ core.SubTaskEntryPoint = ExtractIncidents
+
+func ExtractIncidents(taskCtx core.SubTaskContext) errors.Error {
+	data := taskCtx.GetData().(*PagerDutyTaskData)
+	extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+		RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+			Ctx: taskCtx,
+			Params: models.PagerDutyParams{
+				ConnectionId: data.Options.ConnectionId,
+				Stream:       models.IncidentStream,
+			},
+			Table: RAW_INCIDENTS_TABLE,
+		},
+		Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
+			incidentRaw := &generated.Incidents{}
+			err := errors.Convert(json.Unmarshal(row.Data, incidentRaw))
+			if err != nil {
+				return nil, err
+			}
+			results := make([]interface{}, 0, 1)
+			incident := models.Incident{
+				ConnectionId: data.Options.ConnectionId,
+				Number:       *incidentRaw.IncidentNumber,
+				Url:          *incidentRaw.HtmlUrl,
+				Summary:      *incidentRaw.Summary,
+				Status:       models.IncidentStatus(*incidentRaw.Status),
+				Urgency:      models.IncidentUrgency(*incidentRaw.Urgency),
+				CreatedDate:  *incidentRaw.CreatedAt,
+				UpdatedDate:  *incidentRaw.LastStatusChangeAt,
+			}
+			results = append(results, &incident)
+			if incidentRaw.Service != nil {
+				service := models.Service{
+					ConnectionId: data.Options.ConnectionId,
+					Url:          resolve(incidentRaw.Service.HtmlUrl),
+					Id:           *incidentRaw.Service.Id,
+					Name:         *incidentRaw.Service.Summary,
+				}
+				incident.ServiceId = service.Id
+				results = append(results, &service)
+			}
+			for _, assignmentRaw := range incidentRaw.Assignments {
+				userRaw := assignmentRaw.Assignee
+				results = append(results, &models.Assignment{
+					ConnectionId:   data.Options.ConnectionId,
+					UserId:         *userRaw.Id,
+					IncidentNumber: *incidentRaw.IncidentNumber,
+					AssignedAt:     *assignmentRaw.At,
+				})
+				results = append(results, &models.User{
+					ConnectionId: data.Options.ConnectionId,
+					Id:           *userRaw.Id,
+					Url:          resolve(userRaw.HtmlUrl),
+					Name:         *userRaw.Summary,
+				})
+			}
+			return results, nil
+		},
+	})
+	if err != nil {
+		return err
+	}
+	return extractor.Execute()
+}
+
+func resolve[T any](t *T) T {
+	if t == nil {
+		return *new(T)
+	}
+	return *t
+}
+
+var ExtractIncidentsMeta = core.SubTaskMeta{
+	Name:             "extractIncidents",
+	EntryPoint:       ExtractIncidents,
+	EnabledByDefault: true,
+	Description:      "Extract PagerDuty incidents",
+	DomainTypes:      []string{core.DOMAIN_TYPE_TICKET},
+}
diff --git a/plugins/pagerduty/tasks/task_data.go b/plugins/pagerduty/tasks/task_data.go
new file mode 100644
index 000000000..289caaa58
--- /dev/null
+++ b/plugins/pagerduty/tasks/task_data.go
@@ -0,0 +1,52 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/helpers/pluginhelper/tap"
+	"github.com/apache/incubator-devlake/plugins/helper"
+	"github.com/apache/incubator-devlake/plugins/pagerduty/models"
+)
+
+type PagerDutyOptions struct {
+	ConnectionId    uint64   `json:"connectionId"`
+	Tasks           []string `json:"tasks,omitempty"`
+	Transformations TransformationRules
+}
+
+type PagerDutyTaskData struct {
+	Options *PagerDutyOptions `json:"-"`
+	Config  *models.PagerDutyConfig
+	Client  *tap.SingerTap
+}
+
+type TransformationRules struct {
+	//Placeholder struct for later if needed
+}
+
+func DecodeAndValidateTaskOptions(options map[string]interface{}) (*PagerDutyOptions, errors.Error) {
+	var op PagerDutyOptions
+	if err := helper.Decode(options, &op, nil); err != nil {
+		return nil, err
+	}
+	if op.ConnectionId == 0 {
+		return nil, errors.Default.New("connectionId is invalid")
+	}
+	return &op, nil
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 000000000..a33f6c928
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+setuptools
+wheel
+tap-pagerduty==0.2.0
\ No newline at end of file
diff --git a/scripts/singer-model-generator.sh b/scripts/singer-model-generator.sh
index 79bf04bc5..f5d91938c 100644
--- a/scripts/singer-model-generator.sh
+++ b/scripts/singer-model-generator.sh
@@ -25,60 +25,73 @@ time_format='
   }
 '
 
-#======================================================================================
+#===================================== functions =======================================
 
 json_path=$1 # e.g. "./config/singer/github.json"
-tap_stream=$2 # e.g. "issues"
-plugin_path=$3 # e.g. "./plugins/github_singer"
+plugin_path=$2 # e.g. "./plugins/github_singer"
+tap_stream=$3 # e.g. "issues" or "--all" to generate all streams
+
+print_json() {
+  jq -r < "$1" # for debugging
+}
+
+handle_error() {
+    exitcode=$1
+    if [ "$exitcode" != 0 ]; then
+      exit "$exitcode"
+    fi
+}
+
+generate() {
+  tap_stream=$1
+  package="generated"
+  file_name="$tap_stream".go
+  output_path="$plugin_path/models/generated/$file_name"
+  tmp_dir=$(mktemp -d -t schema-XXXXX)
+  json_schema_path="$tmp_dir"/"$tap_stream"
+  # add, as necessary, more elif blocks for additional transformations
+  modified_schema=$(jq --argjson tf "$time_format" '
+      .streams[] |
+      select(.stream=="'"$tap_stream"'").schema |
+        . += { "$schema": "http://json-schema.org/draft-07/schema#" } |
+        walk(
+          if type == "object" and .format == "date-time" then
+            . += { "goJSONSchema": ($tf) }
+          elif "place_holder" == "" then
+            empty
+          else . end
+        )
+  ' < "$json_path")
+  handle_error $?
+  # additional cleanup
+  modified_schema=$(echo "$modified_schema" | sed -r "/\"null\",/d")
+  modified_schema=$(echo "$modified_schema" | sed -r "/.*additionalProperties.*/d")
+  echo "$modified_schema" > "$json_schema_path" &&\
+  gojsonschema -v -p "$package" "$json_schema_path" -o "$output_path"
+  handle_error $?
+  echo "$output_path"
+  # prepend the license text to the generated files
+  cp "$output_path" "$output_path".bak
+  license_header="$(printf "/*\n%s\n*/\n" "$(cat .golangci-goheader.template)")"
+  echo "$license_header" > "$output_path"
+  cat "$output_path".bak >> "$output_path"
+  rm "$output_path".bak
+}
+
+#======================================================================================
 
 if [ $# != 3 ]; then
   printf "not enough args. Usage: <json_path> <tap_stream> <output_path>: e.g.\n    \"./config/singer/github.json\" \"issues\" \"./plugins/github_singer\"\n"
   exit 1
 fi
 
-package="generated"
-file_name="$tap_stream".go
-output_path="$plugin_path/models/generated/$file_name"
-
-tmp_dir=$(mktemp -d -t schema-XXXXX)
-
-json_schema_path="$tmp_dir"/"$tap_stream"
-
-# add, as necessary, more elif blocks for additional transformations
-modified_schema=$(cat "$json_path" |  jq --argjson tf "$time_format" '
-    .streams[] |
-    select(.stream=="'"$tap_stream"'").schema |
-      . += { "$schema": "http://json-schema.org/draft-07/schema#" } |
-      walk(
-        if type == "object" and .format == "date-time" then
-          . += { "goJSONSchema": ($tf) }
-        elif "place_holder" == "" then
-          empty
-        else . end
-      )
-')
-
-# additional cleanup
-modified_schema=$(echo "$modified_schema" | sed -r "/\"null\",/d")
-modified_schema=$(echo "$modified_schema" | sed -r "/.*additionalProperties.*/d")
-
-echo "$modified_schema" > "$json_schema_path" &&\
-
-# see output
-cat "$json_schema_path" | jq -r
-
-exitcode=$?
-if [ $exitcode != 0 ]; then
-  exit $exitcode
+if [ "$tap_stream" = "--all" ]; then
+  for stream in $(jq -r '.streams[].stream' < "$json_path"); do
+    generate "$stream"
+    handle_error $?
+  done
+else
+  generate "$tap_stream"
+  handle_error $?
 fi
 
-gojsonschema -v -p "$package" "$json_schema_path" -o "$output_path"
-
-echo "$output_path"
-
-# prepend the license text to the generated files
-cp "$output_path" "$output_path".bak
-license_header="$(printf "/*\n%s\n/*\n" "$(cat .golangci-goheader.template)")"
-echo "$license_header" > "$output_path"
-cat "$output_path".bak >> "$output_path"
-rm "$output_path".bak
\ No newline at end of file
diff --git a/utils/ipc.go b/utils/ipc.go
index efad7509d..787689fe6 100644
--- a/utils/ipc.go
+++ b/utils/ipc.go
@@ -112,3 +112,21 @@ func StreamProcess[T any](cmd *exec.Cmd, converter func(b []byte) (T, error)) (<
 	}()
 	return stream, nil
 }
+
+// CreateCmd wraps the args in "sh -c" for shell-level execution
+func CreateCmd(args ...string) *exec.Cmd {
+	if len(args) < 1 {
+		panic("no cmd given")
+	}
+	cmd := "sh"
+	cmdArgs := []string{"-c"}
+	cmdBuilder := &strings.Builder{}
+	for _, elem := range args {
+		if elem != "" {
+			_, _ = cmdBuilder.WriteString(elem)
+			_, _ = cmdBuilder.WriteString(" ")
+		}
+	}
+	cmdArgs = append(cmdArgs, cmdBuilder.String())
+	return exec.Command(cmd, cmdArgs...)
+}