feat: Suggest pull request title/description via AI (OD-2602)

This commit is contained in:
Robin Shen 2025-11-14 10:59:05 +08:00
parent a62db8265f
commit 706b2dbdba
18 changed files with 443 additions and 132 deletions

View File

@ -566,7 +566,7 @@ public class McpHelperResource {
createPullRequestInputSchema.put("Type", "object"); createPullRequestInputSchema.put("Type", "object");
createPullRequestInputSchema.put("Properties", createPullRequestProperties); createPullRequestInputSchema.put("Properties", createPullRequestProperties);
createPullRequestInputSchema.put("Required", List.of("sourceBranch", "title")); createPullRequestInputSchema.put("Required", List.of("sourceBranch"));
inputSchemas.put("createPullRequest", createPullRequestInputSchema); inputSchemas.put("createPullRequest", createPullRequestInputSchema);
@ -1432,13 +1432,6 @@ public class McpHelperResource {
if (baseCommitId == null) if (baseCommitId == null)
throw new NotAcceptableException("No common base for source and target branches"); throw new NotAcceptableException("No common base for source and target branches");
var title = (String) data.remove("title");
if (title == null)
throw new NotAcceptableException("Title is required");
request.cleanTitle();
request.setDescription((String) data.remove("description"));
request.setTarget(target); request.setTarget(target);
request.setSource(source); request.setSource(source);
request.setSubmitter(SecurityUtils.getUser()); request.setSubmitter(SecurityUtils.getUser());
@ -1461,7 +1454,21 @@ public class McpHelperResource {
update.setTargetHeadCommitHash(request.getTarget().getObjectName()); update.setTargetHeadCommitHash(request.getTarget().getObjectName());
request.getUpdates().add(update); request.getUpdates().add(update);
request.generateTitleAndDescriptionIfEmpty(); var title = (String) data.remove("title");
if (title == null)
title = request.generateTitleFromCommits();
else
title = request.cleanTitle(title);
if (title == null)
title = request.generateTitleFromBranch();
request.setTitle(title);
var description = (String) data.remove("description");
if (description == null)
description = request.generateDescriptionFromCommits();
request.setDescription(description);
pullRequestService.checkReviews(request, false); pullRequestService.checkReviews(request, false);

View File

@ -49,7 +49,6 @@ import java.util.Stack;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import javax.persistence.AttributeOverride; import javax.persistence.AttributeOverride;
import javax.persistence.AttributeOverrides; import javax.persistence.AttributeOverrides;
import javax.persistence.CascadeType; import javax.persistence.CascadeType;
@ -68,6 +67,7 @@ import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevCommit;
import org.hibernate.annotations.DynamicUpdate; import org.hibernate.annotations.DynamicUpdate;
import org.jspecify.annotations.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
@ -76,8 +76,6 @@ import com.google.common.collect.Lists;
import io.onedev.server.OneDev; import io.onedev.server.OneDev;
import io.onedev.server.attachment.AttachmentStorageSupport; import io.onedev.server.attachment.AttachmentStorageSupport;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.UserService;
import io.onedev.server.entityreference.EntityReference; import io.onedev.server.entityreference.EntityReference;
import io.onedev.server.entityreference.PullRequestReference; import io.onedev.server.entityreference.PullRequestReference;
import io.onedev.server.git.GitUtils; import io.onedev.server.git.GitUtils;
@ -96,6 +94,9 @@ import io.onedev.server.model.support.pullrequest.MergeStrategy;
import io.onedev.server.rest.annotation.Api; import io.onedev.server.rest.annotation.Api;
import io.onedev.server.search.entity.SortField; import io.onedev.server.search.entity.SortField;
import io.onedev.server.security.SecurityUtils; import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.UserService;
import io.onedev.server.util.BranchSemantic;
import io.onedev.server.util.ComponentContext; import io.onedev.server.util.ComponentContext;
import io.onedev.server.util.ProjectAndBranch; import io.onedev.server.util.ProjectAndBranch;
import io.onedev.server.web.asset.emoji.Emojis; import io.onedev.server.web.asset.emoji.Emojis;
@ -1410,56 +1411,85 @@ public class PullRequest extends ProjectBelonging
} }
// Remove issue number suffix if there is any // Remove issue number suffix if there is any
public void cleanTitle() { public String cleanTitle(String title) {
var cleanedTitle = title.replaceFirst("\\s*\\([a-zA-Z]+-\\d+\\)$", ""); var cleanedTitle = title.replaceFirst("\\s*\\([a-zA-Z]+-\\d+\\)$", "");
cleanedTitle = cleanedTitle.replaceFirst("\\s*\\(#\\d+\\)$", "").trim(); cleanedTitle = cleanedTitle.replaceFirst("\\s*\\(#\\d+\\)$", "").trim();
if (cleanedTitle.length() != 0) if (cleanedTitle.length() != 0)
title = cleanedTitle; return cleanedTitle;
else
return title;
} }
public void generateTitleAndDescriptionIfEmpty() { public BranchSemantic getSourceBranchSemantic() {
if (title == null) { var work = getSourceBranch();
var commits = getLatestUpdate().getCommits(); boolean workInProgress = false;
if (commits.size() == 1) { if (work.toLowerCase().startsWith("wip-")
title = commits.get(0).getShortMessage(); || work.toLowerCase().startsWith("wip_")
cleanTitle(); || work.toLowerCase().startsWith("wip/")) {
} else { workInProgress = true;
title = getSource().getBranch().toLowerCase(); work = work.substring(4);
boolean wip = false; }
if (title.startsWith("wip-") || title.startsWith("wip_") || title.startsWith("wip/")) { var commitTypes = CONVENTIONAL_COMMIT_TYPES;
wip = true; var branchProtection = getProject().getBranchProtection(getTargetBranch(), getSubmitter());
title = title.substring(4); if (branchProtection.isEnforceConventionalCommits() && !branchProtection.getCommitTypes().isEmpty()) {
} commitTypes = branchProtection.getCommitTypes();
var commitTypes = CONVENTIONAL_COMMIT_TYPES; }
var branchProtection = getProject().getBranchProtection(getTargetBranch(), getSubmitter()); String workType = null;
if (branchProtection.isEnforceConventionalCommits() && !branchProtection.getCommitTypes().isEmpty()) { for (var commitType: commitTypes) {
commitTypes = branchProtection.getCommitTypes(); if (work.toLowerCase().startsWith(commitType + "-")
} || work.toLowerCase().startsWith(commitType + "_")
boolean found = false; || work.toLowerCase().startsWith(commitType + "/")) {
for (var commitType: commitTypes) { workType = commitType;
if (title.startsWith(commitType + "-") || title.startsWith(commitType + "_") || title.startsWith(commitType + "/")) { work = work.substring(commitType.length() + 1);
title = commitType + ": " + StringUtils.capitalize(title.substring(commitType.length() + 1).replace('-', ' ').replace('_', ' ').replace('/', ' ')); break;
found = true;
break;
}
}
if (!found) {
title = StringUtils.capitalize(title.replace('-', ' ').replace('_', ' ').replace('/', ' '));
}
if (wip)
title = "[WIP] " + title;
} }
} }
if (description == null) { return new BranchSemantic(workInProgress, workType, StringUtils.trimToNull(work));
var commits = getLatestUpdate().getCommits(); }
if (commits.size() == 1) {
var commit = commits.get(0); public String getTitlePrefix(BranchSemantic sourceBranchSemantic) {
var shortMessage = commits.get(0).getShortMessage(); var prefixBuilder = new StringBuilder();
description = commit.getFullMessage().substring(shortMessage.length()).trim(); if (sourceBranchSemantic.isWorkInProgress()) {
if (description.length() == 0) prefixBuilder.append("[WIP] ");
description = null; }
} if (sourceBranchSemantic.getWorkType() != null) {
} prefixBuilder.append(sourceBranchSemantic.getWorkType()).append(": ");
}
return prefixBuilder.toString();
}
public String generateTitleFromBranch() {
var sourceBranchSemantic = getSourceBranchSemantic();
var title = getTitlePrefix(sourceBranchSemantic);
if (sourceBranchSemantic.getWorkDescription() != null) {
title += StringUtils.capitalize(sourceBranchSemantic.getWorkDescription().toLowerCase().replace('-', ' ').replace('_', ' ').replace('/', ' '));
}
return title.trim();
}
@Nullable
public String generateTitleFromCommits() {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
return cleanTitle(commits.get(0).getShortMessage());
} else {
return null;
}
}
@Nullable
public String generateDescriptionFromCommits() {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
var commit = commits.get(0);
var shortMessage = commit.getShortMessage();
var description = commit.getFullMessage().substring(shortMessage.length()).trim();
if (description.length() == 0)
description = null;
return description;
} else {
return null;
}
} }
} }

