feat: Natural language query for issues/builds/pull requests via AI (OD-2600)

This commit is contained in:
Robin Shen 2025-11-09 11:50:01 +08:00
parent 2b9237b8b1
commit b465a1fd78
34 changed files with 1014 additions and 368 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
server-product/docker/build/
server-product/docker/onedev-*/
**/target/
**/bin/
**/.classpath
**/.gitignore
**/.settings

View File

@ -627,6 +627,11 @@
<artifactId>fastexcel</artifactId>
<version>0.15.7</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
@ -663,5 +668,6 @@
<servlet.version>3.1.0</servlet.version>
<jackson.version>2.15.0</jackson.version>
<tika.version>1.28.3</tika.version>
<langchain4j.version>1.8.0</langchain4j.version>
</properties>
</project>

View File

@ -394,6 +394,10 @@
<artifactId>kotlin-stdlib-jdk7</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
</dependencies>
<properties>
<kotlin.version>1.9.23</kotlin.version>

View File

@ -75,6 +75,7 @@ import io.onedev.commons.utils.ExceptionUtils;
import io.onedev.commons.utils.StringUtils;
import io.onedev.k8shelper.KubernetesHelper;
import io.onedev.k8shelper.OsInfo;
import io.onedev.server.ai.McpHelperResource;
import io.onedev.server.annotation.Shallow;
import io.onedev.server.attachment.AttachmentService;
import io.onedev.server.attachment.DefaultAttachmentService;
@ -144,11 +145,11 @@ import io.onedev.server.persistence.HibernateInterceptor;
import io.onedev.server.persistence.IdService;
import io.onedev.server.persistence.PersistListener;
import io.onedev.server.persistence.PrefixedNamingStrategy;
import io.onedev.server.persistence.SessionFactoryService;
import io.onedev.server.persistence.SessionFactoryProvider;
import io.onedev.server.persistence.SessionFactoryService;
import io.onedev.server.persistence.SessionInterceptor;
import io.onedev.server.persistence.SessionService;
import io.onedev.server.persistence.SessionProvider;
import io.onedev.server.persistence.SessionService;
import io.onedev.server.persistence.TransactionInterceptor;
import io.onedev.server.persistence.TransactionService;
import io.onedev.server.persistence.annotation.Sessional;
@ -160,7 +161,6 @@ import io.onedev.server.rest.DefaultServletContainer;
import io.onedev.server.rest.JerseyConfigurator;
import io.onedev.server.rest.ResourceConfigProvider;
import io.onedev.server.rest.WebApplicationExceptionHandler;
import io.onedev.server.rest.resource.McpHelperResource;
import io.onedev.server.rest.resource.ProjectResource;
import io.onedev.server.search.code.CodeIndexService;
import io.onedev.server.search.code.CodeSearchService;
@ -197,10 +197,10 @@ import io.onedev.server.service.BuildMetricService;
import io.onedev.server.service.BuildParamService;
import io.onedev.server.service.BuildQueryPersonalizationService;
import io.onedev.server.service.BuildService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentMentionService;
import io.onedev.server.service.CodeCommentQueryPersonalizationService;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.service.CodeCommentTouchService;
import io.onedev.server.service.CommitQueryPersonalizationService;
@ -215,9 +215,9 @@ import io.onedev.server.service.GroupAuthorizationService;
import io.onedev.server.service.GroupService;
import io.onedev.server.service.IssueAuthorizationService;
import io.onedev.server.service.IssueChangeService;
import io.onedev.server.service.IssueCommentService;
import io.onedev.server.service.IssueCommentReactionService;
import io.onedev.server.service.IssueCommentRevisionService;
import io.onedev.server.service.IssueCommentService;
import io.onedev.server.service.IssueDescriptionRevisionService;
import io.onedev.server.service.IssueFieldService;
import io.onedev.server.service.IssueLinkService;
@ -248,9 +248,9 @@ import io.onedev.server.service.ProjectLastEventDateService;
import io.onedev.server.service.ProjectService;
import io.onedev.server.service.PullRequestAssignmentService;
import io.onedev.server.service.PullRequestChangeService;
import io.onedev.server.service.PullRequestCommentService;
import io.onedev.server.service.PullRequestCommentReactionService;
import io.onedev.server.service.PullRequestCommentRevisionService;
import io.onedev.server.service.PullRequestCommentService;
import io.onedev.server.service.PullRequestDescriptionRevisionService;
import io.onedev.server.service.PullRequestLabelService;
import io.onedev.server.service.PullRequestMentionService;
@ -285,10 +285,10 @@ import io.onedev.server.service.impl.DefaultBuildMetricService;
import io.onedev.server.service.impl.DefaultBuildParamService;
import io.onedev.server.service.impl.DefaultBuildQueryPersonalizationService;
import io.onedev.server.service.impl.DefaultBuildService;
import io.onedev.server.service.impl.DefaultCodeCommentService;
import io.onedev.server.service.impl.DefaultCodeCommentMentionService;
import io.onedev.server.service.impl.DefaultCodeCommentQueryPersonalizationService;
import io.onedev.server.service.impl.DefaultCodeCommentReplyService;
import io.onedev.server.service.impl.DefaultCodeCommentService;
import io.onedev.server.service.impl.DefaultCodeCommentStatusChangeService;
import io.onedev.server.service.impl.DefaultCodeCommentTouchService;
import io.onedev.server.service.impl.DefaultCommitQueryPersonalizationService;
@ -303,9 +303,9 @@ import io.onedev.server.service.impl.DefaultGroupAuthorizationService;
import io.onedev.server.service.impl.DefaultGroupService;
import io.onedev.server.service.impl.DefaultIssueAuthorizationService;
import io.onedev.server.service.impl.DefaultIssueChangeService;
import io.onedev.server.service.impl.DefaultIssueCommentService;
import io.onedev.server.service.impl.DefaultIssueCommentReactionService;
import io.onedev.server.service.impl.DefaultIssueCommentRevisionService;
import io.onedev.server.service.impl.DefaultIssueCommentService;
import io.onedev.server.service.impl.DefaultIssueDescriptionRevisionService;
import io.onedev.server.service.impl.DefaultIssueFieldService;
import io.onedev.server.service.impl.DefaultIssueLinkService;
@ -336,9 +336,9 @@ import io.onedev.server.service.impl.DefaultProjectLastEventDateService;
import io.onedev.server.service.impl.DefaultProjectService;
import io.onedev.server.service.impl.DefaultPullRequestAssignmentService;
import io.onedev.server.service.impl.DefaultPullRequestChangeService;
import io.onedev.server.service.impl.DefaultPullRequestCommentService;
import io.onedev.server.service.impl.DefaultPullRequestCommentReactionService;
import io.onedev.server.service.impl.DefaultPullRequestCommentRevisionService;
import io.onedev.server.service.impl.DefaultPullRequestCommentService;
import io.onedev.server.service.impl.DefaultPullRequestDescriptionRevisionService;
import io.onedev.server.service.impl.DefaultPullRequestLabelService;
import io.onedev.server.service.impl.DefaultPullRequestMentionService;

View File

