salla-gitpuller 1.1.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ Metadata-Version: 2.4
2
+ Name: salla_gitpuller
3
+ Version: 1.2.0
4
+ Summary: A lightweight utility to git pull a repository using SSH deploy keys stored in environment variables
5
+ Author-email: Mohammed Junaid <safijunaid.ss@gmail.com>, Muhammad Zahid <zahidmuhammad127@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Zahid07/gitpuller
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: requests>=2.20
11
+ Dynamic: license-file
12
+
13
+ # gitpuller
14
+
15
+ A lightweight utility to keep a git repository in sync with its remote, designed
16
+ to run **inside Mage AI pipelines** as an auto-pull step. It authenticates with an
17
+ SSH deploy key stored in an environment variable, forces the local checkout to
18
+ match the remote branch (even if someone manually edited files on the runner),
19
+ and sends a de-duplicated Slack alert if anything goes wrong.
20
+
21
+ ---
22
+
23
+ ## Why this exists
24
+
25
+ Mage runners are long-lived boxes. If anyone manually edits a tracked file,
26
+ adds a stray file, or commits locally, a plain `git pull` fails with an opaque
27
+ error like:
28
+
29
+ ```
30
+ Command '['git', '-c', 'core.sshCommand=...', 'pull', 'git@github.com:...', 'master']'
31
+ returned non-zero exit status 1.
32
+ ```
33
+
34
+ gitpuller solves three problems at once:
35
+
36
+ 1. **Clear errors** — surfaces the actual git output, not the wrapper message.
37
+ 2. **Self-healing sync** — discards local drift so the pull can't be blocked.
38
+ 3. **Alerting without spam** — pings Slack on failure, but suppresses repeats of
39
+ the same error within a configurable window.
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install salla_gitpuller
47
+ ```
48
+
49
+ Dependency: [`requests`](https://pypi.org/project/requests/) (installed
50
+ automatically). Optional: `mage-ai` — only needed if you want alert-suppression
51
+ state to persist across pipeline runs (see [State management](#state-management)).
52
+
53
+ ---
54
+
55
+ ## Quick start
56
+
57
+ ```python
58
+ from gitpuller import GitPullExecutor
59
+
60
+ executor = GitPullExecutor(
61
+ use_mage_ai=True, # persist alert state via Mage
62
+ # Slack: set CDM_PROD_SLACK_BOT_TOKEN (threaded daily alerts).
63
+ # Optional: CDM_PROD_DB_SLACK_CHANNEL (defaults to C05MLHR55JT).
64
+ )
65
+
66
+ result = executor.execute_with_alerting(
67
+ repo_path="/home/src/my-repo",
68
+ git_url="git@github.com:my-org/my-repo.git",
69
+ workspace_name="myworkspace", # reads the private key from {workspace_name}_SSHKEY
70
+ # branch omitted -> defaults to "master"
71
+ )
72
+
73
+ print(result["git_pull_status"]) # "success"
74
+ print(result["discarded_changes"]) # what local drift (if any) was wiped
75
+ ```
76
+
77
+ On failure, `execute_with_alerting` sends a Slack alert (subject to suppression)
78
+ as a **reply in today's daily thread**, then **re-raises**, so the Mage pipeline
79
+ still fails loudly.
80
+
81
+ ---
82
+
83
+ ## How it works
84
+
85
+ `execute_git_pull` does **not** run `git pull`. Instead it forces the local repo
86
+ to exactly match the remote, which is robust against manual edits *and* divergent
87
+ history (local commits / rewritten history) that a stash-based approach can't
88
+ handle:
89
+
90
+ 1. **Prepare the SSH key** — the private deploy key is read from an env var,
91
+ normalized (strips wrapping quotes, converts literal `\n` to real newlines,
92
+ ensures a trailing newline), and written to `~/.ssh` with strict `0600`
93
+ permissions. It is injected per-command via
94
+ `git -c core.sshCommand="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`.
95
+ 2. **`git fetch <url> <branch>`** — `FETCH_HEAD` now points at the remote tip.
96
+ 3. **Snapshot + log local drift** — before anything is discarded, it records:
97
+ - **working tree changes** — uncommitted edits and untracked files
98
+ (`git status --porcelain`),
99
+ - **local-only commits** — commits on the runner but not the remote
100
+ (`FETCH_HEAD..HEAD`),
101
+ and prints them to the pipeline log so you have a record of what was wiped.
102
+ 4. **`git reset --hard FETCH_HEAD`** — makes the working tree and branch pointer
103
+ match the remote exactly.
104
+ 5. **`git clean -fd`** — removes untracked files/directories so the tree truly
105
+ matches remote. **Ignored files are preserved** (no `-x`), so runner-local
106
+ `.env` files and deploy keys survive.
107
+ 6. **Cleanup** — the key file is removed and the working directory is restored,
108
+ even on failure (`finally`).
109
+
110
+ Any failing step raises a `RuntimeError` containing the **real git stdout/stderr
111
+ and exit code**, which becomes the Slack alert body and the pipeline error.
112
+
113
+ > ⚠️ **This is destructive by design.** Local changes on the runner are treated as
114
+ > contamination and discarded. Don't point gitpuller at a repo where the runner
115
+ > holds work you intend to keep.
116
+
117
+ ---
118
+
119
+ ## API
120
+
121
+ ### `GitPullExecutor(slack_webhook_url=None, slack_bot_token=None, slack_channel=None, use_mage_ai=False, state_manager=None)`
122
+
123
+ | Param | Description |
124
+ |-------|-------------|
125
+ | `slack_bot_token` | Slack bot token (`xoxb-...`). Falls back to `CDM_PROD_SLACK_BOT_TOKEN`. Preferred — enables daily-thread replies. |
126
+ | `slack_channel` | Channel ID/name. Falls back to `CDM_PROD_DB_SLACK_CHANNEL`, then `C05MLHR55JT`. |
127
+ | `slack_webhook_url` | Incoming-webhook URL. Falls back to `CDM_SLACK_WEBHOOK_URL`. Used only when no bot token is set (no threading). |
128
+ | `use_mage_ai` | If `True`, persist alert-suppression state via Mage global variables (falls back to in-memory if Mage isn't installed). |
129
+ | `state_manager` | Inject a custom `StateManager`; overrides `use_mage_ai`. |
130
+
131
+ If neither a bot token nor a webhook is configured, gitpuller still runs and
132
+ prints a warning instead of failing construction.
133
+
134
+ ### Slack daily thread
135
+
136
+ When `CDM_PROD_SLACK_BOT_TOKEN` is set, failures are posted with `chat.postMessage`:
137
+
138
+ 1. Open (or reuse) one parent message **per workspace per calendar day**:
139
+ `🚨 Git Pull Failures {workspace_name} — YYYY-MM-DD`.
140
+ 2. Post each later failure for that workspace as a **thread reply**.
141
+
142
+ Daily `thread_ts` values are stored in **one unified Redis hash** shared by every
143
+ Mage workspace (the key is **not** prefixed with `MAGE_WORKSPACE_NAME`):
144
+
145
+ ```text
146
+ gitpuller:slack_thread:{channel}:{YYYY-MM-DD}
147
+ cloud_data → JSON { workspace, thread_ts, errors: [{workspace, repo, error, at, reply_ts}, ...] }
148
+ partner → JSON { workspace, thread_ts, errors: [...] }
149
+ TTL: until midnight (CDM_SLACK_THREAD_TZ, default UTC)
150
+ ```
151
+
152
+ Each workspace keeps the Slack parent `thread_ts` plus up to 20 git errors
153
+ from that day (error text capped at 1500 chars). Legacy plain `thread_ts`
154
+ strings are still read.
155
+
156
+ Redis comes from Mage `io_config.yaml` (`REDIS_HOST` / `REDIS_PORT` /
157
+ `REDIS_PASSWORD`), or those same env vars. If Redis is unavailable, gitpuller
158
+ falls back to Slack `conversations.history`.
159
+
160
+ | Env var | Role |
161
+ |---------|------|
162
+ | `CDM_PROD_SLACK_BOT_TOKEN` | Required for threading. |
163
+ | `CDM_PROD_DB_SLACK_CHANNEL` | Channel; default `C05MLHR55JT`. |
164
+ | `CDM_SLACK_THREAD_TZ` | Timezone for the daily parent date; default `UTC`. |
165
+ | `CDM_PAUSE_SLACK_MESSAGES` | Set to `1` to skip Slack. |
166
+ | `CDM_SLACK_WEBHOOK_URL` | Legacy fallback when no bot token is set. |
167
+
168
+ The bot must be in the channel (`chat:write`). History reuse also needs
169
+ `channels:history` (or `groups:history` for a private channel).
170
+
171
+ ### `execute_with_alerting(...)` → `dict`
172
+
173
+ Runs the sync and, on failure, alerts Slack (with suppression) then re-raises.
174
+
175
+ | Param | Default | Description |
176
+ |-------|---------|-------------|
177
+ | `repo_path` | — | Absolute path to the local repo (must exist). |
178
+ | `git_url` | — | SSH remote URL, e.g. `git@github.com:Org/repo.git`. |
179
+ | `branch` | `"master"` | Branch to sync to. **Note: defaults to `master`, not `main`.** |
180
+ | `ssh_key` | `None` | Private key material. If omitted, read from `{workspace_name}_SSHKEY`. |
181
+ | `workspace_name` | `None` | Used to locate the key env var and name the key file. |
182
+ | `pipeline_uuid` | `"auto_git_pull"` | Key under which alert state is stored. |
183
+ | `suppression_hours` | `1` | Don't re-alert on the *same* error within this many hours. |
184
+ | `key_filename` | `None` | Override the on-disk key filename. |
185
+ | `ssh_dir` | `"/home/src/.ssh"` | Directory to write the key into. |
186
+ | `webhook_url` | `None` | Legacy webhook override for this call. Ignored when a bot token is configured. |
187
+
188
+ ### `execute_git_pull(...)` → `dict`
189
+
190
+ Same signature as above (minus `pipeline_uuid` / `suppression_hours`). Performs
191
+ the sync **without** alerting — use this if you handle errors yourself.
192
+
193
+ ### Return value
194
+
195
+ ```python
196
+ {
197
+ "workspace": "myworkspace",
198
+ "repo_path": "/home/src/my-repo",
199
+ "git_pull_status": "success", # or raises on error
200
+ "git_pull_output": "HEAD is now at <sha> <subject>",
201
+ "discarded_changes": {
202
+ "working_tree_changes": "?? stray.txt", # git status --porcelain output
203
+ "local_commits": "949688e local-only commit" # FETCH_HEAD..HEAD output
204
+ },
205
+ "key_env_var_used": "myworkspace_SSHKEY",
206
+ }
207
+ ```
208
+
209
+ ---
210
+
211
+ ## SSH key setup
212
+
213
+ Provide the **private** deploy key as an environment variable named
214
+ `{workspace_name}_SSHKEY` (e.g. `myworkspace_SSHKEY`), or pass `ssh_key=` directly.
215
+ The matching public key must be registered as a deploy key on the GitHub repo.
216
+
217
+ The key may be stored with literal `\n` (single-line) or real newlines — both are
218
+ handled. Wrapping quotes are stripped automatically.
219
+
220
+ ### Use a read-only deploy key
221
+
222
+ gitpuller is a one-way mirror (remote → runner) and **never pushes**. Its only
223
+ remote operation is `git fetch`; the reset and clean steps are local. So the
224
+ deploy key only needs **read access** — leave GitHub's *"Allow write access"*
225
+ checkbox **unchecked**. This is the least-privilege setup and means the runner
226
+ can never push its discarded local changes back upstream.
227
+
228
+ Setup steps:
229
+
230
+ 1. Generate a dedicated key pair: `ssh-keygen -t ed25519 -f deploy_key -N ""`.
231
+ 2. On the GitHub repo: **Settings → Deploy keys → Add deploy key**, paste
232
+ `deploy_key.pub`, and leave **Allow write access unchecked**.
233
+ 3. Store the **private** key (`deploy_key`) in the `{workspace_name}_SSHKEY` env var.
234
+
235
+ **Note:** GitHub deploy keys are **per-repository** — each repo you sync needs its
236
+ own key pair and its own `{workspace_name}_SSHKEY` env var.
237
+
238
+ On first connection the remote host key is auto-accepted
239
+ (`StrictHostKeyChecking=accept-new`), i.e. trust-on-first-use rather than a
240
+ pre-pinned fingerprint.
241
+
242
+ ---
243
+
244
+ ## State management
245
+
246
+ Alert suppression needs to remember the last error and when it was alerted:
247
+
248
+ - **`InMemoryStateManager`** (default) — process-local; suppression only works
249
+ within a single run.
250
+ - **`MageAIStateManager`** (`use_mage_ai=True`) — persists across runs via Mage
251
+ global variables, so repeated failures across scheduled runs stay de-duplicated.
252
+ - **`StateManager`** — subclass it to plug in your own backend (e.g. Redis, a DB).
253
+
254
+ ---
255
+
256
+ ## Build & release
257
+
258
+ ```bash
259
+ rm -rf build dist *.egg-info
260
+ python -m build
261
+ # then upload to PyPI (twine upload dist/*) and bump the version in pyproject.toml
262
+ ```
263
+
264
+ Keep the version in sync in **both** `pyproject.toml` and `gitpuller/__init__.py`.
265
+
266
+ ---
267
+
268
+ ## Changelog
269
+
270
+ ### 1.2.0 (current)
271
+
272
+ - **Threaded Slack alerts.** Failures post as replies under one daily parent
273
+ **per workspace** (`🚨 Git Pull Failures {workspace} — YYYY-MM-DD`) via
274
+ `CDM_PROD_SLACK_BOT_TOKEN` and `CDM_PROD_DB_SLACK_CHANNEL`
275
+ (default `C05MLHR55JT`). Daily `thread_ts` lives in one unified Redis hash
276
+ (`gitpuller:slack_thread:{channel}:{YYYY-MM-DD}`, field = workspace, TTL
277
+ until midnight) shared across all Mage workspaces. Each workspace field is
278
+ JSON: `thread_ts` plus that day's git errors.
279
+ - Incoming webhooks (`CDM_SLACK_WEBHOOK_URL` / `slack_webhook_url`) remain as a
280
+ non-threaded fallback when no bot token is set.
281
+ - Missing Slack credentials no longer raise on `GitPullExecutor` construction;
282
+ alerts are skipped with a warning.
283
+
284
+ ### 1.1.0
285
+
286
+ Reliability and clarity overhaul.
287
+
288
+ - **Self-healing sync.** Replaced `git pull` with `git fetch` →
289
+ `git reset --hard FETCH_HEAD` → `git clean -fd`. Manual edits, stray files, and
290
+ even local commits / divergent history on the runner no longer break the sync.
291
+ Ignored files (`.env`, keys) are preserved.
292
+ - **Clear error messages.** Failures now raise with the real git stdout/stderr and
293
+ exit code instead of the opaque
294
+ `Command '[...]' returned non-zero exit status 1.` wrapper. The same detail flows
295
+ into the Slack alert.
296
+ - **Audit log of discarded changes.** Before resetting, the working-tree drift and
297
+ any local-only commits are logged and returned under `discarded_changes`, so
298
+ there's always a record of what was wiped.
299
+ - **Packaging fixes.** Declared the previously-missing `requests` dependency; synced
300
+ the version between `pyproject.toml` and `__init__.py`.
301
+ - **Docs & comments.** Full README and inline documentation across all modules.
302
+
303
+ > **Migration note:** `git_pull_output` now reflects `reset --hard` output
304
+ > (`HEAD is now at <sha> <subject>`) rather than pull's `Updating x..y` /
305
+ > `Already up to date`. The result key `recovery_steps` (briefly present during
306
+ > development) is replaced by `discarded_changes`. Update any code that parses
307
+ > these. The public method signatures are unchanged.
308
+
309
+ ### 1.0.x (previous)
310
+
311
+ - Initial release. Ran a plain `git pull <url> <branch>` over an SSH deploy key.
312
+ - Slack alerting with same-error suppression (`AlertManager` + `StateManager`,
313
+ in-memory or Mage-backed).
314
+ - **Limitations addressed in 1.1.0:** any manual change on the runner caused the
315
+ pull to fail; errors were opaque wrapper messages; `requests` was imported but
316
+ not declared as a dependency.
317
+
318
+ ---
319
+
320
+ <sub>Created and maintained by Mohammed Junaid and Muhammad Zahid.</sub>
@@ -0,0 +1,308 @@
1
+ # gitpuller
2
+
3
+ A lightweight utility to keep a git repository in sync with its remote, designed
4
+ to run **inside Mage AI pipelines** as an auto-pull step. It authenticates with an
5
+ SSH deploy key stored in an environment variable, forces the local checkout to
6
+ match the remote branch (even if someone manually edited files on the runner),
7
+ and sends a de-duplicated Slack alert if anything goes wrong.
8
+
9
+ ---
10
+
11
+ ## Why this exists
12
+
13
+ Mage runners are long-lived boxes. If anyone manually edits a tracked file,
14
+ adds a stray file, or commits locally, a plain `git pull` fails with an opaque
15
+ error like:
16
+
17
+ ```
18
+ Command '['git', '-c', 'core.sshCommand=...', 'pull', 'git@github.com:...', 'master']'
19
+ returned non-zero exit status 1.
20
+ ```
21
+
22
+ gitpuller solves three problems at once:
23
+
24
+ 1. **Clear errors** — surfaces the actual git output, not the wrapper message.
25
+ 2. **Self-healing sync** — discards local drift so the pull can't be blocked.
26
+ 3. **Alerting without spam** — pings Slack on failure, but suppresses repeats of
27
+ the same error within a configurable window.
28
+
29
+ ---
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install salla_gitpuller
35
+ ```
36
+
37
+ Dependency: [`requests`](https://pypi.org/project/requests/) (installed
38
+ automatically). Optional: `mage-ai` — only needed if you want alert-suppression
39
+ state to persist across pipeline runs (see [State management](#state-management)).
40
+
41
+ ---
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from gitpuller import GitPullExecutor
47
+
48
+ executor = GitPullExecutor(
49
+ use_mage_ai=True, # persist alert state via Mage
50
+ # Slack: set CDM_PROD_SLACK_BOT_TOKEN (threaded daily alerts).
51
+ # Optional: CDM_PROD_DB_SLACK_CHANNEL (defaults to C05MLHR55JT).
52
+ )
53
+
54
+ result = executor.execute_with_alerting(
55
+ repo_path="/home/src/my-repo",
56
+ git_url="git@github.com:my-org/my-repo.git",
57
+ workspace_name="myworkspace", # reads the private key from {workspace_name}_SSHKEY
58
+ # branch omitted -> defaults to "master"
59
+ )
60
+
61
+ print(result["git_pull_status"]) # "success"
62
+ print(result["discarded_changes"]) # what local drift (if any) was wiped
63
+ ```
64
+
65
+ On failure, `execute_with_alerting` sends a Slack alert (subject to suppression)
66
+ as a **reply in today's daily thread**, then **re-raises**, so the Mage pipeline
67
+ still fails loudly.
68
+
69
+ ---
70
+
71
+ ## How it works
72
+
73
+ `execute_git_pull` does **not** run `git pull`. Instead it forces the local repo
74
+ to exactly match the remote, which is robust against manual edits *and* divergent
75
+ history (local commits / rewritten history) that a stash-based approach can't
76
+ handle:
77
+
78
+ 1. **Prepare the SSH key** — the private deploy key is read from an env var,
79
+ normalized (strips wrapping quotes, converts literal `\n` to real newlines,
80
+ ensures a trailing newline), and written to `~/.ssh` with strict `0600`
81
+ permissions. It is injected per-command via
82
+ `git -c core.sshCommand="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`.
83
+ 2. **`git fetch <url> <branch>`** — `FETCH_HEAD` now points at the remote tip.
84
+ 3. **Snapshot + log local drift** — before anything is discarded, it records:
85
+ - **working tree changes** — uncommitted edits and untracked files
86
+ (`git status --porcelain`),
87
+ - **local-only commits** — commits on the runner but not the remote
88
+ (`FETCH_HEAD..HEAD`),
89
+ and prints them to the pipeline log so you have a record of what was wiped.
90
+ 4. **`git reset --hard FETCH_HEAD`** — makes the working tree and branch pointer
91
+ match the remote exactly.
92
+ 5. **`git clean -fd`** — removes untracked files/directories so the tree truly
93
+ matches remote. **Ignored files are preserved** (no `-x`), so runner-local
94
+ `.env` files and deploy keys survive.
95
+ 6. **Cleanup** — the key file is removed and the working directory is restored,
96
+ even on failure (`finally`).
97
+
98
+ Any failing step raises a `RuntimeError` containing the **real git stdout/stderr
99
+ and exit code**, which becomes the Slack alert body and the pipeline error.
100
+
101
+ > ⚠️ **This is destructive by design.** Local changes on the runner are treated as
102
+ > contamination and discarded. Don't point gitpuller at a repo where the runner
103
+ > holds work you intend to keep.
104
+
105
+ ---
106
+
107
+ ## API
108
+
109
+ ### `GitPullExecutor(slack_webhook_url=None, slack_bot_token=None, slack_channel=None, use_mage_ai=False, state_manager=None)`
110
+
111
+ | Param | Description |
112
+ |-------|-------------|
113
+ | `slack_bot_token` | Slack bot token (`xoxb-...`). Falls back to `CDM_PROD_SLACK_BOT_TOKEN`. Preferred — enables daily-thread replies. |
114
+ | `slack_channel` | Channel ID/name. Falls back to `CDM_PROD_DB_SLACK_CHANNEL`, then `C05MLHR55JT`. |
115
+ | `slack_webhook_url` | Incoming-webhook URL. Falls back to `CDM_SLACK_WEBHOOK_URL`. Used only when no bot token is set (no threading). |
116
+ | `use_mage_ai` | If `True`, persist alert-suppression state via Mage global variables (falls back to in-memory if Mage isn't installed). |
117
+ | `state_manager` | Inject a custom `StateManager`; overrides `use_mage_ai`. |
118
+
119
+ If neither a bot token nor a webhook is configured, gitpuller still runs and
120
+ prints a warning instead of failing construction.
121
+
122
+ ### Slack daily thread
123
+
124
+ When `CDM_PROD_SLACK_BOT_TOKEN` is set, failures are posted with `chat.postMessage`:
125
+
126
+ 1. Open (or reuse) one parent message **per workspace per calendar day**:
127
+ `🚨 Git Pull Failures {workspace_name} — YYYY-MM-DD`.
128
+ 2. Post each later failure for that workspace as a **thread reply**.
129
+
130
+ Daily `thread_ts` values are stored in **one unified Redis hash** shared by every
131
+ Mage workspace (the key is **not** prefixed with `MAGE_WORKSPACE_NAME`):
132
+
133
+ ```text
134
+ gitpuller:slack_thread:{channel}:{YYYY-MM-DD}
135
+ cloud_data → JSON { workspace, thread_ts, errors: [{workspace, repo, error, at, reply_ts}, ...] }
136
+ partner → JSON { workspace, thread_ts, errors: [...] }
137
+ TTL: until midnight (CDM_SLACK_THREAD_TZ, default UTC)
138
+ ```
139
+
140
+ Each workspace keeps the Slack parent `thread_ts` plus up to 20 git errors
141
+ from that day (error text capped at 1500 chars). Legacy plain `thread_ts`
142
+ strings are still read.
143
+
144
+ Redis comes from Mage `io_config.yaml` (`REDIS_HOST` / `REDIS_PORT` /
145
+ `REDIS_PASSWORD`), or those same env vars. If Redis is unavailable, gitpuller
146
+ falls back to Slack `conversations.history`.
147
+
148
+ | Env var | Role |
149
+ |---------|------|
150
+ | `CDM_PROD_SLACK_BOT_TOKEN` | Required for threading. |
151
+ | `CDM_PROD_DB_SLACK_CHANNEL` | Channel; default `C05MLHR55JT`. |
152
+ | `CDM_SLACK_THREAD_TZ` | Timezone for the daily parent date; default `UTC`. |
153
+ | `CDM_PAUSE_SLACK_MESSAGES` | Set to `1` to skip Slack. |
154
+ | `CDM_SLACK_WEBHOOK_URL` | Legacy fallback when no bot token is set. |
155
+
156
+ The bot must be in the channel (`chat:write`). History reuse also needs
157
+ `channels:history` (or `groups:history` for a private channel).
158
+
159
+ ### `execute_with_alerting(...)` → `dict`
160
+
161
+ Runs the sync and, on failure, alerts Slack (with suppression) then re-raises.
162
+
163
+ | Param | Default | Description |
164
+ |-------|---------|-------------|
165
+ | `repo_path` | — | Absolute path to the local repo (must exist). |
166
+ | `git_url` | — | SSH remote URL, e.g. `git@github.com:Org/repo.git`. |
167
+ | `branch` | `"master"` | Branch to sync to. **Note: defaults to `master`, not `main`.** |
168
+ | `ssh_key` | `None` | Private key material. If omitted, read from `{workspace_name}_SSHKEY`. |
169
+ | `workspace_name` | `None` | Used to locate the key env var and name the key file. |
170
+ | `pipeline_uuid` | `"auto_git_pull"` | Key under which alert state is stored. |
171
+ | `suppression_hours` | `1` | Don't re-alert on the *same* error within this many hours. |
172
+ | `key_filename` | `None` | Override the on-disk key filename. |
173
+ | `ssh_dir` | `"/home/src/.ssh"` | Directory to write the key into. |
174
+ | `webhook_url` | `None` | Legacy webhook override for this call. Ignored when a bot token is configured. |
175
+
176
+ ### `execute_git_pull(...)` → `dict`
177
+
178
+ Same signature as above (minus `pipeline_uuid` / `suppression_hours`). Performs
179
+ the sync **without** alerting — use this if you handle errors yourself.
180
+
181
+ ### Return value
182
+
183
+ ```python
184
+ {
185
+ "workspace": "myworkspace",
186
+ "repo_path": "/home/src/my-repo",
187
+ "git_pull_status": "success", # or raises on error
188
+ "git_pull_output": "HEAD is now at <sha> <subject>",
189
+ "discarded_changes": {
190
+ "working_tree_changes": "?? stray.txt", # git status --porcelain output
191
+ "local_commits": "949688e local-only commit" # FETCH_HEAD..HEAD output
192
+ },
193
+ "key_env_var_used": "myworkspace_SSHKEY",
194
+ }
195
+ ```
196
+
197
+ ---
198
+
199
+ ## SSH key setup
200
+
201
+ Provide the **private** deploy key as an environment variable named
202
+ `{workspace_name}_SSHKEY` (e.g. `myworkspace_SSHKEY`), or pass `ssh_key=` directly.
203
+ The matching public key must be registered as a deploy key on the GitHub repo.
204
+
205
+ The key may be stored with literal `\n` (single-line) or real newlines — both are
206
+ handled. Wrapping quotes are stripped automatically.
207
+
208
+ ### Use a read-only deploy key
209
+
210
+ gitpuller is a one-way mirror (remote → runner) and **never pushes**. Its only
211
+ remote operation is `git fetch`; the reset and clean steps are local. So the
212
+ deploy key only needs **read access** — leave GitHub's *"Allow write access"*
213
+ checkbox **unchecked**. This is the least-privilege setup and means the runner
214
+ can never push its discarded local changes back upstream.
215
+
216
+ Setup steps:
217
+
218
+ 1. Generate a dedicated key pair: `ssh-keygen -t ed25519 -f deploy_key -N ""`.
219
+ 2. On the GitHub repo: **Settings → Deploy keys → Add deploy key**, paste
220
+ `deploy_key.pub`, and leave **Allow write access unchecked**.
221
+ 3. Store the **private** key (`deploy_key`) in the `{workspace_name}_SSHKEY` env var.
222
+
223
+ **Note:** GitHub deploy keys are **per-repository** — each repo you sync needs its
224
+ own key pair and its own `{workspace_name}_SSHKEY` env var.
225
+
226
+ On first connection the remote host key is auto-accepted
227
+ (`StrictHostKeyChecking=accept-new`), i.e. trust-on-first-use rather than a
228
+ pre-pinned fingerprint.
229
+
230
+ ---
231
+
232
+ ## State management
233
+
234
+ Alert suppression needs to remember the last error and when it was alerted:
235
+
236
+ - **`InMemoryStateManager`** (default) — process-local; suppression only works
237
+ within a single run.
238
+ - **`MageAIStateManager`** (`use_mage_ai=True`) — persists across runs via Mage
239
+ global variables, so repeated failures across scheduled runs stay de-duplicated.
240
+ - **`StateManager`** — subclass it to plug in your own backend (e.g. Redis, a DB).
241
+
242
+ ---
243
+
244
+ ## Build & release
245
+
246
+ ```bash
247
+ rm -rf build dist *.egg-info
248
+ python -m build
249
+ # then upload to PyPI (twine upload dist/*) and bump the version in pyproject.toml
250
+ ```
251
+
252
+ Keep the version in sync in **both** `pyproject.toml` and `gitpuller/__init__.py`.
253
+
254
+ ---
255
+
256
+ ## Changelog
257
+
258
+ ### 1.2.0 (current)
259
+
260
+ - **Threaded Slack alerts.** Failures post as replies under one daily parent
261
+ **per workspace** (`🚨 Git Pull Failures {workspace} — YYYY-MM-DD`) via
262
+ `CDM_PROD_SLACK_BOT_TOKEN` and `CDM_PROD_DB_SLACK_CHANNEL`
263
+ (default `C05MLHR55JT`). Daily `thread_ts` lives in one unified Redis hash
264
+ (`gitpuller:slack_thread:{channel}:{YYYY-MM-DD}`, field = workspace, TTL
265
+ until midnight) shared across all Mage workspaces. Each workspace field is
266
+ JSON: `thread_ts` plus that day's git errors.
267
+ - Incoming webhooks (`CDM_SLACK_WEBHOOK_URL` / `slack_webhook_url`) remain as a
268
+ non-threaded fallback when no bot token is set.
269
+ - Missing Slack credentials no longer raise on `GitPullExecutor` construction;
270
+ alerts are skipped with a warning.
271
+
272
+ ### 1.1.0
273
+
274
+ Reliability and clarity overhaul.
275
+
276
+ - **Self-healing sync.** Replaced `git pull` with `git fetch` →
277
+ `git reset --hard FETCH_HEAD` → `git clean -fd`. Manual edits, stray files, and
278
+ even local commits / divergent history on the runner no longer break the sync.
279
+ Ignored files (`.env`, keys) are preserved.
280
+ - **Clear error messages.** Failures now raise with the real git stdout/stderr and
281
+ exit code instead of the opaque
282
+ `Command '[...]' returned non-zero exit status 1.` wrapper. The same detail flows
283
+ into the Slack alert.
284
+ - **Audit log of discarded changes.** Before resetting, the working-tree drift and
285
+ any local-only commits are logged and returned under `discarded_changes`, so
286
+ there's always a record of what was wiped.
287
+ - **Packaging fixes.** Declared the previously-missing `requests` dependency; synced
288
+ the version between `pyproject.toml` and `__init__.py`.
289
+ - **Docs & comments.** Full README and inline documentation across all modules.
290
+
291
+ > **Migration note:** `git_pull_output` now reflects `reset --hard` output
292
+ > (`HEAD is now at <sha> <subject>`) rather than pull's `Updating x..y` /
293
+ > `Already up to date`. The result key `recovery_steps` (briefly present during
294
+ > development) is replaced by `discarded_changes`. Update any code that parses
295
+ > these. The public method signatures are unchanged.
296
+
297
+ ### 1.0.x (previous)
298
+
299
+ - Initial release. Ran a plain `git pull <url> <branch>` over an SSH deploy key.
300
+ - Slack alerting with same-error suppression (`AlertManager` + `StateManager`,
301
+ in-memory or Mage-backed).
302
+ - **Limitations addressed in 1.1.0:** any manual change on the runner caused the
303
+ pull to fail; errors were opaque wrapper messages; `requests` was imported but
304
+ not declared as a dependency.
305
+
306
+ ---
307
+
308
+ <sub>Created and maintained by Mohammed Junaid and Muhammad Zahid.</sub>
@@ -5,7 +5,7 @@ from .slack_notifier import SlackNotifier
5
5
  from .utils import transform_custom, get_repo_path, get_env_base_path
6
6
 
7
7
 
8
- __version__ = "1.0.0"
8
+ __version__ = "1.2.0"
9
9
  __all__ = [
10
10
  "GitPullExecutor",
11
11
  "AlertManager",