View File

@ -0,0 +1,33 @@
package io.onedev.server.util;
import org.jspecify.annotations.Nullable;
public class BranchSemantic {
private final boolean workInProgress;
private final String workType;
private final String workDescription;
public BranchSemantic(boolean workInProgress, @Nullable String workType, @Nullable String workDescription) {
this.workInProgress = workInProgress;
this.workType = workType;
this.workDescription = workDescription;
}
public boolean isWorkInProgress() {
return workInProgress;
}
@Nullable
public String getWorkType() {
return workType;
}
@Nullable
public String getWorkDescription() {
return workDescription;
}
}

View File

@ -30,8 +30,6 @@ import static io.onedev.server.web.translation.Translation._T;
import static java.util.Collections.sort; import static java.util.Collections.sort;
public class AgentQueryBehavior extends ANTLRAssistBehavior { public class AgentQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final boolean forExecutor; private final boolean forExecutor;
@ -188,8 +186,8 @@ public class AgentQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -38,8 +38,6 @@ import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils; import io.onedev.server.web.util.SuggestionUtils;
public class BuildQueryBehavior extends ANTLRAssistBehavior { public class BuildQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
@ -286,8 +284,8 @@ public class BuildQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -30,8 +30,6 @@ import static io.onedev.server.search.entity.codecomment.CodeCommentQueryLexer.*
import static io.onedev.server.web.translation.Translation._T; import static io.onedev.server.web.translation.Translation._T;
public class CodeCommentQueryBehavior extends ANTLRAssistBehavior { public class CodeCommentQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
@ -175,8 +173,8 @@ public class CodeCommentQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -36,8 +36,6 @@ import io.onedev.server.xodus.CommitInfoService;
public class CommitQueryBehavior extends ANTLRAssistBehavior { public class CommitQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
private final boolean withCurrentUserCriteria; private final boolean withCurrentUserCriteria;
@ -198,8 +196,8 @@ public class CommitQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -109,8 +109,6 @@ import io.onedev.server.web.util.SuggestionUtils;
import io.onedev.server.web.util.WicketUtils; import io.onedev.server.web.util.WicketUtils;
public class IssueQueryBehavior extends ANTLRAssistBehavior { public class IssueQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
@ -439,7 +437,8 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null && (suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX)); return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
} }
@Override @Override

View File

@ -33,8 +33,6 @@ import static io.onedev.server.web.translation.Translation._T;
public class PackQueryBehavior extends ANTLRAssistBehavior { public class PackQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
private final String packType; private final String packType;
@ -222,8 +220,8 @@ public class PackQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -37,8 +37,6 @@ import static io.onedev.server.web.translation.Translation._T;
public class ProjectQueryBehavior extends ANTLRAssistBehavior { public class ProjectQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final boolean childQuery; private final boolean childQuery;
public ProjectQueryBehavior(boolean childQuery) { public ProjectQueryBehavior(boolean childQuery) {
@ -225,8 +223,8 @@ public class ProjectQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
} }

View File

@ -56,8 +56,6 @@ import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils; import io.onedev.server.web.util.SuggestionUtils;
public class PullRequestQueryBehavior extends ANTLRAssistBehavior { public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel; private final IModel<Project> projectModel;
@ -270,8 +268,8 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
@Override @Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) { protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null return suggestion.getLabel().startsWith("~")
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX); && suggestion.getLabel().endsWith("~");
} }
@Override @Override

View File

@ -12,31 +12,31 @@ import io.onedev.server.OneDev;
public class JavascriptTranslations { public class JavascriptTranslations {
static String get() { static String get() {
try { try {
var map = new HashMap<String, String>(); var map = new HashMap<String, String>();
map.put("loading", _T("Loading...")); map.put("loading", _T("Loading..."));
map.put("loading-emojis", _T("Loading emojis...")); map.put("loading-emojis", _T("Loading emojis..."));
map.put("commented-code-outdated", _T("Commented code is outdated")); map.put("commented-code-outdated", _T("Commented code is outdated"));
map.put("suggest-changes", _T("Suggest changes")); map.put("suggest-changes", _T("Suggest changes"));
map.put("upload-should-be-less-than", _T("Upload should be less than {0} Mb")); map.put("upload-should-be-less-than", _T("Upload should be less than {0} Mb"));
map.put("uploading-file", _T("Uploading file")); map.put("uploading-file", _T("Uploading file"));
map.put("unable-to-connect-to-server", _T("Unable to connect to server")); map.put("unable-to-connect-to-server", _T("Unable to connect to server"));
map.put("programming-language", _T("Programming language")); map.put("programming-language", _T("Programming language"));
map.put("copy-to-clipboard", _T("Copy to clipboard")); map.put("copy-to-clipboard", _T("Copy to clipboard"));
map.put("suggestion-outdated", _T("Suggestion is outdated either due to code change or pull request close")); map.put("suggestion-outdated", _T("Suggestion is outdated either due to code change or pull request close"));
map.put("remove-from-batch", _T("Remove from batch")); map.put("remove-from-batch", _T("Remove from batch"));
map.put("add-to-batch", _T("Add to batch to commit with other suggestions later")); map.put("add-to-batch", _T("Add to batch to commit with other suggestions later"));
map.put("commit-suggestion", _T("Commit suggestion")); map.put("commit-suggestion", _T("Commit suggestion"));
map.put("issue-not-exist-or-access-denied", _T("Issue not exist or access denied")); map.put("issue-not-exist-or-access-denied", _T("Issue not exist or access denied"));
map.put("pull-request-not-exist-or-access-denied", _T("Pull request not exist or access denied")); map.put("pull-request-not-exist-or-access-denied", _T("Pull request not exist or access denied"));
map.put("build-not-exist-or-access-denied", _T("Build not exist or access denied")); map.put("build-not-exist-or-access-denied", _T("Build not exist or access denied"));
map.put("commit-not-exist-or-access-denied", _T("Commit not exist or access denied")); map.put("commit-not-exist-or-access-denied", _T("Commit not exist or access denied"));
map.put("enter-description-here", _T("Enter description here")); map.put("enter-description-here", _T("Enter description here"));
return OneDev.getInstance(ObjectMapper.class).writeValueAsString(map); return OneDev.getInstance(ObjectMapper.class).writeValueAsString(map);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
} }

View File

@ -17,9 +17,9 @@ public class SymbolContext implements Serializable {
private final List<String> linesAtStart; private final List<String> linesAtStart;
public SymbolContext(String fileName, String symbolLine, public SymbolContext(String blobPath, String symbolLine,
List<String> linesBeforeSymbolLine, List<String> linesAfterSymbolLine, List<String> linesAtStart) { List<String> linesBeforeSymbolLine, List<String> linesAfterSymbolLine, List<String> linesAtStart) {
this.blobPath = fileName; this.blobPath = blobPath;
this.symbolLine = symbolLine; this.symbolLine = symbolLine;
this.linesBeforeSymbolLine = linesBeforeSymbolLine; this.linesBeforeSymbolLine = linesBeforeSymbolLine;
this.linesAfterSymbolLine = linesAfterSymbolLine; this.linesAfterSymbolLine = linesAfterSymbolLine;

View File

@ -37,8 +37,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.message.UserMessage;
import io.onedev.commons.jsymbol.Symbol; import io.onedev.commons.jsymbol.Symbol;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.LinearRange; import io.onedev.commons.utils.LinearRange;
import io.onedev.commons.utils.PlanarRange; import io.onedev.commons.utils.PlanarRange;
import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.git.Blob; import io.onedev.server.git.Blob;
import io.onedev.server.git.BlobIdent; import io.onedev.server.git.BlobIdent;
import io.onedev.server.model.Project; import io.onedev.server.model.Project;
@ -59,7 +61,7 @@ import io.onedev.server.web.page.project.blob.render.BlobRenderer;
public abstract class SymbolTooltipPanel extends Panel { public abstract class SymbolTooltipPanel extends Panel {
private static final int QUERY_ENTRIES = 20; private static final int QUERY_ENTRIES = 25;
private static final int BEFORE_CONTEXT_SIZE = 5; private static final int BEFORE_CONTEXT_SIZE = 5;
@ -321,6 +323,7 @@ public abstract class SymbolTooltipPanel extends Panel {
mapperCopy.addMixIn(PlanarRange.class, IgnorePlanarRangeMixin.class); mapperCopy.addMixIn(PlanarRange.class, IgnorePlanarRangeMixin.class);
mapperCopy.addMixIn(LinearRange.class, IgnoreLinearRangeMixin.class); mapperCopy.addMixIn(LinearRange.class, IgnoreLinearRangeMixin.class);
var jsonOfSymbolHits = mapperCopy.writeValueAsString(symbolHits); var jsonOfSymbolHits = mapperCopy.writeValueAsString(symbolHits);
var symbolContext = getSymbolContext(symbolPosition, BEFORE_CONTEXT_SIZE, var symbolContext = getSymbolContext(symbolPosition, BEFORE_CONTEXT_SIZE,
AFTER_CONTEXT_SIZE, AT_START_CONTEXT_SIZE); AFTER_CONTEXT_SIZE, AT_START_CONTEXT_SIZE);
var jsonOfSymbolContext = mapperCopy.writeValueAsString(symbolContext); var jsonOfSymbolContext = mapperCopy.writeValueAsString(symbolContext);
@ -349,9 +352,14 @@ public abstract class SymbolTooltipPanel extends Panel {
if (index < 0 || index >= symbolHits.size()) if (index < 0 || index >= symbolHits.size())
Session.get().warn("Unable to find most likely definition"); Session.get().warn("Unable to find most likely definition");
} catch (Exception e) { } catch (Exception e) {
var explicitException = ExceptionUtils.find(e, ExplicitException.class);
if (explicitException != null) {
Session.get().error(explicitException.getMessage());
} else {
logger.error("Error inferring most likely symbol definition", e);
Session.get().error("Error inferring most likely symbol definition, check server log for details");
}
index = -1; index = -1;
logger.error("Error inferring most likely symbol definition", e);
Session.get().error("Error inferring most likely symbol definition, check server log for details");
} }
var script = String.format("onedev.server.symboltooltip.doneInfer('%s', %d);", var script = String.format("onedev.server.symboltooltip.doneInfer('%s', %d);",
getMarkupId() + "-symbol-tooltip", index); getMarkupId() + "-symbol-tooltip", index);

View File

@ -67,13 +67,13 @@
<div class="card can-send mb-5"> <div class="card can-send mb-5">
<div class="card-body"> <div class="card-body">
<form wicket:id="form" class="leave-confirm"> <form wicket:id="form" class="leave-confirm">
<div class="form-group"> <div class="form-group pull-request-title">
<label><wicket:t>Title</wicket:t> <span class="text-danger">*</span></label> <label><wicket:t>Title</wicket:t> <span class="text-danger">*</span></label>
<input wicket:id="title" type="text" class="form-control"> <input wicket:id="title" type="text" class="form-control">
<div wicket:id="titleFeedback"></div> <div wicket:id="titleFeedback"></div>
<div class="text-muted form-text"><wicket:t>Prefix the title with <code>WIP</code> or <code>[WIP]</code> to mark the pull request as work in progress</wicket:t></div> <div class="text-muted form-text"><wicket:t>Prefix the title with <code>WIP</code> or <code>[WIP]</code> to mark the pull request as work in progress</wicket:t></div>
</div> </div>
<div class="form-group"> <div class="form-group pull-request-description">
<label><wicket:t>Description</wicket:t></label> <label><wicket:t>Description</wicket:t></label>
<div wicket:id="description"></div> <div wicket:id="description"></div>
<div wicket:id="descriptionFeedback"></div> <div wicket:id="descriptionFeedback"></div>

View File

@ -1,7 +1,11 @@
package io.onedev.server.web.page.project.pullrequests.create; package io.onedev.server.web.page.project.pullrequests.create;
import static io.onedev.server.model.PullRequest.MAX_DESCRIPTION_LEN;
import static io.onedev.server.model.PullRequest.MAX_TITLE_LEN;
import static io.onedev.server.search.commit.Revision.Type.COMMIT; import static io.onedev.server.search.commit.Revision.Type.COMMIT;
import static io.onedev.server.web.translation.Translation._T; 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.text.MessageFormat; import java.text.MessageFormat;
import java.util.ArrayList; import java.util.ArrayList;
@ -16,10 +20,11 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable; import javax.inject.Inject;
import org.apache.wicket.Component; import org.apache.wicket.Component;
import org.apache.wicket.RestartResponseAtInterceptPageException; import org.apache.wicket.RestartResponseAtInterceptPageException;
import org.apache.wicket.Session;
import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.form.OnChangeAjaxBehavior; import org.apache.wicket.ajax.form.OnChangeAjaxBehavior;
@ -27,8 +32,9 @@ import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.behavior.AttributeAppender; import org.apache.wicket.behavior.AttributeAppender;
import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel; import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel;
import org.apache.wicket.feedback.FencedFeedbackPanel; import org.apache.wicket.feedback.FencedFeedbackPanel;
import org.apache.wicket.markup.head.CssHeaderItem;
import org.apache.wicket.markup.head.IHeaderResponse; import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.markup.head.OnLoadHeaderItem;
import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Button; import org.apache.wicket.markup.html.form.Button;
@ -47,9 +53,17 @@ import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevCommit;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.PlanarRange; import io.onedev.commons.utils.PlanarRange;
import io.onedev.server.OneDev; import io.onedev.server.OneDev;
import io.onedev.server.attachment.AttachmentSupport; import io.onedev.server.attachment.AttachmentSupport;
@ -58,11 +72,7 @@ import io.onedev.server.codequality.CodeProblem;
import io.onedev.server.codequality.CodeProblemContribution; import io.onedev.server.codequality.CodeProblemContribution;
import io.onedev.server.codequality.CoverageStatus; import io.onedev.server.codequality.CoverageStatus;
import io.onedev.server.codequality.LineCoverageContribution; import io.onedev.server.codequality.LineCoverageContribution;
import io.onedev.server.service.CodeCommentService; import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.service.LabelSpecService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.git.GitUtils; import io.onedev.server.git.GitUtils;
import io.onedev.server.git.service.GitService; import io.onedev.server.git.service.GitService;
import io.onedev.server.git.service.RefFacade; import io.onedev.server.git.service.RefFacade;
@ -87,10 +97,17 @@ import io.onedev.server.search.commit.CommitQuery;
import io.onedev.server.search.commit.Revision; import io.onedev.server.search.commit.Revision;
import io.onedev.server.search.commit.RevisionCriteria; import io.onedev.server.search.commit.RevisionCriteria;
import io.onedev.server.security.SecurityUtils; import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.service.LabelSpecService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.SettingService;
import io.onedev.server.util.Pair; import io.onedev.server.util.Pair;
import io.onedev.server.util.ProjectAndBranch; import io.onedev.server.util.ProjectAndBranch;
import io.onedev.server.util.diff.WhitespaceOption; import io.onedev.server.util.diff.WhitespaceOption;
import io.onedev.server.web.ajaxlistener.DisableGlobalAjaxIndicatorListener; import io.onedev.server.web.ajaxlistener.DisableGlobalAjaxIndicatorListener;
import io.onedev.server.web.behavior.AbstractPostAjaxBehavior;
import io.onedev.server.web.behavior.ReferenceInputBehavior; import io.onedev.server.web.behavior.ReferenceInputBehavior;
import io.onedev.server.web.component.branch.BranchLink; import io.onedev.server.web.component.branch.BranchLink;
import io.onedev.server.web.component.branch.picker.AffinalBranchPicker; import io.onedev.server.web.component.branch.picker.AffinalBranchPicker;
@ -119,10 +136,18 @@ import io.onedev.server.web.util.editbean.LabelsBean;
public class NewPullRequestPage extends ProjectPage implements RevisionAnnotationSupport { public class NewPullRequestPage extends ProjectPage implements RevisionAnnotationSupport {
private static final Logger logger = LoggerFactory.getLogger(NewPullRequestPage.class);
private static final String TABS_ID = "tabs"; private static final String TABS_ID = "tabs";
private static final String TAB_PANEL_ID = "tabPanel"; private static final String TAB_PANEL_ID = "tabPanel";
@Inject
private SettingService settingService;
@Inject
private ObjectMapper objectMapper;
private ProjectAndBranch target; private ProjectAndBranch target;
private ProjectAndBranch source; private ProjectAndBranch source;
@ -262,7 +287,11 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
update.setHeadCommitHash(source.getObjectName()); update.setHeadCommitHash(source.getObjectName());
update.setTargetHeadCommitHash(request.getTarget().getObjectName()); update.setTargetHeadCommitHash(request.getTarget().getObjectName());
request.generateTitleAndDescriptionIfEmpty(); var title = request.generateTitleFromCommits();
if (title == null && settingService.getAISetting().getLiteModelSetting() == null)
title = request.generateTitleFromBranch();
request.setTitle(title);
request.setDescription(request.generateDescriptionFromCommits());
getPullRequestService().checkReviews(request, false); getPullRequestService().checkReviews(request, false);
@ -645,7 +674,140 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
} }
private Fragment newCanSendFrag() { private Fragment newCanSendFrag() {
Fragment fragment = new Fragment("status", "canSendFrag", this); var behavior = new AbstractPostAjaxBehavior() {
@Override
protected void respond(AjaxRequestTarget target) {
var params = RequestCycle.get().getRequest().getPostParameters();
var suggestTitle = params.getParameterValue("suggestTitle").toBoolean();
var suggestDescription = params.getParameterValue("suggestDescription").toBoolean();
String title;
String description;
try {
if (suggestTitle || suggestDescription) {
var chatModel = settingService.getAISetting().getLiteModel();
var sourceBranchSemantic = getPullRequest().getSourceBranchSemantic();
String titleSuggestInstruction;
if (sourceBranchSemantic.isWorkInProgress()) {
if (sourceBranchSemantic.getWorkType() != null) {
titleSuggestInstruction = """
When suggesting pull request title, you should not add work in progress prefix
or conventional commit type prefix to the title even if commit messages
indicate that.
""";
} else {
titleSuggestInstruction = """
When suggesting pull request title, you should not add work in progress prefix
to the title even if commit messages indicate that.
""";
}
} else {
if (sourceBranchSemantic.getWorkType() != null) {
titleSuggestInstruction = """
When suggesting pull request title, you should not add conventional commit type prefix
to the title even if commit messages indicate that.
""";
} else {
titleSuggestInstruction = "";
}
}
var userPrompt = getPullRequest().getLatestUpdate().getCommits().stream()
.map(it -> it.getFullMessage())
.collect(Collectors.toList());
var userMessage = new UserMessage("A json array of commit messages:\n" + objectMapper.writeValueAsString(userPrompt));
if (!suggestTitle) {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request description by
summarizing multiple commit messages. Maximum %d characters allowed for
the description.
IMPORTANT: only return the description, no other text or comments.
""", MAX_DESCRIPTION_LEN));
description = chatModel.chat(systemMessage, userMessage).aiMessage().text();
title = "";
} else if (!suggestDescription) {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request title by summarizing
multiple commit messages. %s Maximum %d characters allowed for the title.
IMPORTANT: only return the title, no other text or comments.
""", titleSuggestInstruction, MAX_TITLE_LEN));
title = (getPullRequest().getTitlePrefix(sourceBranchSemantic) + chatModel.chat(systemMessage, userMessage).aiMessage().text()).trim();
description = "";
} else {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request title and description
by summarizing multiple commit messages. %s Maximum %d characters allowed for the title,
and %d characters allowed for the description.
IMPORTANT: only return a VALID json object with "title" property set to the title and "description"
property set to the description, no other text or comments.
""", titleSuggestInstruction, MAX_TITLE_LEN, MAX_DESCRIPTION_LEN));
var responseText = chatModel.chat(systemMessage, userMessage).aiMessage().text();
if (responseText.startsWith("```json"))
responseText = responseText.substring("```json".length());
if (responseText.endsWith("```"))
responseText = responseText.substring(0, responseText.length() - "```".length());
var response = objectMapper.readTree(responseText);
if (response.has("title")) {
title = (getPullRequest().getTitlePrefix(sourceBranchSemantic) + response.get("title").asText()).trim();
} else {
title = "";
}
if (response.has("description"))
description = response.get("description").asText();
else
description = "";
}
} else {
title = "";
description = "";
}
} catch (Exception e) {
title = "";
description = "";
var explicitException = ExceptionUtils.find(e, ExplicitException.class);
if (explicitException != null) {
Session.get().error(explicitException.getMessage());
} else {
logger.error("Error suggesting title/description", e);
Session.get().error("Error suggesting title/description, check server log for details");
}
}
var script = String.format("onedev.server.newPullRequest.titleAndDescriptionSuggested('%s', '%s');",
escapeJavaScript(title), escapeJavaScript(description));
target.appendJavaScript(script);
}
};
Fragment fragment = new Fragment("status", "canSendFrag", this) {
@Override
public void renderHead(IHeaderResponse response) {
super.renderHead(response);
CharSequence callback;
if (settingService.getAISetting().getLiteModelSetting() != null)
callback = behavior.getCallbackFunction(explicit("suggestTitle"), explicit("suggestDescription"));
else
callback = "undefined";
var translations = new HashMap<String, String>();
translations.put("suggesting-title", _T("Suggesting title..."));
translations.put("suggesting-description", _T("Suggesting description..."));
try {
var script = String.format("onedev.server.newPullRequest.onCanSendLoad(%s, %s);",
callback, objectMapper.writeValueAsString(translations));
response.render(OnLoadHeaderItem.forScript(script));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
};
fragment.add(behavior);
Form<?> form = new Form<Void>("form"); Form<?> form = new Form<Void>("form");
fragment.add(form); fragment.add(form);
@ -763,7 +925,7 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
}; };
descriptionInput.add(validatable -> { descriptionInput.add(validatable -> {
if (validatable.getValue().length() > PullRequest.MAX_DESCRIPTION_LEN) { if (validatable.getValue().length() > MAX_DESCRIPTION_LEN) {
validatable.error(messageSource -> _T("Description too long")); validatable.error(messageSource -> _T("Description too long"));
} }
}); });
@ -943,7 +1105,7 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
@Override @Override
public void renderHead(IHeaderResponse response) { public void renderHead(IHeaderResponse response) {
super.renderHead(response); super.renderHead(response);
response.render(CssHeaderItem.forReference(new NewPullRequestCssResourceReference())); response.render(JavaScriptHeaderItem.forReference(new NewPullRequestResourceReference()));
} }
@Override @Override

View File

@ -7,18 +7,21 @@ import org.apache.wicket.markup.head.HeaderItem;
import io.onedev.server.web.asset.revisioncompare.RevisionCompareCssResourceReference; import io.onedev.server.web.asset.revisioncompare.RevisionCompareCssResourceReference;
import io.onedev.server.web.page.base.BaseDependentCssResourceReference; import io.onedev.server.web.page.base.BaseDependentCssResourceReference;
import io.onedev.server.web.page.base.BaseDependentResourceReference;
public class NewPullRequestCssResourceReference extends BaseDependentCssResourceReference { public class NewPullRequestResourceReference extends BaseDependentResourceReference {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public NewPullRequestCssResourceReference() { public NewPullRequestResourceReference() {
super(NewPullRequestCssResourceReference.class, "new-pull-request.css"); super(NewPullRequestResourceReference.class, "new-pull-request.js");
} }
@Override @Override
public List<HeaderItem> getDependencies() { public List<HeaderItem> getDependencies() {
List<HeaderItem> dependencies = super.getDependencies(); List<HeaderItem> dependencies = super.getDependencies();
dependencies.add(CssHeaderItem.forReference(new BaseDependentCssResourceReference(
NewPullRequestResourceReference.class, "new-pull-request.css")));
dependencies.add(CssHeaderItem.forReference(new RevisionCompareCssResourceReference())); dependencies.add(CssHeaderItem.forReference(new RevisionCompareCssResourceReference()));
return dependencies; return dependencies;
} }

View File

@ -0,0 +1,83 @@
onedev.server.newPullRequest = {
onCanSendLoad: function(titleAndDescriptionSuggestionCallback, translations) {
// Do below logic in onLoad as unsaved description will be set in onLoad event of markdown editor,
// and we do not want to suggest description if unsaved description is loaded
if (titleAndDescriptionSuggestionCallback) {
var icon = onedev.server.isDarkMode()? "sparkle.gif": "sparkle-dark.gif";
var indicatorHtml = "<div class='ajax-loading-indicator suggesting-indicator'><img src='/~img/" + icon + "' width='16' height='16'></div>";
var $title = $(".pull-request-title");
var $titleInput = $title.find("input");
if ($titleInput.val().length == 0) {
$title.css("position", "relative");
$titleInput.prop("readonly", true);
$titleInput.data("placeholder", $titleInput.attr("placeholder"));
$titleInput.removeAttr("placeholder");
var $indicator = $(indicatorHtml);
$indicator.append("<span class='text-muted'>" + translations["suggesting-title"] + "</span>");
var titleCoord = $title.offset();
var titleInputCoord = $titleInput.offset();
var left = titleInputCoord.left - titleCoord.left + 5;
var top = titleInputCoord.top - titleCoord.top + 10;
$indicator.css({
position: "absolute",
left: left + "px",
top: top + "px",
zIndex: 1000
});
$title.append($indicator);
}
var $description = $(".pull-request-description");
var $descriptionInput = $description.find("textarea");
if ($descriptionInput.val().length == 0) {
$description.css("position", "relative");
$descriptionInput.prop("readonly", true);
$indicator = $(indicatorHtml);
$indicator.append("<span class='text-muted'>" + translations["suggesting-description"] + "</span>");
var descriptionCoord = $description.offset();
var descriptionInputCoord = $descriptionInput.offset();
var left = descriptionInputCoord.left - descriptionCoord.left + 5;
var top = descriptionInputCoord.top - descriptionCoord.top + 10;
$indicator.css({
position: "absolute",
left: left + "px",
top: top + "px",
zIndex: 1000
});
$description.append($indicator);
}
titleAndDescriptionSuggestionCallback($titleInput.val().length == 0, $descriptionInput.val().length == 0);
}
},
titleAndDescriptionSuggested: function(title, description) {
var $title = $(".pull-request-title");
var $titleInput = $title.find("input");
$titleInput.prop("readonly", false);
$title.children(".suggesting-indicator").remove();
var placeholder = $titleInput.data("placeholder");
if (placeholder)
$titleInput.attr("placeholder", placeholder);
if (title)
$titleInput.val(title);
var $description = $(".pull-request-description");
var $descriptionInput = $description.find("textarea");
$descriptionInput.prop("readonly", false);
$description.children(".suggesting-indicator").remove();
if (description) {
$descriptionInput.val(description);
$descriptionInput.trigger("input");
}
}
}