├── src ├── test │ ├── resources │ │ ├── LICENSE.md │ │ ├── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ │ └── versionInfo_test.properties │ └── java │ │ └── org │ │ └── jenkinsci │ │ └── backend │ │ └── ircbot │ │ ├── IrcBotConfigTest.java │ │ ├── IrcBotBuildInfoTest.java │ │ ├── fallback │ │ └── WeightedRandomAnswerTest.java │ │ └── IrcListenerTest.java └── main │ ├── java │ └── org │ │ └── jenkinsci │ │ └── backend │ │ └── ircbot │ │ ├── fallback │ │ ├── BotsnackMessage.java │ │ ├── WeightedRandomAnswer.java │ │ └── FallbackMessage.java │ │ ├── util │ │ └── ConnectionInfo.java │ │ ├── IrcBotBuildInfo.java │ │ ├── IrcBotConfig.java │ │ ├── JiraHelper.java │ │ └── IrcListener.java │ └── assembly.xml ├── .gitignore ├── .github ├── release-drafter.yml ├── dependabot.yml └── workflows │ └── release-drafter.yml ├── Jenkinsfile ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md └── pom.xml /src/test/resources/LICENSE.md: -------------------------------------------------------------------------------- 1 | This is a license! -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /src/test/resources/versionInfo_test.properties: -------------------------------------------------------------------------------- 1 | buildNumber=a 2 | buildDate=b 3 | buildID=c 4 | buildURL=d 5 | gitCommit=e 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings 4 | *.iml 5 | *.ipr 6 | *.iws 7 | target 8 | /src/main/resources/versionInfo.properties 9 | /unknown-commands.txt 10 | 11 | .idea/ 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/jenkinsci/.github/blob/master/.github/release-drafter.adoc 2 | _extends: .github 3 | # Semantic versioning: https://semver.org/ 4 | version-template: $MAJOR.$MINOR.$PATCH 5 | tag-template: v$NEXT_MINOR_VERSION 6 | name-template: v$NEXT_MINOR_VERSION 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "maven" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: read 8 | jobs: 9 | update_release_draft: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: release-drafter/release-drafter@65c5fb495d1e69aa8c08a3317bc44ff8aabe9772 # v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /src/test/java/org/jenkinsci/backend/ircbot/IrcBotConfigTest.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot; 2 | 3 | import java.util.Map; 4 | import org.junit.jupiter.api.Test; 5 | 6 | /** 7 | * Tests for {@link IrcBotConfig}. 8 | * @author Oleg Nenashev 9 | */ 10 | public class IrcBotConfigTest { 11 | @Test 12 | public void testGetConfig() { 13 | System.out.println("name = " + IrcBotConfig.NAME); 14 | System.out.println("default JIRA project = " + IrcBotConfig.JIRA_DEFAULT_PROJECT); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/fallback/BotsnackMessage.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot.fallback; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.pircbotx.User; 5 | 6 | public class BotsnackMessage { 7 | 8 | public String answer() { 9 | return new WeightedRandomAnswer() 10 | .addAnswer("Yum!", 4) 11 | .addAnswer("Om nom nom", 4) 12 | .addAnswer("Delish!", 3) 13 | .addAnswer("Thanks for the treat!", 3) 14 | .addAnswer("Mmmmm, can I have another?", 2) 15 | .addAnswer("Woot Woot", 2) 16 | .addAnswer("Where did you buy these delicious snacks?", 1) 17 | .get(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/assembly.xml: -------------------------------------------------------------------------------- 1 | 4 | bin 5 | 6 | zip 7 | dir 8 | 9 | false 10 | 11 | 12 | ${project.build.directory} 13 | / 14 | 15 | *.jar 16 | 17 | 18 | 19 | 20 | 21 | /lib 22 | true 23 | runtime 24 | 25 | 26 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | 3 | pipeline { 4 | agent { 5 | // 'docker' is the (legacy) label used on ci.jenkins.io for "Docker Linux AMD64" while 'linux-amd64-docker' is the label used on infra.ci.jenkins.io 6 | label 'docker || linux-amd64-docker' 7 | } 8 | options { 9 | buildDiscarder(logRotator(numToKeepStr: '5')) 10 | timestamps() 11 | } 12 | 13 | stages { 14 | stage('Build') { 15 | environment { 16 | JAVA_HOME = '/opt/jdk-17/' 17 | BUILD_NUMBER = env.GIT_COMMIT.take(6) 18 | } 19 | steps { 20 | sh 'make clean bot' 21 | } 22 | 23 | post { 24 | always { 25 | junit '**/target/surefire-reports/**/*.xml' 26 | } 27 | success { 28 | stash name: 'binary', includes: 'target/ircbot-2.0-SNAPSHOT-bin.zip' 29 | } 30 | } 31 | } 32 | 33 | stage('Docker image') { 34 | steps { 35 | buildDockerAndPublishImage('ircbot', [unstash: 'binary']) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17-jre-alpine 2 | 3 | RUN adduser -D -h /home/ircbot -u 1013 ircbot 4 | 5 | ARG APP_NAME=ircbot-2.0-SNAPSHOT 6 | COPY "target/${APP_NAME}-bin.zip" /usr/local/bin/ircbot.zip 7 | 8 | ## Always use the latest "unzip" package version. 9 | ## TODO: change assembly from zip to a jar file to get rid of the "unzip" step here (no need for ZIP) 10 | # hadolint ignore=DL3018 11 | RUN apk add --no-cache unzip \ 12 | && unzip /usr/local/bin/ircbot.zip -d /usr/local/bin \ 13 | && rm -f ircbot.zip 14 | 15 | # Add Tini 16 | ENV TINI_VERSION v0.19.0 17 | ADD "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static" /tini 18 | RUN chmod a+x /tini 19 | 20 | EXPOSE 8080 21 | USER ircbot 22 | 23 | # Persist the variable in the image as an env. variable 24 | ENV APP_NAME="${APP_NAME}" 25 | ENTRYPOINT [\ 26 | "/tini", "--",\ 27 | "/bin/sh","-c",\ 28 | "java -Dircbot.name=jenkins-admin -Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat='yyyy-MM-dd HH:mm:ss:SSS Z' -jar /usr/local/bin/${APP_NAME}.jar"] 29 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/util/ConnectionInfo.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot.util; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.util.Properties; 8 | 9 | /** 10 | * Stores JIRA connection credentials. 11 | * Originally this code 12 | * @author Kohsuke Kawaguchi 13 | */ 14 | public class ConnectionInfo { 15 | 16 | public String userName,password; 17 | 18 | public ConnectionInfo() throws IOException { 19 | this(new File(new File(System.getProperty("user.home")),".jenkins-ci.org")); 20 | } 21 | 22 | /*package*/ ConnectionInfo(File f) throws IOException { 23 | Properties prop = new Properties(); 24 | InputStream propInputStream = new FileInputStream(f); 25 | try { 26 | prop.load(propInputStream); 27 | } finally { 28 | propInputStream.close(); 29 | } 30 | userName = prop.getProperty("userName"); 31 | password = prop.getProperty("password"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2004-2014 Jenkins contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/fallback/WeightedRandomAnswer.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot.fallback; 2 | 3 | 4 | import java.security.SecureRandom; 5 | import java.util.ArrayList; 6 | import java.util.Collections; 7 | import java.util.LinkedHashMap; 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.Random; 11 | 12 | /** 13 | * Provides a random answer based on the relative weight of each answer this was constructed with. 14 | */ 15 | class WeightedRandomAnswer { 16 | private final SecureRandom random = new SecureRandom(); 17 | Map answers = new LinkedHashMap<>(); 18 | 19 | public WeightedRandomAnswer addAnswer(String answer, int weight) { 20 | answers.put(answer, weight); 21 | return this; 22 | } 23 | 24 | public String get() { 25 | List possibleAnswers = new ArrayList<>(); 26 | answers.entrySet().stream().forEach(entrySet -> 27 | possibleAnswers.addAll(Collections.nCopies(entrySet.getValue(), entrySet.getKey())) 28 | ); 29 | 30 | return possibleAnswers.get(random.nextInt(possibleAnswers.size())); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGENAME=jenkinsciinfra/ircbot 2 | TAG=$(shell date '+%Y%m%d_%H%M%S') 3 | 4 | # Build Info 5 | VERSION_FILE_DIR=src/main/resources 6 | VERSION_FILE=${VERSION_FILE_DIR}/versionInfo.properties 7 | VERSION_BUILD_NUMBER=$(BUILD_NUMBER) 8 | VERSION_BUILD_DATE=$(shell date '+%Y%m%d_%H%M%S') 9 | VERSION_BUILD_ID=$(BUILD_ID) 10 | VERSION_BUILD_URL=$(BUILD_URL) 11 | VERSION_GIT_COMMIT=$(GIT_COMMIT) 12 | 13 | target/ircbot-2.0-SNAPSHOT-bin.zip : ${VERSION_FILE} 14 | mvn -ntp install 15 | 16 | ${VERSION_FILE} : ${VERSION_FILE_DIR} 17 | echo buildNumber=${VERSION_BUILD_NUMBER} > ${VERSION_FILE} 18 | echo buildDate=${VERSION_BUILD_DATE} >> ${VERSION_FILE} 19 | echo buildID=${VERSION_BUILD_ID} >> ${VERSION_FILE} 20 | echo buildURL=${VERSION_BUILD_URL} >> ${VERSION_FILE} 21 | echo gitCommit=${VERSION_GIT_COMMIT} >> ${VERSION_FILE} 22 | 23 | ${VERSION_FILE_DIR} : 24 | mkdir ${VERSION_FILE_DIR} 25 | 26 | bot: target/ircbot-2.0-SNAPSHOT-bin.zip 27 | 28 | image : clean bot 29 | docker build -t ${IMAGENAME} . 30 | 31 | run : 32 | docker run -P --rm -i -t ${IMAGENAME} 33 | 34 | tag : 35 | docker tag ${IMAGENAME} ${IMAGENAME}:${TAG} 36 | 37 | clean: 38 | rm -rf target/* 39 | rm -f ${VERSION_FILE} 40 | 41 | push: 42 | docker push ${IMAGENAME} 43 | 44 | .PHONY: clean tag run image bot 45 | -------------------------------------------------------------------------------- /src/test/java/org/jenkinsci/backend/ircbot/IrcBotBuildInfoTest.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | import static org.junit.jupiter.api.Assumptions.assumingThat; 7 | 8 | import org.junit.jupiter.api.Test; 9 | import org.jvnet.hudson.test.Issue; 10 | 11 | 12 | /** 13 | * 14 | * @author Oleg Nenashev 15 | */ 16 | public class IrcBotBuildInfoTest { 17 | 18 | @Test 19 | @Issue("INFRA-135") 20 | public void testVersionInfoStub() throws IOException { 21 | IrcBotBuildInfo info = IrcBotBuildInfo.readResourceFile("/versionInfo_test.properties"); 22 | assertEquals("a", info.getBuildNumber()); 23 | assertEquals("b", info.getBuildDate()); 24 | assertEquals("c", info.getBuildID()); 25 | assertEquals("d", info.getBuildURL()); 26 | assertEquals("e", info.getGitCommit()); 27 | } 28 | 29 | /** 30 | * Reads the real version info. 31 | * This test is expected to work in https://ci.jenkins-ci.org/job/infra_ircbot/ job only. 32 | * @throws IOException 33 | */ 34 | @Test 35 | @Issue("INFRA-135") 36 | public void testVersionInfoReal() throws IOException { 37 | InputStream istream = IrcBotBuildInfo.class.getResourceAsStream("/versionInfo.properties"); 38 | assumingThat(istream != null, () -> { 39 | System.out.println("This test is expected to work in https://ci.jenkins.io/job/Infra/job/ircbot/ job only"); 40 | IrcBotBuildInfo info = IrcBotBuildInfo.readResourceFile("/versionInfo.properties"); 41 | System.out.println(info); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/fallback/FallbackMessage.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot.fallback; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.pircbotx.User; 5 | 6 | /** 7 | * This class is mostly to separate the core code from the joke-based one. This is VAI: very artificial intelligence. 8 | * The code below is generally intended to be called after normal commands have been parsed, and nothing was found. 9 | *