@ -1,6 +1,8 @@
package io.onedev.server.rest.resource;
package io.onedev.server.ai;
import static io.onedev.server.ai.QueryDescriptions.getBuildQueryDescription;
import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE;
import static org.unbescape.html.HtmlEscape.escapeHtml5;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
@ -16,7 +18,6 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.persistence.EntityNotFoundException;
@ -37,7 +38,7 @@ import javax.ws.rs.core.Response;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.eclipse.jgit.lib.ObjectId;
import org.unbescape.html.HtmlEscape;
import org.jspecify.annotations.Nullable;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -165,8 +166,6 @@ public class McpHelperResource {
private final BuildService buildService;
private final BuildParamService buildParamService;
private final JobService jobService;
private final GitService gitService;
@ -215,243 +214,11 @@ public class McpHelperResource {
this.urlService = urlService;
this.pullRequestCommentService = pullRequestCommentService;
this.buildService = buildService;
this.buildParamService = buildParamService;
this.jobService = jobService;
this.validator = validator;
this.serverConfig = serverConfig;
}
private String getIssueQueryStringDescription() {
var stateNames = new StringBuilder();
for (var state: settingService.getIssueSetting().getStateSpecs()) {
stateNames.append(" - ");
stateNames.append(state.getName());
if (state.getDescription() != null) {
stateNames.append(": ").append(state.getDescription().replace("\n", " "));
}
stateNames.append("\n");
}
var fieldCriterias = new StringBuilder();
for (var field: settingService.getIssueSetting().getFieldSpecs()) {
if (field instanceof ChoiceField) {
var choiceField = (ChoiceField) field;
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<" + field.getName().toLowerCase()
+ " value>\" (quotes are required), where <" + field.getName().toLowerCase()
+ " value> is one of below:\n");
for (var choice : choiceField.getPossibleValues())
fieldCriterias.append(" - " + choice).append("\n");
} else if (field instanceof UserChoiceField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<login name of a user>\" (quotes are required)\n");
fieldCriterias.append(
"- " + field.getName().toLowerCase() + " criteria for current user in form of: \""
+ field.getName() + "\" is me (quotes are required)\n");
} else if (field instanceof GroupChoiceField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<group name>\" (quotes are required)\n");
} else if (field instanceof BooleanField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " is true criteria in form of: \""
+ field.getName() + "\" is \"true\" (quotes are required)\n");
fieldCriterias.append("- " + field.getName().toLowerCase() + " is false criteria in form of: \""
+ field.getName() + "\" is \"false\" (quotes are required)\n");
} else if (field instanceof DateField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is before certain date criteria in form of: \"" + field.getName()
+ "\" is before \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is after certain date criteria in form of: \"" + field.getName()
+ "\" is after \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD\n");
} else if (field instanceof DateTimeField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is before certain date time criteria in form of: \"" + field.getName()
+ "\" is before \"<date time>\" (quotes are required), where <date time> is of format YYYY-MM-DD HH:mm\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is after certain date time criteria in form of: \"" + field.getName()
+ "\" is after \"<date time>\" (quotes are required), where <date time> is of format YYYY-MM-DD HH:mm\n");
} else if (field instanceof IntegerField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is equal to certain integer criteria in form of: \"" + field.getName()
+ "\" is \"<integer>\" (quotes are required), where <integer> is an integer\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is greater than certain integer criteria in form of: \"" + field.getName()
+ "\" is greater than \"<integer>\" (quotes are required), where <integer> is an integer\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is less than certain integer criteria in form of: \"" + field.getName()
+ "\" is less than \"<integer>\" (quotes are required), where <integer> is an integer\n");
}
fieldCriterias.append("- " + field.getName().toLowerCase() + " is not set criteria in form of: \""
+ field.getName() + "\" is empty (quotes are required)\n");
}
var linkCriterias = new StringBuilder();
for (var linkSpec: linkSpecService.query()) {
linkCriterias.append("- criteria to list issues with any " + linkSpec.getName().toLowerCase()
+ " issues matching certain criteria in form of: any \"" + linkSpec.getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with all " + linkSpec.getName().toLowerCase()
+ " issues matching certain criteria in form of: all \"" + linkSpec.getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with some " + linkSpec.getName().toLowerCase()
+ " issues in form of: has any \"" + linkSpec.getName() + "\" (quotes are required)\n");
if (linkSpec.getOpposite() != null) {
linkCriterias.append("- criteria to list issues with any "
+ linkSpec.getOpposite().getName().toLowerCase()
+ " issues matching certain criteria in form of: any \"" + linkSpec.getOpposite().getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with all "
+ linkSpec.getOpposite().getName().toLowerCase()
+ " issues matching certain criteria in form of: all \"" + linkSpec.getOpposite().getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with some " + linkSpec.getOpposite().getName().toLowerCase()
+ " issues in form of: has any \"" + linkSpec.getOpposite().getName() + "\" (quotes are required)\n");
}
}
var orderFields = new StringBuilder();
for (var field: Issue.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var description =
"A query string is one of below criteria:\n" +
"- issue with specified number in form of: \"Number\" is \"#<issue number>\", or in form of: \"Number\" is \"<project key>-<issue number>\" (quotes are required)\n" +
"- criteria to check if title/description/comment contains specified text in form of: ~<containing text>~\n" +
"- state criteria in form of: \"State\" is \"<state name>\" (quotes are required), where <state name> is one of below:\n" +
stateNames +
fieldCriterias +
linkCriterias +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- submitted before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- submitted after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated before certain date criteria in form of: \"Last Activity Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated after certain date criteria in form of: \"Last Activity Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- confidential criteria in form of: confidential\n" +
"- iteration criteria in form of: \"Iteration\" is \"<iteration name>\" (quotes are required)\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>. Note that \"and criteria\" takes precedence over \"or criteria\", use braces to group \"or criteria\" like \"(criteria1 or criteria2) and criteria3\" if you want to override precedence\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n" +
orderFields +
"\n" +
"Leave empty to list all accessible issues";
return HtmlEscape.escapeHtml5(description);
}
private String getPullRequestQueryStringDescription() {
var orderFields = new StringBuilder();
for (var field : PullRequest.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var labelNames = labelSpecService.query().stream().map(LabelSpec::getName).collect(Collectors.joining(", "));
var mergeStrategyNames = Arrays.stream(MergeStrategy.values()).map(MergeStrategy::name).collect(Collectors.joining(", "));
var description =
"A query string is one of below criteria:\n" +
"- pull request with specified number in form of: \"Number\" is \"#<pull request number>\", or in form of: \"Number\" is \"<project key>-<pull request number>\" (quotes are required)\n" +
"- criteria to check if title/description/comment contains specified text in form of: ~<containing text>~\n" +
"- open criteria in form of: open\n" +
"- merged criteria in form of: merged\n" +
"- discarded criteria in form of: discarded\n" +
"- source branch criteria in form of: \"Source Branch\" is \"<branch name>\" (quotes are required)\n" +
"- target branch criteria in form of: \"Target Branch\" is \"<branch name>\" (quotes are required)\n" +
"- merge strategy criteria in form of: \"Merge Strategy\" is \"<merge strategy>\" (quotes are required), where <merge strategy> is one of: " + mergeStrategyNames + "\n" +
"- label criteria in form of: \"Label\" is \"<label name>\" (quotes are required), where <label name> is one of: " + labelNames + "\n" +
"- ready to merge criteria in form of: ready to merge\n" +
"- waiting for someone to review criteria in form of: has pending reviews\n" +
"- some builds are unsuccessful criteria in form of: has unsuccessful builds\n" +
"- some builds are not finished criteria in form of: has unfinished builds\n" +
"- has merge conflicts criteria in form of: has merge conflicts\n" +
"- assigned to specified user criteria in form of: assigned to \"<login name of a user>\" (quotes are required)\n" +
"- approved by specified user criteria in form of: approved by \"<login name of a user>\" (quotes are required)\n" +
"- to be reviewed by specified user criteria in form of: to be reviewed by \"<login name of a user>\" (quotes are required)\n" +
"- to be changed by specified user criteria in form of: to be changed by \"<login name of a user>\" (quotes are required)\n" +
"- to be merged by specified user criteria in form of: to be merged by \"<login name of a user>\" (quotes are required)\n" +
"- requested for changes by specified user in form of: requested for changes by \"<login name of a user>\" (quotes are required)\n" +
"- need action of specified user criteria in form of: need action by \"<login name of a user>\" (quotes are required)\n" +
"- assigned to current user criteria in form of: assigned to me\n" +
"- approved by current user criteria in form of: approved by me\n" +
"- to be reviewed by current user criteria in form of: to be reviewed by me\n" +
"- to be changed by current user criteria in form of: to be changed by me\n" +
"- to be merged by current user criteria in form of: to be merged by me\n" +
"- requested for changes by current user in form of: requested for changes by me\n" +
"- requested for changes by any user criteria in form of: someone requested for changes\n" +
"- need action of current user criteria in form of: need my action\n" +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- submitted before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- submitted after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated before certain date criteria in form of: \"Last Activity Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated after certain date criteria in form of: \"Last Activity Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- closed (merged or discarded) before certain date criteria in form of: \"Close Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- closed (merged or discarded) after certain date criteria in form of: \"Close Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- includes specified issue criteria in form of: includes issue \"<issue reference>\" (quotes are required)\n" +
"- includes specified commit criteria in form of: includes commit \"<commit hash>\" (quotes are required)\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>. Note that \"and criteria\" takes precedence over \"or criteria\", use braces to group \"or criteria\" like \"(criteria1 or criteria2) and criteria3\" if you want to override precedence\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n" +
orderFields +
"\n" +
"Leave empty to list all pull requests";
return HtmlEscape.escapeHtml5(description);
}
private String getBuildQueryStringDescription() {
var orderFields = new StringBuilder();
for (var field : Build.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var jobNames = buildService.getJobNames(null).stream().collect(Collectors.joining(", "));
var paramNames = buildParamService.getParamNames(null).stream().collect(Collectors.joining(", "));
var labelNames = labelSpecService.query().stream().map(LabelSpec::getName).collect(Collectors.joining(", "));
var description =
"A query string is one of below criteria:\n" +
"- build with specified number in form of: \"Number\" is \"#<build number>\", or in form of: \"Number\" is \"<project key>-<build number>\" (quotes are required)\n" +
"- criteria to check if version/job contains specified text in form of: ~<containing text>~\n" +
"- sucessful criteria in form of: sucessful\n" +
"- failed criteria in form of: failed\n" +
"- cancelled criteria in form of: cancelled\n" +
"- timed out criteria in form of: timed out\n" +
"- finished criteria in form of: finished\n" +
"- running criteria in form of: running\n" +
"- waiting criteria in form of: waiting\n" +
"- pending criteria in form of: pending\n" +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- cancelled by specified user criteria in form of: cancelled by \"<login name of a user>\" (quotes are required)\n" +
"- cancelled by current user criteria in form of: cancelled by me (quotes are required)\n" +
"- depends on specified build criteria in form of: depends on \"<build reference>\" (quotes are required)\n" +
"- dependencies of specified build criteria in form of: dependencies of \"<build reference>\" (quotes are required)\n" +
"- fixed specified issue criteria in form of: fixed issue \"<issue reference>\" (quotes are required)\n" +
"- job criteria in form of: \"Job\" is \"<job name>\" (quotes are required), where <job name> is one of: " + jobNames + "\n" +
"- version criteria in form of: \"Version\" is \"<version>\" (quotes are required)\n" +
"- branch criteria in form of: \"Branch\" is \"<branch name>\" (quotes are required)\n" +
"- tag criteria in form of: \"Tag\" is \"<tag name>\" (quotes are required)\n" +
"- param criteria in form of: \"<param name>\" is \"<param value>\" (quotes are required), where <param name> is one of: " + paramNames + "\n" +
"- label criteria in form of: \"Label\" is \"<label name>\" (quotes are required), where <label name> is one of: " + labelNames + "\n" +
"- pull request criteria in form of: \"Pull Request\" is \"<pull request reference>\" (quotes are required)\n" +
"- commit criteria in form of: \"Commit\" is \"<commit hash>\" (quotes are required)\n" +
"- before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>. Note that \"and criteria\" takes precedence over \"or criteria\", use braces to group \"or criteria\" like \"(criteria1 or criteria2) and criteria3\" if you want to override precedence\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n" +
orderFields +
"\n" +
"Leave empty to list all pull requests";
return HtmlEscape.escapeHtml5(description);
}
private String getToolParamName(String fieldName) {
return fieldName.replace(" ", "_");
}
@ -521,7 +288,7 @@ public class McpHelperResource {
"Expects unix timestamp in milliseconds since epoch");
}
fieldDescription = HtmlEscape.escapeHtml5(fieldDescription);
fieldDescription = escapeHtml5(fieldDescription);
var fieldProperties = new HashMap<String, Object>();
if (field.isAllowMultiple()) {
@ -556,7 +323,7 @@ public class McpHelperResource {
"description", "Project to query issues in. Leave empty to query in current project"));
queryIssuesProperties.put("query", Map.of(
"type", "string",
"description", getIssueQueryStringDescription()));
"description", escapeHtml5(QueryDescriptions.getIssueQueryDescription())));
queryIssuesProperties.put("offset", Map.of(
"type", "integer",
"description", "start position for the query (optional, defaults to 0)"));
@ -716,7 +483,7 @@ public class McpHelperResource {
"description", "Project to query pull requests in. Leave empty to query in current project"));
queryPullRequestsProperties.put("query", Map.of(
"type", "string",
"description", getPullRequestQueryStringDescription()));
"description", escapeHtml5(QueryDescriptions.getPullRequestQueryDescription())));
queryPullRequestsProperties.put("offset", Map.of(
"type", "integer",
"description", "start position for the query (optional, defaults to 0)"));
@ -738,7 +505,7 @@ public class McpHelperResource {
"description", "Project to query builds in. Leave empty to query in current project"));
queryBuildsProperties.put("query", Map.of(
"type", "string",
"description", getBuildQueryStringDescription()));
"description", escapeHtml5(getBuildQueryDescription())));
queryBuildsProperties.put("offset", Map.of(
"type", "integer",
"description", "start position for the query (optional, defaults to 0)"));

