Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
push-workflow-sync.test.ts6.8 KB · 203 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
/**
 * Tests for src/lib/push-workflow-sync.ts — the fix for the missing
 * "push actually triggers CI" wiring (2026-07-15). Before this, nothing
 * synced `.gluecron/workflows/*.yml` into the `workflows` table on push,
 * so `on: push` triggers never fired automatically — only manual dispatch,
 * PR slash commands, and scheduled (cron) runs worked.
 *
 * git tree/blob reads and enqueueRun are passed in via the injectable
 * `deps` param rather than mock.module()'d — those two modules
 * (../git/repository, ./workflow-runner) are imported by dozens of
 * unrelated test files, and a top-level mock.module() on either leaks
 * globally across the whole `bun test` run and breaks them (verified:
 * an earlier version of this file did exactly that and broke 15 tests
 * in git.test.ts/api-v2/web-routes). Only ../db is mocked, matching the
 * established, already-safe pattern in push.test.ts / notify-activity-feed.test.ts.
 */

import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";

const _realDb = await import("../db");

let _insertedRows: Array<{ id: string; disabled: boolean }> = [];
let _upsertCalls: any[] = [];

mock.module("../db", () => ({
  db: {
    insert: () => ({
      values: (vals: any) => ({
        onConflictDoUpdate: (args: any) => {
          _upsertCalls.push({ vals, args });
          const row = _insertedRows.shift();
          return {
            returning: async () => (row ? [row] : []),
          };
        },
      }),
    }),
  },
}));

const { syncAndEnqueuePushWorkflows } = await import("../lib/push-workflow-sync");

afterAll(() => {
  mock.module("../db", () => _realDb);
});

let _treeEntries: Array<{ mode: string; type: string; sha: string; size?: number; name: string }> = [];
let _blobs: Record<string, { content: string; size: number; isBinary: boolean }> = {};
let _enqueueCalls: any[] = [];
let _enqueueShouldThrow = false;

const fakeDeps = {
  getTree: async () => _treeEntries,
  getBlob: async (_owner: string, _repo: string, _ref: string, path: string) =>
    _blobs[path] ?? null,
  enqueueRun: async (opts: any) => {
    if (_enqueueShouldThrow) throw new Error("enqueue boom");
    _enqueueCalls.push(opts);
    return "run-id-1";
  },
} as any;

beforeEach(() => {
  _treeEntries = [];
  _blobs = {};
  _insertedRows = [];
  _upsertCalls = [];
  _enqueueCalls = [];
  _enqueueShouldThrow = false;
});

const CI_YML = `
name: CI
on: push
jobs:
  build:
    steps:
      - run: echo hi
`;

const DISPATCH_ONLY_YML = `
name: Manual only
on: workflow_dispatch
jobs:
  build:
    steps:
      - run: echo manual
`;

const BROKEN_YML = `
jobs:
  - this is not valid: [
`;

function baseOpts(overrides: Partial<Parameters<typeof syncAndEnqueuePushWorkflows>[0]> = {}) {
  return {
    owner: "acme",
    repo: "widgets",
    repositoryId: "repo-1",
    branch: "main",
    commitSha: "a".repeat(40),
    triggeredBy: "user-1",
    ...overrides,
  };
}

describe("syncAndEnqueuePushWorkflows", () => {
  it("returns zeros when .gluecron/workflows is empty/missing", async () => {
    _treeEntries = [];
    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
    expect(result).toEqual({ synced: 0, enqueued: 0, errors: [] });
    expect(_upsertCalls).toHaveLength(0);
    expect(_enqueueCalls).toHaveLength(0);
  });

  it("syncs and enqueues a workflow with an on:push trigger", async () => {
    _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
    _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
    _insertedRows = [{ id: "wf-1", disabled: false }];

    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);

    expect(result.synced).toBe(1);
    expect(result.enqueued).toBe(1);
    expect(result.errors).toEqual([]);
    expect(_enqueueCalls[0]).toMatchObject({
      workflowId: "wf-1",
      repositoryId: "repo-1",
      event: "push",
      ref: "main",
      commitSha: "a".repeat(40),
      triggeredBy: "user-1",
    });
    // Upsert wrote the parsed shape + onEvents.
    expect(_upsertCalls[0].vals.path).toBe(".gluecron/workflows/ci.yml");
    expect(JSON.parse(_upsertCalls[0].vals.onEvents)).toEqual(["push"]);
  });

  it("syncs but does NOT enqueue a workflow with no push trigger", async () => {
    _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "manual.yml" }];
    _blobs[".gluecron/workflows/manual.yml"] = {
      content: DISPATCH_ONLY_YML,
      size: DISPATCH_ONLY_YML.length,
      isBinary: false,
    };
    _insertedRows = [{ id: "wf-2", disabled: false }];

    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);

    expect(result.synced).toBe(1);
    expect(result.enqueued).toBe(0);
    expect(_enqueueCalls).toHaveLength(0);
  });

  it("syncs but does NOT enqueue a disabled workflow", async () => {
    _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
    _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
    _insertedRows = [{ id: "wf-3", disabled: true }];

    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);

    expect(result.synced).toBe(1);
    expect(result.enqueued).toBe(0);
    expect(_enqueueCalls).toHaveLength(0);
  });

  it("records a parse error per-file without crashing or blocking other files", async () => {
    _treeEntries = [
      { mode: "100644", type: "blob", sha: "x", name: "broken.yml" },
      { mode: "100644", type: "blob", sha: "y", name: "ci.yml" },
    ];
    _blobs[".gluecron/workflows/broken.yml"] = { content: BROKEN_YML, size: 1, isBinary: false };
    _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
    _insertedRows = [{ id: "wf-4", disabled: false }];

    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);

    // The good file still gets synced + enqueued despite the broken one.
    expect(result.synced).toBe(1);
    expect(result.enqueued).toBe(1);
  });

  it("ignores non-yaml entries in the workflows directory", async () => {
    _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "README.md" }];
    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
    expect(result.synced).toBe(0);
    expect(_upsertCalls).toHaveLength(0);
  });

  it("records an error but keeps synced count when enqueueRun throws", async () => {
    _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
    _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
    _insertedRows = [{ id: "wf-5", disabled: false }];
    _enqueueShouldThrow = true;

    const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);

    expect(result.synced).toBe(1);
    expect(result.enqueued).toBe(0);
    expect(result.errors.length).toBe(1);
    expect(result.errors[0]).toContain("enqueue");
  });
});