From 754f072ef9bb1fcebe4676bc5bb6e3b92ba1880d Mon Sep 17 00:00:00 2001
From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:37:51 +0100
Subject: [PATCH 1/5] fix(web): disable drag and drop for internal items
(#26897)
---
.../drag-and-drop-upload-overlay.svelte | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
index 77178aa992aff..b37b8c07399bc 100644
--- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
+++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
@@ -13,6 +13,7 @@
let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id));
let dragStartTarget: EventTarget | null = $state(null);
+ let isInternalDrag = false;
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
@@ -133,7 +134,19 @@
}
};
+ const ondragstart = () => {
+ isInternalDrag = true;
+ };
+
+ const ondragend = () => {
+ isInternalDrag = false;
+ };
+
const ondragenter = (e: DragEvent) => {
+ if (isInternalDrag) {
+ return;
+ }
+
e.preventDefault();
e.stopPropagation();
onDragEnter(e);
@@ -146,6 +159,10 @@
};
const ondrop = async (e: DragEvent) => {
+ if (isInternalDrag) {
+ return;
+ }
+
e.preventDefault();
e.stopPropagation();
await onDrop(e);
@@ -159,7 +176,7 @@
-
+
{#if dragStartTarget}
From 226b9390dbf810d3c0c2961b672383fdb803e7af Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Fri, 13 Mar 2026 09:38:21 -0500
Subject: [PATCH 2/5] fix(mobile): video auth (#26887)
* fix video auth
* update commit
---
mobile/android/app/build.gradle | 2 +
.../app/alextran/immich/MainActivity.kt | 2 +
.../alextran/immich/core/HttpClientManager.kt | 66 +++++++++++++++++++
.../immich/images/RemoteImagesImpl.kt | 57 ++++------------
mobile/ios/Runner/AppDelegate.swift | 2 +
mobile/pubspec.lock | 12 ++--
mobile/pubspec.yaml | 2 +-
7 files changed, 90 insertions(+), 53 deletions(-)
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index bd90986f60b2a..103cf79e4eef7 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -113,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
+ implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
+ implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index a85929a0e93d3..06649de8f0afc 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
+import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
+ NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt
index 180ae4735df77..5b53b2a49aab6 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt
@@ -3,7 +3,13 @@ package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
+import androidx.annotation.OptIn
import androidx.core.content.edit
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.ResolvingDataSource
+import androidx.media3.datasource.cronet.CronetDataSource
+import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
@@ -16,6 +22,7 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
+import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
@@ -25,6 +32,8 @@ import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
+ const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
@@ -67,6 +77,11 @@ object HttpClientManager {
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
+ var cronetEngine: CronetEngine? = null
+ private set
+ private lateinit var cronetStorageDir: File
+ val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
+
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null
@@ -107,6 +122,10 @@ object HttpClientManager {
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
+
+ cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
+ cronetEngine = buildCronetEngine()
+
initialized = true
}
}
@@ -223,6 +242,53 @@ object HttpClientManager {
?.joinToString("; ") { "${it.name}=${it.value}" }
}
+ fun getAuthHeaders(url: String): Map {
+ val result = mutableMapOf()
+ headers.forEach { (key, value) -> result[key] = value }
+ loadCookieHeader(url)?.let { result["Cookie"] = it }
+ url.toHttpUrlOrNull()?.let { httpUrl ->
+ if (httpUrl.username.isNotEmpty()) {
+ result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
+ }
+ }
+ return result
+ }
+
+ fun rebuildCronetEngine(): CronetEngine {
+ val old = cronetEngine!!
+ cronetEngine = buildCronetEngine()
+ return old
+ }
+
+ val cronetStoragePath: File get() = cronetStorageDir
+
+ @OptIn(UnstableApi::class)
+ fun createDataSourceFactory(headers: Map): DataSource.Factory {
+ return if (isMtls) {
+ OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
+ } else {
+ ResolvingDataSource.Factory(
+ CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
+ ) { dataSpec ->
+ val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
+ newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
+ newHeaders["Cache-Control"] = "no-store"
+ dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
+ }
+ }
+ }
+
+ private fun buildCronetEngine(): CronetEngine {
+ return CronetEngine.Builder(appContext)
+ .enableHttp2(true)
+ .enableQuic(true)
+ .enableBrotli(true)
+ .setStoragePath(cronetStorageDir.absolutePath)
+ .setUserAgent(USER_AGENT)
+ .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
+ .build()
+ }
+
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
index b820b454254dc..8e9fc3f6d5b4a 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
-import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
@@ -15,9 +14,6 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
-import okhttp3.Credentials
-import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
@@ -31,10 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.Executors
-
-
-private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
private object ImageFetcherManager {
- private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
- appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
@@ -143,7 +133,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
- CronetImageFetcher(appContext, cacheDir)
+ CronetImageFetcher()
}
}
}
@@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result) -> Unit)
}
-private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
- private val ctx = context
- private var engine: CronetEngine
- private val executor = Executors.newFixedThreadPool(4)
+private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result) -> Unit)? = null
- private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
-
- init {
- engine = build(context)
- }
override fun fetch(
url: String,
@@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
- val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
- HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
- HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
- url.toHttpUrlOrNull()?.let { httpUrl ->
- if (httpUrl.username.isNotEmpty()) {
- requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
- }
+ val requestBuilder = HttpClientManager.cronetEngine!!
+ .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
+ HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
+ requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
- private fun build(ctx: Context): CronetEngine {
- return CronetEngine.Builder(ctx)
- .enableHttp2(true)
- .enableQuic(true)
- .enableBrotli(true)
- .setStoragePath(storageDir.absolutePath)
- .setUserAgent(USER_AGENT)
- .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
- .build()
- }
-
private fun onComplete() {
val didDrain = synchronized(stateLock) {
activeCount--
@@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
private fun onDrained() {
- engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
- if (onCacheCleared == null) {
- executor.shutdown()
- } else {
+ if (onCacheCleared != null) {
+ val oldEngine = HttpClientManager.rebuildCronetEngine()
+ oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
- val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
- // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
- engine = build(ctx)
+ val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
@@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
- .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
+ .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index f842285b23448..8487db7b484a0 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
+import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -18,6 +19,7 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
+ SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index de116abb7e572..89a43f328bed0 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
- version: "1.16.0"
+ version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
- resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
+ ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
+ resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
+ sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
- version: "0.7.6"
+ version: "0.7.7"
thumbhash:
dependency: "direct main"
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 3a075d67ff086..77955c06abeae 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -56,7 +56,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
- ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
+ ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:
From c2a279e49ea585065f5a4e21450544862dc668c4 Mon Sep 17 00:00:00 2001
From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:40:04 +0100
Subject: [PATCH 3/5] fix(web): keep header fixed on individual shared links
(#26892)
---
.../individual-shared-viewer.svelte | 25 ++++++++++---------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte
index 64eb98bec0e56..0bf1a2f7f24d6 100644
--- a/web/src/lib/components/share-page/individual-shared-viewer.svelte
+++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte
@@ -74,8 +74,12 @@
};
-
- {#if sharedLink?.allowUpload || assets.length > 1}
+{#if sharedLink?.allowUpload || assets.length > 1}
+
+
+
+
+
{#if assetInteraction.selectionActive}
{/if}
-
- {:else if assets.length === 1}
- {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
- {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
-
- {/await}
+
+{:else if assets.length === 1}
+ {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
+ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
+
{/await}
- {/if}
-
+ {/await}
+{/if}
From e322d44f9553e51da46ed85568aaa3dc9951b7d2 Mon Sep 17 00:00:00 2001
From: Nathaniel Hourt
Date: Fri, 13 Mar 2026 09:41:50 -0500
Subject: [PATCH 4/5] fix: SMTP over TLS (#26893)
Final step on #22833
PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end.
This adds that last line of code and makes it work correctly (for me, anyways!).
Co-authored-by: Nathaniel
---
server/src/repositories/email.repository.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts
index 1bc4f0981afd0..a0cc23661aaeb 100644
--- a/server/src/repositories/email.repository.ts
+++ b/server/src/repositories/email.repository.ts
@@ -162,6 +162,7 @@ export class EmailRepository {
host: options.host,
port: options.port,
tls: { rejectUnauthorized: !options.ignoreCert },
+ secure: options.secure,
auth:
options.username || options.password
? {
From 10fa928abeee45e994ab29f9b36178982fb971a0 Mon Sep 17 00:00:00 2001
From: bo0tzz
Date: Fri, 13 Mar 2026 15:43:00 +0100
Subject: [PATCH 5/5] feat: require pull requests to follow template (#26902)
* feat: require pull requests to follow template
* fix: persist-credentials: false
---
.github/workflows/check-pr-template.yml | 80 +++++++++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 .github/workflows/check-pr-template.yml
diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml
new file mode 100644
index 0000000000000..f60498d269336
--- /dev/null
+++ b/.github/workflows/check-pr-template.yml
@@ -0,0 +1,80 @@
+name: Check PR Template
+
+on:
+ pull_request_target: # zizmor: ignore[dangerous-triggers]
+ types: [opened, edited]
+
+permissions: {}
+
+jobs:
+ parse:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.pull_request.head.repo.fork == true }}
+ permissions:
+ contents: read
+ outputs:
+ uses_template: ${{ steps.check.outputs.uses_template }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: .github/pull_request_template.md
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Check required sections
+ id: check
+ env:
+ BODY: ${{ github.event.pull_request.body }}
+ run: |
+ OK=true
+ while IFS= read -r header; do
+ printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
+ done < <(grep "^## " .github/pull_request_template.md)
+ echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
+
+ act:
+ runs-on: ubuntu-latest
+ needs: parse
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Close PR
+ if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ NODE_ID: ${{ github.event.pull_request.node_id }}
+ run: |
+ gh api graphql \
+ -f prId="$NODE_ID" \
+ -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
+ -f query='
+ mutation CommentAndClosePR($prId: ID!, $body: String!) {
+ addComment(input: {
+ subjectId: $prId,
+ body: $body
+ }) {
+ __typename
+ }
+ closePullRequest(input: {
+ pullRequestId: $prId
+ }) {
+ __typename
+ }
+ }'
+
+ - name: Reopen PR (sections now present, PR closed)
+ if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ NODE_ID: ${{ github.event.pull_request.node_id }}
+ run: |
+ gh api graphql \
+ -f prId="$NODE_ID" \
+ -f query='
+ mutation ReopenPR($prId: ID!) {
+ reopenPullRequest(input: {
+ pullRequestId: $prId
+ }) {
+ __typename
+ }
+ }'