View File

@ -0,0 +1,300 @@
package io.onedev.server.ai;
import java.util.Arrays;
import java.util.stream.Collectors;
import io.onedev.server.OneDev;
import io.onedev.server.model.Build;
import io.onedev.server.model.Issue;
import io.onedev.server.model.LabelSpec;
import io.onedev.server.model.PullRequest;
import io.onedev.server.model.support.issue.field.spec.BooleanField;
import io.onedev.server.model.support.issue.field.spec.DateField;
import io.onedev.server.model.support.issue.field.spec.DateTimeField;
import io.onedev.server.model.support.issue.field.spec.GroupChoiceField;
import io.onedev.server.model.support.issue.field.spec.IntegerField;
import io.onedev.server.model.support.issue.field.spec.choicefield.ChoiceField;
import io.onedev.server.model.support.issue.field.spec.userchoicefield.UserChoiceField;
import io.onedev.server.model.support.pullrequest.MergeStrategy;
import io.onedev.server.service.BuildParamService;
import io.onedev.server.service.BuildService;
import io.onedev.server.service.LabelSpecService;
import io.onedev.server.service.LinkSpecService;
import io.onedev.server.service.SettingService;
public class QueryDescriptions {
private static SettingService getSettingService() {
return OneDev.getInstance(SettingService.class);
}
private static LinkSpecService getLinkSpecService() {
return OneDev.getInstance(LinkSpecService.class);
}
private static LabelSpecService getLabelSpecService() {
return OneDev.getInstance(LabelSpecService.class);
}
private static BuildService getBuildService() {
return OneDev.getInstance(BuildService.class);
}
private static BuildParamService getBuildParamService() {
return OneDev.getInstance(BuildParamService.class);
}
public static String getIssueQueryDescription() {
var settingService = getSettingService();
var linkSpecService = getLinkSpecService();
var stateNames = new StringBuilder();
for (var state: settingService.getIssueSetting().getStateSpecs()) {
stateNames.append(" - ");
stateNames.append(state.getName());
if (state.getDescription() != null) {
stateNames.append(": ").append(state.getDescription().replace("\n", " "));
}
stateNames.append("\n");
}
var fieldCriterias = new StringBuilder();
for (var field: settingService.getIssueSetting().getFieldSpecs()) {
if (field instanceof ChoiceField) {
var choiceField = (ChoiceField) field;
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<" + field.getName().toLowerCase()
+ " value>\" (quotes are required), where <" + field.getName().toLowerCase()
+ " value> is one of below:\n");
for (var choice : choiceField.getPossibleValues())
fieldCriterias.append(" - " + choice).append("\n");
} else if (field instanceof UserChoiceField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<login name of a user>\" (quotes are required)\n");
fieldCriterias.append(
"- " + field.getName().toLowerCase() + " criteria for current user in form of: \""
+ field.getName() + "\" is me (quotes are required)\n");
} else if (field instanceof GroupChoiceField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " criteria in form of: \""
+ field.getName() + "\" is \"<group name>\" (quotes are required)\n");
} else if (field instanceof BooleanField) {
fieldCriterias.append("- " + field.getName().toLowerCase() + " is true criteria in form of: \""
+ field.getName() + "\" is \"true\" (quotes are required)\n");
fieldCriterias.append("- " + field.getName().toLowerCase() + " is false criteria in form of: \""
+ field.getName() + "\" is \"false\" (quotes are required)\n");
} else if (field instanceof DateField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is before certain date criteria in form of: \"" + field.getName()
+ "\" is before \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is after certain date criteria in form of: \"" + field.getName()
+ "\" is after \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD\n");
} else if (field instanceof DateTimeField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is before certain date time criteria in form of: \"" + field.getName()
+ "\" is before \"<date time>\" (quotes are required), where <date time> is of format YYYY-MM-DD HH:mm\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is after certain date time criteria in form of: \"" + field.getName()
+ "\" is after \"<date time>\" (quotes are required), where <date time> is of format YYYY-MM-DD HH:mm\n");
} else if (field instanceof IntegerField) {
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is equal to certain integer criteria in form of: \"" + field.getName()
+ "\" is \"<integer>\" (quotes are required), where <integer> is an integer\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is greater than certain integer criteria in form of: \"" + field.getName()
+ "\" is greater than \"<integer>\" (quotes are required), where <integer> is an integer\n");
fieldCriterias.append("- " + field.getName().toLowerCase()
+ " is less than certain integer criteria in form of: \"" + field.getName()
+ "\" is less than \"<integer>\" (quotes are required), where <integer> is an integer\n");
}
fieldCriterias.append("- " + field.getName().toLowerCase() + " is not set criteria in form of: \""
+ field.getName() + "\" is empty (quotes are required)\n");
}
var linkCriterias = new StringBuilder();
for (var linkSpec: linkSpecService.query()) {
linkCriterias.append("- criteria to list issues with any " + linkSpec.getName().toLowerCase()
+ " matching certain criteria in form of: any \"" + linkSpec.getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with all " + linkSpec.getName().toLowerCase()
+ " matching certain criteria in form of: all \"" + linkSpec.getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with some " + linkSpec.getName().toLowerCase()
+ " in form of: has any \"" + linkSpec.getName() + "\" (quotes are required)\n");
if (linkSpec.getOpposite() != null) {
linkCriterias.append("- criteria to list issues with any "
+ linkSpec.getOpposite().getName().toLowerCase()
+ " matching certain criteria in form of: any \"" + linkSpec.getOpposite().getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with all "
+ linkSpec.getOpposite().getName().toLowerCase()
+ " matching certain criteria in form of: all \"" + linkSpec.getOpposite().getName()
+ "\" matching(another criteria) (quotes are required)\n");
linkCriterias.append("- criteria to list issues with some " + linkSpec.getOpposite().getName().toLowerCase()
+ " in form of: has any \"" + linkSpec.getOpposite().getName() + "\" (quotes are required)\n");
}
}
var orderFields = new StringBuilder();
for (var field: Issue.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var description =
"A query string is one of below criteria:\n\n" +
"- issue with specified number in form of: \"Number\" is \"#<issue number>\", or in form of: \"Number\" is \"<project key>-<issue number>\" (quotes are required)\n" +
"- state criteria in form of: \"State\" is \"<state name>\" (quotes are required), where <state name> is one of below:\n" +
stateNames +
fieldCriterias +
linkCriterias +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- submitted before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- submitted after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated before certain date criteria in form of: \"Last Activity Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated after certain date criteria in form of: \"Last Activity Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- confidential criteria in form of: confidential\n" +
"- iteration criteria in form of: \"Iteration\" is \"<iteration name>\" (quotes are required)\n" +
"- title contains specified text criteria in form of: \"Title\" contains \"<containing text>\" (quotes are required)\n" +
"- description contains specified text criteria in form of: \"Description\" contains \"<containing text>\" (quotes are required)\n" +
"- comment contains specified text criteria in form of: \"Comment\" contains \"<containing text>\" (quotes are required)\n" +
"- project criteria in form of: \"Project\" is \"<project path pattern>\" (quotes are required)\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>\n" +
"- operator 'and' takes precedence over 'or' when used together, unless parentheses are used to group 'or' criterias\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n\n" +
orderFields + "\n" +
"Issue, build or pull request can be referenced by their number in form of: #<number>, <project path>#<number> or <project key>-<number>\n" +
"\n" +
"Leave empty to list all accessible issues";
return description;
}
public static String getPullRequestQueryDescription() {
var labelSpecService = getLabelSpecService();
var orderFields = new StringBuilder();
for (var field : PullRequest.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var labelNames = labelSpecService.query().stream().map(LabelSpec::getName).collect(Collectors.joining(", "));
var mergeStrategyNames = Arrays.stream(MergeStrategy.values()).map(MergeStrategy::name).collect(Collectors.joining(", "));
var description =
"A query string is one of below criteria:\n\n" +
"- pull request with specified number in form of: \"Number\" is \"#<pull request number>\", or in form of: \"Number\" is \"<project key>-<pull request number>\" (quotes are required)\n" +
"- open criteria in form of: open\n" +
"- merged criteria in form of: merged\n" +
"- discarded criteria in form of: discarded\n" +
"- source branch criteria in form of: \"Source Branch\" is \"<branch name>\" (quotes are required)\n" +
"- target branch criteria in form of: \"Target Branch\" is \"<branch name>\" (quotes are required)\n" +
"- merge strategy criteria in form of: \"Merge Strategy\" is \"<merge strategy>\" (quotes are required), where <merge strategy> is one of: " + mergeStrategyNames + "\n" +
"- label criteria in form of: \"Label\" is \"<label name>\" (quotes are required), where <label name> is one of: " + labelNames + "\n" +
"- ready to merge criteria in form of: ready to merge\n" +
"- waiting for someone to review criteria in form of: has pending reviews\n" +
"- some builds are unsuccessful criteria in form of: has unsuccessful builds\n" +
"- some builds are not finished criteria in form of: has unfinished builds\n" +
"- has merge conflicts criteria in form of: has merge conflicts\n" +
"- assigned to specified user criteria in form of: assigned to \"<login name of a user>\" (quotes are required)\n" +
"- approved by specified user criteria in form of: approved by \"<login name of a user>\" (quotes are required)\n" +
"- to be reviewed by specified user criteria in form of: to be reviewed by \"<login name of a user>\" (quotes are required)\n" +
"- to be changed by specified user criteria in form of: to be changed by \"<login name of a user>\" (quotes are required)\n" +
"- to be merged by specified user criteria in form of: to be merged by \"<login name of a user>\" (quotes are required)\n" +
"- requested for changes by specified user in form of: requested for changes by \"<login name of a user>\" (quotes are required)\n" +
"- need action of specified user criteria in form of: need action by \"<login name of a user>\" (quotes are required)\n" +
"- assigned to current user criteria in form of: assigned to me\n" +
"- approved by current user criteria in form of: approved by me\n" +
"- to be reviewed by current user criteria in form of: to be reviewed by me\n" +
"- to be changed by current user criteria in form of: to be changed by me\n" +
"- to be merged by current user criteria in form of: to be merged by me\n" +
"- requested for changes by current user in form of: requested for changes by me\n" +
"- requested for changes by any user criteria in form of: someone requested for changes\n" +
"- need action of current user criteria in form of: need my action\n" +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- submitted before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- submitted after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated before certain date criteria in form of: \"Last Activity Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- updated after certain date criteria in form of: \"Last Activity Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- closed (merged or discarded) before certain date criteria in form of: \"Close Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- closed (merged or discarded) after certain date criteria in form of: \"Close Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- includes specified issue criteria in form of: includes issue \"<issue reference>\" (quotes are required)\n" +
"- includes specified commit criteria in form of: includes commit \"<commit hash>\" (quotes are required)\n" +
"- title contains specified text criteria in form of: \"Title\" contains \"<containing text>\" (quotes are required)\n" +
"- description contains specified text criteria in form of: \"Description\" contains \"<containing text>\" (quotes are required)\n" +
"- comment contains specified text criteria in form of: \"Comment\" contains \"<containing text>\" (quotes are required)\n" +
"- project criteria in form of: \"Project\" is \"<project path pattern>\" (quotes are required)\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>\n" +
"- operator 'and' takes precedence over 'or' when used together, unless parentheses are used to group 'or' criterias\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n\n" +
orderFields + "\n" +
"Issue, build or pull request can be referenced by their number in form of: #<number>, <project path>#<number> or <project key>-<number>\n" +
"\n" +
"Leave empty to list all accessible pull requests";
return description;
}
public static String getBuildQueryDescription() {
var buildService = getBuildService();
var buildParamService = getBuildParamService();
var labelSpecService = getLabelSpecService();
var orderFields = new StringBuilder();
for (var field : Build.SORT_FIELDS.keySet()) {
orderFields.append("- ").append(field).append("\n");
}
var jobNames = buildService.getJobNames(null).stream().collect(Collectors.joining(", "));
var paramNames = buildParamService.getParamNames(null).stream().collect(Collectors.joining(", "));
var labelNames = labelSpecService.query().stream().map(LabelSpec::getName).collect(Collectors.joining(", "));
var description =
"A query string is one of below criteria:\n\n" +
"- build with specified number in form of: \"Number\" is \"#<build number>\", or in form of: \"Number\" is \"<project key>-<build number>\" (quotes are required)\n" +
"- criteria to check if version/job contains specified text in form of: ~<containing text>~\n" +
"- sucessful criteria in form of: sucessful\n" +
"- failed criteria in form of: failed\n" +
"- cancelled criteria in form of: cancelled\n" +
"- timed out criteria in form of: timed out\n" +
"- finished criteria in form of: finished\n" +
"- running criteria in form of: running\n" +
"- waiting criteria in form of: waiting\n" +
"- pending criteria in form of: pending\n" +
"- submitted by specified user criteria in form of: submitted by \"<login name of a user>\" (quotes are required)\n" +
"- submitted by current user criteria in form of: submitted by me (quotes are required)\n" +
"- cancelled by specified user criteria in form of: cancelled by \"<login name of a user>\" (quotes are required)\n" +
"- cancelled by current user criteria in form of: cancelled by me (quotes are required)\n" +
"- depends on specified build criteria in form of: depends on \"<build reference>\" (quotes are required)\n" +
"- dependencies of specified build criteria in form of: dependencies of \"<build reference>\" (quotes are required)\n" +
"- fixed specified issue criteria in form of: fixed issue \"<issue reference>\" (quotes are required)\n" +
"- job criteria in form of: \"Job\" is \"<job name>\" (quotes are required), where <job name> is one of: " + jobNames + "\n" +
"- version criteria in form of: \"Version\" is \"<version>\" (quotes are required)\n" +
"- branch criteria in form of: \"Branch\" is \"<branch name>\" (quotes are required)\n" +
"- tag criteria in form of: \"Tag\" is \"<tag name>\" (quotes are required)\n" +
"- param criteria in form of: \"<param name>\" is \"<param value>\" (quotes are required), where <param name> is one of: " + paramNames + "\n" +
"- label criteria in form of: \"Label\" is \"<label name>\" (quotes are required), where <label name> is one of: " + labelNames + "\n" +
"- pull request criteria in form of: \"Pull Request\" is \"<pull request reference>\" (quotes are required)\n" +
"- commit criteria in form of: \"Commit\" is \"<commit hash>\" (quotes are required)\n" +
"- before certain date criteria in form of: \"Submit Date\" is until \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- after certain date criteria in form of: \"Submit Date\" is since \"<date>\" (quotes are required), where <date> is of format YYYY-MM-DD HH:mm\n" +
"- project criteria in form of: \"Project\" is \"<project path pattern>\" (quotes are required)\n" +
"- and criteria in form of <criteria1> and <criteria2>\n" +
"- or criteria in form of <criteria1> or <criteria2>\n" +
"- operator 'and' takes precedence over 'or' when used together, unless parentheses are used to group 'or' criterias\n" +
"- not criteria in form of not(<criteria>)\n" +
"\n" +
"And can optionally add order clause at end of query string in form of: order by \"<field1>\" <asc|desc>,\"<field2>\" <asc|desc>,... (quotes are required), where <field> is one of below:\n\n" +
orderFields + "\n" +
"Issue, build or pull request can be referenced by their number in form of: #<number>, <project path>#<number> or <project key>-<number>\n" +
"\n" +
"Leave empty to list all accessible builds";
return description;
}
}

