Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Ignore silences run emails; import campaign peek, reports and token retry; live test schedule gating (#114)

Ignoring a repository now silences its workflow run emails and notifications too, and the import campaign ignores the test repositories it imports. The campaign report compares Diffinery's string run ids as text, a peek dispatch shows a running campaign's progress and failed jobs, and the token copy retries. The live test's schedule check runs only when the scheduled workflow was pushed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Canberk Demirci authored · GitHub committed signed1 parent e438b3bcommit 63db7ddb059c4a5ab6b48f3bbc02d644d54567a3Browse files
6 files changed+83−16
Files (6)
modified.github/workflows/import-campaign.yml+36−3View file
@@ -31,12 +31,17 @@
3131 description: Instead of starting a campaign, print the report and logs of this Diffinery run (its id)
3232 type: string
3333 default: ""
34+ peek:
35+ description: Instead of starting a campaign, print how the running one is doing (imports, steps, runs) and stop
36+ type: boolean
37+ default: false
3438
3539 permissions:
3640 contents: read
3741
42+# A peek runs next to the campaign it looks at; campaigns queue behind each other.
3843 concurrency:
39- group: import-campaign
44+ group: ${{ inputs.peek && 'import-campaign-peek' || 'import-campaign' }}
4045 cancel-in-progress: false
4146
4247 jobs:
@@ -52,13 +57,13 @@
5257 R: codician-team/diffinery
5358 steps:
5459 - name: Copy IMPORT_GITHUB_TOKEN to Diffinery
55- if: ${{ inputs.follow_run == '' }}
60+ if: ${{ inputs.follow_run == '' && !inputs.peek }}
5661 env:
5762 IMPORT_GITHUB_TOKEN: ${{ secrets.IMPORT_GITHUB_TOKEN }}
5863 run: |
5964 set -euo pipefail
6065 if [ -n "$IMPORT_GITHUB_TOKEN" ]; then
61- jq -n --arg v "$IMPORT_GITHUB_TOKEN" '{value: $v}' | curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$R/actions/secrets/IMPORT_GITHUB_TOKEN" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-
66+ jq -n --arg v "$IMPORT_GITHUB_TOKEN" '{value: $v}' | curl -fsS --retry 4 --retry-delay 30 -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$R/actions/secrets/IMPORT_GITHUB_TOKEN" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-
6267 echo "IMPORT_GITHUB_TOKEN is set on GitHub and was copied to Diffinery"
6368 else
6469 echo "IMPORT_GITHUB_TOKEN is not set on GitHub; Diffinery's own copy is used if it has one"
@@ -69,9 +74,37 @@
6974 MINUTES: ${{ inputs.minutes }}
7075 RUN_WORKFLOWS: ${{ inputs.run_workflows }}
7176 FOLLOW_RUN: ${{ inputs.follow_run }}
77+ PEEK: ${{ inputs.peek }}
7278 run: |
7379 set -euo pipefail
7480 api() { curl -fsS --retry 3 -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
81+ if [ "$PEEK" = true ]; then
82+ for id in $(api GET "/repos/$R/actions/workflows/import-campaign.yml/runs?per_page=5" | jq -r '.workflow_runs[] | select(.status != "completed") | .id'); do
83+ echo "Campaign run $id on Diffinery:"
84+ api GET "/repos/$R/actions/runs/$id/jobs" | jq -r '.jobs[] | " job \(.name): \(.status) \(.conclusion // "")"'
85+ done
86+ owner=$(api GET /user | jq -r .login)
87+ since=$(date -u -d '-12 hours' +%Y-%m-%dT%H:%M:%S)
88+ imports=$(api GET /diffinery/imports | jq -c --arg since "$since" '[.[] | select((.target.name // "") | startswith("campaign-")) | select(.created_at >= $since)] | sort_by(.created_at)')
89+ jq -r '.[] | "\(.target.name): \(.status) (attempt \(.attempt))\(if .error then ": \(.error)" else "" end)",
90+ ([.steps[] | select(.status == "running" or .status == "failed")][] | " step \(.title): \(.status) \(.message // "") \(.count // "")"),
91+ (if .git then " git \(.git.phase // "") \(.git.message // "") (\(.git.bytes) bytes, \(.git.chunks) chunks, \(.git.tries) tries)" else empty end),
92+ (.warnings[]? | " warning: \(.)")' <<<"$imports"
93+ # Runs on the imported repositories, with each failed job's reason and the end of its log.
94+ for name in $(jq -r '.[] | select(.status == "succeeded") | .target.name' <<<"$imports"); do
95+ runs=$(api GET "/repos/$owner/$name/actions/runs?per_page=30") || continue
96+ jq -r --arg n "$name" '.workflow_runs[] | "\($n) run \(.name): \(.status) \(.conclusion // "")"' <<<"$runs"
97+ for run in $(jq -r '.workflow_runs[] | select(.conclusion == "failure") | .id' <<<"$runs"); do
98+ jobs=$(api GET "/repos/$owner/$name/actions/runs/$run/jobs?per_page=100") || continue
99+ for job in $(jq -r '.jobs[] | select(.conclusion == "failure") | .id' <<<"$jobs"); do
100+ echo "::group::$name: $(jq -r --arg id "$job" '.jobs[] | select((.id | tostring) == $id) | "\(.name)\(if .failure then " (\(.failure.message? // .failure))" else "" end)"' <<<"$jobs")"
101+ { api GET "/repos/$owner/$name/actions/jobs/$job/logs" || true; } | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //' | tail -60
102+ echo "::endgroup::"
103+ done
104+ done
105+ done
106+ exit 0
107+ fi
75108 if [ -n "$FOLLOW_RUN" ]; then
76109 id=$FOLLOW_RUN
77110 else
modified.github/workflows/live-test.yml+4−2View file
@@ -165,6 +165,7 @@
165165 echo "created $OWNER/$REPO"
166166
167167 - name: Push workflows and an LFS file
168+ id: push
168169 if: steps.token.outputs.skip != 'true'
169170 run: |
170171 set -euo pipefail
@@ -592,8 +593,9 @@
592593 [ "$runs" = 0 ] || { echo "::error::the import started $runs workflow runs"; exit 1; }
593594
594595 - name: Scheduled workflow
595- # Runs even when the import check failed: the two are independent.
596- if: ${{ !cancelled() && steps.token.outputs.skip != 'true' }}
596+ # Runs even when the import check failed: the two are independent. It needs the scheduled
597+ # workflow pushed, though, so a failed setup doesn't add a 13-minute wait for a run that can't come.
598+ if: ${{ !cancelled() && steps.token.outputs.skip != 'true' && steps.push.outcome == 'success' }}
597599 run: |
598600 set -euo pipefail
599601 # The cron trigger fires every 5 minutes, so allow two windows.
modifiedapps/diffinery/docs/accounts/notifications.md+3−3View file
@@ -109,7 +109,7 @@
109109 Rules that apply to every event:
110110
111111 - Your own actions don't notify you, unless you turn on **Email me about my own comments and changes** (email only; such emails carry the reason `your_activity`).
112-- A repository set to **Ignore** sends you nothing about its issues and pull requests.
112+- A repository set to **Ignore** sends you nothing about its issues, pull requests and workflow runs.
113113 - A muted thread (you clicked **Unsubscribe**) sends you nothing, except when that event @mentions you, assigns you or asks for your review.
114114 - You must still be able to read the repository. People who lost access stop hearing about it.
115115 - Agents don't receive email.
@@ -122,7 +122,7 @@
122122 | --- | --- | --- |
123123 | **Watch: participating** | Participating and @mentions | The default: only threads you take part in |
124124 | **Watch: all activity** | All activity | Every new issue, pull request, comment, review and state change, plus scheduled workflow runs |
125-| **Watch: ignore** | Ignore | Nothing about this repository's issues and pull requests |
125+| **Watch: ignore** | Ignore | Nothing about this repository's issues, pull requests and workflow runs, even runs you started |
126126
127127 Choosing a level saves it right away ("Watching acme/website: All activity."). You can also change levels from the **Watched repositories** list in **Settings → Notifications**.
128128
@@ -167,7 +167,7 @@
167167 | An agent | People watching the repository with **All activity** (the agent's owner isn't notified directly yet) |
168168 | Diffinery itself (for example a schedule) | People watching the repository with **All activity** |
169169
170-Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run.
170+Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run. Set a repository to **Ignore** to hear nothing about its runs, for example a test repository whose CI you expect to fail.
171171
172172 ## Unsubscribe links
173173
modifiedapps/diffinery/src/mail/notify.ts+6−6View file
@@ -9,7 +9,8 @@
99 * assignment or review request in the same event still gets through a mute).
1010 * - Your own actions never notify you, unless you turn on "Include your own updates" for email.
1111 * - Workflow runs notify whoever triggered them (an agent's owner for agents;
12- * watchers for scheduled runs), for failures unless they ask for every run.
12+ * watchers for scheduled runs), for failures unless they ask for every run,
13+ * and not while they ignore the repository.
1314 * - Security emails (sign-ins, passkeys, tokens, agents, push protection) can't be turned off.
1415 *
1516 * Every recipient must still be able to read the repository.
@@ -171,16 +172,15 @@
171172 const repo = await dir.repoById(ev.repoId);
172173 if (!repo) return;
173174 const store = new MailStore(env.DB);
175+ const watch = await store.watchers(ev.repoId);
174176 let ids: string[] = [];
175177 if (ev.actor.kind === "user") ids = [ev.actor.id];
176178 else if (ev.actor.kind === "agent" && ev.actor.onBehalfOf) {
177179 const owner = await dir.accountByLogin(ev.actor.onBehalfOf);
178180 if (owner?.kind === "user") ids = [owner.id];
179- } else {
180- const w = await store.watchers(ev.repoId);
181- ids = w.all;
182- }
183- const accounts = await readable(dir, repo, ids);
181+ } else ids = watch.all;
182+ // Ignoring a repository silences its runs too, even the ones you started.
183+ const accounts = await readable(dir, repo, ids.filter((id) => !watch.ignore.has(id)));
184184 if (!accounts.length) return;
185185 const prefs = await store.prefsFor(accounts.map((a) => a.id));
186186 const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}`;
modifiedapps/diffinery/test/e2e/email.test.mjs+29−0View file
@@ -254,6 +254,35 @@
254254 assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`));
255255 assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity");
256256 assert.match(failed.dev_copy.text, /test/);
257+
258+ // Ignoring the repository silences its runs too, even the ones you pushed.
259+ const runOnce = async (file) => {
260+ writeFileSync(join(src, file), `${file}\n`);
261+ git(src, ["add", "-A"]);
262+ git(src, ["commit", "-q", "-m", `Add ${file}`]);
263+ git(src, ["push", "-q", "origin", "main"]);
264+ const c = await until("a job to claim", async () => {
265+ const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
266+ return r.status === 200 ? r.json : null;
267+ });
268+ await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: c.api_url, DIFFINERY_JOB_ID: c.job_id, DIFFINERY_JOB_TOKEN: c.token, DIFFINERY_WORK_ROOT: join(work, `job-${c.job_id}`) }, 180_000);
269+ };
270+ await ok("PUT", `/repos/${owner}/mailbox/subscription`, { ignored: true });
271+ await runOnce("ignored.txt");
272+ const ignoredSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim();
273+ await until("the ignored run to finish", async () => {
274+ const r = await ok("GET", `/repos/${owner}/mailbox/actions/runs?head_sha=${git(src, ["rev-parse", "HEAD"]).trim()}`);
275+ return r.workflow_runs[0]?.status === "completed" ? r : null;
276+ });
277+ // The run's event goes through two queues (1 s batches here); give them a few rounds.
278+ await new Promise((r) => setTimeout(r, 8000));
279+ const ignoredMails = (await emails("&kind=run")).filter((e) => e.subject.includes(`(${ignoredSha})`));
280+ assert.deepEqual(ignoredMails, [], "no email for a run in an ignored repository");
281+ // Back to the default, and run emails come again.
282+ await ok("DELETE", `/repos/${owner}/mailbox/subscription`);
283+ await runOnce("watched.txt");
284+ const watchedSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim();
285+ await email("run failed after un-ignoring", (e) => e.kind === "run" && e.subject.includes(`(${watchedSha})`));
257286 });
258287
259288 test("email: a commit blocked by push protection, made without git, is emailed like a blocked push", async () => {
modifiedscripts/import-campaign.sh+5−2View file
@@ -102,6 +102,8 @@
102102 name=${target[$src]}
103103 f="$work/$name.import.json"
104104 [ "$(jq -r .status "$f")" = succeeded ] || continue
105+ # Test repositories: their runs fail often and shouldn't email whoever runs the campaign.
106+ code PUT "/repos/$owner/$name/subscription" '{"ignored":true}' >/dev/null || true
105107 repo=$(api GET "/repos/$owner/$name")
106108 branch=$(jq -r .default_branch <<<"$repo")
107109 api GET "/repos/$owner/$name/actions/workflows" | jq '[.workflows[] | {name, path, state}]' > "$work/$name.workflows.json" || echo '[]' > "$work/$name.workflows.json"
@@ -167,12 +169,13 @@
167169 if [ "$warnings" != 0 ]; then details+=$'\n'"Warnings:"$'\n'"$(jq -r '.warnings[] | "- \(.)"' "$f")"$'\n'; fi
168170 [ -f "$work/$name.runs.json" ] || continue
169171 for run in $(jq -r '.workflow_runs[].id' "$work/$name.runs.json"); do
170- r=$(jq -c --argjson id "$run" '.workflow_runs[] | select(.id == $id)' "$work/$name.runs.json")
172+ # Diffinery's run and job ids are strings, so they are compared as text.
173+ r=$(jq -c --arg id "$run" '.workflow_runs[] | select((.id | tostring) == $id)' "$work/$name.runs.json")
171174 details+=$'\n'"### $(jq -r '"\(.name) [\(.event)]: \(.conclusion // .status)"' <<<"$r")"$'\n'
172175 jobs=$(api GET "/repos/$owner/$name/actions/runs/$run/jobs?per_page=100" || echo '{"jobs":[]}')
173176 details+="$(jq -r '.jobs[] | "- \(.name): \(.conclusion // .status)\(if .failure then " (\(.failure.message? // .failure))" elif .status != "completed" then " (still \(.status))" else "" end)"' <<<"$jobs")"$'\n'
174177 for job in $(jq -r '.jobs[] | select(.conclusion == "failure") | .id' <<<"$jobs"); do
175- details+=$'\n'"<details><summary>$(jq -r --argjson id "$job" '.jobs[] | select(.id == $id) | .name' <<<"$jobs") log (last 40 lines)</summary>"$'\n\n```\n'
178+ details+=$'\n'"<details><summary>$(jq -r --arg id "$job" '.jobs[] | select((.id | tostring) == $id) | .name' <<<"$jobs") log (last 40 lines)</summary>"$'\n\n```\n'
176179 details+="$(curl -fsS "$DIFFINERY/api/v3/repos/$owner/$name/actions/jobs/$job/logs" -H "Authorization: Bearer $TOKEN" | tail -40 || echo "(no log)")"
177180 details+=$'\n```\n</details>\n'
178181 done