From 19cbd1561e3e04dce21b961602e8a897915e6a9e Mon Sep 17 00:00:00 2001 From: lkbhitesh07 Date: Tue, 9 Jun 2026 17:09:57 +0530 Subject: [PATCH] Deprecates the probot-scheduler and introduces a custom scheduler --- lib/scheduler.js | 155 ++++++++++++++++++++++++++- package-lock.json | 1 - package.json | 1 - spec/schedulerSpec.js | 237 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 spec/schedulerSpec.js diff --git a/lib/scheduler.js b/lib/scheduler.js index 7645d2a1..f0da28f3 100644 --- a/lib/scheduler.js +++ b/lib/scheduler.js @@ -12,6 +12,159 @@ // See the License for the specific language governing permissions and // limitations under the License. -const createScheduler = require('probot-scheduler'); +/** + * @fileoverview Schedules a recurring "schedule.repository" event for every + * repository the GitHub App is installed on. + * + * This module is vendored from the (now archived) probot-scheduler package + * so that oppiabot no longer depends on an unmaintained npm release. The + * implementation is derived from: + * + * https://github.com/probot/scheduler (commit 060b19a, master branch) + * + * Original work is licensed under the ISC License, Copyright (c) 2016 + * Brandon Keepers and Probot contributors. A copy of the ISC notice is + * reproduced below per the terms of that license. + * + * --- BEGIN ISC NOTICE --------------------------------------------------- + * ISC License + * + * Copyright (c) 2016, Brandon Keepers + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * --- END ISC NOTICE ----------------------------------------------------- + */ + +const ignoredAccounts = (process.env.IGNORED_ACCOUNTS || '') + .toLowerCase() + .split(','); + +const defaults = { + // Whether to add a random delay before the first run for each repository. + // Disabled by setting DISABLE_DELAY=true in the environment. + delay: !process.env.DISABLE_DELAY, + // Event name received by Probot. With action "repository", the default + // handler name remains "schedule.repository". This can be changed later for + // stale-specific scheduling, e.g. "stale.schedule.repository". + eventName: 'schedule', + // Interval between scheduled events, in milliseconds. Default: 1 hour. + interval: 60 * 60 * 1000 +}; + +const createScheduler = (app, options) => { + options = Object.assign({}, defaults, options || {}); + const intervals = {}; + + const schedule = (installation, repository) => { + if (intervals[repository.id]) { + return; + } + + const delay = options.delay ? options.interval * Math.random() : 0; + + app.log.debug( + { repository, delay, interval: options.interval }, + 'Scheduling interval' + ); + + intervals[repository.id] = setTimeout(() => { + const event = { + name: options.eventName, + payload: { action: 'repository', installation, repository } + }; + + intervals[repository.id] = setInterval( + () => app.receive(event), + options.interval + ); + + app.receive(event); + }, delay); + }; + + const eachInstallation = async (callback) => { + app.log.trace('Fetching installations'); + const github = await app.auth(); + + const installations = await github.paginate( + github.apps.listInstallations.endpoint.merge({ per_page: 100 }) + ); + + const filteredInstallations = options.filter ? + installations.filter((installation) => options.filter(installation)) : + installations; + + for (const installation of filteredInstallations) { + await callback(installation); + } + }; + + const eachRepository = async (installation, callback) => { + app.log.trace({ installation }, 'Fetching repositories for installation'); + const github = await app.auth(installation.id); + + const repositories = await github.paginate( + github.apps.listRepos.endpoint.merge({ per_page: 100 }), + (response) => response.data.repositories + ); + + const filteredRepositories = options.filter ? + repositories.filter((repository) => { + return options.filter(installation, repository); + }) : + repositories; + + for (const repository of filteredRepositories) { + await callback(repository, github); + } + }; + + const setupInstallation = async (installation) => { + if (ignoredAccounts.includes(installation.account.login.toLowerCase())) { + app.log.info({ installation }, 'Installation is ignored'); + return; + } + + await eachRepository(installation, (repository) => { + schedule(installation, repository); + }); + }; + + const setup = async () => { + await eachInstallation(setupInstallation); + }; + + const stop = (repository) => { + app.log.info({ repository }, 'Canceling interval'); + clearTimeout(intervals[repository.id]); + clearInterval(intervals[repository.id]); + delete intervals[repository.id]; + }; + + app.on('installation.created', async (event) => { + const installation = event.payload.installation; + await eachRepository(installation, (repository) => { + schedule(installation, repository); + }); + }); + + app.on('installation_repositories.added', async (event) => { + return setupInstallation(event.payload.installation); + }); + + setup(); + + return { stop }; +}; module.exports = { createScheduler }; diff --git a/package-lock.json b/package-lock.json index acba59cd..02205e01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "newrelic": "^10.0.0", "probot": "^9.11.3", "probot-config": "^1.1.0", - "probot-scheduler": "^2.0.0-beta.1", "probot-stale": "github:oppia/oppiabot-stale" }, "devDependencies": { diff --git a/package.json b/package.json index b3f861bb..43600536 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "newrelic": "^10.0.0", "probot": "^9.11.3", "probot-config": "^1.1.0", - "probot-scheduler": "^2.0.0-beta.1", "probot-stale": "github:oppia/oppiabot-stale" }, "probot": { diff --git a/spec/schedulerSpec.js b/spec/schedulerSpec.js new file mode 100644 index 00000000..67b27549 --- /dev/null +++ b/spec/schedulerSpec.js @@ -0,0 +1,237 @@ +// Copyright 2020 The Oppia Authors. All Rights Reserved. +// +// Licensed 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. + +/** + * @fileoverview Spec for local scheduler. + */ + +const scheduler = require('../lib/scheduler'); + +const flushPromises = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +}; + +describe('Scheduler', () => { + let app; + let handlers; + let installation; + let installations; + let repositories; + let repository; + + beforeEach(() => { + jasmine.clock().install(); + handlers = {}; + installation = { id: 1, account: { login: 'oppia' } }; + installations = [installation]; + repository = { id: 2, name: 'oppia' }; + repositories = [repository]; + + const rootGithub = { + apps: { + listInstallations: { + endpoint: { merge: jasmine.createSpy('merge').and.returnValue({}) } + } + }, + paginate: jasmine.createSpy('paginate').and.callFake(() => { + return Promise.resolve(installations); + }) + }; + const installationGithub = { + apps: { + listRepos: { + endpoint: { merge: jasmine.createSpy('merge').and.returnValue({}) } + } + }, + paginate: jasmine.createSpy('paginate').and.callFake( + (endpoint, mapFn) => { + return Promise.resolve(mapFn({ data: { repositories } })); + }) + }; + + app = { + auth: jasmine.createSpy('auth').and.callFake((installationId) => { + return Promise.resolve( + installationId ? installationGithub : rootGithub); + }), + log: { + debug: jasmine.createSpy('debug'), + info: jasmine.createSpy('info'), + trace: jasmine.createSpy('trace') + }, + on: jasmine.createSpy('on').and.callFake((eventName, handler) => { + handlers[eventName] = handler; + }), + receive: jasmine.createSpy('receive') + }; + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('emits schedule.repository events by default', async () => { + scheduler.createScheduler(app, { delay: false, interval: 1000 }); + await flushPromises(); + + jasmine.clock().tick(0); + + expect(app.receive).toHaveBeenCalledWith({ + name: 'schedule', + payload: { action: 'repository', installation, repository } + }); + }); + + it('supports custom schedule event names', async () => { + scheduler.createScheduler(app, { + delay: false, + eventName: 'stale.schedule', + interval: 1000 + }); + await flushPromises(); + + jasmine.clock().tick(0); + + expect(app.receive).toHaveBeenCalledWith({ + name: 'stale.schedule', + payload: { action: 'repository', installation, repository } + }); + }); + + it('can stop scheduled repository events', async () => { + const scheduled = scheduler.createScheduler(app, { + delay: false, + interval: 1000 + }); + await flushPromises(); + + scheduled.stop(repository); + jasmine.clock().tick(0); + + expect(app.receive).not.toHaveBeenCalled(); + }); + + it('keeps receiving events on the configured interval', async () => { + scheduler.createScheduler(app, { delay: false, interval: 1000 }); + await flushPromises(); + + jasmine.clock().tick(0); + jasmine.clock().tick(1000); + + expect(app.receive).toHaveBeenCalledTimes(2); + }); + + it('does not schedule the same repository twice', async () => { + scheduler.createScheduler(app, { delay: false, interval: 1000 }); + await flushPromises(); + + await handlers['installation.created']({ payload: { installation } }); + await flushPromises(); + + expect(app.log.debug).toHaveBeenCalledTimes(1); + }); + + it('schedules repositories when an installation is created', async () => { + installations = []; + scheduler.createScheduler(app, { delay: false, interval: 1000 }); + await flushPromises(); + + await handlers['installation.created']({ payload: { installation } }); + await flushPromises(); + jasmine.clock().tick(0); + + expect(app.receive).toHaveBeenCalledWith({ + name: 'schedule', + payload: { action: 'repository', installation, repository } + }); + }); + + it('sets up repositories when repositories are added', async () => { + installations = []; + scheduler.createScheduler(app, { delay: false, interval: 1000 }); + await flushPromises(); + + await handlers['installation_repositories.added']({ + payload: { installation } + }); + await flushPromises(); + jasmine.clock().tick(0); + + expect(app.receive).toHaveBeenCalledWith({ + name: 'schedule', + payload: { action: 'repository', installation, repository } + }); + }); + + it('filters installations before scheduling repositories', async () => { + const filter = jasmine.createSpy('filter').and.callFake((inst, repo) => { + return Boolean(repo); + }); + scheduler.createScheduler(app, { + delay: false, + filter, + interval: 1000 + }); + await flushPromises(); + jasmine.clock().tick(0); + + expect(filter).toHaveBeenCalledWith(installation); + expect(app.receive).not.toHaveBeenCalled(); + }); + + it('filters repositories before scheduling them', async () => { + const filter = jasmine.createSpy('filter').and.callFake((inst, repo) => { + return !repo; + }); + scheduler.createScheduler(app, { + delay: false, + filter, + interval: 1000 + }); + await flushPromises(); + jasmine.clock().tick(0); + + expect(filter).toHaveBeenCalledWith(installation, repository); + expect(app.receive).not.toHaveBeenCalled(); + }); + + it('does not schedule repositories for ignored installations', async () => { + const originalIgnoredAccounts = process.env.IGNORED_ACCOUNTS; + process.env.IGNORED_ACCOUNTS = 'oppia'; + delete require.cache[require.resolve('../lib/scheduler')]; + const schedulerWithIgnoredAccounts = require('../lib/scheduler'); + + try { + schedulerWithIgnoredAccounts.createScheduler(app, { + delay: false, + interval: 1000 + }); + await flushPromises(); + jasmine.clock().tick(0); + + expect(app.log.info).toHaveBeenCalledWith( + { installation }, 'Installation is ignored'); + expect(app.receive).not.toHaveBeenCalled(); + } finally { + if (originalIgnoredAccounts === undefined) { + delete process.env.IGNORED_ACCOUNTS; + } else { + process.env.IGNORED_ACCOUNTS = originalIgnoredAccounts; + } + delete require.cache[require.resolve('../lib/scheduler')]; + } + }); +});