View File

@ -6,9 +6,9 @@ import static io.onedev.server.model.User.PROP_SERVICE_ACCOUNT;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_CURL_LOCATION;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_DISABLE_AUTO_UPDATE_CHECK;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_GIT_LOCATION;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_SESSION_TIMEOUT;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_SSH_ROOT_URL;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_USE_AVATAR_SERVICE;
import static io.onedev.server.model.support.administration.SystemSetting.PROP_SESSION_TIMEOUT;
import static io.onedev.server.persistence.PersistenceUtils.tableExists;
import static org.unbescape.html.HtmlEscape.escapeHtml5;
@ -74,19 +74,13 @@ import io.onedev.commons.utils.FileUtils;
import io.onedev.commons.utils.StringUtils;
import io.onedev.commons.utils.ZipUtils;
import io.onedev.server.OneDev;
import io.onedev.server.cluster.ClusterService;
import io.onedev.server.cluster.ClusterRunnable;
import io.onedev.server.cluster.ClusterService;
import io.onedev.server.cluster.ClusterTask;
import io.onedev.server.commandhandler.Upgrade;
import io.onedev.server.data.migration.DataMigrator;
import io.onedev.server.data.migration.MigrationHelper;
import io.onedev.server.data.migration.VersionedXmlDoc;
import io.onedev.server.service.AlertService;
import io.onedev.server.service.EmailAddressService;
import io.onedev.server.service.LinkSpecService;
import io.onedev.server.service.RoleService;
import io.onedev.server.service.SettingService;
import io.onedev.server.service.UserService;
import io.onedev.server.event.Listen;
import io.onedev.server.event.entity.EntityPersisted;
import io.onedev.server.event.system.SystemStarted;
@ -98,6 +92,7 @@ import io.onedev.server.model.Role;
import io.onedev.server.model.Setting;
import io.onedev.server.model.Setting.Key;
import io.onedev.server.model.User;
import io.onedev.server.model.support.administration.AISetting;
import io.onedev.server.model.support.administration.AgentSetting;
import io.onedev.server.model.support.administration.AlertSetting;
import io.onedev.server.model.support.administration.AuditSetting;
@ -124,6 +119,12 @@ import io.onedev.server.persistence.TransactionService;
import io.onedev.server.persistence.annotation.Sessional;
import io.onedev.server.persistence.annotation.Transactional;
import io.onedev.server.persistence.dao.Dao;
import io.onedev.server.service.AlertService;
import io.onedev.server.service.EmailAddressService;
import io.onedev.server.service.LinkSpecService;
import io.onedev.server.service.RoleService;
import io.onedev.server.service.SettingService;
import io.onedev.server.service.UserService;
import io.onedev.server.ssh.SshKeyUtils;
import io.onedev.server.taskschedule.SchedulableTask;
import io.onedev.server.taskschedule.TaskScheduler;
@ -878,10 +879,12 @@ public class DefaultDataService implements DataService, Serializable {
}
setting = settingService.findSetting(Key.AUDIT);
if (setting == null) {
AuditSetting auditSetting = new AuditSetting();
settingService.saveAuditSetting(auditSetting);
}
if (setting == null)
settingService.saveAuditSetting(new AuditSetting());
setting = settingService.findSetting(Key.AI);
if (setting == null)
settingService.saveAISetting(new AISetting());
if (roleService.get(Role.OWNER_ID) == null) {
Role owner = new Role();

View File

@ -8378,4 +8378,8 @@ public class DataMigrator {
}
}
}
private void migrate214(File dataDir, Stack<Integer> versions) {
}
}