10 | * So... Main rule below is to be creative with answers :-). 11 | */ 12 | public class FallbackMessage { 13 | private final String payload; 14 | private final String sender; 15 | 16 | public FallbackMessage(String payload, String sender) { 17 | this.payload = payload; 18 | this.sender = sender; 19 | } 20 | 21 | public String answer() { 22 | if (StringUtils.containsIgnoreCase(payload, "thank")) { 23 | return new WeightedRandomAnswer() 24 | .addAnswer("You're welcome", 5) 25 | .addAnswer("my pleasure", 3) 26 | .addAnswer("no worries, mate", 2) 27 | .addAnswer("no drama, mate", 1) // https://www.daytranslations.com/blog/2013/01/australian-slang-a-unique-way-of-saying-and-describing-things-524/ 28 | .get(); 29 | } 30 | 31 | if (StringUtils.startsWithIgnoreCase(payload, "hello")) { 32 | return "Hello, " + sender + "!"; 33 | } 34 | 35 | return new WeightedRandomAnswer() 36 | .addAnswer("I didn't understand the command", 4) 37 | .addAnswer("Say it again?", 3) 38 | .addAnswer("Come again?", 2) 39 | .addAnswer("Wut?", 2) 40 | .addAnswer("Gnih?", 1) 41 | .get(); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/test/java/org/jenkinsci/backend/ircbot/fallback/WeightedRandomAnswerTest.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot.fallback; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 6 | import static org.junit.jupiter.api.Assertions.assertNotNull; 7 | import static org.junit.jupiter.api.Assertions.assertTrue; 8 | 9 | public class WeightedRandomAnswerTest { 10 | 11 | @Test 12 | public void smoke() { 13 | final String answer = new WeightedRandomAnswer() 14 | .addAnswer("You're welcome", 5) 15 | .get(); 16 | assertNotNull(answer); 17 | assertNotEquals("", answer.trim()); 18 | } 19 | 20 | /** 21 | * This test runs the get() a lot of times, and checks the distribution looks roughly good. 22 | * This could theoretically fail, but should generally not (there is a margin of error used below, raise it if deemed finally too low). 23 | */ 24 | @Test 25 | public void getAllAnswers() { 26 | final WeightedRandomAnswer answer = new WeightedRandomAnswer() 27 | .addAnswer("A", 80) 28 | .addAnswer("B", 15) 29 | .addAnswer("C", 5); 30 | 31 | int gotA = 0; 32 | int gotB = 0; 33 | int gotC = 0; 34 | for (int i = 0; i < 100_000; ++i) { 35 | if (answer.get().equals("A")) { 36 | gotA++; 37 | } 38 | if (answer.get().equals("B")) { 39 | gotB++; 40 | } 41 | if (answer.get().equals("C")) { 42 | gotC++; 43 | } 44 | } 45 | assertTrue(79_000 < gotA && gotA < 81_000, "Got A " + gotA + " times"); 46 | assertTrue(14_500 < gotB && gotB < 15_500, "Got B " + gotB + " times"); 47 | assertTrue(4_500 < gotC && gotC < 5_500, "Got C " + gotC + " times"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/IrcBotBuildInfo.java: -------------------------------------------------------------------------------- 1 | /* 2 | * To change this license header, choose License Headers in Project Properties. 3 | * To change this template file, choose Tools | Templates 4 | * and open the template in the editor. 5 | */ 6 | 7 | package org.jenkinsci.backend.ircbot; 8 | 9 | import java.io.IOException; 10 | import java.io.InputStream; 11 | import java.util.Properties; 12 | 13 | /** 14 | * Contains the info about IRC Bot Build. 15 | * The version info will be taken from versionInfo.properties file. 16 | * @author Oleg Nenashev 17 | */ 18 | /*package*/ class IrcBotBuildInfo { 19 | private final String buildNumber; 20 | private final String buildDate; 21 | private final String buildID; 22 | private final String buildURL; 23 | private final String gitCommit; 24 | 25 | private IrcBotBuildInfo(String buildNumber, String buildDate, String buildID, String buildURL, String gitCommit) { 26 | this.buildNumber = buildNumber; 27 | this.buildDate = buildDate; 28 | this.buildID = buildID; 29 | this.buildURL = buildURL; 30 | this.gitCommit = gitCommit; 31 | } 32 | 33 | public String getBuildID() { 34 | return buildID; 35 | } 36 | 37 | public String getBuildDate() { 38 | return buildDate; 39 | } 40 | 41 | public String getBuildNumber() { 42 | return buildNumber; 43 | } 44 | 45 | public String getBuildURL() { 46 | return buildURL; 47 | } 48 | 49 | public String getGitCommit() { 50 | return gitCommit; 51 | } 52 | 53 | @Override 54 | public String toString() { 55 | return String.format("build-%s %s (%s)", buildNumber, gitCommit, buildDate); 56 | } 57 | 58 | private static String readProperty(Properties propFile, String key) throws IOException { 59 | String value = propFile.getProperty(key); 60 | if (value == null) { 61 | throw new IOException("Property "+key+" does not exist"); 62 | } 63 | return value; 64 | } 65 | 66 | public static IrcBotBuildInfo readResourceFile (String resourcePath) throws IOException { 67 | InputStream istream = IrcBotBuildInfo.class.getResourceAsStream(resourcePath); 68 | if (istream == null) { 69 | throw new IOException("Cannot find resource "+resourcePath); 70 | } 71 | try { 72 | return readFile(istream); 73 | } finally { 74 | istream.close(); 75 | } 76 | } 77 | 78 | public static IrcBotBuildInfo readFile(InputStream istream) throws IOException { 79 | final Properties prop = new Properties(); 80 | prop.load(istream); 81 | 82 | return new IrcBotBuildInfo( 83 | readProperty(prop,"buildNumber"), 84 | readProperty(prop,"buildDate"), 85 | readProperty(prop,"buildID"), 86 | readProperty(prop,"buildURL"), 87 | readProperty(prop,"gitCommit")); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/IrcBotConfig.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot; 2 | 3 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 4 | 5 | import java.net.URI; 6 | import java.net.URL; 7 | import java.util.Arrays; 8 | import java.util.Collections; 9 | import java.util.HashSet; 10 | import java.util.Map; 11 | import java.util.Set; 12 | import java.util.TreeMap; 13 | import javax.annotation.Nonnull; 14 | 15 | /** 16 | * Stores configurations of {@link IrcListener}. 17 | * This class has been created to achieve the better IRC Bot flexibility according to INFRA-146. 18 | * @author Oleg Nenashev 19 | */ 20 | public class IrcBotConfig { 21 | 22 | private static final String varPrefix = "ircbot."; 23 | 24 | // General 25 | /** 26 | * Name of the bot (up to 16 symbols). 27 | */ 28 | private static final String DEFAULT_IRCBOT_NAME = ("ircbot-"+System.getProperty("user.name")); 29 | static String NAME = System.getProperty(varPrefix+"name", DEFAULT_IRCBOT_NAME); 30 | static String SERVER = System.getProperty(varPrefix+"server", "irc.libera.chat"); 31 | static final Set DEFAULT_CHANNELS = new HashSet(Arrays.asList("#jenkins-hosting")); 32 | static final String CHANNELS_LIST = System.getProperty(varPrefix+"channels", "#jenkins-hosting"); 33 | 34 | // Testing 35 | /** 36 | * Name of the user, for which security checks should be skipped. 37 | * @since 2.0-SNAPSHOT 38 | */ 39 | static String TEST_SUPERUSER = System.getProperty(varPrefix+"testSuperUser", null); 40 | 41 | // JIRAs 42 | /** 43 | * Specifies target JIRA URL. 44 | * @since 2.0-SNAPSHOT 45 | */ 46 | static final String JIRA_URL = System.getProperty(varPrefix+"jira.url", "https://issues.jenkins.io"); 47 | static final URI JIRA_URI; 48 | static String JIRA_DEFAULT_PROJECT = System.getProperty(varPrefix+"jira.defaultProject", "JENKINS"); 49 | /** 50 | * Specifies timeout for JIRA requests (in seconds). 51 | * @since 2.0-SNAPSHOT 52 | */ 53 | static final int JIRA_TIMEOUT_SEC = Integer.getInteger(varPrefix+"jira.requestTimeout", 30); 54 | 55 | // Github 56 | static String GITHUB_ORGANIZATION = System.getProperty(varPrefix+"github.organization", "jenkinsci"); 57 | static String GITHUB_POST_COMMIT_HOOK_EMAIL = System.getProperty(varPrefix+"github.postCommitHookEmail", "jenkinsci-commits@googlegroups.com"); 58 | 59 | static { 60 | try { 61 | JIRA_URI = new URL(JIRA_URL).toURI(); 62 | } catch (Exception ex) { 63 | throw new IllegalStateException("Cannot create URI for JIRA URL " + JIRA_URL, ex); 64 | } 65 | } 66 | 67 | public static @Nonnull Set getChannels() { 68 | HashSet res = new HashSet(); 69 | if (CHANNELS_LIST != null) { 70 | String[] channels = CHANNELS_LIST.split(","); 71 | for (String channel : channels) { 72 | if (channel.startsWith("#")) { 73 | res.add(channel); 74 | } 75 | } 76 | } 77 | return res.isEmpty() ? DEFAULT_CHANNELS : res; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!CAUTION] 2 | > This repository and the associated Docker images are archived. 3 | > 4 | > More details in https://github.com/jenkins-infra/helpdesk/issues/4645. 5 | 6 | 7 | 8 | # JIRA/GitHub management IRCBot 9 | 10 | [![Build Status](https://ci.jenkins.io/job/Infra/job/ircbot/job/main/badge/icon)](https://ci.jenkins.io/job/Infra/job/ircbot/job/main/) 11 | [![Docker Pulls](https://img.shields.io/docker/pulls/jenkinsciinfra/ircbot)](https://hub.docker.com/r/jenkinsciinfra/ircbot) 12 | 13 | This IRC bot sits on `#jenkins` as `jenkins-admin` and allow users to create/fork repositories on GitHub, etc. More info: [Jenkins IRC Bot Page](https://jenkins.io/projects/infrastructure/ircbot/) 14 | 15 | ## Deployment 16 | 17 | This repo is containerized (image available [on docker hub](https://hub.docker.com/r/jenkinsciinfra/ircbot/)), then [deployed to our infrastructure](https://github.com/jenkins-infra/kubernetes-management/blob/ccf43dc44f10e813ec50d9c3358b3ae0b4482f8b/clusters/privatek8s.yaml#L137-L144) via Helmfile. 18 | 19 | You can find the helm chart and instructions to install it in [jenkins-infra/helm-charts](https://github.com/jenkins-infra/helm-charts/tree/main/charts/ircbot). 20 | 21 | ## License 22 | 23 | [MIT License](https://opensource.org/licenses/mit-license.php) 24 | 25 | ## Developer guide 26 | 27 | This section contains some info for developers. 28 | 29 | ### Reusing IRCBot in non-Jenkins project 30 | 31 | The bot is designed to be used in Jenkins, but it can be adjusted in other projects, 32 | which use the similar infrastructure (GitHub, IRC, JIRA). 33 | Adjustements can be made via System properties. 34 | These properties are located and documented in the 35 | org.jenkinsci.backend.ircbot.IrcBotConfig class. 36 | 37 | Several examples are provided below. 38 | 39 | ### Building the bot 40 | 41 | 0. Use Maven to build the project and to run the unit tests. 42 | 0. Then use Dockerfile to create a Docker image 43 | 44 | For detailed examples see [Jenkinsfile](Jenkinsfile) located in this repository. 45 | 46 | ### Testing the bot locally 47 | 48 | Preconditions: 49 | 50 | 0. You have a JIRA **Test** Project, where you have admin permissions. 51 | 1. You have a GitHub Organization with ```Administer``` permissions 52 | 53 | Setting up the environment: 54 | 55 | 0. Setup Github credentials in the ```~/.github``` file 56 | * Format: Java properties 57 | * Entries to set: ```login``` and ```password``` 58 | * It's also possible ```oauth``` and ```endpoint``` properties 59 | (see [github-api](https://github.com/kohsuke/github-api)) 60 | 1. Setup JIRA credentials in the ```~/.jenkins-ci.org``` file 61 | * Format: Java properties 62 | * Entries to set: ```userName``` and ```password``` 63 | 64 | Running the bot for testing: 65 | 66 | ```sh 67 | java -Dircbot.name=test-ircbot \ 68 | -Dircbot.channels="#jenkins-ircbot-test" \ 69 | -Dircbot.testSuperUser="${YOUR_IRC_NAME}" \ 70 | -Dircbot.github.organization="jenkinsci-infra-ircbot-test" \ 71 | -Dircbot.jira.url=${JIRA_URL} \ 72 | -Dircbot.jira.defaultProject=TEST \ 73 | -Dorg.slf4j.simpleLogger.showDateTime=true \ 74 | -Dorg.slf4j.simpleLogger.dateTimeFormat="yyyy-MM-dd HH:mm:ss:SSS Z" 75 | -jar target/ircbot-2.0-SNAPSHOT-bin/ircbot-2.0-SNAPSHOT.jar 76 | ``` 77 | 78 | After executing this command the bot should connect to your IRC chat. 79 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/JiraHelper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2016 Oleg Nenashev. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package org.jenkinsci.backend.ircbot; 25 | 26 | import com.atlassian.jira.rest.client.api.JiraRestClient; 27 | import com.atlassian.jira.rest.client.api.domain.BasicComponent; 28 | import com.atlassian.jira.rest.client.api.domain.Component; 29 | import com.atlassian.jira.rest.client.api.domain.Issue; 30 | import com.atlassian.jira.rest.client.api.domain.IssueField; 31 | import com.atlassian.jira.rest.client.api.domain.Project; 32 | import com.atlassian.jira.rest.client.api.domain.Transition; 33 | import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory; 34 | import java.io.IOException; 35 | import java.util.concurrent.ExecutionException; 36 | import java.util.concurrent.TimeUnit; 37 | import java.util.concurrent.TimeoutException; 38 | import javax.annotation.CheckForNull; 39 | import javax.annotation.Nonnull; 40 | import javax.annotation.Nullable; 41 | 42 | import io.atlassian.util.concurrent.Promise; 43 | import org.codehaus.jettison.json.JSONException; 44 | import org.codehaus.jettison.json.JSONObject; 45 | import org.jenkinsci.backend.ircbot.util.ConnectionInfo; 46 | 47 | /** 48 | * Provides helper methods for JIRA access. 49 | * @author Oleg Nenashev 50 | * @since 2.0-SNAPSHOT 51 | */ 52 | public class JiraHelper { 53 | 54 | /** 55 | * Creates JIRA client using settings from {@link ConnectionInfo} and {@link IrcBotConfig}. 56 | * @return Created client with configured authentication settings. 57 | * @throws IOException Client creation failure 58 | */ 59 | @Nonnull 60 | static JiraRestClient createJiraClient() throws IOException { 61 | ConnectionInfo con = new ConnectionInfo(); 62 | JiraRestClient client = new AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication( 63 | IrcBotConfig.JIRA_URI, con.userName, con.password); 64 | return client; 65 | } 66 | 67 | /** 68 | * Waits till the completion of the synchronized command. 69 | * @param Type of the promise 70 | * @param promise Ongoing operation 71 | * @return Operation result 72 | * @throws InterruptedException Operation interrupted externally 73 | * @throws ExecutionException Execution failure 74 | * @throws TimeoutException Timeout (configured by {@link IrcBotConfig#JIRA_TIMEOUT_SEC}) 75 | */ 76 | @Nonnull 77 | static T wait(Promise promise) 78 | throws InterruptedException, ExecutionException, TimeoutException { 79 | return promise.get(IrcBotConfig.JIRA_TIMEOUT_SEC, TimeUnit.SECONDS); 80 | } 81 | 82 | static boolean close(JiraRestClient client) { 83 | try { 84 | if (client != null) { 85 | client.close(); 86 | } 87 | } catch (IOException e) { 88 | return false; 89 | } 90 | return true; 91 | } 92 | 93 | @Nonnull 94 | static BasicComponent getBasicComponent(JiraRestClient client, String projectId, String componentName) 95 | throws ExecutionException, TimeoutException, InterruptedException, IOException { 96 | Project project = wait(client.getProjectClient().getProject(projectId)); 97 | for (BasicComponent component : project.getComponents()) { 98 | if (component.getName().equals(componentName)) { 99 | return component; 100 | } 101 | } 102 | throw new IOException("Unable to find component " + componentName + " in the " + projectId + " issue tracker"); 103 | } 104 | 105 | @Nonnull 106 | static Component getComponent(JiraRestClient client, String projectName, String componentName) 107 | throws ExecutionException, TimeoutException, InterruptedException, IOException { 108 | BasicComponent bc = getBasicComponent(client, projectName, componentName); 109 | return wait(client.getComponentClient().getComponent(bc.getSelf())); 110 | } 111 | 112 | /** 113 | * Gets issue summary string. 114 | * @param ticket Ticket to be retrieved 115 | * @return Summary string for the issue 116 | * @throws IOException Operation failure 117 | * @throws InterruptedException Operation has been interrupted 118 | * @throws TimeoutException Timeout violation. See {@link IrcBotConfig#JIRA_TIMEOUT_SEC}. 119 | */ 120 | static String getSummary(String ticket) throws IOException, ExecutionException, TimeoutException, InterruptedException { 121 | String result; 122 | JiraRestClient client = createJiraClient(); 123 | Issue issue = client.getIssueClient().getIssue(ticket).get(IrcBotConfig.JIRA_TIMEOUT_SEC, TimeUnit.SECONDS); 124 | result = String.format("%s:%s (%s) %s", 125 | issue.getKey(), issue.getSummary(), issue.getStatus().getName(), 126 | IrcBotConfig.JIRA_URL + "/browse/" + ticket); 127 | close(client); 128 | return result; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.jenkins-ci 7 | jenkins 8 | 1.103 9 | 10 | 11 | 12 | ircbot 13 | 2.0-SNAPSHOT 14 | IRCBot for Jenkins 15 | https://github.com/jenkins-infra/ircbot 16 | 17 | 18 | 19 | MIT License 20 | http://www.opensource.org/licenses/mit-license.php 21 | repo 22 | 23 | 24 | 25 | 26 | UTF-8 27 | 28 | 29 | 30 | 31 | 32 | maven-compiler-plugin 33 | 34 | 35 | maven-jar-plugin 36 | 37 | 38 | 39 | org.jenkinsci.backend.ircbot.IrcListener 40 | lib 41 | true 42 | 43 | 44 | 45 | 46 | 47 | maven-assembly-plugin 48 | 49 | 50 | 51 | single 52 | 53 | package 54 | 55 | 56 | src/main/assembly.xml 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | com.atlassian.jira 68 | jira-rest-java-client-app 69 | 5.2.5 70 | 71 | 72 | jakarta.activation 73 | jakarta.activation-api 74 | 75 | 76 | com.atlassian.plugins 77 | atlassian-plugins-core 78 | 79 | 80 | org.springframework 81 | spring-jcl 82 | 83 | 84 | org.slf4j 85 | slf4j-api 86 | 87 | 88 | log4j 89 | log4j 90 | 91 | 92 | 93 | 94 | org.apache.httpcomponents 95 | httpclient 96 | 4.5.14 97 | 98 | 99 | org.slf4j 100 | slf4j-simple 101 | 2.0.7 102 | 103 | 104 | com.github.pircbotx 105 | pircbotx 106 | 2.3.1 107 | 108 | 109 | org.apache.commons 110 | commons-lang3 111 | 3.13.0 112 | 113 | 114 | commons-codec 115 | commons-codec 116 | 1.16.0 117 | 118 | 119 | commons-io 120 | commons-io 121 | 2.13.0 122 | 123 | 124 | org.kohsuke 125 | github-api 126 | 1.315 127 | 128 | 129 | com.github.spotbugs 130 | spotbugs-annotations 131 | 4.7.3 132 | 133 | 134 | 135 | 136 | org.jenkins-ci 137 | test-annotations 138 | 1.4 139 | test 140 | jar 141 | 142 | 143 | org.junit.jupiter 144 | junit-jupiter-api 145 | 5.9.3 146 | test 147 | 148 | 149 | org.mockito 150 | mockito-core 151 | 4.11.0 152 | test 153 | 154 | 155 | org.objenesis 156 | objenesis 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | repo.jenkins-ci.org 165 | https://repo.jenkins-ci.org/public/ 166 | 167 | true 168 | 169 | 170 | false 171 | 172 | 173 | 174 | atlassian-public 175 | https://maven.atlassian.com/repository/public 176 | 177 | false 178 | 179 | 180 | true 181 | 182 | 183 | 184 | jitpack 185 | https://jitpack.io 186 | 187 | 188 | 189 | -------------------------------------------------------------------------------- /src/test/java/org/jenkinsci/backend/ircbot/IrcListenerTest.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot; 2 | 3 | import com.google.common.collect.ImmutableSortedSet; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.AfterAll; 6 | import org.kohsuke.github.*; 7 | import org.mockito.Mockito; 8 | import org.mockito.MockedStatic; 9 | import org.mockito.invocation.InvocationOnMock; 10 | import org.mockito.stubbing.Answer; 11 | import org.pircbotx.Channel; 12 | import org.pircbotx.User; 13 | import org.pircbotx.UserLevel; 14 | import org.pircbotx.output.OutputChannel; 15 | 16 | import static java.util.Collections.emptyList; 17 | import static org.junit.jupiter.api.Assertions.assertFalse; 18 | import static org.junit.jupiter.api.Assertions.assertTrue; 19 | import static org.mockito.ArgumentMatchers.any; 20 | import static org.mockito.Mockito.mock; 21 | import static org.mockito.Mockito.when; 22 | 23 | 24 | /** 25 | * Created by slide on 11/14/2016. 26 | */ 27 | public class IrcListenerTest { 28 | static MockedStatic utilities = Mockito.mockStatic(GitHub.class); 29 | 30 | @AfterAll 31 | public static void afterClass() throws Exception { 32 | utilities.closeOnDemand(); 33 | } 34 | 35 | @Test 36 | public void testForkGithubExistingRepo() throws Exception { 37 | final String repoName = "jenkins"; 38 | final String channel = "#dummy"; 39 | final String botUser = "bot-user"; 40 | final String owner = "bar"; 41 | final String from = "foobar"; 42 | 43 | GitHub gh = mock(GitHub.class); 44 | utilities.when(GitHub::connect).thenReturn(gh); 45 | 46 | GHRepository repo = mock(GHRepository.class); 47 | 48 | GHOrganization gho = mock(GHOrganization.class); 49 | when(gho.getRepository(repoName)).thenReturn(repo); 50 | 51 | when(gh.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION)).thenReturn(gho); 52 | 53 | System.setProperty("ircbot.testSuperUser", botUser); 54 | 55 | IrcListener ircListener = new IrcListener(null); 56 | User sender = mock(User.class); 57 | when(sender.getNick()).thenReturn(botUser); 58 | 59 | Channel chan = mock(Channel.class); 60 | when(chan.getName()).thenReturn(channel); 61 | 62 | OutputChannel out = mock(OutputChannel.class); 63 | when(chan.send()).thenReturn(out); 64 | 65 | ImmutableSortedSet.Builder builder = ImmutableSortedSet.naturalOrder(); 66 | builder.add(UserLevel.VOICE); 67 | when(sender.getUserLevels(chan)).thenReturn(builder.build()); 68 | 69 | assertFalse(ircListener.forkGitHub(chan, sender, owner, from, repoName, emptyList(), false)); 70 | } 71 | 72 | @Test 73 | public void testForkOriginSameNameAsExisting() throws Exception { 74 | final String repoName = "some-new-name"; 75 | final String channel = "#dummy"; 76 | final String botUser = "bot-user"; 77 | final String owner = "bar"; 78 | final String from = "jenkins"; 79 | 80 | GitHub gh = mock(GitHub.class); 81 | utilities.when(GitHub::connect).thenReturn(gh); 82 | 83 | GHRepository originRepo = mock(GHRepository.class); 84 | final GHRepository newRepo = mock(GHRepository.class); 85 | 86 | GHUser user = mock(GHUser.class); 87 | when(gh.getUser(owner)).thenReturn(user); 88 | when(user.getRepository(from)).thenReturn(originRepo); 89 | when(originRepo.getName()).thenReturn(from); 90 | 91 | GHRepository repo = mock(GHRepository.class); 92 | final GHOrganization gho = mock(GHOrganization.class); 93 | when(gho.getRepository(from)).thenReturn(repo); 94 | when(repo.getName()).thenReturn(from); 95 | 96 | when(gh.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION)).thenReturn(gho); 97 | 98 | when(originRepo.forkTo(gho)).thenReturn(newRepo); 99 | when(newRepo.getName()).thenReturn(repoName); 100 | 101 | Mockito.doAnswer(new Answer() { 102 | @Override 103 | public Void answer(InvocationOnMock invocation) throws Throwable { 104 | Object[] arguments = invocation.getArguments(); 105 | if (arguments != null && arguments.length > 0 && arguments[0] != null) { 106 | String newName = (String) arguments[0]; 107 | when(gho.getRepository(newName)).thenReturn(newRepo); 108 | } 109 | return null; 110 | } 111 | }).when(newRepo).renameTo(repoName); 112 | 113 | GHTeam t = mock(GHTeam.class); 114 | Mockito.doNothing().when(t).add(user); 115 | 116 | System.setProperty("ircbot.testSuperUser", botUser); 117 | 118 | IrcListener ircListener = new IrcListener(null); 119 | User sender = mock(User.class); 120 | when(sender.getNick()).thenReturn(botUser); 121 | 122 | Channel chan = mock(Channel.class); 123 | when(chan.getName()).thenReturn(channel); 124 | 125 | OutputChannel out = mock(OutputChannel.class); 126 | when(chan.send()).thenReturn(out); 127 | 128 | ImmutableSortedSet.Builder builder = ImmutableSortedSet.naturalOrder(); 129 | builder.add(UserLevel.VOICE); 130 | when(sender.getUserLevels(chan)).thenReturn(builder.build()); 131 | assertFalse(ircListener.forkGitHub(chan, sender, owner, from, repoName, emptyList(), false)); 132 | } 133 | 134 | @Test 135 | public void testForkOriginSameNameAsRenamed() throws Exception { 136 | final String repoName = "some-new-name"; 137 | final String channel = "#dummy"; 138 | final String botUser = "bot-user"; 139 | final String owner = "bar"; 140 | final String from = "jenkins"; 141 | 142 | GitHub gh = mock(GitHub.class); 143 | utilities.when(GitHub::connect).thenReturn(gh); 144 | 145 | GHRepository originRepo = mock(GHRepository.class); 146 | final GHRepository newRepo = mock(GHRepository.class); 147 | 148 | GHUser user = mock(GHUser.class); 149 | when(gh.getUser(owner)).thenReturn(user); 150 | when(user.getRepository(from)).thenReturn(originRepo); 151 | when(originRepo.getName()).thenReturn(from); 152 | 153 | GHRepository repo = mock(GHRepository.class); 154 | final GHOrganization gho = mock(GHOrganization.class); 155 | when(gho.getRepository(from)).thenReturn(repo); 156 | when(repo.getName()).thenReturn("othername"); 157 | 158 | when(gh.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION)).thenReturn(gho); 159 | 160 | when(originRepo.forkTo(gho)).thenReturn(newRepo); 161 | when(newRepo.getName()).thenReturn(repoName); 162 | 163 | Mockito.doAnswer(new Answer() { 164 | @Override 165 | public Void answer(InvocationOnMock invocation) throws Throwable { 166 | Object[] arguments = invocation.getArguments(); 167 | if (arguments != null && arguments.length > 0 && arguments[0] != null) { 168 | String newName = (String) arguments[0]; 169 | when(gho.getRepository(newName)).thenReturn(newRepo); 170 | } 171 | return null; 172 | } 173 | }).when(newRepo).renameTo(repoName); 174 | 175 | GHTeamBuilder teamBuilder = mock(GHTeamBuilder.class); 176 | 177 | when(gho.createTeam("some-new-name Developers")).thenReturn(teamBuilder); 178 | when(teamBuilder.maintainers(any())).thenReturn(teamBuilder); 179 | when(teamBuilder.privacy(GHTeam.Privacy.CLOSED)).thenReturn(teamBuilder); 180 | 181 | GHTeam t = mock(GHTeam.class); 182 | when(teamBuilder.create()).thenReturn(t); 183 | Mockito.doNothing().when(t).add(newRepo, GHOrganization.Permission.ADMIN); 184 | 185 | System.setProperty("ircbot.testSuperUser", botUser); 186 | 187 | IrcListener ircListener = new IrcListener(null); 188 | User sender = mock(User.class); 189 | when(sender.getNick()).thenReturn(botUser); 190 | 191 | Channel chan = mock(Channel.class); 192 | when(chan.getName()).thenReturn(channel); 193 | 194 | OutputChannel out = mock(OutputChannel.class); 195 | when(chan.send()).thenReturn(out); 196 | 197 | ImmutableSortedSet.Builder builder = ImmutableSortedSet.naturalOrder(); 198 | builder.add(UserLevel.VOICE); 199 | when(sender.getUserLevels(chan)).thenReturn(builder.build()); 200 | assertTrue(ircListener.forkGitHub(chan, sender, owner, from, repoName, emptyList(), false)); 201 | } 202 | 203 | @Test 204 | public void testCreateRepoWithNoIssueTracker() throws Exception { 205 | // see https://github.com/jenkins-infra/ircbot/issues/101 206 | 207 | final String repoName = "memkins"; 208 | final String channel = "#dummy"; 209 | final String botUser = "bot-user"; 210 | final String owner = "bar"; 211 | final String from = "foobar"; 212 | 213 | GitHub gh = mock(GitHub.class); 214 | utilities.when(GitHub::connect).thenReturn(gh); 215 | 216 | GHUser awesomeUser = mock(GHUser.class); 217 | when(gh.getUser("awesome-user")).thenReturn(awesomeUser); 218 | 219 | GHRepository repo = mock(GHRepository.class); 220 | when(repo.getName()).thenReturn("memkins"); 221 | 222 | GHCreateRepositoryBuilder repositoryBuilder = mock(GHCreateRepositoryBuilder.class); 223 | when(repositoryBuilder.private_(false)).thenReturn(repositoryBuilder); 224 | 225 | GHOrganization gho = mock(GHOrganization.class); 226 | when(gho.createRepository("memkins")).thenReturn(repositoryBuilder); 227 | when(gho.hasMember(awesomeUser)).thenReturn(true); 228 | 229 | when(repositoryBuilder.create()).thenReturn(repo); 230 | 231 | when(gh.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION)).thenReturn(gho); 232 | 233 | GHTeam team = mock(GHTeam.class); 234 | 235 | GHTeamBuilder teamBuilder = mock(GHTeamBuilder.class); 236 | when(gho.createTeam("memkins Developers")).thenReturn(teamBuilder); 237 | when(teamBuilder.privacy(GHTeam.Privacy.CLOSED)).thenReturn(teamBuilder); 238 | when(teamBuilder.maintainers(any())).thenReturn(teamBuilder); 239 | when(teamBuilder.create()).thenReturn(team); 240 | 241 | System.setProperty("ircbot.testSuperUser", botUser); 242 | 243 | IrcListener ircListener = new IrcListener(null); 244 | User sender = mock(User.class); 245 | when(sender.getNick()).thenReturn(botUser); 246 | 247 | Channel chan = mock(Channel.class); 248 | when(chan.getName()).thenReturn(channel); 249 | 250 | OutputChannel out = mock(OutputChannel.class); 251 | when(chan.send()).thenReturn(out); 252 | 253 | ImmutableSortedSet.Builder builder = ImmutableSortedSet.naturalOrder(); 254 | builder.add(UserLevel.VOICE); 255 | when(sender.getUserLevels(chan)).thenReturn(builder.build()); 256 | 257 | ircListener.handleDirectCommand(chan, sender, "Create memkins on github for awesome-user"); 258 | //assertFalse(ircListener.forkGitHub(chan, sender, owner, from, repoName, emptyList(), false)); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/main/java/org/jenkinsci/backend/ircbot/IrcListener.java: -------------------------------------------------------------------------------- 1 | package org.jenkinsci.backend.ircbot; 2 | 3 | import com.atlassian.jira.rest.client.api.ComponentRestClient; 4 | import com.atlassian.jira.rest.client.api.JiraRestClient; 5 | import com.atlassian.jira.rest.client.api.domain.AssigneeType; 6 | import com.atlassian.jira.rest.client.api.domain.Component; 7 | import com.atlassian.jira.rest.client.api.domain.input.ComponentInput; 8 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 9 | import java.io.FileOutputStream; 10 | import java.io.OutputStreamWriter; 11 | import java.io.Writer; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.Arrays; 14 | import java.util.concurrent.ExecutionException; 15 | import java.util.concurrent.TimeoutException; 16 | import java.util.function.Consumer; 17 | import java.util.stream.Collectors; 18 | 19 | import io.atlassian.util.concurrent.Promise; 20 | import org.jenkinsci.backend.ircbot.fallback.BotsnackMessage; 21 | import org.jenkinsci.backend.ircbot.fallback.FallbackMessage; 22 | import org.kohsuke.github.GHOrganization.Permission; 23 | import org.kohsuke.github.GHTeamBuilder; 24 | import org.pircbotx.cap.SASLCapHandler; 25 | import org.pircbotx.hooks.ListenerAdapter; 26 | import org.pircbotx.hooks.events.MessageEvent; 27 | import org.kohsuke.github.GHOrganization; 28 | import org.kohsuke.github.GHRepository; 29 | import org.kohsuke.github.GHTeam; 30 | import org.kohsuke.github.GHUser; 31 | import org.kohsuke.github.GitHub; 32 | import org.pircbotx.Channel; 33 | import org.pircbotx.Configuration; 34 | import org.pircbotx.PircBotX; 35 | import org.pircbotx.User; 36 | import org.pircbotx.UserLevel; 37 | import org.pircbotx.output.OutputChannel; 38 | import org.pircbotx.output.OutputIRC; 39 | import org.slf4j.Logger; 40 | import org.slf4j.LoggerFactory; 41 | 42 | import javax.net.ssl.HttpsURLConnection; 43 | import javax.net.ssl.SSLContext; 44 | import javax.net.ssl.TrustManager; 45 | import javax.net.ssl.X509TrustManager; 46 | import java.io.File; 47 | import java.io.IOException; 48 | import java.security.GeneralSecurityException; 49 | import java.security.cert.X509Certificate; 50 | import java.util.ArrayList; 51 | import java.util.Collections; 52 | import java.util.HashMap; 53 | import java.util.List; 54 | import java.util.Map; 55 | import java.util.Random; 56 | import java.util.Set; 57 | import java.util.regex.Matcher; 58 | import java.util.regex.Pattern; 59 | 60 | import static java.util.Collections.emptyList; 61 | import static java.util.Collections.singletonList; 62 | import static java.util.regex.Pattern.*; 63 | 64 | import javax.annotation.CheckForNull; 65 | 66 | /** 67 | * IRC Bot on irc.libera.chat as a means to delegate administrative work to committers. 68 | * 69 | * @author Kohsuke Kawaguchi 70 | */ 71 | public class IrcListener extends ListenerAdapter { 72 | 73 | private static final Logger LOGGER = LoggerFactory.getLogger(IrcListener.class); 74 | 75 | /** 76 | * Records commands that we didn't understand. 77 | */ 78 | private File unknownCommands; 79 | 80 | /** 81 | * Map from the issue number to the time it was last mentioned. 82 | * Used so that we don't repeatedly mention the same issues. 83 | */ 84 | @SuppressWarnings("unchecked") 85 | private final Map recentIssues = Collections.synchronizedMap(new HashMap(10)); 86 | 87 | public IrcListener(File unknownCommands) { 88 | this.unknownCommands = unknownCommands; 89 | } 90 | 91 | @Override 92 | public void onMessage(MessageEvent e) { 93 | Channel channel = e.getChannel(); 94 | User sender = e.getUser(); 95 | String message = e.getMessage(); 96 | 97 | String senderNick = sender.getNick(); 98 | 99 | if (!IrcBotConfig.getChannels().contains(channel.getName())) return; // not in this channel 100 | if (senderNick.equals("jenkinsci_builds") || senderNick.equals("jenkins-admin") || senderNick.startsWith("ircbot-")) 101 | return; // ignore messages from other bots 102 | final String directMessagePrefix = e.getBot().getNick() + ":"; 103 | 104 | message = message.trim(); 105 | try { 106 | if (message.startsWith(directMessagePrefix)) { // Direct command to the bot 107 | // remove prefixes, trim whitespaces 108 | String payload = message.substring(directMessagePrefix.length()).trim(); 109 | payload = payload.replaceAll("\\s+", " "); 110 | handleDirectCommand(channel, sender, payload); 111 | } 112 | } catch (RuntimeException ex) { // Catch unhandled runtime issues 113 | ex.printStackTrace(); 114 | channel.send().message("An error ocurred. Please submit a ticket to the Jenkins infra helpdesk with the following exception:"); 115 | channel.send().message("https://github.com/jenkins-infra/helpdesk/issues/new?assignees=&labels=triage,irc&template=1-report-issue.yml"); 116 | channel.send().message(ex.getMessage()); 117 | throw ex; // Propagate the error to the caller in order to let it log and handle the issue 118 | } 119 | } 120 | 121 | /** 122 | * Handles direct commands coming to the bot. 123 | * The handler presumes the external trimming of the payload. 124 | */ 125 | void handleDirectCommand(Channel channel, User sender, String payload) { 126 | Matcher m; 127 | 128 | m = Pattern.compile("(?:create|make|add) (\\S+)(?: repository)? (?:on|in) github(?: for (\\S+))?(?: with (jira|github issues))?",CASE_INSENSITIVE).matcher(payload); 129 | if (m.matches()) { 130 | createGitHubRepository(channel,sender,m.group(1),m.group(2),m.group(3) != null && m.group(3).toLowerCase().contains("github")); 131 | return; 132 | } 133 | 134 | m = Pattern.compile("fork (?:https://github\\.com/)?(\\S+)/(\\S+)(?: on github)?(?: as (\\S+))?(?: with (jira|github issues))?",CASE_INSENSITIVE).matcher(payload); 135 | if (m.matches()) { 136 | forkGitHub(channel,sender,m.group(1),m.group(2),m.group(3), emptyList(), m.group(4).toLowerCase().contains("github")); 137 | return; 138 | } 139 | 140 | m = Pattern.compile("rename (?:github )repo (\\S+) to (\\S+)",CASE_INSENSITIVE).matcher(payload); 141 | if (m.matches()) { 142 | renameGitHubRepo(channel,sender,m.group(1),m.group(2)); 143 | return; 144 | } 145 | 146 | m = Pattern.compile("(?:make|give|grant|add) (\\S+)(?: as)? (?:a )?(?:committ?er|commit access) (?:of|on|to|at) (.+)",CASE_INSENSITIVE).matcher(payload); 147 | if (m.matches()) { 148 | List repos = collectGroups(m, 2); 149 | addGitHubCommitter(channel,sender,m.group(1),repos); 150 | return; 151 | } 152 | 153 | m = Pattern.compile("(?:remove|revoke) (\\S+)(?: as)? (?:a )?(committ?er|member) (?:from|on) (.+)",CASE_INSENSITIVE).matcher(payload); 154 | if (m.matches()) { 155 | List repos = collectGroups(m, 2); 156 | removeGitHubCommitter(channel,sender,m.group(1),repos); 157 | return; 158 | } 159 | 160 | m = Pattern.compile("(?:make|give|grant|add) (\\S+)(?: as)? (?:a )?(?:maintainer) on (.+)",CASE_INSENSITIVE).matcher(payload); 161 | if (m.matches()) { 162 | List repos = collectGroups(m, 2); 163 | makeGitHubTeamMaintainer(channel, sender, m.group(1), repos); 164 | return; 165 | } 166 | 167 | m = Pattern.compile("(?:make) (.*) team(?:s)? visible",CASE_INSENSITIVE).matcher(payload); 168 | if (m.matches()) { 169 | List teams = collectGroups(m, 1); 170 | makeGitHubTeamVisible(channel, sender, teams); 171 | return; 172 | } 173 | 174 | m = Pattern.compile("(?:create|make|add) (\\S+)(?: component)? in (?:the )?(?:issue|bug)(?: tracker| database)? for (\\S+)",CASE_INSENSITIVE).matcher(payload); 175 | if (m.matches()) { 176 | createComponent(channel, sender, m.group(1), m.group(2)); 177 | return; 178 | } 179 | 180 | m = Pattern.compile("(?:rem|remove|del|delete) component (\\S+) and move its issues to (\\S+)",CASE_INSENSITIVE).matcher(payload); 181 | if (m.matches()) { 182 | deleteComponent(channel, sender, m.group(1), m.group(2)); 183 | return; 184 | } 185 | 186 | m = Pattern.compile("rename component (\\S+) to (\\S+)",CASE_INSENSITIVE).matcher(payload); 187 | if (m.matches()) { 188 | renameComponent(channel, sender, m.group(1), m.group(2)); 189 | return; 190 | } 191 | 192 | m = Pattern.compile("(?:rem|remove) (?:the )?(?:lead|default assignee) (?:for|of|from) (.+)",CASE_INSENSITIVE).matcher(payload); 193 | if (m.matches()) { 194 | List subcomponents = collectGroups(m,1); 195 | removeDefaultAssignee(channel, sender, subcomponents); 196 | return; 197 | } 198 | 199 | m = Pattern.compile("(?:make|set) (\\S+) (?:the |as )?(?:lead|default assignee) (?:for|of) (.+)",CASE_INSENSITIVE).matcher(payload); 200 | if (m.matches()) { 201 | List subcomponents = collectGroups(m, 2); 202 | setDefaultAssignee(channel, sender, subcomponents, m.group(1)); 203 | return; 204 | } 205 | 206 | m = Pattern.compile("set (?:the )?description (?:for|of) (?:component )?(\\S+) to \\\"(.*)\\\"",CASE_INSENSITIVE).matcher(payload); 207 | if (m.matches()) { 208 | setComponentDescription(channel, sender, m.group(1) , m.group(2)); 209 | return; 210 | } 211 | 212 | m = Pattern.compile("(?:rem|remove) (?:the )?description (?:for|of) (?:component )?(\\S+)",CASE_INSENSITIVE).matcher(payload); 213 | if (m.matches()) { 214 | setComponentDescription(channel, sender, m.group(1) , null); 215 | return; 216 | } 217 | 218 | m = Pattern.compile("(?:make|give|grant|add) (\\S+) voice(?: on irc)?",CASE_INSENSITIVE).matcher(payload); 219 | if (m.matches()) { 220 | grantAutoVoice(channel,sender,m.group(1)); 221 | return; 222 | } 223 | 224 | m = Pattern.compile("(?:rem|remove|ungrant|del|delete) (\\S+) voice(?: on irc)?",CASE_INSENSITIVE).matcher(payload); 225 | if (m.matches()) { 226 | removeAutoVoice(channel,sender,m.group(1)); 227 | return; 228 | } 229 | 230 | m = Pattern.compile("(?:kick) (\\S+)",CASE_INSENSITIVE).matcher(payload); 231 | if (m.matches()) { 232 | kickUser(channel,sender,m.group(1)); 233 | return; 234 | } 235 | 236 | m = Pattern.compile("(?:set) (?:topic) (.*)", CASE_INSENSITIVE).matcher(payload); 237 | if (m.matches()) { 238 | setTopic(channel,sender,m.group(1)); 239 | return; 240 | } 241 | 242 | if (payload.equalsIgnoreCase("version")) { 243 | version(channel); 244 | return; 245 | } 246 | 247 | if (payload.equalsIgnoreCase("help")) { 248 | help(channel); 249 | return; 250 | } 251 | 252 | if (payload.equalsIgnoreCase("refresh")) { 253 | // get the updated list 254 | channel.getBot().sendRaw().rawLineNow("NAMES " + channel); 255 | return; 256 | } 257 | 258 | if(payload.equalsIgnoreCase("botsnack")) { 259 | sendBotsnackMessage(channel, sender); 260 | return; 261 | } 262 | 263 | if (payload.equalsIgnoreCase("restart")) { 264 | restart(channel,sender); 265 | } 266 | 267 | sendFallbackMessage(channel, payload, sender); 268 | 269 | try { 270 | Writer w = new OutputStreamWriter(new FileOutputStream(unknownCommands), StandardCharsets.UTF_8); 271 | w.append(payload); 272 | w.close(); 273 | } catch (IOException e) {// if we fail to write, let it be. 274 | e.printStackTrace(); 275 | } 276 | } 277 | 278 | private void sendBotsnackMessage(Channel channel, User sender) { 279 | OutputChannel out = channel.send(); 280 | out.message((new BotsnackMessage().answer())); 281 | } 282 | 283 | private void sendFallbackMessage(Channel channel, String payload, User sender) { 284 | OutputChannel out = channel.send(); 285 | out.message(new FallbackMessage(payload, sender.getNick()).answer()); 286 | } 287 | 288 | /** 289 | * Restart ircbot. 290 | * 291 | * We just need to quit, and docker container manager will automatically restart 292 | * another one. We've seen for some reasons sometimes jenkins-admin loses its +o flag, 293 | * and when that happens a restart fixes it quickly. 294 | */ 295 | @SuppressFBWarnings( 296 | value="DM_EXIT", 297 | justification="Intentionally restarting the app" 298 | ) 299 | private void restart(Channel channel, User sender) { 300 | if (!isSenderAuthorized(channel,sender)) { 301 | insufficientPermissionError(channel); 302 | return; 303 | } 304 | 305 | channel.send().message("I'll quit and come back"); 306 | System.exit(0); 307 | } 308 | 309 | private void kickUser(Channel channel, User sender, String target) { 310 | if (!isSenderAuthorized(channel, sender)) { 311 | insufficientPermissionError(channel); 312 | return; 313 | } 314 | 315 | OutputChannel out = channel.send(); 316 | for (User u : channel.getUsers()) { 317 | if (u.getNick().equalsIgnoreCase(target)) { 318 | out.kick(u, "kicked"); 319 | out.message("Kicked user " + target); 320 | break; 321 | } 322 | } 323 | } 324 | 325 | private void setTopic(Channel channel, User sender, String newTopic) { 326 | if(!isSenderAuthorized(channel, sender)) { 327 | insufficientPermissionError(channel); 328 | return; 329 | } 330 | channel.send().setTopic(newTopic); 331 | } 332 | 333 | private void replyBugStatus(Channel channel, String ticket) { 334 | Long time = recentIssues.get(ticket); 335 | 336 | recentIssues.put(ticket,System.currentTimeMillis()); 337 | 338 | if (time!=null) { 339 | if (System.currentTimeMillis()-time < 60*1000) { 340 | return; // already mentioned recently. don't repeat 341 | } 342 | } 343 | 344 | try { 345 | channel.send().message(JiraHelper.getSummary(ticket)); 346 | } catch (Exception e) { 347 | e.printStackTrace(); 348 | } 349 | } 350 | 351 | /** 352 | * Is the sender respected in the channel? 353 | * 354 | * IOW, does he have a voice of a channel operator? 355 | */ 356 | private boolean isSenderAuthorized(Channel channel, User sender) { 357 | return isSenderAuthorized(channel, sender, true); 358 | } 359 | 360 | private boolean isSenderAuthorized(Channel channel, User sender, boolean acceptVoice) { 361 | return (IrcBotConfig.TEST_SUPERUSER != null && IrcBotConfig.TEST_SUPERUSER.equals(sender.getNick())) 362 | || sender.getUserLevels(channel).stream().anyMatch(e -> e == UserLevel.OP 363 | || (acceptVoice && e == UserLevel.VOICE)); 364 | } 365 | 366 | private void help(Channel channel) { 367 | channel.send().message("See https://jenkins.io/projects/infrastructure/ircbot/"); 368 | } 369 | 370 | private void version(Channel channel) { 371 | OutputChannel out = channel.send(); 372 | try { 373 | IrcBotBuildInfo buildInfo = IrcBotBuildInfo.readResourceFile("/versionInfo.properties"); 374 | out.message("My version is "+buildInfo); 375 | out.message("Build URL: "+buildInfo.getBuildURL()); 376 | } catch (IOException e) { 377 | e.printStackTrace(); 378 | out.message("I don't know who I am"); 379 | } 380 | } 381 | 382 | private void insufficientPermissionError(Channel channel) { 383 | insufficientPermissionError(channel, true); 384 | } 385 | 386 | private void insufficientPermissionError(Channel channel, boolean acceptVoice ) { 387 | OutputChannel out = channel.send(); 388 | final String requiredPrefix = acceptVoice ? "+ or @" : "@"; 389 | out.message("Only people with "+requiredPrefix+" can run this command."); 390 | // I noticed that sometimes the bot just get out of sync, so ask the sender to retry 391 | channel.getBot().sendRaw().rawLineNow("NAMES "+channel); 392 | out.message("I'll refresh the member list, so if you think this is an error, try again in a few seconds."); 393 | } 394 | 395 | /** 396 | * Creates an issue tracker component. 397 | */ 398 | private boolean createComponent(Channel channel, User sender, String subcomponent, String owner) { 399 | if (!isSenderAuthorized(channel,sender)) { 400 | insufficientPermissionError(channel); 401 | return false; 402 | } 403 | 404 | OutputChannel out = channel.send(); 405 | 406 | out.message(String.format("Adding a new JIRA subcomponent %s to the %s project, owned by %s", 407 | subcomponent, IrcBotConfig.JIRA_DEFAULT_PROJECT, owner)); 408 | 409 | boolean result = false; 410 | JiraRestClient client = null; 411 | try { 412 | client = JiraHelper.createJiraClient(); 413 | final ComponentRestClient componentClient = client.getComponentClient(); 414 | final Promise createComponent = componentClient.createComponent(IrcBotConfig.JIRA_DEFAULT_PROJECT, 415 | new ComponentInput(subcomponent, "subcomponent", owner, AssigneeType.COMPONENT_LEAD)); 416 | final Component component = JiraHelper.wait(createComponent); 417 | out.message("New component created. URL is " + component.getSelf().toURL()); 418 | result = true; 419 | } catch (Exception e) { 420 | out.message("Failed to create a new component: "+e.getMessage()); 421 | e.printStackTrace(); 422 | } finally { 423 | if(!JiraHelper.close(client)) { 424 | out.message("Failed to close JIRA client, possible leaked file descriptors"); 425 | } 426 | } 427 | 428 | return result; 429 | } 430 | 431 | /** 432 | * Renames an issue tracker component. 433 | */ 434 | private void renameComponent(Channel channel, User sender, String oldName, String newName) { 435 | if (!isSenderAuthorized(channel,sender)) { 436 | insufficientPermissionError(channel); 437 | return; 438 | } 439 | 440 | OutputChannel out = channel.send(); 441 | out.message(String.format("Renaming subcomponent %s to %s", oldName, newName)); 442 | 443 | JiraRestClient client = null; 444 | try { 445 | client = JiraHelper.createJiraClient(); 446 | final Component component = JiraHelper.getComponent(client, IrcBotConfig.JIRA_DEFAULT_PROJECT, oldName); 447 | final ComponentRestClient componentClient = JiraHelper.createJiraClient().getComponentClient(); 448 | Promise updateComponent = componentClient.updateComponent(component.getSelf(), 449 | new ComponentInput(newName, null, null, null)); 450 | JiraHelper.wait(updateComponent); 451 | out.message("The component has been renamed"); 452 | } catch (Exception e) { 453 | out.message(e.getMessage()); 454 | e.printStackTrace(); 455 | } finally { 456 | if(!JiraHelper.close(client)) { 457 | out.message("Failed to close JIRA client, possible leaked file descriptors"); 458 | } 459 | } 460 | } 461 | 462 | /** 463 | * Deletes an issue tracker component. 464 | */ 465 | private void deleteComponent(Channel channel, User sender, String deletedComponent, String backupComponent) { 466 | if (!isSenderAuthorized(channel,sender)) { 467 | insufficientPermissionError(channel); 468 | return; 469 | } 470 | 471 | OutputChannel out = channel.send(); 472 | 473 | out.message(String.format("Deleting the subcomponent %s. All issues will be moved to %s", deletedComponent, backupComponent)); 474 | 475 | JiraRestClient client = null; 476 | try { 477 | client = JiraHelper.createJiraClient(); 478 | final Component component = JiraHelper.getComponent(client, IrcBotConfig.JIRA_DEFAULT_PROJECT, deletedComponent); 479 | final Component componentBackup = JiraHelper.getComponent(client, IrcBotConfig.JIRA_DEFAULT_PROJECT, backupComponent); 480 | Promise removeComponent = client.getComponentClient().removeComponent(component.getSelf(), componentBackup.getSelf()); 481 | JiraHelper.wait(removeComponent); 482 | out.message("The component has been deleted"); 483 | } catch (Exception e) { 484 | out.message(e.getMessage()); 485 | e.printStackTrace(); 486 | } finally { 487 | if(!JiraHelper.close(client)) { 488 | out.message("Failed to close JIRA client, possible leaked file descriptors"); 489 | } 490 | } 491 | } 492 | 493 | /** 494 | * Deletes an assignee from the specified component 495 | */ 496 | private void removeDefaultAssignee(Channel channel, User sender, List subcomponents) { 497 | setDefaultAssignee(channel, sender, subcomponents, null); 498 | } 499 | 500 | /** 501 | * Creates an issue tracker component. 502 | * @param owner User ID or null if the owner should be removed 503 | */ 504 | private void setDefaultAssignee(Channel channel, User sender, List subcomponents, 505 | @CheckForNull String owner) { 506 | if (!isSenderAuthorized(channel, sender)) { 507 | insufficientPermissionError(channel); 508 | return; 509 | } 510 | 511 | OutputChannel out = channel.send(); 512 | JiraRestClient client = null; 513 | try { 514 | client = JiraHelper.createJiraClient(); 515 | for (String subcomponent : subcomponents) { 516 | try { 517 | out.message(String.format("Changing default assignee of subcomponent %s to %s", subcomponent, owner)); 518 | final Component component = JiraHelper.getComponent(client, IrcBotConfig.JIRA_DEFAULT_PROJECT, subcomponent); 519 | Promise updateComponent = client.getComponentClient().updateComponent(component.getSelf(), 520 | new ComponentInput(null, null, owner != null ? owner : "", AssigneeType.COMPONENT_LEAD)); 521 | JiraHelper.wait(updateComponent); 522 | out.message(owner != null ? String.format("Default assignee set to %s for %s", owner, subcomponent) 523 | : "Default assignee has been removed for " + subcomponent); 524 | } catch(ExecutionException | TimeoutException | InterruptedException | IOException e) { 525 | out.message(String.format("Failed to set default assignee for %s: %s", subcomponent, e.getMessage())); 526 | e.printStackTrace(); 527 | } 528 | } 529 | } catch (Throwable e) { 530 | out.message("Failed to connect to Jira: " + e.getMessage()); 531 | e.printStackTrace(); 532 | } finally { 533 | if (!JiraHelper.close(client)) { 534 | out.message("Failed to close JIRA client, possible leaked file descriptors"); 535 | } 536 | } 537 | } 538 | 539 | /** 540 | * Sets the component description. 541 | * @param description Component description. Use null to remove the description 542 | */ 543 | private void setComponentDescription(Channel channel, User sender, String componentName, @CheckForNull String description) { 544 | if (!isSenderAuthorized(channel,sender)) { 545 | insufficientPermissionError(channel); 546 | return; 547 | } 548 | 549 | OutputChannel out = channel.send(); 550 | 551 | out.message(String.format("Updating the description of component %s", componentName)); 552 | 553 | JiraRestClient client = null; 554 | try { 555 | client = JiraHelper.createJiraClient(); 556 | final Component component = JiraHelper.getComponent(client, IrcBotConfig.JIRA_DEFAULT_PROJECT, componentName); 557 | Promise updateComponent = client.getComponentClient().updateComponent(component.getSelf(), 558 | new ComponentInput(null, description != null ? description : "", null, null)); 559 | JiraHelper.wait(updateComponent); 560 | out.message("The component description has been " + (description != null ? "updated" : "removed")); 561 | } catch (Exception e) { 562 | out.message(e.getMessage()); 563 | e.printStackTrace(); 564 | } finally { 565 | if(!JiraHelper.close(client)) { 566 | out.message("Failed to close JIRA client, possible leaked file descriptors"); 567 | } 568 | } 569 | } 570 | 571 | private void grantAutoVoice(Channel channel, User sender, String target) { 572 | if (!isSenderAuthorized(channel,sender)) { 573 | insufficientPermissionError(channel); 574 | return; 575 | } 576 | 577 | OutputIRC out = channel.getBot().sendIRC(); 578 | out.message("CHANSERV", "flags " + channel.getName() + " " + target + " +V"); 579 | out.message("CHANSERV", "voice " + channel.getName() + " " + target); 580 | channel.send().message("Voice privilege (+V) added for " + target); 581 | } 582 | 583 | private void removeAutoVoice(Channel channel, User sender, String target) { 584 | if (!isSenderAuthorized(channel, sender)) { 585 | insufficientPermissionError(channel); 586 | return; 587 | } 588 | 589 | OutputIRC out = channel.getBot().sendIRC(); 590 | out.message("CHANSERV", "flags " + channel.getName() + " " + target + " -V"); 591 | out.message("CHANSERV", "devoice " + channel.getName() + " " + target); 592 | channel.send().message("Voice privilege (-V) removed for " + target); 593 | } 594 | 595 | private void createGitHubRepository(Channel channel, User sender, String name, String collaborator, boolean useGHIssues) { 596 | OutputChannel out = channel.send(); 597 | try { 598 | if (!isSenderAuthorized(channel,sender)) { 599 | insufficientPermissionError(channel); 600 | return; 601 | } 602 | 603 | GitHub github = GitHub.connect(); 604 | GHOrganization org = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 605 | GHRepository r = org.createRepository(name).private_(false).create(); 606 | setupRepository(r, useGHIssues); 607 | 608 | getOrCreateRepoLocalTeam(out, github, org, r, singletonList(collaborator)); 609 | 610 | out.message("New github repository created at "+r.getUrl()); 611 | } catch (IOException e) { 612 | out.message("Failed to create a repository: "+e.getMessage()); 613 | e.printStackTrace(); 614 | } 615 | } 616 | 617 | /** 618 | * Makes GitHub team visible. 619 | * 620 | * @param teams 621 | * teams to make visible 622 | */ 623 | private void makeGitHubTeamVisible(Channel channel, User sender, List teams) { 624 | if (!isSenderAuthorized(channel, sender)) { 625 | insufficientPermissionError(channel); 626 | return; 627 | } 628 | OutputChannel out = channel.send(); 629 | try { 630 | GitHub github = GitHub.connect(); 631 | GHOrganization o = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 632 | 633 | for (String team : teams) { 634 | try { 635 | final GHTeam ghTeam = o.getTeamByName(team); 636 | if (ghTeam == null) { 637 | out.message("No team for " + team); 638 | continue; 639 | } 640 | 641 | ghTeam.setPrivacy(GHTeam.Privacy.CLOSED); 642 | 643 | out.message("Made GitHub team " + team + " visible"); 644 | } catch (IOException e) { 645 | out.message("Failed to make GitHub team " + team + " visible: " + e.getMessage()); 646 | e.printStackTrace(); 647 | } 648 | } 649 | } catch(IOException e) { 650 | out.message("Failed to connect to GitHub or retrieve organization information: " + e.getMessage()); 651 | } 652 | } 653 | 654 | /** 655 | * Makes a user a maintainer of a GitHub team 656 | * 657 | * @param teams 658 | * make user a maintainer of one oe more teams. 659 | */ 660 | private void makeGitHubTeamMaintainer(Channel channel, User sender, String newTeamMaintainer, List teams) { 661 | if (!isSenderAuthorized(channel, sender)) { 662 | insufficientPermissionError(channel); 663 | return; 664 | } 665 | OutputChannel out = channel.send(); 666 | try { 667 | GitHub github = GitHub.connect(); 668 | GHOrganization o = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 669 | for (String team : teams) { 670 | try { 671 | GHUser c = github.getUser(newTeamMaintainer); 672 | 673 | final GHTeam ghTeam = o.getTeamByName(team); 674 | if (ghTeam == null) { 675 | out.message("No team for " + team); 676 | continue; 677 | } 678 | 679 | ghTeam.add(c, GHTeam.Role.MAINTAINER); 680 | out.message("Added " + newTeamMaintainer + " as a GitHub maintainer for team " + team); 681 | } catch (IOException e) { 682 | out.message("Failed to make user maintainer of one or more teams: " + e.getMessage()); 683 | e.printStackTrace(); 684 | } 685 | } 686 | } catch (IOException e) { 687 | out.message("Failed to connect to GitHub or get organization information: " + e.getMessage()); 688 | e.printStackTrace(); 689 | } 690 | } 691 | 692 | /** 693 | * Adds a new collaborator to existing repositories. 694 | * 695 | * @param repos 696 | * List of repositories to add the collaborator to. 697 | */ 698 | private void addGitHubCommitter(Channel channel, User sender, String collaborator, List repos) { 699 | if (!isSenderAuthorized(channel,sender)) { 700 | insufficientPermissionError(channel); 701 | return; 702 | } 703 | OutputChannel out = channel.send(); 704 | 705 | if (repos == null || repos.isEmpty()) { 706 | // legacy command 707 | out.message("I'm not longer managing the Everyone team. Please add committers to specific repos."); 708 | return; 709 | } 710 | 711 | try { 712 | GitHub github = GitHub.connect(); 713 | GHUser c = github.getUser(collaborator); 714 | GHOrganization o = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 715 | 716 | for(String repo : repos) { 717 | try { 718 | final GHTeam t; 719 | GHRepository forThisRepo = o.getRepository(repo); 720 | if (forThisRepo == null) { 721 | out.message("Could not find repository: " + repo); 722 | continue; 723 | } 724 | 725 | t = getOrCreateRepoLocalTeam(out, github, o, forThisRepo, emptyList()); 726 | t.add(c); 727 | out.message(String.format("Added %s as a GitHub committer for repository %s", collaborator, repo)); 728 | } catch(IOException e) { 729 | out.message("Failed to add user to team: "+e.getMessage()); 730 | e.printStackTrace(); 731 | } 732 | } 733 | } catch (IOException e) { 734 | out.message("Failed to connect to GitHub or get organization/user information: "+e.getMessage()); 735 | e.printStackTrace(); 736 | } 737 | } 738 | 739 | private void removeGitHubCommitter(Channel channel, User sender, String collaborator, List repos) { 740 | if (!isSenderAuthorized(channel,sender)) { 741 | insufficientPermissionError(channel); 742 | return; 743 | } 744 | OutputChannel out = channel.send(); 745 | try { 746 | GitHub github = GitHub.connect(); 747 | GHUser githubUser = github.getUser(collaborator); 748 | GHOrganization githubOrganization = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 749 | for(String repo : repos) { 750 | try { 751 | final GHTeam ghTeam; 752 | GHRepository forThisRepo = githubOrganization.getRepository(repo); 753 | if (forThisRepo == null) { 754 | out.message("Could not find repository: " + repo); 755 | continue; 756 | } 757 | 758 | ghTeam = getOrCreateRepoLocalTeam(out, github, githubOrganization, forThisRepo, emptyList()); 759 | ghTeam.remove(githubUser); 760 | out.message("Removed " + collaborator + " as a GitHub committer for repository " + repo); 761 | } catch(IOException e) { 762 | out.message("Failed to remove user from team: "+e.getMessage()); 763 | e.printStackTrace(); 764 | } 765 | } 766 | } catch (IOException e) { 767 | out.message("Failed to connect to GitHub or retrieve organization or user information: "+e.getMessage()); 768 | e.printStackTrace(); 769 | } 770 | } 771 | 772 | private void renameGitHubRepo(Channel channel, User sender, String repo, String newName) { 773 | OutputChannel out = channel.send(); 774 | try { 775 | if (!isSenderAuthorized(channel, sender, false)) { 776 | insufficientPermissionError(channel, false); 777 | return; 778 | } 779 | 780 | out.message("Renaming " + repo + " to " + newName); 781 | 782 | GitHub github = GitHub.connect(); 783 | GHOrganization o = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 784 | 785 | GHRepository orig = o.getRepository(repo); 786 | if (orig == null) { 787 | out.message("No such repository: " + repo); 788 | return; 789 | } 790 | 791 | orig.renameTo(newName); 792 | out.message("The repository has been renamed: https://github.com/" + IrcBotConfig.GITHUB_ORGANIZATION+"/"+newName); 793 | } catch (IOException e) { 794 | out.message("Failed to rename a repository: " + e.getMessage()); 795 | e.printStackTrace(); 796 | } 797 | } 798 | 799 | /** 800 | * @param newName 801 | * If not null, rename a repository after a fork. 802 | */ 803 | boolean forkGitHub(Channel channel, User sender, String owner, String repo, String newName, List maintainers, boolean useGHIssues) { 804 | boolean result = false; 805 | OutputChannel out = channel.send(); 806 | try { 807 | if (!isSenderAuthorized(channel,sender)) { 808 | insufficientPermissionError(channel); 809 | return false; 810 | } 811 | 812 | GitHub github = GitHub.connect(); 813 | GHOrganization org = github.getOrganization(IrcBotConfig.GITHUB_ORGANIZATION); 814 | GHRepository check = org.getRepository(newName); 815 | if(check != null) { 816 | out.message("Repository with name "+newName+" already exists in "+IrcBotConfig.GITHUB_ORGANIZATION); 817 | return false; 818 | } 819 | 820 | // check if there is an existing real (not-renamed) repository with the name 821 | // if a repo has been forked and renamed, we can clone as that name and be fine 822 | // we just want to make sure we don't fork to an current repository name. 823 | check = org.getRepository(repo); 824 | if(check != null && check.getName().equalsIgnoreCase(repo)) { 825 | out.message("Repository " + repo + " can't be forked, an existing repository with that name already exists in " + IrcBotConfig.GITHUB_ORGANIZATION); 826 | return false; 827 | } 828 | 829 | out.message("Forking "+repo); 830 | 831 | GHUser user = github.getUser(owner); 832 | if (user==null) { 833 | out.message("No such user: "+owner); 834 | return false; 835 | } 836 | GHRepository orig = user.getRepository(repo); 837 | if (orig==null) { 838 | out.message("No such repository: "+repo); 839 | return false; 840 | } 841 | 842 | GHRepository r; 843 | try { 844 | r = orig.forkTo(org); 845 | } catch (IOException e) { 846 | // we started seeing 500 errors, presumably due to time out. 847 | // give it a bit of time, and see if the repository is there 848 | LOGGER.warn("GitHub reported that it failed to fork {}/{}. But we aren't trusting", owner, repo); 849 | r = null; 850 | for (int i=0; r==null && i<5; i++) { 851 | Thread.sleep(1000); 852 | r = org.getRepository(repo); 853 | } 854 | if (r==null) 855 | throw e; 856 | } 857 | if (newName!=null) { 858 | r.renameTo(newName); 859 | 860 | r = null; 861 | for (int i=0; r==null && i<5; i++) { 862 | Thread.sleep(1000); 863 | r = org.getRepository(newName); 864 | } 865 | if (r==null) 866 | throw new IOException(repo+" renamed to "+newName+" but not finding the new repository"); 867 | } 868 | 869 | // GitHub adds a lot of teams to this repo by default, which we don't want 870 | Set legacyTeams = r.getTeams(); 871 | 872 | try { 873 | getOrCreateRepoLocalTeam(out, github, org, r, maintainers.isEmpty() ? singletonList(user.getName()) : maintainers); 874 | } catch (IOException e) { 875 | // if 'user' is an org, the above command would fail 876 | out.message("Failed to add "+user+" to the new repository. Maybe an org?: "+e.getMessage()); 877 | // fall through 878 | } 879 | setupRepository(r, useGHIssues); 880 | 881 | out.message("Created https://github.com/" + IrcBotConfig.GITHUB_ORGANIZATION + "/" + (newName != null ? newName : repo)); 882 | 883 | // remove all the existing teams 884 | for (GHTeam team : legacyTeams) 885 | team.remove(r); 886 | 887 | result = true; 888 | } catch (InterruptedException e) { 889 | out.message("Failed to fork a repository: "+e.getMessage()); 890 | e.printStackTrace(); 891 | } catch (IOException e) { 892 | out.message("Failed to fork a repository: "+e.getMessage()); 893 | e.printStackTrace(); 894 | } 895 | 896 | return result; 897 | } 898 | 899 | /** 900 | * Fix up the repository set up to our policy. 901 | */ 902 | private static void setupRepository(GHRepository r, boolean useGHIssues) throws IOException { 903 | r.enableIssueTracker(useGHIssues); 904 | r.enableWiki(false); 905 | } 906 | 907 | /** 908 | * Creates a repository local team, and grants access to the repository. 909 | */ 910 | private static GHTeam getOrCreateRepoLocalTeam(OutputChannel out, GitHub github, GHOrganization org, GHRepository r, List githubUsers) throws IOException { 911 | String teamName = r.getName() + " Developers"; 912 | GHTeam t = org.getTeams().get(teamName); 913 | if (t == null) { 914 | GHTeamBuilder ghCreateTeamBuilder = org.createTeam(teamName).privacy(GHTeam.Privacy.CLOSED); 915 | List maintainers = emptyList(); 916 | if (!githubUsers.isEmpty()) { 917 | maintainers = githubUsers.stream() 918 | // in order to be added as a maintainer of a team you have to be a member of the org already 919 | .filter(user -> isMemberOfOrg(github, org, user)) 920 | .collect(Collectors.toList()); 921 | ghCreateTeamBuilder = ghCreateTeamBuilder.maintainers(maintainers.toArray(new String[0])); 922 | } 923 | t = ghCreateTeamBuilder.create(); 924 | 925 | List usersNotInMaintainers = new ArrayList<>(githubUsers); 926 | usersNotInMaintainers.removeAll(maintainers); 927 | final GHTeam team = t; 928 | usersNotInMaintainers.forEach(addUserToTeam(out, github, team)); 929 | // github automatically adds the user to the team who created the team, we don't want that 930 | team.remove(github.getMyself()); 931 | } 932 | 933 | t.add(r, Permission.ADMIN); // make team an admin on the given repository, always do in case the config is wrong 934 | return t; 935 | } 936 | 937 | private static Consumer addUserToTeam(OutputChannel out, GitHub github, GHTeam team) { 938 | return user -> { 939 | try { 940 | team.add(github.getUser(user)); 941 | } catch (IOException e) { 942 | out.message(String.format("Failed to add user %s to team %s, error was: %s", user, team.getName(), e.getMessage())); 943 | e.printStackTrace(); 944 | } 945 | }; 946 | } 947 | 948 | private static boolean isMemberOfOrg(GitHub gitHub, GHOrganization org, String user) { 949 | try { 950 | GHUser ghUser = gitHub.getUser(user); 951 | return org.hasMember(ghUser); 952 | } catch (IOException e) { 953 | e.printStackTrace(); 954 | return false; 955 | } 956 | } 957 | 958 | private static List collectGroups(Matcher m, int startingGroup) { 959 | List items = new ArrayList<>( 960 | Arrays.asList(m.group(startingGroup).split("\\s*,\\s*"))); 961 | 962 | return items; 963 | } 964 | 965 | public static void main(String[] args) throws Exception { 966 | Configuration.Builder builder = new Configuration.Builder() 967 | .setName(IrcBotConfig.NAME) 968 | .addServer(IrcBotConfig.SERVER, 6667) 969 | .setAutoReconnect(true) 970 | .addListener(new IrcListener(new File("unknown-commands.txt"))); 971 | 972 | for(String channel : IrcBotConfig.getChannels()) { 973 | builder.addAutoJoinChannel(channel); 974 | } 975 | 976 | if(args.length>0) { 977 | builder.setCapEnabled(true) 978 | .addCapHandler(new SASLCapHandler(IrcBotConfig.NAME, args[0])); 979 | } 980 | 981 | LOGGER.info("Connecting to {} as {}.", IrcBotConfig.SERVER, IrcBotConfig.NAME); 982 | LOGGER.info("GitHub organization: {}", IrcBotConfig.GITHUB_ORGANIZATION); 983 | 984 | PircBotX bot = new PircBotX(builder.buildConfiguration()); 985 | bot.startBot(); 986 | } 987 | } 988 | --------------------------------------------------------------------------------