View File

@ -21,7 +21,7 @@ public class Setting extends AbstractEntity {
GROOVY_SCRIPTS, PULL_REQUEST, BUILD, PACK, PROJECT, SSH, GPG,
EMAIL_TEMPLATES, CONTRIBUTED_SETTINGS, SERVICE_DESK_SETTING,
AGENT, PERFORMANCE, BRANDING, CLUSTER_SETTING, SUBSCRIPTION_DATA, ALERT,
SYSTEM_UUID, AUDIT
SYSTEM_UUID, AUDIT, AI
};
@Column(nullable=false, unique=true)

View File

@ -0,0 +1,135 @@
package io.onedev.server.model.support;
import java.io.IOException;
import java.io.Serializable;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonParser;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import io.onedev.server.annotation.ChoiceProvider;
import io.onedev.server.annotation.Editable;
import io.onedev.server.util.EditContext;
@Editable(order=100)
public class AIModelSetting implements Serializable {
private static final long serialVersionUID = 1L;
private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 10;
private static final int DEFAULT_READ_TIMEOUT_SECONDS = 30;
private static final Logger logger = LoggerFactory.getLogger(AIModelSetting.class);
private String baseUrl;
private String apiKey;
private String name;
@Editable(order=200, name="Base URL", placeholder="https://api.openai.com/v1", description="Base URL of <b class='text-info'>OpenAI compatible</b> API endpoint. Leave empty to use OpenAI official endpoint")
@Pattern(regexp="https?://.+", message="Base URL should be a valid http/https URL")
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
@Editable(order=300, name="API Key")
@NotEmpty
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
@Editable(order=400)
@ChoiceProvider("getModels")
@NotEmpty
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@SuppressWarnings("unused")
private static List<String> getModels() {
var baseUrl = (String) EditContext.get().getInputValue("baseUrl");
if (baseUrl == null)
baseUrl = "https://api.openai.com/v1";
var apiKey = (String) EditContext.get().getInputValue("apiKey");
if (apiKey != null) {
try {
var modelsUrl = baseUrl.endsWith("/") ? baseUrl + "models" : baseUrl + "/models";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(modelsUrl))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.GET()
.timeout(Duration.ofSeconds(DEFAULT_READ_TIMEOUT_SECONDS))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
var models = new ArrayList<String>();
var jsonResponse = JsonParser.parseString(response.body()).getAsJsonObject();
var dataArray = jsonResponse.getAsJsonArray("data");
for (var element : dataArray) {
models.add(element.getAsJsonObject().get("id").getAsString());
}
models.sort(String::compareTo);
return models;
} else {
logger.error("Error getting models (status code: {}, response body: {})",
response.statusCode(), response.body());
return List.of("<Error getting models, check server log for details>");
}
} catch (IOException | InterruptedException e) {
logger.error("Error getting models", e);
return List.of("<Error getting models, check server log for details>");
}
} else {
return List.of("<Specify API key to get models>");
}
}
public ChatModel getChatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.modelName(name)
.timeout(Duration.ofSeconds(DEFAULT_READ_TIMEOUT_SECONDS))
.build();
}
}

View File

@ -0,0 +1,41 @@
package io.onedev.server.model.support.administration;
import java.io.Serializable;
import org.jspecify.annotations.Nullable;
import dev.langchain4j.model.chat.ChatModel;
import io.onedev.server.annotation.Editable;
import io.onedev.server.model.support.AIModelSetting;
@Editable
public class AISetting implements Serializable {
private static final long serialVersionUID = 1L;
private AIModelSetting naturalLanguageQueryModelSetting;
@Editable(order=100, name="Natural Language Query Model", description=
"""
If specified, one will be able to query issues, pull requests and builds via natural language. Suggested models in terms of performance and cost for this task:
<ul>
<li>Google/gemini-2.5-flash</li>
<li>OpenAI/gpt-4.1-mini</li>
<li>Qwen/Qwen-2.5-72B-instruct</li>
</ul>
""")
@Nullable
public AIModelSetting getNaturalLanguageQueryModelSetting() {
return naturalLanguageQueryModelSetting;
}
public void setNaturalLanguageQueryModelSetting(AIModelSetting naturalLanguageQueryModelSetting) {
this.naturalLanguageQueryModelSetting = naturalLanguageQueryModelSetting;
}
@Nullable
public ChatModel getNaturalLanguageQueryModel() {
return naturalLanguageQueryModelSetting != null ? naturalLanguageQueryModelSetting.getChatModel() : null;
}
}

View File

@ -9,6 +9,7 @@ import org.jspecify.annotations.Nullable;
import io.onedev.server.annotation.NoDBAccess;
import io.onedev.server.model.Setting;
import io.onedev.server.model.Setting.Key;
import io.onedev.server.model.support.administration.AISetting;
import io.onedev.server.model.support.administration.AgentSetting;
import io.onedev.server.model.support.administration.AlertSetting;
import io.onedev.server.model.support.administration.AuditSetting;
@ -172,6 +173,10 @@ public interface SettingService extends EntityService<Setting> {
PerformanceSetting getPerformanceSetting();
AISetting getAISetting();
void saveAISetting(AISetting aiSetting);
void saveSshSetting(SshSetting sshSetting);
void saveGpgSetting(GpgSetting gpgSetting);

View File

@ -13,7 +13,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.validation.Validator;
@ -21,6 +20,7 @@ import javax.validation.Validator;
import org.apache.commons.codec.binary.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.crypto.CipherService;
import org.jspecify.annotations.Nullable;
import io.onedev.commons.loader.ManagedSerializedForm;
import io.onedev.server.OneDev;
@ -30,6 +30,7 @@ import io.onedev.server.event.entity.EntityPersisted;
import io.onedev.server.event.system.SystemStarting;
import io.onedev.server.model.Setting;
import io.onedev.server.model.Setting.Key;
import io.onedev.server.model.support.administration.AISetting;
import io.onedev.server.model.support.administration.AgentSetting;
import io.onedev.server.model.support.administration.AlertSetting;
import io.onedev.server.model.support.administration.AuditSetting;
@ -166,6 +167,11 @@ public class DefaultSettingService extends BaseEntityService<Setting> implements
return (AuditSetting) getSettingValue(Key.AUDIT);
}
@Override
public AISetting getAISetting() {
return (AISetting) getSettingValue(Key.AI);
}
@Override
public SecuritySetting getSecuritySetting() {
return (SecuritySetting) getSettingValue(Key.SECURITY);
@ -296,6 +302,12 @@ public class DefaultSettingService extends BaseEntityService<Setting> implements
saveSetting(Key.AUDIT, auditSetting);
}
@Transactional
@Override
public void saveAISetting(AISetting aiSetting) {
saveSetting(Key.AI, aiSetting);
}
@Transactional
@Override
public void saveSecuritySetting(SecuritySetting securitySetting) {

View File

@ -1,7 +1,18 @@
package io.onedev.server.web.behavior;
import static io.onedev.server.web.translation.Translation._T;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.model.IModel;
import org.jspecify.annotations.Nullable;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import edu.emory.mathcs.backport.java.util.Collections;
import io.onedev.commons.codeassist.FenceAware;
import io.onedev.commons.codeassist.InputCompletion;
@ -12,26 +23,19 @@ import io.onedev.commons.codeassist.parser.ParseExpect;
import io.onedev.commons.codeassist.parser.TerminalExpect;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.server.OneDev;
import io.onedev.server.service.BuildParamService;
import io.onedev.server.ai.QueryDescriptions;
import io.onedev.server.model.Build;
import io.onedev.server.model.Project;
import io.onedev.server.search.entity.build.BuildQuery;
import io.onedev.server.search.entity.build.BuildQueryLexer;
import io.onedev.server.search.entity.build.BuildQueryParser;
import io.onedev.server.service.BuildParamService;
import io.onedev.server.service.SettingService;
import io.onedev.server.util.DateUtils;
import io.onedev.server.web.behavior.inputassist.ANTLRAssistBehavior;
import io.onedev.server.web.behavior.inputassist.InputAssistBehavior;
import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.model.IModel;
import org.jspecify.annotations.Nullable;
import static io.onedev.server.web.translation.Translation._T;
import java.util.ArrayList;
import java.util.List;
public class BuildQueryBehavior extends ANTLRAssistBehavior {
@ -254,9 +258,32 @@ public class BuildQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
return hints;
}
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
@Override
public String getQueryDescription() {
return QueryDescriptions.getBuildQueryDescription();
}
};
} else {
return null;
}
}
private SettingService getSettingService() {
return OneDev.getInstance(SettingService.class);
}
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null

View File

@ -57,11 +57,10 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.model.IModel;
import org.jspecify.annotations.Nullable;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
@ -75,9 +74,7 @@ import io.onedev.commons.codeassist.parser.ParseExpect;
import io.onedev.commons.codeassist.parser.TerminalExpect;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.server.OneDev;
import io.onedev.server.service.GroupService;
import io.onedev.server.service.LinkSpecService;
import io.onedev.server.service.SettingService;
import io.onedev.server.ai.QueryDescriptions;
import io.onedev.server.model.Issue;
import io.onedev.server.model.IssueSchedule;
import io.onedev.server.model.LinkSpec;
@ -100,8 +97,12 @@ import io.onedev.server.model.support.issue.field.spec.userchoicefield.UserChoic
import io.onedev.server.search.entity.issue.IssueQueryParseOption;
import io.onedev.server.search.entity.issue.IssueQueryParser;
import io.onedev.server.search.entity.project.ProjectQuery;
import io.onedev.server.service.GroupService;
import io.onedev.server.service.LinkSpecService;
import io.onedev.server.service.SettingService;
import io.onedev.server.util.ComponentContext;
import io.onedev.server.util.DateUtils;
import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.behavior.inputassist.ANTLRAssistBehavior;
import io.onedev.server.web.behavior.inputassist.InputAssistBehavior;
import io.onedev.server.web.util.SuggestionUtils;
@ -339,8 +340,21 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
}
}.suggest(terminalExpect);
} else if (spec.getRuleName().equals("Fuzzy")) {
} else if (spec.getRuleName().equals("Ai")) {
return new FenceAware(codeAssist.getGrammar(), '`', '`') {
@Override
protected List<InputSuggestion> match(String matchWith) {
return null;
}
@Override
protected String getFencingDescription() {
return _T("enclose with ` to query with AI");
}
}.suggest(terminalExpect);
} else if (spec.getRuleName().equals("Fuzzy")) {
return new FenceAware(codeAssist.getGrammar(), '~', '~') {
@Override
@ -397,7 +411,7 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
@Override
protected List<String> getHints(TerminalExpect terminalExpect) {
List<String> hints = new ArrayList<>();
GlobalIssueSetting issueSetting = OneDev.getInstance(SettingService.class).getIssueSetting();
GlobalIssueSetting issueSetting = getSettingService().getIssueSetting();
if (terminalExpect.getElementSpec() instanceof LexerRuleRefElementSpec) {
LexerRuleRefElementSpec spec = (LexerRuleRefElementSpec) terminalExpect.getElementSpec();
if ("criteriaValue".equals(spec.getLabel())) {
@ -418,13 +432,35 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
return hints;
}
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getDescription() != null && (suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX));
}
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
@Override
public String getQueryDescription() {
return QueryDescriptions.getIssueQueryDescription();
}
};
} else {
return null;
}
}
private SettingService getSettingService() {
return OneDev.getInstance(SettingService.class);
}
}

View File

@ -1,7 +1,38 @@
package io.onedev.server.web.behavior;
import static io.onedev.server.model.AbstractEntity.NAME_NUMBER;
import static io.onedev.server.search.entity.EntityQuery.getValue;
import static io.onedev.server.search.entity.pullrequest.PullRequestQuery.checkField;
import static io.onedev.server.search.entity.pullrequest.PullRequestQuery.getOperator;
import static io.onedev.server.search.entity.pullrequest.PullRequestQuery.getRuleName;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.ApprovedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.AssignedToMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.CommentedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.IgnoredByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.IncludesCommit;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.IncludesIssue;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.MentionedMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.NeedMyAction;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.OrderBy;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.RequestedForChangesByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.SubmittedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.ToBeChangedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.ToBeMergedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.ToBeReviewedByMe;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.WatchedByMe;
import static io.onedev.server.web.translation.Translation._T;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.model.IModel;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import io.onedev.commons.codeassist.FenceAware;
import io.onedev.commons.codeassist.InputCompletion;
import io.onedev.commons.codeassist.InputSuggestion;
@ -10,28 +41,19 @@ import io.onedev.commons.codeassist.parser.Element;
import io.onedev.commons.codeassist.parser.ParseExpect;
import io.onedev.commons.codeassist.parser.TerminalExpect;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.server.OneDev;
import io.onedev.server.ai.QueryDescriptions;
import io.onedev.server.model.Project;
import io.onedev.server.model.PullRequest;
import io.onedev.server.model.support.pullrequest.MergeStrategy;
import io.onedev.server.search.entity.project.ProjectQuery;
import io.onedev.server.search.entity.pullrequest.PullRequestQueryParser;
import io.onedev.server.service.SettingService;
import io.onedev.server.util.DateUtils;
import io.onedev.server.web.behavior.inputassist.ANTLRAssistBehavior;
import io.onedev.server.web.behavior.inputassist.InputAssistBehavior;
import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.model.IModel;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static io.onedev.server.model.AbstractEntity.NAME_NUMBER;
import static io.onedev.server.search.entity.EntityQuery.getValue;
import static io.onedev.server.search.entity.pullrequest.PullRequestQuery.*;
import static io.onedev.server.search.entity.pullrequest.PullRequestQueryLexer.*;
import static io.onedev.server.web.translation.Translation._T;
public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
@ -241,6 +263,8 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
return hints;
}
@ -250,4 +274,25 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
}
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
@Override
public String getQueryDescription() {
return QueryDescriptions.getPullRequestQueryDescription();
}
};
} else {
return null;
}
}
private SettingService getSettingService() {
return OneDev.getInstance(SettingService.class);
}
}

View File

@ -2,12 +2,14 @@ package io.onedev.server.web.behavior.inputassist;
import static io.onedev.server.web.translation.Translation._T;
import static org.apache.wicket.ajax.attributes.CallbackParameter.explicit;
import static org.unbescape.javascript.JavaScriptEscape.escapeJavaScript;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.apache.wicket.Component;
import org.apache.wicket.Session;
import org.apache.wicket.ajax.AjaxChannel;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
@ -17,6 +19,9 @@ import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
import org.apache.wicket.request.IRequestParameters;
import org.apache.wicket.request.cycle.RequestCycle;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.unbescape.javascript.JavaScriptEscape;
import com.fasterxml.jackson.core.JsonProcessingException;
@ -27,7 +32,10 @@ import com.google.common.base.Splitter;
import io.onedev.commons.codeassist.InputCompletion;
import io.onedev.commons.codeassist.InputStatus;
import io.onedev.commons.loader.AppLoader;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.LinearRange;
import io.onedev.commons.utils.StringUtils;
import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.util.ComponentContext;
import io.onedev.server.util.RangeUtils;
import io.onedev.server.web.behavior.AbstractPostAjaxBehavior;
@ -39,6 +47,8 @@ import io.onedev.server.web.component.floating.FloatingPanel;
public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
private static final Logger logger = LoggerFactory.getLogger(InputAssistBehavior.class);
public static final int MAX_SUGGESTIONS = 1000;
private FloatingPanel dropdown;
@ -113,9 +123,27 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
if (type.equals("close")) {
if (dropdown != null)
dropdown.close();
} else if (type.equals("translate")) {
String toTranslate = params.getParameterValue("input").toString();
String translated;
try {
translated = Preconditions.checkNotNull(getNaturalLanguageTranslator()).translate(toTranslate);
} catch (Exception e) {
translated = toTranslate;
var explicitException = ExceptionUtils.find(e, ExplicitException.class);
if (explicitException != null) {
Session.get().error(explicitException.getMessage());
} else {
logger.error("Error translating natural language input", e);
Session.get().error("Error translating natural language input. Check server log for details");
}
}
target.appendJavaScript(
String.format("onedev.server.inputassist.naturalLanguageTranslated('%s', '%s');",
getComponent().getMarkupId(), escapeJavaScript(translated)));
} else {
String inputContent = params.getParameterValue("input").toString();
Integer inputCaret = params.getParameterValue("caret").toOptionalInteger();
int inputCaret = params.getParameterValue("caret").toInt();
Preconditions.checkArgument(inputContent.indexOf('\r') == -1);
@ -133,7 +161,7 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
getComponent().getMarkupId(), json);
target.appendJavaScript(script);
if (inputCaret != null) {
if (inputCaret != -1) {
InputStatus inputStatus = new InputStatus(inputContent, inputCaret);
List<InputCompletion> suggestions = new ArrayList<>();
ComponentContext.push(new ComponentContext(getComponent()));
@ -165,13 +193,43 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
String.format("onedev.server.inputassist.appendSpace('%s');",
getComponent().getMarkupId()));
} else {
int anchor = getAnchor(inputContent.substring(0, inputCaret));
addTranslationSuggestion(inputStatus, suggestions);
showSuggestions(target, inputStatus, suggestions);
}
} else if (addTranslationSuggestion(inputStatus, suggestions)) {
showSuggestions(target, inputStatus, suggestions);
} else if (dropdown != null) {
dropdown.close();
}
} else if (dropdown != null) {
dropdown.close();
}
onInput(target, inputContent);
}
}
private boolean addTranslationSuggestion(InputStatus status, List<InputCompletion> suggestions) {
var queryTranslator = getNaturalLanguageTranslator();
if (queryTranslator != null) {
var contentBeforeCaret = status.getContentBeforeCaret();
if (StringUtils.isNotBlank(contentBeforeCaret) && suggestions.stream().noneMatch(it->!isFuzzySuggestion(it))) {
contentBeforeCaret += "🤖";
suggestions.add(0, new InputCompletion(contentBeforeCaret, contentBeforeCaret + status.getContentAfterCaret(), contentBeforeCaret.length(), _T("Natural language query via AI"), null));
return true;
}
}
return false;
}
private void showSuggestions(AjaxRequestTarget target, InputStatus status, List<InputCompletion> suggestions) {
int anchor = getAnchor(status.getContent().substring(0, status.getCaret()));
if (dropdown == null) {
dropdown = new FloatingPanel(target, new Alignment(new ComponentTarget(getComponent(), anchor), AlignPlacement.bottom(0))) {
@Override
protected Component newContent(String id) {
return new AssistPanel(id, getComponent(), suggestions, getHints(inputStatus)) {
return new AssistPanel(id, getComponent(), suggestions, getHints(status)) {
@Override
protected void onClose(AjaxRequestTarget target) {
@ -188,12 +246,12 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
}
};
script = String.format("onedev.server.inputassist.assistOpened('%s', '%s', '%s');",
getComponent().getMarkupId(), dropdown.getMarkupId(), JavaScriptEscape.escapeJavaScript(inputContent));
var script = String.format("onedev.server.inputassist.assistOpened('%s', '%s', '%s');",
getComponent().getMarkupId(), dropdown.getMarkupId(), JavaScriptEscape.escapeJavaScript(status.getContent()));
target.appendJavaScript(script);
} else {
Component content = dropdown.getContent();
Component newContent = new AssistPanel(content.getId(), getComponent(), suggestions, getHints(inputStatus)) {
Component newContent = new AssistPanel(content.getId(), getComponent(), suggestions, getHints(status)) {
@Override
protected void onClose(AjaxRequestTarget target) {
@ -205,23 +263,14 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
target.add(newContent);
AlignTarget alignTarget = new ComponentTarget(getComponent(), anchor);
script = String.format("$('#%s').data('alignment').target=%s;", dropdown.getMarkupId(), alignTarget);
var script = String.format("$('#%s').data('alignment').target=%s;", dropdown.getMarkupId(), alignTarget);
target.prependJavaScript(script);
script = String.format("onedev.server.inputassist.assistUpdated('%s', '%s', '%s');",
getComponent().getMarkupId(), dropdown.getMarkupId(), JavaScriptEscape.escapeJavaScript(inputContent));
getComponent().getMarkupId(), dropdown.getMarkupId(), JavaScriptEscape.escapeJavaScript(status.getContent()));
target.appendJavaScript(script);
}
}
} else if (dropdown != null) {
dropdown.close();
}
} else if (dropdown != null) {
dropdown.close();
}
onInput(target, inputContent);
}
}
public void close() {
if (dropdown != null)
@ -242,10 +291,10 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
translations.put("inactiveHelp", _T("<span class='keycap'>Tab</span> to complete."));
String script;
try {
script = String.format("onedev.server.inputassist.onDomReady('%s', %s, %s);",
script = String.format("onedev.server.inputassist.onDomReady('%s', %s, %b, %s);",
getComponent().getMarkupId(true),
getCallbackFunction(explicit("type"), explicit("input"), explicit("caret"), explicit("event")),
AppLoader.getInstance(ObjectMapper.class).writeValueAsString(translations));
getNaturalLanguageTranslator() != null, AppLoader.getInstance(ObjectMapper.class).writeValueAsString(translations));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
@ -274,4 +323,10 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return false;
}
@Nullable
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
return null;
}
}

View File

@ -0,0 +1,41 @@
package io.onedev.server.web.behavior.inputassist;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import io.onedev.commons.utils.ExplicitException;
public abstract class NaturalLanguageTranslator {
private static final int MAX_QUERY_LENGTH = 512;
private final ChatModel model;
public NaturalLanguageTranslator(ChatModel model) {
this.model = model;
}
public abstract String getQueryDescription();
public String translate(String naturalLanguage) {
if (naturalLanguage.length() > MAX_QUERY_LENGTH) {
throw new ExplicitException("Query is too long. Max " + MAX_QUERY_LENGTH + " characters");
}
var systemMessage = new SystemMessage("""
You are a query translator that converts natural language into structured query string described as below:
""" + getQueryDescription() + """
Also note that the user input might also be a structure query but with some syntax errors. In that case,
fix syntax errors and return the corrected query string.
IMPORTANT: only structured query string should be returned, no other text or comments.
""");
var userMessage = new UserMessage(naturalLanguage);
return model.chat(systemMessage, userMessage).aiMessage().text();
}
}

View File

@ -1,6 +1,7 @@
onedev.server.inputassist = {
onDomReady: function(inputId, callback, translations) {
onDomReady: function(inputId, callback, supportNaturalLanguageInput, translations) {
var $input = $("#" + inputId);
$input.data("supportNaturalLanguageInput", supportNaturalLanguageInput);
onedev.server.inputassist.translations = translations;
onedev.server.inputassist.markErrors(inputId, []);
@ -21,9 +22,21 @@ onedev.server.inputassist = {
if (value != $input.data("prevValue") || caret != $input.data("prevCaret") || !$input.data("dropdown")) {
$input.data("prevValue", value);
$input.data("prevCaret", caret);
if ($input.is(":focus") && e.keyCode != 27 && e.keyCode != 13) // ignore esc, enter
if ($input.is(":focus") && e.keyCode != 27 && e.keyCode != 13) { // ignore esc, enter
if (caret != -1 && $input.data("supportNaturalLanguageInput")) {
var contentBeforeCaret = value.substring(0, caret);
if (contentBeforeCaret.endsWith("🤖")) {
var contentToTranslate = contentBeforeCaret.substring(0, contentBeforeCaret.length-2);
onedev.server.inputassist.showNaturalLanguageTranslatingIndicator($input, caret);
callback("translate", contentToTranslate, caret);
} else {
callback("input", value, caret, e.type);
}
} else {
callback("input", value, caret, e.type);
}
}
}
if (value.trim().length == 0)
onedev.server.inputassist.markErrors(inputId, []);
});
@ -133,28 +146,28 @@ onedev.server.inputassist = {
markErrors: function(inputId, errors) {
var $input = $("#" + inputId);
$input.data("errors", errors);
var $parent = $input.closest("form");
$parent.css("position", "relative");
$parent.find(">.input-error-mark").remove();
var $form = $input.closest("form");
$form.css("position", "relative");
$form.find(">.input-error-mark").remove();
if ($input.val().length != 0) {
for (var i in errors) {
var error = errors[i];
var fromCoord = getCaretCoordinates($input[0], error.from);
var toCoord = getCaretCoordinates($input[0], error.to+1);
var $error = $("<div class='input-error-mark'></div>");
$error.appendTo($parent);
$error.appendTo($form);
var inputCoord = $input.offset();
var parentCoord = $parent.offset();
var parentCoord = $form.offset();
var textHeight = 16;
var errorHeight = 5;
var minWidth = 5;
var textMargin = 10;
var left = fromCoord.left + inputCoord.left - parentCoord.left - $input.scrollLeft();
if (left < $input.offset().left - $parent.offset().left + textMargin)
left = $input.offset().left - $parent.offset().left + textMargin;
if (left < $input.offset().left - $form.offset().left + textMargin)
left = $input.offset().left - $form.offset().left + textMargin;
var top = fromCoord.top + inputCoord.top - parentCoord.top + textHeight - $input.scrollTop();
if (top < $input.offset().top - $parent.offset().top + textMargin)
top = $input.offset().top - $parent.offset().top + textMargin;
if (top < $input.offset().top - $form.offset().top + textMargin)
top = $input.offset().top - $form.offset().top + textMargin;
$error.css({left: left, top: top});
$error.outerWidth(Math.max(toCoord.left-fromCoord.left, minWidth));
$error.outerHeight(errorHeight);
@ -172,6 +185,19 @@ onedev.server.inputassist = {
$input.trigger("assist");
},
naturalLanguageTranslated: function(inputId, translatedInput) {
var $input = $("#" + inputId);
onedev.server.inputassist.hideNaturalLanguageTranslatingIndicator($input);
var caret = $input.caret();
var content = translatedInput + $input.val().substring(caret);
$input.val(content);
$input.caret(translatedInput.length);
$input.blur();
$input.focus();
$input.trigger("input");
$input.trigger("assist");
},
assistOpened: function(inputId, dropdownId, inputContent) {
var $input = $("#" + inputId);
var $dropdown = $("#" + dropdownId);
@ -217,5 +243,36 @@ onedev.server.inputassist = {
} else {
$dropdown.find(".help .complete").empty().append(onedev.server.inputassist.translations["inactiveHelp"]);
}
},
showNaturalLanguageTranslatingIndicator: function($input, caret) {
$input.prop("readonly", true);
var $form = $input.closest("form");
$form.css("position", "relative");
var coord = getCaretCoordinates($input[0], caret);
var $indicator;
if (onedev.server.isDarkMode())
$indicator = $("<div class='ajax-loading-indicator natural-language-translating-indicator'><img src='/~img/dark-ajax-indicator.gif' width='16' height='16'></div>");
else
$indicator = $("<div class='ajax-loading-indicator natural-language-translating-indicator'><img src='/~img/ajax-indicator.gif' width='16' height='16'></div>");
$indicator.appendTo($form);
var inputCoord = $input.offset();
var parentCoord = $form.offset();
var left = coord.left + inputCoord.left - parentCoord.left - $input.scrollLeft() + 5;
var top = coord.top + inputCoord.top - parentCoord.top - $input.scrollTop() - 3;
$indicator.css({
position: "absolute",
left: left + "px",
top: top + "px",
zIndex: 1000
});
},
hideNaturalLanguageTranslatingIndicator: function($input) {
$input.prop("readonly", false);
$input.closest("form").children(".natural-language-translating-indicator").remove();
}
};

View File

@ -8,6 +8,7 @@ import org.apache.wicket.request.mapper.CompoundRequestMapper;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.server.web.asset.icon.IconScope;
import io.onedev.server.web.page.admin.aisetting.AISettingPage;
import io.onedev.server.web.page.admin.alertsettings.AlertSettingPage;
import io.onedev.server.web.page.admin.authenticator.AuthenticatorPage;
import io.onedev.server.web.page.admin.brandingsetting.BrandingSettingPage;
@ -346,6 +347,7 @@ public class BaseUrlMapper extends CompoundRequestMapper {
add(new BasePageMapper("~administration/labels", LabelManagementPage.class));
add(new BasePageMapper("~administration/settings/alert", AlertSettingPage.class));
add(new BasePageMapper("~administration/settings/performance", PerformanceSettingPage.class));
add(new BasePageMapper("~administration/settings/ai", AISettingPage.class));
add(new BasePageMapper("~administration/settings/backup", DatabaseBackupPage.class));
add(new BasePageMapper("~administration/settings/authenticator", AuthenticatorPage.class));
add(new BasePageMapper("~administration/settings/sso-providers", SsoProviderListPage.class));

View File

@ -0,0 +1,10 @@
<wicket:extend>
<div class="card">
<div class="card-body">
<form wicket:id="form" class="leave-confirm">
<div wicket:id="editor" class="mb-4"></div>
<input type="submit" class="btn btn-primary dirty-aware" t:value="Save Settings">
</form>
</div>
</div>
</wicket:extend>

View File

@ -0,0 +1,58 @@
package io.onedev.server.web.page.admin.aisetting;
import static io.onedev.server.web.translation.Translation._T;
import javax.inject.Inject;
import org.apache.wicket.Component;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import io.onedev.server.data.migration.VersionedXmlDoc;
import io.onedev.server.model.support.administration.AISetting;
import io.onedev.server.service.SettingService;
import io.onedev.server.web.editable.BeanContext;
import io.onedev.server.web.page.admin.AdministrationPage;
public class AISettingPage extends AdministrationPage {
@Inject
private SettingService settingService;
public AISettingPage(PageParameters params) {
super(params);
}
@Override
protected void onInitialize() {
super.onInitialize();
AISetting aiSetting = settingService.getAISetting();
var oldAuditContent = VersionedXmlDoc.fromBean(aiSetting).toXML();
Form<?> form = new Form<Void>("form") {
@Override
protected void onSubmit() {
super.onSubmit();
var newAuditContent = VersionedXmlDoc.fromBean(aiSetting).toXML();
settingService.saveAISetting(aiSetting);
auditService.audit(null, "changed AI settings", oldAuditContent, newAuditContent);
getSession().success(_T("AI settings have been saved"));
setResponsePage(AISettingPage.class);
}
};
form.add(BeanContext.edit("editor", aiSetting));
add(form);
}
@Override
protected Component newTopbarTitle(String componentId) {
return new Label(componentId, _T("AI Settings"));
}
}

View File

@ -177,7 +177,7 @@ onedev.server = {
setupAjaxLoadingIndicator: function() {
var ongoingAjaxRequests = 0;
Wicket.Event.subscribe('/ajax/call/beforeSend', function(e, attributes) {
if (ongoingAjaxRequests == 0) {
if (ongoingAjaxRequests == 0 && $(".ajax-loading-indicator:visible").length == 0) {
var $ajaxLoadingIndicator = $("#ajax-loading-indicator");
if ($ajaxLoadingIndicator[0].timer)
clearTimeout($ajaxLoadingIndicator[0].timer);

View File

@ -65,12 +65,12 @@ import io.onedev.server.OneDev;
import io.onedev.server.ServerConfig;
import io.onedev.server.SubscriptionService;
import io.onedev.server.cluster.ClusterService;
import io.onedev.server.service.AlertService;
import io.onedev.server.service.SettingService;
import io.onedev.server.model.Alert;
import io.onedev.server.model.User;
import io.onedev.server.persistence.dao.EntityCriteria;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.AlertService;
import io.onedev.server.service.SettingService;
import io.onedev.server.updatecheck.UpdateCheckService;
import io.onedev.server.util.DateUtils;
import io.onedev.server.web.WebConstants;
@ -91,6 +91,7 @@ import io.onedev.server.web.component.svg.SpriteImage;
import io.onedev.server.web.component.user.UserAvatar;
import io.onedev.server.web.editable.EditableUtils;
import io.onedev.server.web.page.HomePage;
import io.onedev.server.web.page.admin.aisetting.AISettingPage;
import io.onedev.server.web.page.admin.alertsettings.AlertSettingPage;
import io.onedev.server.web.page.admin.authenticator.AuthenticatorPage;
import io.onedev.server.web.page.admin.brandingsetting.BrandingSettingPage;
@ -338,6 +339,9 @@ public abstract class LayoutPage extends BasePage {
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("Groovy Scripts"),
GroovyScriptListPage.class, new PageParameters()));
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("AI Settings"),
AISettingPage.class, new PageParameters()));
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("Branding"),
BrandingSettingPage.class, new PageParameters()));

View File

@ -21,6 +21,7 @@ public class TestPage extends BasePage {
@Override
public void onClick() {
System.out.println("bb");
}
});

View File

@ -4635,6 +4635,10 @@ public class Translation_de extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "AI-Modell");
m.put("AI Model Provider", "AI-Modell-Anbieter");
m.put("Model", "Modell");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_es extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "Modelo de IA");
m.put("AI Model Provider", "Proveedor de Modelo de IA");
m.put("Model", "Modelo");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_fr extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "Modèle d'IA");
m.put("AI Model Provider", "Fournisseur de modèle d'IA");
m.put("Model", "Modèle");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_it extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "Modello AI");
m.put("AI Model Provider", "Fornitore di Modelli AI");
m.put("Model", "Modello");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_ja extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "AIモデル");
m.put("AI Model Provider", "AIモデルプロバイダー");
m.put("Model", "モデル");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_ko extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "AI 모델");
m.put("AI Model Provider", "AI 모델 제공자");
m.put("Model", "모델");
m.put("Open AI", "오픈 AI");
}
@Override

View File

@ -4635,6 +4635,10 @@ public class Translation_pt extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "{javax.validation.constraints.NotEmpty.message}");
m.put("{javax.validation.constraints.NotNull.message}", "{javax.validation.constraints.NotNull.message}");
m.put("{javax.validation.constraints.Size.message}", "{javax.validation.constraints.Size.message}");
m.put("AI Model", "Modelo de IA");
m.put("AI Model Provider", "Provedor de Modelo de IA");
m.put("Model", "Modelo");
m.put("Open AI", "Open AI");
}
@Override

View File

@ -4653,6 +4653,10 @@ public class Translation_zh extends TranslationResourceBundle {
m.put("{javax.validation.constraints.NotEmpty.message}", "不能为空");
m.put("{javax.validation.constraints.NotNull.message}", "不能为空");
m.put("{javax.validation.constraints.Size.message}", "至少需要指定一个值");
m.put("AI Model", "AI 模型");
m.put("AI Model Provider", "AI 模型提供方");
m.put("Model", "模型");
m.put("Open AI", "Open AI");
}
@Override